feat: Implement internationalization for task management components

- Added translation support for task-related strings in ProjectTaskForm and ProjectTasksSection components.
- Integrated translation for navigation items in the Navigation component.
- Created ProjectCalendarWidget component with Polish translations for project statuses and deadlines.
- Developed Tooltip component for enhanced user experience with tooltips.
- Established a field change history logging system in the database with associated queries.
- Enhanced task update logging to include translated status and priority changes.
- Introduced server-side translations for system messages to improve localization.
This commit is contained in:
2025-09-11 15:49:07 +02:00
parent 50adc50a24
commit 0dd988730f
24 changed files with 1945 additions and 114 deletions

View File

@@ -0,0 +1,126 @@
"use client";
import { useState, useEffect } from "react";
import Tooltip from "@/components/ui/Tooltip";
import { formatDate } from "@/lib/utils";
export default function FieldWithHistory({
tableName,
recordId,
fieldName,
currentValue,
displayValue = null,
label = null,
className = "",
}) {
const [hasHistory, setHasHistory] = useState(false);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchHistory = async () => {
try {
const response = await fetch(
`/api/field-history?table_name=${tableName}&record_id=${recordId}&field_name=${fieldName}`
);
if (response.ok) {
const historyData = await response.json();
setHistory(historyData);
setHasHistory(historyData.length > 0);
}
} catch (error) {
console.error("Failed to fetch field history:", error);
} finally {
setLoading(false);
}
};
if (tableName && recordId && fieldName) {
fetchHistory();
} else {
setLoading(false);
}
}, [tableName, recordId, fieldName]);
// Format value for display
const getDisplayValue = (value) => {
if (displayValue !== null) return displayValue;
if (value && fieldName.includes("date")) {
try {
return formatDate(value);
} catch {
return value;
}
}
return value || "N/A";
};
// Create tooltip content
const tooltipContent = history.length > 0 && (
<div className="space-y-2">
<div className="font-medium text-white mb-2">Change History:</div>
{history.map((change, index) => (
<div key={change.id} className="text-xs border-b border-gray-600 pb-1 last:border-b-0">
<div className="flex justify-between items-start gap-2">
<div className="flex-1">
<div className="text-white font-medium">
Changed to: {getDisplayValue(change.new_value)}
</div>
{change.old_value && (
<div className="text-gray-400 text-xs">
From: {getDisplayValue(change.old_value)}
</div>
)}
{change.changed_by_name && (
<div className="text-gray-300">
by {change.changed_by_name}
</div>
)}
</div>
<div className="text-gray-400 text-right text-xs">
{formatDate(change.changed_at)}
</div>
</div>
{change.change_reason && (
<div className="text-gray-400 text-xs mt-1">
Reason: {change.change_reason}
</div>
)}
</div>
))}
</div>
);
if (loading) {
return (
<div className={className}>
{label && <span className="text-sm font-medium text-gray-500 block mb-1">{label}</span>}
<p className="text-gray-900 font-medium">{getDisplayValue(currentValue)}</p>
</div>
);
}
return (
<div className={className}>
{label && <span className="text-sm font-medium text-gray-500 block mb-1">{label}</span>}
<div className="flex items-center gap-2">
<p className="text-gray-900 font-medium">{getDisplayValue(currentValue)}</p>
{hasHistory && (
<Tooltip content={tooltipContent}>
<svg
className="w-4 h-4 text-blue-500 hover:text-blue-700 cursor-help"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</Tooltip>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useState, useEffect } from "react";
import Tooltip from "@/components/ui/Tooltip";
import { formatDate } from "@/lib/utils";
export default function FinishDateWithHistory({ projectId, finishDate }) {
const [hasUpdates, setHasUpdates] = useState(false);
const [updates, setUpdates] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUpdates = async () => {
try {
const res = await fetch(`/api/projects/${projectId}/finish-date-updates`);
if (res.ok) {
const data = await res.json();
setUpdates(data);
setHasUpdates(data.length > 0);
}
} catch (error) {
console.error("Failed to fetch finish date updates:", error);
} finally {
setLoading(false);
}
};
if (projectId) {
fetchUpdates();
}
}, [projectId]);
const formatDateTime = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString("pl-PL", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const tooltipContent = (
<div className="space-y-2">
<div className="font-medium text-xs text-gray-300 mb-2">
Historia zmian terminu:
</div>
{updates.map((update, index) => (
<div key={update.id} className="text-xs">
<div className="flex justify-between items-start gap-2">
<div>
<div className="font-medium">
{update.new_finish_date
? formatDate(update.new_finish_date)
: "Usunięto termin"}
</div>
{update.old_finish_date && (
<div className="text-gray-400 text-xs">
poprzednio: {formatDate(update.old_finish_date)}
</div>
)}
</div>
</div>
<div className="text-gray-400 text-xs mt-1">
{update.updated_by_name && (
<span>przez {update.updated_by_name} </span>
)}
<span>{formatDateTime(update.updated_at)}</span>
</div>
{update.reason && (
<div className="text-gray-300 text-xs mt-1 italic">
"{update.reason}"
</div>
)}
{index < updates.length - 1 && (
<div className="border-b border-gray-600 my-2"></div>
)}
</div>
))}
</div>
);
if (loading) {
return (
<p className="text-gray-900 font-medium">
{finishDate ? formatDate(finishDate) : "N/A"}
</p>
);
}
return (
<div className="flex items-center gap-1">
<p className="text-gray-900 font-medium">
{finishDate ? formatDate(finishDate) : "N/A"}
</p>
{hasUpdates && (
<Tooltip content={tooltipContent} position="top">
<svg
className="w-4 h-4 text-blue-500 cursor-help hover:text-blue-600 transition-colors"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</Tooltip>
)}
</div>
);
}

View File

@@ -103,11 +103,11 @@ export default function ProjectForm({ initialData = null }) {
router.push("/projects");
}
} else {
alert("Failed to save project.");
alert(t('projects.saveError'));
}
} catch (error) {
console.error("Error saving project:", error);
alert("Failed to save project.");
alert(t('projects.saveError'));
} finally {
setLoading(false);
}
@@ -116,7 +116,7 @@ export default function ProjectForm({ initialData = null }) {
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
{isEdit ? "Edit Project Details" : "Project Details"}
{isEdit ? t('projects.editProjectDetails') : t('projects.projectDetails')}
</h2>
</CardHeader>
<CardContent>
@@ -125,7 +125,7 @@ export default function ProjectForm({ initialData = null }) {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contract <span className="text-red-500">*</span>
{t('projects.contract')} <span className="text-red-500">*</span>
</label>
<select
name="contract_id"
@@ -134,7 +134,7 @@ export default function ProjectForm({ initialData = null }) {
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="">Select Contract</option>
<option value="">{t('projects.selectContract')}</option>
{contracts.map((contract) => (
<option
key={contract.contract_id}
@@ -148,7 +148,7 @@ export default function ProjectForm({ initialData = null }) {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Typ projektu <span className="text-red-500">*</span>
{t('projects.type')} <span className="text-red-500">*</span>
</label>
<select
name="project_type"
@@ -287,19 +287,19 @@ export default function ProjectForm({ initialData = null }) {
{/* Additional Information Section */}
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Additional Information
{t('projects.additionalInfo')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Investment Number
{t('projects.investmentNumber')}
</label>
<Input
type="text"
name="investment_number"
value={form.investment_number || ""}
onChange={handleChange}
placeholder="Enter investment number"
placeholder={t('projects.placeholders.investmentNumber')}
/>
</div>
@@ -312,7 +312,7 @@ export default function ProjectForm({ initialData = null }) {
name="wp"
value={form.wp || ""}
onChange={handleChange}
placeholder="Enter WP"
placeholder={t('projects.placeholders.wp')}
/>
</div>

View File

@@ -3,8 +3,10 @@
import { useState, useEffect } from "react";
import Button from "./ui/Button";
import Badge from "./ui/Badge";
import { useTranslation } from "@/lib/i18n";
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
const { t } = useTranslation();
const [taskTemplates, setTaskTemplates] = useState([]);
const [users, setUsers] = useState([]);
const [taskType, setTaskType] = useState("template"); // "template" or "custom"
@@ -67,10 +69,10 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
setAssignedTo("");
if (onTaskAdded) onTaskAdded();
} else {
alert("Failed to add task to project.");
alert(t("tasks.addTaskError"));
}
} catch (error) {
alert("Error adding task to project.");
alert(t("tasks.addTaskError"));
} finally {
setIsSubmitting(false);
}
@@ -79,7 +81,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Task Type
{t("tasks.taskType")}
</label>
<div className="flex space-x-6">
<label className="flex items-center">
@@ -90,7 +92,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
onChange={(e) => setTaskType(e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-900">From Template</span>
<span className="ml-2 text-sm text-gray-900">{t("tasks.fromTemplate")}</span>
</label>
<label className="flex items-center">
<input
@@ -100,7 +102,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
onChange={(e) => setTaskType(e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-900">Custom Task</span>
<span className="ml-2 text-sm text-gray-900">{t("tasks.customTask")}</span>
</label>
</div>
</div>
@@ -108,7 +110,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
{taskType === "template" ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Task Template
{t("tasks.selectTemplate")}
</label>{" "}
<select
value={selectedTemplate}
@@ -116,10 +118,10 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="">Choose a task template...</option>
<option value="">{t("tasks.chooseTemplate")}</option>
{taskTemplates.map((template) => (
<option key={template.task_id} value={template.task_id}>
{template.name} ({template.max_wait_days} days)
{template.name} ({template.max_wait_days} {t("tasks.days")})
</option>
))}
</select>
@@ -128,20 +130,20 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Task Name
{t("tasks.taskName")}
</label>
<input
type="text"
value={customTaskName}
onChange={(e) => setCustomTaskName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter custom task name..."
placeholder={t("tasks.enterTaskName")}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Max Wait Days
{t("tasks.maxWait")}
</label>
<input
type="number"
@@ -154,13 +156,13 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Description
{t("tasks.description")}
</label>
<textarea
value={customDescription}
onChange={(e) => setCustomDescription(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter task description (optional)..."
placeholder={t("tasks.enterDescription")}
rows={3}
/>
</div>
@@ -169,14 +171,14 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Assign To <span className="text-gray-500 text-xs">(optional)</span>
{t("tasks.assignedTo")} <span className="text-gray-500 text-xs">({t("common.optional")})</span>
</label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Unassigned</option>
<option value="">{t("projects.unassigned")}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
@@ -187,17 +189,17 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Priority
{t("tasks.priority")}
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
<option value="low">{t("tasks.low")}</option>
<option value="normal">{t("tasks.normal")}</option>
<option value="high">{t("tasks.high")}</option>
<option value="urgent">{t("tasks.urgent")}</option>
</select>
</div>
@@ -211,7 +213,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
(taskType === "custom" && !customTaskName.trim())
}
>
{isSubmitting ? "Adding..." : "Add Task"}
{isSubmitting ? t("tasks.adding") : t("tasks.addTask")}
</Button>
</div>
</form>

View File

@@ -202,7 +202,7 @@ export default function ProjectTasksSection({ projectId }) {
alert(t("errors.generic") + ": " + (errorData.error || t("errors.unknown")));
}
} catch (error) {
alert("Error deleting task: " + error.message);
alert(t("tasks.deleteError") + ": " + error.message);
}
};
@@ -238,6 +238,28 @@ export default function ProjectTasksSection({ projectId }) {
}
};
const handleDeleteNote = async (noteId, taskId) => {
if (!confirm(t("common.deleteConfirm"))) return;
try {
const res = await fetch(`/api/task-notes/${noteId}`, {
method: "DELETE",
});
if (res.ok) {
// Refresh notes for this task
const notesRes = await fetch(`/api/task-notes?task_id=${taskId}`);
const notes = await notesRes.json();
setTaskNotes((prev) => ({ ...prev, [taskId]: notes }));
} else {
alert(t("errors.generic"));
}
} catch (error) {
console.error("Error deleting task note:", error);
alert(t("errors.generic"));
}
};
const handleEditTask = (task) => {
setEditingTask(task);
@@ -373,7 +395,7 @@ export default function ProjectTasksSection({ projectId }) {
>
<div className="flex items-center justify-between p-6 border-b">
<h3 className="text-lg font-semibold text-gray-900">
Add New Task
{t("tasks.newTask")}
</h3>
<button
onClick={() => setShowAddTaskModal(false)}
@@ -530,7 +552,7 @@ export default function ProjectTasksSection({ projectId }) {
size="sm"
onClick={() => toggleNotes(task.id)}
className="text-xs px-2 py-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 border-0"
title={`${taskNotes[task.id]?.length || 0} notes`}
title={`${taskNotes[task.id]?.length || 0} ${t("tasks.notes")}`}
>
<svg
className="w-3 h-3 mr-1"
@@ -658,7 +680,7 @@ export default function ProjectTasksSection({ projectId }) {
)
}
className="ml-2 text-red-500 hover:text-red-700 text-xs font-bold"
title="Delete note"
title={t("tasks.deleteNote")}
>
×
</button>

View File

@@ -22,6 +22,7 @@ const Navigation = () => {
const navItems = [
{ href: "/projects", label: t('navigation.projects') },
{ href: "/calendar", label: t('navigation.calendar') || 'Kalendarz' },
{ href: "/tasks/templates", label: t('navigation.taskTemplates') },
{ href: "/project-tasks", label: t('navigation.projectTasks') },
{ href: "/contracts", label: t('navigation.contracts') },

View File

@@ -0,0 +1,329 @@
"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",
};
const statusTranslations = {
registered: "Zarejestrowany",
approved: "Zatwierdzony",
pending: "Oczekujący",
in_progress: "W trakcie",
fulfilled: "Zakończony",
};
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">
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>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
export default function Tooltip({ children, content, className = "" }) {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
const updatePosition = () => {
if (triggerRef.current && tooltipRef.current) {
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const scrollY = window.scrollY;
const scrollX = window.scrollX;
// Calculate position (above the trigger by default)
let top = triggerRect.top + scrollY - tooltipRect.height - 8;
let left = triggerRect.left + scrollX + (triggerRect.width / 2) - (tooltipRect.width / 2);
// Keep tooltip within viewport
if (left < 10) left = 10;
if (left + tooltipRect.width > window.innerWidth - 10) {
left = window.innerWidth - tooltipRect.width - 10;
}
// If tooltip would go above viewport, show below instead
if (top < scrollY + 10) {
top = triggerRect.bottom + scrollY + 8;
}
setPosition({ top, left });
}
};
const handleMouseEnter = () => {
setIsVisible(true);
};
const handleMouseLeave = () => {
setIsVisible(false);
};
useEffect(() => {
if (isVisible) {
// Small delay to ensure tooltip is rendered before positioning
const timer = setTimeout(() => {
updatePosition();
}, 10);
const handleScroll = () => updatePosition();
const handleResize = () => updatePosition();
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize);
return () => {
clearTimeout(timer);
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleResize);
};
}
}, [isVisible]);
const tooltip = isVisible && (
<div
ref={tooltipRef}
className={`fixed z-50 px-3 py-2 text-sm bg-gray-900 text-white rounded-lg shadow-lg border max-w-sm ${className}`}
style={{
top: position.top,
left: position.left,
}}
>
{content}
{/* Arrow pointing down */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
);
return (
<>
<span
ref={triggerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="inline-block"
>
{children}
</span>
{typeof document !== "undefined" && createPortal(tooltip, document.body)}
</>
);
}