Files
panel/src/components/ProjectTasksSection.js

595 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from "react";
import ProjectTaskForm from "./ProjectTaskForm";
import TaskStatusDropdownSimple from "./TaskStatusDropdownSimple";
import { Card, CardHeader, CardContent } from "./ui/Card";
import Button from "./ui/Button";
import Badge from "./ui/Badge";
import { formatDate } from "@/lib/utils";
export default function ProjectTasksSection({ projectId }) {
const [projectTasks, setProjectTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [taskNotes, setTaskNotes] = useState({});
const [newNote, setNewNote] = useState({});
const [loadingNotes, setLoadingNotes] = useState({});
const [showAddTaskModal, setShowAddTaskModal] = useState(false);
const [expandedDescriptions, setExpandedDescriptions] = useState({});
const [expandedNotes, setExpandedNotes] = useState({});
useEffect(() => {
const fetchProjectTasks = async () => {
try {
const res = await fetch(`/api/project-tasks?project_id=${projectId}`);
const tasks = await res.json();
setProjectTasks(tasks);
// Fetch notes for each task
const notesPromises = tasks.map(async (task) => {
try {
const notesRes = await fetch(`/api/task-notes?task_id=${task.id}`);
const notes = await notesRes.json();
return { taskId: task.id, notes };
} catch (error) {
console.error(`Failed to fetch notes for task ${task.id}:`, error);
return { taskId: task.id, notes: [] };
}
});
const notesResults = await Promise.all(notesPromises);
const notesMap = {};
notesResults.forEach(({ taskId, notes }) => {
notesMap[taskId] = notes;
});
setTaskNotes(notesMap);
} catch (error) {
console.error("Failed to fetch project tasks:", error);
} finally {
setLoading(false);
}
};
fetchProjectTasks();
}, [projectId]);
// Handle escape key to close modal
useEffect(() => {
const handleEscape = (e) => {
if (e.key === "Escape" && showAddTaskModal) {
setShowAddTaskModal(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [showAddTaskModal]);
// Prevent body scroll when modal is open and handle map z-index
useEffect(() => {
if (showAddTaskModal) {
// Prevent body scroll
document.body.style.overflow = "hidden";
// Find and temporarily lower z-index of leaflet containers
const leafletContainers = document.querySelectorAll(".leaflet-container");
leafletContainers.forEach((container) => {
container.style.zIndex = "1";
});
// Also handle navigation and other potential high z-index elements
const navElements = document.querySelectorAll("nav");
navElements.forEach((nav) => {
nav.style.position = "relative";
nav.style.zIndex = "1";
});
} else {
// Restore body scroll
document.body.style.overflow = "unset";
// Restore leaflet container z-index
const leafletContainers = document.querySelectorAll(".leaflet-container");
leafletContainers.forEach((container) => {
container.style.zIndex = "";
});
// Restore navigation z-index
const navElements = document.querySelectorAll("nav");
navElements.forEach((nav) => {
nav.style.position = "";
nav.style.zIndex = "";
});
}
// Cleanup function
return () => {
document.body.style.overflow = "unset";
const leafletContainers = document.querySelectorAll(".leaflet-container");
leafletContainers.forEach((container) => {
container.style.zIndex = "";
});
const navElements = document.querySelectorAll("nav");
navElements.forEach((nav) => {
nav.style.position = "";
nav.style.zIndex = "";
});
};
}, [showAddTaskModal]);
const refetchTasks = async () => {
try {
const res = await fetch(`/api/project-tasks?project_id=${projectId}`);
const tasks = await res.json();
setProjectTasks(tasks);
// Refresh notes for all tasks
const notesPromises = tasks.map(async (task) => {
try {
const notesRes = await fetch(`/api/task-notes?task_id=${task.id}`);
const notes = await notesRes.json();
return { taskId: task.id, notes };
} catch (error) {
console.error(`Failed to fetch notes for task ${task.id}:`, error);
return { taskId: task.id, notes: [] };
}
});
const notesResults = await Promise.all(notesPromises);
const notesMap = {};
notesResults.forEach(({ taskId, notes }) => {
notesMap[taskId] = notes;
});
setTaskNotes(notesMap);
} catch (error) {
console.error("Failed to fetch project tasks:", error);
}
};
const handleTaskAdded = () => {
refetchTasks(); // Refresh the list
setShowAddTaskModal(false); // Close the modal
};
const handleStatusChange = async (taskId, newStatus) => {
try {
const res = await fetch(`/api/project-tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
refetchTasks(); // Refresh the list
} else {
alert("Failed to update task status");
}
} catch (error) {
alert("Error updating task status");
}
};
const handleDeleteTask = async (taskId) => {
if (!confirm("Are you sure you want to delete this task?")) return;
try {
const res = await fetch(`/api/project-tasks/${taskId}`, {
method: "DELETE",
});
if (res.ok) {
refetchTasks(); // Refresh the list
} else {
alert("Failed to delete task");
}
} catch (error) {
alert("Error deleting task");
}
};
const handleAddNote = async (taskId) => {
const noteContent = newNote[taskId]?.trim();
if (!noteContent) return;
setLoadingNotes((prev) => ({ ...prev, [taskId]: true }));
try {
const res = await fetch("/api/task-notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: taskId,
note: noteContent,
}),
});
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 }));
setNewNote((prev) => ({ ...prev, [taskId]: "" }));
} else {
alert("Failed to add note");
}
} catch (error) {
alert("Error adding note");
} finally {
setLoadingNotes((prev) => ({ ...prev, [taskId]: false }));
}
};
const handleDeleteNote = async (noteId, taskId) => {
if (!confirm("Are you sure you want to delete this note?")) return;
try {
const res = await fetch(`/api/task-notes?note_id=${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("Failed to delete note");
}
} catch (error) {
alert("Error deleting note");
}
};
const getPriorityVariant = (priority) => {
switch (priority) {
case "urgent":
return "urgent";
case "high":
return "high";
case "normal":
return "normal";
case "low":
return "low";
default:
return "default";
}
};
const toggleDescription = (taskId) => {
setExpandedDescriptions((prev) => ({
...prev,
[taskId]: !prev[taskId],
}));
};
const toggleNotes = (taskId) => {
setExpandedNotes((prev) => ({
...prev,
[taskId]: !prev[taskId],
}));
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Project Tasks</h2>
<div className="flex items-center gap-3">
<Badge variant="default" className="text-sm">
{projectTasks.length} {projectTasks.length === 1 ? "task" : "tasks"}
</Badge>
<Button
variant="primary"
size="sm"
onClick={() => setShowAddTaskModal(true)}
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add Task
</Button>
</div>
</div>{" "}
{/* Add Task Modal */}
{showAddTaskModal && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center"
style={{ zIndex: 99999 }}
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowAddTaskModal(false);
}
}}
>
<div
className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
style={{ zIndex: 100000 }}
>
<div className="flex items-center justify-between p-6 border-b">
<h3 className="text-lg font-semibold text-gray-900">
Add New Task
</h3>
<button
onClick={() => setShowAddTaskModal(false)}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="p-6">
<ProjectTaskForm
projectId={projectId}
onTaskAdded={handleTaskAdded}
/>
</div>
</div>
</div>
)}
{/* Current Tasks */}
<Card>
<CardHeader>
<h3 className="text-lg font-medium text-gray-900">Current Tasks</h3>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-6 text-center">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/4 mx-auto mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-1/6 mx-auto"></div>
</div>
</div>
) : projectTasks.length === 0 ? (
<div className="p-6 text-center">
<div className="text-gray-400 mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
clipRule="evenodd"
/>
</svg>
</div>
<p className="text-gray-500 text-sm">
No tasks assigned to this project yet.
</p>
<p className="text-gray-400 text-xs mt-1">
Add a task above to get started.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Task
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Priority
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Max Wait
</th>{" "}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date Started
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{projectTasks.map((task) => (
<React.Fragment key={task.id}>
{/* Main task row */}
<tr className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-900">
{task.task_name}
</h4>
{task.description && (
<button
onClick={() => toggleDescription(task.id)}
className="text-gray-400 hover:text-gray-600 transition-colors"
title="Toggle description"
>
<svg
className={`w-4 h-4 transform transition-transform ${
expandedDescriptions[task.id]
? "rotate-180"
: ""
}`}
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>
)}
</div>
</td>
<td className="px-4 py-4">
<Badge
variant={getPriorityVariant(task.priority)}
size="sm"
>
{task.priority}
</Badge>
</td>
<td className="px-4 py-4 text-sm text-gray-600">
{task.max_wait_days} days
</td>{" "}
<td className="px-4 py-4 text-sm text-gray-600">
{task.date_started
? formatDate(task.date_started)
: "Not started"}
</td>{" "}
<td className="px-4 py-4">
<TaskStatusDropdownSimple
task={task}
size="sm"
onStatusChange={handleStatusChange}
/>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<button
onClick={() => toggleNotes(task.id)}
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
title={`${taskNotes[task.id]?.length || 0} notes`}
>
Notes ({taskNotes[task.id]?.length || 0})
</button>
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteTask(task.id)}
className="text-xs"
>
Delete
</Button>
</div>
</td>
</tr>
{/* Description row (expandable) */}
{task.description && expandedDescriptions[task.id] && (
<tr className="bg-blue-50">
<td colSpan="6" className="px-4 py-3">
<div className="text-sm text-gray-700">
<span className="font-medium text-gray-900">
Description:
</span>
<p className="mt-1">{task.description}</p>
</div>
</td>
</tr>
)}{" "}
{/* Notes row (expandable) */}
{expandedNotes[task.id] && (
<tr className="bg-gray-50">
<td colSpan="6" className="px-4 py-4">
<div className="space-y-3">
<h5 className="text-sm font-medium text-gray-900">
Notes ({taskNotes[task.id]?.length || 0})
</h5>
{/* Existing Notes */}
{taskNotes[task.id] &&
taskNotes[task.id].length > 0 && (
<div className="space-y-2">
{taskNotes[task.id].map((note) => (
<div
key={note.note_id}
className={`p-3 rounded border flex justify-between items-start ${
note.is_system
? "bg-blue-50 border-blue-200"
: "bg-white border-gray-200"
}`}
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{note.is_system && (
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
System
</span>
)}
</div>
<p className="text-sm text-gray-800">
{note.note}
</p>{" "}
<p className="text-xs text-gray-500 mt-1">
{formatDate(note.note_date, {
includeTime: true,
})}
</p>
</div>
{!note.is_system && (
<button
onClick={() =>
handleDeleteNote(
note.note_id,
task.id
)
}
className="ml-2 text-red-500 hover:text-red-700 text-xs font-bold"
title="Delete note"
>
×
</button>
)}
</div>
))}
</div>
)}
{/* Add New Note */}
<div className="flex gap-2">
<input
type="text"
placeholder="Add a note..."
value={newNote[task.id] || ""}
onChange={(e) =>
setNewNote((prev) => ({
...prev,
[task.id]: e.target.value,
}))
}
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onKeyPress={(e) => {
if (e.key === "Enter") {
handleAddNote(task.id);
}
}}
/>
<Button
size="sm"
variant="primary"
onClick={() => handleAddNote(task.id)}
disabled={
loadingNotes[task.id] ||
!newNote[task.id]?.trim()
}
>
{loadingNotes[task.id] ? "Adding..." : "Add"}
</Button>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}