feat: Implement project search functionality and task management features

- Added search functionality to the Project List page, allowing users to filter projects by name, WP, plot, or investment number.
- Created a new Project Tasks page to manage tasks across all projects, including filtering by status and priority.
- Implemented task status updates and deletion functionality.
- Added a new Task Template Edit page for modifying existing task templates.
- Enhanced Task Template Form to include a description field and loading state during submission.
- Updated UI components for better user experience, including badges for task status and priority.
- Introduced new database queries for managing contracts and projects, including fetching tasks related to projects.
- Added migrations to the database for new columns and improved data handling.
This commit is contained in:
Chop
2025-06-02 23:21:04 +02:00
parent b06aad72b8
commit 35569846bc
24 changed files with 2019 additions and 169 deletions

487
src/app/tasks/page.js Normal file
View File

@@ -0,0 +1,487 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
import { Input } from "@/components/ui/Input";
import { formatDistanceToNow, parseISO } from "date-fns";
export default function ProjectTasksPage() {
const [allTasks, setAllTasks] = useState([]);
const [filteredTasks, setFilteredTasks] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [priorityFilter, setPriorityFilter] = useState("all");
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAllTasks = async () => {
try {
const res = await fetch("/api/all-project-tasks");
const tasks = await res.json();
setAllTasks(tasks);
setFilteredTasks(tasks);
} catch (error) {
console.error("Failed to fetch all project tasks:", error);
} finally {
setLoading(false);
}
};
fetchAllTasks();
}, []);
// Filter tasks based on search term and filters
useEffect(() => {
let filtered = allTasks;
// Apply search filter
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter((task) => {
return (
task.task_name?.toLowerCase().includes(searchLower) ||
task.project_name?.toLowerCase().includes(searchLower) ||
task.wp?.toLowerCase().includes(searchLower) ||
task.plot?.toLowerCase().includes(searchLower)
);
});
}
// Apply status filter
if (statusFilter !== "all") {
filtered = filtered.filter((task) => task.status === statusFilter);
}
// Apply priority filter
if (priorityFilter !== "all") {
filtered = filtered.filter((task) => task.priority === priorityFilter);
}
setFilteredTasks(filtered);
}, [searchTerm, statusFilter, priorityFilter, allTasks]);
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) {
// Update the task in the local state
setAllTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === taskId ? { ...task, status: newStatus } : task
)
);
} 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) {
// Remove the task from local state
setAllTasks((prevTasks) =>
prevTasks.filter((task) => task.id !== taskId)
);
} else {
alert("Failed to delete task");
}
} catch (error) {
alert("Error deleting task");
}
};
const getPriorityVariant = (priority) => {
switch (priority) {
case "high":
return "danger";
case "normal":
return "secondary";
case "low":
return "success";
default:
return "secondary";
}
};
const getStatusVariant = (status) => {
switch (status) {
case "completed":
return "success";
case "in_progress":
return "warning";
case "pending":
return "secondary";
default:
return "secondary";
}
};
const getStatusDisplayName = (status) => {
switch (status) {
case "in_progress":
return "In Progress";
case "completed":
return "Completed";
case "pending":
return "Pending";
default:
return status;
}
};
const statusCounts = {
all: allTasks.length,
pending: allTasks.filter((task) => task.status === "pending").length,
in_progress: allTasks.filter((task) => task.status === "in_progress")
.length,
completed: allTasks.filter((task) => task.status === "completed").length,
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto p-6">
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading tasks...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto p-6">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Project Tasks</h1>
<p className="text-gray-600 mt-1">
Monitor and manage tasks across all projects
</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<div className="p-2 bg-blue-100 rounded-lg">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
Total Tasks
</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.all}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<div className="p-2 bg-gray-100 rounded-lg">
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Pending</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.pending}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<div className="p-2 bg-yellow-100 rounded-lg">
<svg
className="w-6 h-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
In Progress
</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.in_progress}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<div className="p-2 bg-green-100 rounded-lg">
<svg
className="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Completed</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.completed}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card className="mb-6">
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search Tasks
</label>
<Input
type="text"
placeholder="Search by task name, project name, WP, or plot..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Status
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Priority
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
>
<option value="all">All Priorities</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
</div>
</div>
</CardContent>
</Card>
{/* Tasks List */}
{filteredTasks.length === 0 ? (
<Card>
<CardContent className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg
className="w-16 h-16 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>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No tasks found
</h3>
<p className="text-gray-500 mb-6">
{searchTerm ||
statusFilter !== "all" ||
priorityFilter !== "all"
? "Try adjusting your filters to see more tasks"
: "No tasks have been created yet"}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{filteredTasks.map((task) => (
<Card key={task.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900">
{task.task_name}
</h3>
<Badge
variant={getStatusVariant(task.status)}
size="sm"
>
{getStatusDisplayName(task.status)}
</Badge>
<Badge
variant={getPriorityVariant(task.priority)}
size="sm"
>
{task.priority}
</Badge>
{task.task_type === "template" && (
<Badge variant="primary" size="sm">
Template
</Badge>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<p className="text-sm text-gray-600">Project</p>
<p className="font-medium text-gray-900">
{task.project_name}
</p>
</div>
{task.wp && (
<div>
<p className="text-sm text-gray-600">WP</p>
<p className="font-medium text-gray-900">
{task.wp}
</p>
</div>
)}
{task.plot && (
<div>
<p className="text-sm text-gray-600">Plot</p>
<p className="font-medium text-gray-900">
{task.plot}
</p>
</div>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>
Added{" "}
{formatDistanceToNow(parseISO(task.date_added), {
addSuffix: true,
})}
</span>
{task.max_wait_days > 0 && (
<span>Max wait: {task.max_wait_days} days</span>
)}
</div>
</div>
<div className="flex items-center space-x-2 ml-6">
{task.status !== "completed" && (
<select
className="text-sm px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value={task.status}
onChange={(e) =>
handleStatusChange(task.id, e.target.value)
}
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</select>
)}
<Link href={`/projects/${task.project_id}`}>
<Button variant="outline" size="sm">
View Project
</Button>
</Link>
<Button
variant="secondary"
size="sm"
onClick={() => handleDeleteTask(task.id)}
>
Delete
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}