feat: Add ProjectTasksPage and ProjectTasksDashboard components with task categorization and filtering
This commit is contained in:
519
src/components/ProjectTasksDashboard.js
Normal file
519
src/components/ProjectTasksDashboard.js
Normal file
@@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardHeader, CardContent } from "./ui/Card";
|
||||
import Button from "./ui/Button";
|
||||
import Badge from "./ui/Badge";
|
||||
import SearchBar from "./ui/SearchBar";
|
||||
import { Select } from "./ui/Input";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
differenceInCalendarDays,
|
||||
parseISO,
|
||||
formatDistanceToNow,
|
||||
} from "date-fns";
|
||||
|
||||
export default function ProjectTasksDashboard() {
|
||||
const [allTasks, setAllTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllTasks = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/all-project-tasks");
|
||||
const tasks = await res.json();
|
||||
setAllTasks(tasks);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch project tasks:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAllTasks();
|
||||
}, []);
|
||||
// Calculate task status based on date_added and max_wait_days
|
||||
const getTaskStatus = (task) => {
|
||||
if (task.status === "completed" || task.status === "cancelled") {
|
||||
return { type: "completed", days: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle different date formats
|
||||
const addedDate = task.date_added.includes("T")
|
||||
? parseISO(task.date_added)
|
||||
: new Date(task.date_added + "T00:00:00");
|
||||
|
||||
const daysElapsed = differenceInCalendarDays(new Date(), addedDate);
|
||||
const maxWaitDays = task.max_wait_days || 0;
|
||||
const daysOverdue = daysElapsed - maxWaitDays;
|
||||
|
||||
if (daysOverdue > 0) {
|
||||
return { type: "overdue", days: daysOverdue };
|
||||
} else if (maxWaitDays - daysElapsed <= 2) {
|
||||
return { type: "due_soon", days: maxWaitDays - daysElapsed };
|
||||
} else {
|
||||
return { type: "pending", days: maxWaitDays - daysElapsed };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing date:", task.date_added, error);
|
||||
return { type: "pending", days: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
// Categorize tasks
|
||||
const categorizeTasks = () => {
|
||||
const now = new Date();
|
||||
const categories = {
|
||||
overdue: [],
|
||||
due_soon: [],
|
||||
pending: [],
|
||||
in_progress: [],
|
||||
recent_completed: [],
|
||||
};
|
||||
allTasks.forEach((task) => {
|
||||
const taskStatus = getTaskStatus(task);
|
||||
try {
|
||||
const addedDate = task.date_added.includes("T")
|
||||
? parseISO(task.date_added)
|
||||
: new Date(task.date_added + "T00:00:00");
|
||||
const daysAgo = differenceInCalendarDays(now, addedDate); // First check if task is overdue (regardless of status)
|
||||
if (
|
||||
taskStatus.type === "overdue" &&
|
||||
task.status !== "completed" &&
|
||||
task.status !== "cancelled"
|
||||
) {
|
||||
categories.overdue.push({ ...task, statusInfo: taskStatus });
|
||||
}
|
||||
// Then check if it's due soon (regardless of status)
|
||||
else if (
|
||||
taskStatus.type === "due_soon" &&
|
||||
task.status !== "completed" &&
|
||||
task.status !== "cancelled"
|
||||
) {
|
||||
categories.due_soon.push({ ...task, statusInfo: taskStatus });
|
||||
}
|
||||
// Then categorize by actual status
|
||||
else if (task.status === "pending") {
|
||||
categories.pending.push({ ...task, statusInfo: taskStatus });
|
||||
} else if (task.status === "in_progress") {
|
||||
categories.in_progress.push({ ...task, statusInfo: taskStatus });
|
||||
} else if (task.status === "completed" || task.status === "cancelled") {
|
||||
// Show all completed/cancelled tasks (most recent activity)
|
||||
categories.recent_completed.push({ ...task, statusInfo: taskStatus });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing task:", task, error);
|
||||
// Still add to appropriate category if there's an error
|
||||
if (task.status === "pending") {
|
||||
categories.pending.push({
|
||||
...task,
|
||||
statusInfo: { type: "pending", days: 0 },
|
||||
});
|
||||
} else if (task.status === "in_progress") {
|
||||
categories.in_progress.push({
|
||||
...task,
|
||||
statusInfo: { type: "pending", days: 0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}); // Sort each category
|
||||
categories.overdue.sort((a, b) => b.statusInfo.days - a.statusInfo.days);
|
||||
categories.due_soon.sort((a, b) => a.statusInfo.days - b.statusInfo.days);
|
||||
categories.pending.sort((a, b) => {
|
||||
try {
|
||||
const dateA = a.date_added.includes("T")
|
||||
? parseISO(a.date_added)
|
||||
: new Date(a.date_added + "T00:00:00");
|
||||
const dateB = b.date_added.includes("T")
|
||||
? parseISO(b.date_added)
|
||||
: new Date(b.date_added + "T00:00:00");
|
||||
return dateB - dateA;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
categories.in_progress.sort((a, b) => {
|
||||
try {
|
||||
const dateA = a.date_added.includes("T")
|
||||
? parseISO(a.date_added)
|
||||
: new Date(a.date_added + "T00:00:00");
|
||||
const dateB = b.date_added.includes("T")
|
||||
? parseISO(b.date_added)
|
||||
: new Date(b.date_added + "T00:00:00");
|
||||
return dateB - dateA;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
categories.recent_completed.sort((a, b) => {
|
||||
try {
|
||||
const dateA = a.date_added.includes("T")
|
||||
? parseISO(a.date_added)
|
||||
: new Date(a.date_added + "T00:00:00");
|
||||
const dateB = b.date_added.includes("T")
|
||||
? parseISO(b.date_added)
|
||||
: new Date(b.date_added + "T00:00:00");
|
||||
return dateB - dateA;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
const categorizedTasks = categorizeTasks();
|
||||
|
||||
// Filter tasks based on search and filter
|
||||
const filterTasks = (tasks) => {
|
||||
if (!searchTerm) return tasks;
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.wp.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
};
|
||||
const getVisibleTasks = () => {
|
||||
switch (filter) {
|
||||
case "overdue":
|
||||
return filterTasks(categorizedTasks.overdue);
|
||||
case "due_soon":
|
||||
return filterTasks(categorizedTasks.due_soon);
|
||||
case "pending":
|
||||
return filterTasks(categorizedTasks.pending);
|
||||
case "in_progress":
|
||||
return filterTasks(categorizedTasks.in_progress);
|
||||
case "completed":
|
||||
return filterTasks(categorizedTasks.recent_completed);
|
||||
default:
|
||||
return {
|
||||
overdue: filterTasks(categorizedTasks.overdue),
|
||||
due_soon: filterTasks(categorizedTasks.due_soon),
|
||||
pending: filterTasks(categorizedTasks.pending),
|
||||
in_progress: filterTasks(categorizedTasks.in_progress),
|
||||
recent_completed: filterTasks(categorizedTasks.recent_completed),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const visibleTasks = getVisibleTasks();
|
||||
|
||||
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) {
|
||||
// Refresh tasks
|
||||
const res2 = await fetch("/api/all-project-tasks");
|
||||
const tasks = await res2.json();
|
||||
setAllTasks(tasks);
|
||||
} else {
|
||||
alert("Failed to update task status");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error updating task status");
|
||||
}
|
||||
};
|
||||
|
||||
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 getStatusBadgeVariant = (status) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "in_progress":
|
||||
return "primary";
|
||||
case "pending":
|
||||
return "warning";
|
||||
case "cancelled":
|
||||
return "danger";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getOverdueBadgeVariant = (days) => {
|
||||
if (days > 7) return "danger";
|
||||
if (days > 3) return "warning";
|
||||
return "high";
|
||||
};
|
||||
|
||||
const TaskCard = ({ task, showStatusBadge = false }) => (
|
||||
<div className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow bg-white">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||
{task.task_name}
|
||||
</h4>
|
||||
<Badge variant={getPriorityVariant(task.priority)} size="sm">
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{showStatusBadge && (
|
||||
<Badge variant={getStatusBadgeVariant(task.status)} size="sm">
|
||||
{task.status.replace("_", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-600 mb-2">
|
||||
<Link
|
||||
href={`/projects/${task.project_id}`}
|
||||
className="font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{task.project_name}
|
||||
</Link>
|
||||
<span>WP: {task.wp}</span>
|
||||
<span>Plot: {task.plot}</span>
|
||||
</div>{" "}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
Added:{" "}
|
||||
{(() => {
|
||||
try {
|
||||
const addedDate = task.date_added.includes("T")
|
||||
? parseISO(task.date_added)
|
||||
: new Date(task.date_added + "T00:00:00");
|
||||
return formatDistanceToNow(addedDate, { addSuffix: true });
|
||||
} catch (error) {
|
||||
return task.date_added;
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
<span>Max wait: {task.max_wait_days} days</span>
|
||||
<span>Type: {task.task_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-col items-end gap-2">
|
||||
{task.statusInfo && task.statusInfo.type === "overdue" && (
|
||||
<Badge
|
||||
variant={getOverdueBadgeVariant(task.statusInfo.days)}
|
||||
size="sm"
|
||||
>
|
||||
{task.statusInfo.days} days overdue
|
||||
</Badge>
|
||||
)}
|
||||
{task.statusInfo && task.statusInfo.type === "due_soon" && (
|
||||
<Badge variant="warning" size="sm">
|
||||
Due in {task.statusInfo.days} days
|
||||
</Badge>
|
||||
)}
|
||||
{(task.status === "pending" || task.status === "in_progress") && (
|
||||
<select
|
||||
value={task.status}
|
||||
onChange={(e) => handleStatusChange(task.id, e.target.value)}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionCard = ({ title, tasks, variant = "default", count }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
|
||||
<Badge variant={variant} size="md">
|
||||
{count || tasks.length} {tasks.length === 1 ? "task" : "tasks"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p className="text-sm">No tasks in this category</p>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
showStatusBadge={
|
||||
title === "📊 Recent Activity" || title === "🔄 In Progress"
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
const filterOptions = [
|
||||
{ value: "all", label: "All Categories" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
{ value: "due_soon", label: "Due Soon" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "in_progress", label: "In Progress" },
|
||||
{ value: "completed", label: "Recent Activity" },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-64 bg-gray-200 rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{" "}
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{categorizedTasks.overdue.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Overdue</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{categorizedTasks.due_soon.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Due Soon</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{categorizedTasks.pending.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Pending</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{categorizedTasks.in_progress.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">In Progress</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{categorizedTasks.recent_completed.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Recent Activity</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Search and Filters */}
|
||||
<SearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search tasks, projects, or WP..."
|
||||
resultsCount={
|
||||
filter === "all"
|
||||
? allTasks.length
|
||||
: Array.isArray(visibleTasks)
|
||||
? visibleTasks.length
|
||||
: 0
|
||||
}
|
||||
resultsText="tasks"
|
||||
filters={
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">View:</label>
|
||||
<Select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="min-w-[150px]"
|
||||
>
|
||||
{filterOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
/>{" "}
|
||||
{/* Task Sections */}
|
||||
{filter === "all" ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<SectionCard
|
||||
title="🚨 Overdue Tasks"
|
||||
tasks={visibleTasks.overdue}
|
||||
variant="danger"
|
||||
/>
|
||||
<SectionCard
|
||||
title="⚠️ Due Soon"
|
||||
tasks={visibleTasks.due_soon}
|
||||
variant="warning"
|
||||
/>
|
||||
<SectionCard
|
||||
title="📋 Pending Tasks"
|
||||
tasks={visibleTasks.pending}
|
||||
variant="primary"
|
||||
/>
|
||||
<SectionCard
|
||||
title="🔄 In Progress"
|
||||
tasks={visibleTasks.in_progress}
|
||||
variant="secondary"
|
||||
/>
|
||||
<SectionCard
|
||||
title="📊 Recent Activity"
|
||||
tasks={visibleTasks.recent_completed}
|
||||
variant="success"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl">
|
||||
<SectionCard
|
||||
title={
|
||||
filterOptions.find((f) => f.value === filter)?.label || "Tasks"
|
||||
}
|
||||
tasks={Array.isArray(visibleTasks) ? visibleTasks : []}
|
||||
variant={
|
||||
filter === "overdue"
|
||||
? "danger"
|
||||
: filter === "due_soon"
|
||||
? "warning"
|
||||
: filter === "pending"
|
||||
? "primary"
|
||||
: filter === "in_progress"
|
||||
? "secondary"
|
||||
: "success"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user