tududi/frontend/components/Project/ProjectDetails.tsx
2025-06-20 12:04:36 +03:00

429 lines
No EOL
16 KiB
TypeScript

import React, { useEffect, useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
PencilSquareIcon,
TrashIcon,
FolderIcon,
Squares2X2Icon
} from "@heroicons/react/24/outline";
import TaskList from "../Task/TaskList";
import ProjectModal from "../Project/ProjectModal";
import ConfirmDialog from "../Shared/ConfirmDialog";
import { useStore } from "../../store/useStore";
import NewTask from "../Task/NewTask";
import { Project } from "../../entities/Project";
import { PriorityType, Task } from "../../entities/Task";
import { fetchProjectById, updateProject, deleteProject } from "../../utils/projectsService";
import { createTask, updateTask, deleteTask } from "../../utils/tasksService";
import { fetchAreas } from "../../utils/areasService";
import { isAuthError } from "../../utils/authUtils";
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/react/24/solid";
type PriorityStyles = Record<PriorityType, string> & { default: string };
const priorityStyles: PriorityStyles = {
high: 'bg-red-500',
medium: 'bg-yellow-500',
low: 'bg-green-500',
default: 'bg-gray-400',
};
const ProjectDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const areas = useStore((state) => state.areasStore.areas);
const [project, setProject] = useState<Project | undefined>(undefined);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
useEffect(() => {
const loadProjectData = async () => {
if (!id) {
console.error("Project ID is missing.");
return;
}
setLoading(true);
try {
fetchAreas();
const projectData = await fetchProjectById(id);
console.log("Project data received:", projectData);
console.log("Tasks in project:", projectData.tasks);
console.log("Tasks (capital T):", projectData.Tasks);
setProject(projectData);
// Handle both 'tasks' and 'Tasks' property names
const projectTasks = projectData.tasks || projectData.Tasks || [];
setTasks(projectTasks);
} catch (error) {
console.error("Error fetching project data:", error);
} finally {
setLoading(false);
}
};
loadProjectData();
}, [id, fetchAreas]);
const handleTaskCreate = async (taskName: string) => {
if (!project) {
console.error("Cannot create task: Project is missing");
throw new Error("Cannot create task: Project is missing");
}
try {
const newTask = await createTask({
name: taskName,
status: "not_started",
project_id: project.id,
});
setTasks((prevTasks) => [...prevTasks, newTask]);
} catch (err: any) {
console.error("Error creating task:", err);
// Check if it's an authentication error
if (isAuthError(err)) {
return;
}
throw err; // Re-throw to allow proper error handling by NewTask component
}
};
const handleTaskUpdate = async (updatedTask: Task) => {
if (!updatedTask.id) {
console.error("Cannot update task: Task ID is missing");
return;
}
try {
await updateTask(updatedTask.id, updatedTask);
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
} catch (err) {
console.error("Error updating task:", err);
}
};
const handleTaskDelete = async (taskId: number | undefined) => {
if (!taskId) {
console.error("Cannot delete task: Task ID is missing");
return;
}
try {
await deleteTask(taskId);
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
} catch (err) {
console.error("Error deleting task:", err);
}
};
const handleEditProject = () => {
setIsModalOpen(true);
};
const handleSaveProject = async (updatedProject: Project) => {
if (!updatedProject.id) {
console.error("Cannot save project: Project ID is missing");
return;
}
try {
const savedProject = await updateProject(updatedProject.id, updatedProject);
setProject(savedProject);
setIsModalOpen(false);
} catch (err) {
console.error("Error saving project:", err);
}
};
const handleDeleteProject = async () => {
if (!project?.id) {
console.error("Cannot delete project: Project ID is missing");
return;
}
try {
await deleteProject(project.id);
navigate("/projects");
} catch (err) {
console.error("Error deleting project:", err);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading project details...
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">{error}</div>
</div>
);
}
if (!project) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">Project not found.</div>
</div>
);
}
const activeTasks = tasks?.filter((task) => {
return typeof task.status === 'number' ? task.status !== 2 : task.status !== 'done';
}) || []; //TODO: Also add archived
const completedTasks = tasks?.filter((task) => {
return typeof task.status === 'number' ? task.status === 2 : task.status === 'done';
});
const displayTasks = showCompleted ? [...activeTasks, ...completedTasks] : activeTasks;
const formatProjectDueDate = (dateString: string) => {
const date = new Date(dateString);
const currentLang = i18n.language;
// Format based on language
const formatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
};
return date.toLocaleDateString(currentLang, formatOptions);
};
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Project Banner Image */}
{project.image_url && (
<div className="mb-6 rounded-lg overflow-hidden relative">
<img
src={project.image_url}
alt={project.name}
className="w-full h-48 object-cover"
/>
{/* Title Overlay */}
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
<h1 className="text-4xl md:text-5xl font-bold text-white text-center px-4 drop-shadow-lg">
{project.name}
</h1>
</div>
{/* Priority Indicator on Image */}
{project.priority && (
<div className="absolute top-3 left-3">
<div
className={`w-4 h-4 rounded-full border-2 border-white shadow-lg ${
priorityStyles[project.priority] || priorityStyles.default
}`}
title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div>
</div>
)}
{/* Edit/Delete Buttons on Image */}
<div className="absolute bottom-4 right-4 flex space-x-2">
<button
onClick={handleEditProject}
className="p-2 bg-black bg-opacity-50 text-white hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => setIsConfirmDialogOpen(true)}
className="p-2 bg-black bg-opacity-50 text-white hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
)}
{/* Project Metadata Box */}
{(project.description || project.area || project.due_date_at || (project.tags && project.tags.length > 0)) && (
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="grid gap-3">
{project.description && (
<div className="flex items-start">
<InformationCircleIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Description:</span>
<p className="text-sm text-gray-900 dark:text-gray-100 leading-relaxed mt-1">
{project.description}
</p>
</div>
</div>
)}
{project.area && (
<div className="flex items-center">
<Squares2X2Icon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Area:</span>
<span className="text-sm text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{project.area.name}
</span>
</div>
)}
{project.due_date_at && (
<div className="flex items-center">
<CalendarDaysIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Due Date:</span>
<span className="text-sm text-gray-900 dark:text-gray-100">
{formatProjectDueDate(project.due_date_at)}
</span>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div className="flex items-start">
<div className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5">
<svg fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
</div>
<div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Tags:</span>
<div className="flex flex-wrap gap-1 mt-1">
{project.tags.map((tag, index) => (
<span
key={index}
className="inline-block px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full"
>
{tag.name}
</span>
))}
</div>
</div>
</div>
)}
</div>
</div>
)}
{/* Project Header - Only show when no image */}
{!project.image_url && (
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<FolderIcon className="h-6 w-6 text-gray-500 mr-3" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100 mr-2">
{project.name}
</h2>
{/* Show priority indicator only when no image */}
{project.priority && (
<div
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${
priorityStyles[project.priority] || priorityStyles.default
}`}
title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div>
)}
</div>
<div className="flex space-x-2">
<button
onClick={handleEditProject}
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => setIsConfirmDialogOpen(true)}
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
)}
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Tasks</h3>
{completedTasks.length > 0 && (
<label className="flex items-center space-x-2 cursor-pointer">
<span className="text-sm text-gray-600 dark:text-gray-400">Show completed</span>
<div className="relative flex items-center">
<input
type="checkbox"
checked={showCompleted}
onChange={(e) => setShowCompleted(e.target.checked)}
className="sr-only"
/>
<div className={`w-10 h-5 rounded-full transition-colors ${
showCompleted ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
}`}>
<div className={`w-4 h-4 bg-white rounded-full shadow-md transform transition-transform duration-200 ease-in-out ${
showCompleted ? 'translate-x-5' : 'translate-x-0.5'
} translate-y-0.5`}></div>
</div>
</div>
</label>
)}
</div>
<NewTask onTaskCreate={handleTaskCreate} />
<div className="mt-2">
{displayTasks.length > 0 ? (
<TaskList
tasks={displayTasks}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={project ? [project] : []}
hideProjectName={true}
/>
) : (
<p className="text-gray-500 dark:text-gray-400">No tasks.</p>
)}
</div>
<ProjectModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveProject}
project={project}
areas={areas}
/>
{isConfirmDialogOpen && (
<ConfirmDialog
title="Delete Project"
message={`Are you sure you want to delete the project "${project.name}"?`}
onConfirm={handleDeleteProject}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
</div>
);
};
const priorityLabel = (priority: PriorityType) => {
switch (priority) {
case 'high':
return 'High';
case 'medium':
return 'Medium';
case 'low':
return 'Low';
default:
return '';
}
};
export default ProjectDetails;