feat: Add ProjectTasksPage and ProjectTasksDashboard components with task categorization and filtering

This commit is contained in:
Chop
2025-06-03 00:14:17 +02:00
parent a1261b2169
commit a9afebdda5
3 changed files with 535 additions and 1 deletions

View File

@@ -0,0 +1,15 @@
import ProjectTasksDashboard from "@/components/ProjectTasksDashboard";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
export default function ProjectTasksPage() {
return (
<PageContainer>
<PageHeader
title="Project Tasks"
description="Monitor pending tasks, overdue items, and recent activity across all projects"
/>
<ProjectTasksDashboard />
</PageContainer>
);
}

View 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>
);
}

View File

@@ -17,7 +17,7 @@ const Navigation = () => {
{ href: "/", label: "Dashboard" }, { href: "/", label: "Dashboard" },
{ href: "/projects", label: "Projects" }, { href: "/projects", label: "Projects" },
{ href: "/tasks/templates", label: "Task Templates" }, { href: "/tasks/templates", label: "Task Templates" },
{ href: "/tasks", label: "Project Tasks" }, { href: "/project-tasks", label: "Project Tasks" },
{ href: "/contracts", label: "Contracts" }, { href: "/contracts", label: "Contracts" },
]; ];