353 lines
10 KiB
TypeScript
353 lines
10 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
import { useParams, useNavigate, Link } from "react-router-dom";
|
|
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 { useDataContext } from "../../contexts/DataContext";
|
|
import NewTask from "../Task/NewTask";
|
|
import { Project } from "../../entities/Project";
|
|
import { PriorityType, Task } from "../../entities/Task";
|
|
|
|
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 { updateTask, deleteTask, updateProject, deleteProject } = useDataContext();
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
const { areas } = useDataContext();
|
|
|
|
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 projectTitle = project?.name || "Project";
|
|
|
|
const [isCompletedOpen, setIsCompletedOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const fetchProject = async () => {
|
|
try {
|
|
const response = await fetch(`/api/project/${id}`, {
|
|
credentials: "include",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
setProject(data);
|
|
setTasks(data.tasks || []);
|
|
} else {
|
|
throw new Error(data.error || "Failed to fetch project.");
|
|
}
|
|
} catch (error) {
|
|
setError((error as Error).message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchProject();
|
|
}, [id]);
|
|
|
|
const handleTaskCreate = async (taskName: string) => {
|
|
if (!project || project.id === undefined) {
|
|
console.error("Cannot create task: Project or Project ID is missing");
|
|
return;
|
|
}
|
|
|
|
const taskPayload = {
|
|
name: taskName,
|
|
status: "not_started",
|
|
project_id: project.id,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`/api/task`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify(taskPayload),
|
|
});
|
|
|
|
const newTask = await response.json();
|
|
if (response.ok) {
|
|
setTasks([...tasks, newTask]);
|
|
} else {
|
|
throw new Error(newTask.error || "Failed to create task");
|
|
}
|
|
} catch (err) {
|
|
console.error("Error creating task:", err);
|
|
}
|
|
};
|
|
|
|
const handleTaskUpdate = async (updatedTask: Task) => {
|
|
if (updatedTask.id === undefined) {
|
|
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 === undefined) {
|
|
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 || updatedProject.id === undefined) {
|
|
console.error("Cannot save project: Project or 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 || project.id === undefined) {
|
|
console.error("Cannot delete project: Project or 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 => task.status !== 'done');
|
|
const completedTasks = tasks.filter(task => task.status === 'done');
|
|
|
|
const toggleCompleted = () => {
|
|
setIsCompletedOpen(!isCompletedOpen);
|
|
};
|
|
|
|
return (
|
|
<div className="flex justify-center px-4 lg:px-2">
|
|
<div className="w-full max-w-5xl">
|
|
{/* Project Header */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div className="flex items-center">
|
|
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
|
|
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100 mr-2">
|
|
{projectTitle}
|
|
</h2>
|
|
{/* Priority Circle placed after the title */}
|
|
{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">
|
|
{/* Edit Project Button */}
|
|
<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>
|
|
|
|
{/* Delete Project 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>
|
|
|
|
{/* Project Area */}
|
|
{project.area && (
|
|
<div className="flex items-center mb-4">
|
|
<Squares2X2Icon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
|
<Link
|
|
to={`/projects/?area_id=${project.area.id}`}
|
|
className="text-gray-600 dark:text-gray-400 hover:underline"
|
|
>
|
|
{project.area.name.toUpperCase()}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Project Description */}
|
|
{project.description && (
|
|
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
|
{project.description}
|
|
</p>
|
|
)}
|
|
|
|
{/* New Task Form */}
|
|
<NewTask
|
|
onTaskCreate={handleTaskCreate}
|
|
/>
|
|
|
|
{/* Active Tasks */}
|
|
<div className="mt-2">
|
|
{activeTasks.length > 0 ? (
|
|
<TaskList
|
|
tasks={activeTasks}
|
|
onTaskUpdate={handleTaskUpdate}
|
|
onTaskDelete={handleTaskDelete}
|
|
projects={project ? [project] : []}
|
|
/>
|
|
) : (
|
|
<p className="text-gray-500 dark:text-gray-400">No active tasks.</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Collapsible Completed Tasks */}
|
|
<div className="mt-6">
|
|
<button
|
|
onClick={toggleCompleted}
|
|
className="flex items-center justify-between w-full px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-md focus:outline-none"
|
|
>
|
|
<span className="text-sm uppercase font-medium">Completed Tasks</span>
|
|
<svg
|
|
className={`w-6 h-6 transform transition-transform duration-200 ${
|
|
isCompletedOpen ? "rotate-180" : "rotate-0"
|
|
}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{isCompletedOpen && (
|
|
<div className="mt-4">
|
|
{completedTasks.length > 0 ? (
|
|
<TaskList
|
|
tasks={completedTasks}
|
|
onTaskUpdate={handleTaskUpdate}
|
|
onTaskDelete={handleTaskDelete}
|
|
projects={project ? [project] : []}
|
|
/>
|
|
) : (
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|
No completed tasks.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modals */}
|
|
<ProjectModal
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
onSave={handleSaveProject}
|
|
project={project}
|
|
areas={areas}
|
|
/>
|
|
|
|
{/* Confirm Delete Dialog */}
|
|
{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;
|