Files
panel/src/components/ProjectTasksList.js

672 lines
20 KiB
JavaScript

"use client";
import { useState, useEffect, Fragment } from "react";
import { Card, CardHeader, CardContent } from "./ui/Card";
import Button from "./ui/Button";
import Badge from "./ui/Badge";
import TaskStatusDropdownSimple from "./TaskStatusDropdownSimple";
import TaskCommentsModal from "./TaskCommentsModal";
import SearchBar from "./ui/SearchBar";
import { Select } from "./ui/Input";
import Link from "next/link";
import {
differenceInCalendarDays,
parseISO,
formatDistanceToNow,
} from "date-fns";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { useSession } from "next-auth/react";
export default function ProjectTasksList() {
const { t } = useTranslation();
const { data: session } = useSession();
const [allTasks, setAllTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [groupBy, setGroupBy] = useState("none");
const [selectedTask, setSelectedTask] = useState(null);
const [showCommentsModal, setShowCommentsModal] = useState(false);
const [mine, setMine] = useState(true);
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();
}, []);
// Handle escape key to close modal
useEffect(() => {
const handleEscape = (e) => {
if (e.key === "Escape" && showCommentsModal) {
handleCloseComments();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [showCommentsModal]); // 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 {
// For in-progress tasks, use date_started if available and valid, otherwise fall back to date_added
let referenceDate;
console.log(task.date_started);
if (
task.status === "in_progress" &&
task.date_started &&
task.date_started.trim() !== ""
) {
// Handle the format "2025-06-20 08:40:38"
referenceDate = new Date(task.date_started);
} else {
// Handle date_added format
referenceDate = task.date_added.includes("T")
? parseISO(task.date_added)
: new Date(task.date_added);
}
// Check if date is valid
if (isNaN(referenceDate.getTime())) {
throw new Error("Invalid date");
}
const daysElapsed = differenceInCalendarDays(new Date(), referenceDate);
const maxWaitDays = task.max_wait_days || 0;
const daysRemaining = maxWaitDays - daysElapsed;
if (task.status === "in_progress") {
if (daysRemaining < 0) {
return {
type: "overdue",
days: Math.abs(daysRemaining),
daysRemaining: daysRemaining,
};
} else {
return {
type: "in_progress",
days: daysRemaining,
daysRemaining: daysRemaining,
};
}
}
// For pending tasks, use original logic
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,
task.date_started,
error
);
return { type: "pending", days: 0 };
}
};
// Group tasks by status
const groupTasksByStatus = () => {
const groups = {
pending: [],
in_progress: [],
completed: [],
};
allTasks.forEach((task) => {
const statusInfo = getTaskStatus(task);
const taskWithStatus = { ...task, statusInfo };
if (task.status === "completed" || task.status === "cancelled") {
groups.completed.push(taskWithStatus);
} else if (task.status === "in_progress") {
groups.in_progress.push(taskWithStatus);
} else if (task.status === "pending") {
groups.pending.push(taskWithStatus);
}
// not_started tasks are not displayed in the UI
});
// Sort pending tasks by date_added (newest first)
groups.pending.sort((a, b) => {
try {
const dateA = new Date(a.date_added);
const dateB = new Date(b.date_added);
return dateB - dateA; // Newest first
} catch (error) {
return 0;
}
});
// Sort in_progress tasks by time left (urgent first - less time left comes first)
groups.in_progress.sort((a, b) => {
// If both have valid time remaining, sort by days remaining (ascending - urgent first)
if (
!isNaN(a.statusInfo.daysRemaining) &&
!isNaN(b.statusInfo.daysRemaining)
) {
return a.statusInfo.daysRemaining - b.statusInfo.daysRemaining;
}
// If one has invalid time, sort by date_started as fallback
try {
const dateA = a.date_started
? new Date(a.date_started)
: new Date(a.date_added);
const dateB = b.date_started
? new Date(b.date_started)
: new Date(b.date_added);
return dateA - dateB; // Oldest started first
} catch (error) {
return 0;
}
}); // Sort completed tasks by date_completed if available, otherwise by date_added (most recently completed first)
groups.completed.sort((a, b) => {
try {
// Try to use date_completed first
if (a.date_completed && b.date_completed) {
const dateA = new Date(a.date_completed);
const dateB = new Date(b.date_completed);
return dateB - dateA; // Most recently completed first
}
// If only one has date_completed, prioritize it
if (a.date_completed && !b.date_completed) return -1;
if (!a.date_completed && b.date_completed) return 1;
// Fall back to date_added for both
const dateA = new Date(a.date_added);
const dateB = new Date(b.date_added);
return dateB - dateA; // Newest first
} catch (error) {
return 0;
}
});
return groups;
};
const taskGroups = groupTasksByStatus();
// Filter tasks based on search term
const filterTasks = (tasks) => {
let filtered = tasks;
// Apply mine filter
if (mine && session?.user?.id) {
filtered = filtered.filter(task => task.assigned_to === session.user.id);
}
// Apply search term
if (searchTerm) {
filtered = filtered.filter(
(task) =>
task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.city?.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.address?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
};
// Group tasks by task name when groupBy is set to "task_name"
const groupTasksByName = (tasks) => {
if (groupBy !== "task_name") return { [t("tasks.allTasks")]: tasks };
const groups = {};
tasks.forEach((task) => {
const taskName = task.task_name;
if (!groups[taskName]) {
groups[taskName] = [];
}
groups[taskName].push(task);
});
return groups;
};
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(t("errors.generic"));
}
} catch (error) {
alert(t("errors.generic"));
}
};
const handleShowComments = (task) => {
setSelectedTask(task);
setShowCommentsModal(true);
};
const handleCloseComments = () => {
setShowCommentsModal(false);
setSelectedTask(null);
};
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 getOverdueBadgeVariant = (days) => {
if (days > 7) return "danger";
if (days > 3) return "warning";
return "high";
};
const TaskRow = ({ task, showTimeLeft = false, showMaxWait = true }) => (
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-gray-100">{task.task_name}</span>
<Badge variant={getPriorityVariant(task.priority)} size="sm">
{t(`tasks.${task.priority}`)}
</Badge>
</div>
</td>
<td className="px-4 py-3">
{" "}
<Link
href={`/projects/${task.project_id}`}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium"
>
{task.project_name}
</Link>
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{task.city || "N/A"}</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{task.address || "N/A"}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{task.assigned_to_name ? (
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">{task.assigned_to_name}</div>
{/* <div className="text-xs text-gray-500">
{task.assigned_to_email}
</div> */}
</div>
) : (
<span className="text-gray-400 dark:text-gray-500 italic">{t("projects.unassigned")}</span>
)}
</td>
{showTimeLeft && (
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{task.statusInfo && task.statusInfo.type === "in_progress" && (
<Badge
variant={
task.statusInfo.daysRemaining <= 2 ? "warning" : "secondary"
}
size="sm"
>
{!isNaN(task.statusInfo.daysRemaining)
? task.statusInfo.daysRemaining > 0
? `${task.statusInfo.daysRemaining}${t("tasks.daysLeft")}`
: `${Math.abs(task.statusInfo.daysRemaining)}${t("tasks.daysOverdue")}`
: t("common.loading")}
</Badge>
)}
{task.statusInfo &&
task.statusInfo.type === "overdue" &&
task.status === "in_progress" && (
<Badge variant="danger" size="sm">
{!isNaN(task.statusInfo.daysRemaining)
? `${Math.abs(task.statusInfo.daysRemaining)}${t("tasks.daysOverdue")}`
: t("tasks.overdue")}
</Badge>
)}
</div>
</td>
)}
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{task.status === "completed" && task.date_completed ? (
<div>
<div>
{t("taskStatus.completed")}:{" "}
{(() => {
try {
const completedDate = new Date(task.date_completed);
return formatDistanceToNow(completedDate, {
addSuffix: true,
});
} catch (error) {
return task.date_completed;
}
})()}
</div>
</div>
) : task.status === "in_progress" && task.date_started ? (
<div>
<div>
{t("tasks.dateStarted")}:{" "}
{(() => {
try {
const startedDate = new Date(task.date_started);
return formatDistanceToNow(startedDate, { addSuffix: true });
} catch (error) {
return task.date_started;
}
})()}
</div>
</div>
) : (
(() => {
try {
const addedDate = task.date_added.includes("T")
? parseISO(task.date_added)
: new Date(task.date_added);
return formatDistanceToNow(addedDate, { addSuffix: true });
} catch (error) {
return task.date_added;
}
})()
)}
</td>
{showMaxWait && (
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{task.max_wait_days} {t("tasks.days")}
</td>
)}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<TaskStatusDropdownSimple
task={task}
size="sm"
onStatusChange={handleStatusChange}
/>
<Button
variant="secondary"
size="sm"
onClick={() => handleShowComments(task)}
title={t("tasks.comments")}
>
💬
</Button>
</div>
</td>
</tr>
);
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false, showMaxWait = true }) => {
const filteredTasks = filterTasks(tasks);
const groupedTasks = groupTasksByName(filteredTasks);
const colSpan = showTimeLeft && showMaxWait ? "9" : showTimeLeft || showMaxWait ? "8" : "7";
return (
<div className="overflow-x-auto">
<table className="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.taskName")}
</th>{" "}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.project")}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("projects.city")}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("projects.address")}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.assignedTo")}
</th>
{showTimeLeft && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.daysLeft")}
</th>
)}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.dateCreated")}
</th>
{showMaxWait && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.maxWait")}
</th>
)}{" "}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.actions")}
</th>
</tr>
</thead>
<tbody>
{Object.entries(groupedTasks).map(([groupName, groupTasks]) => (
<Fragment key={`group-fragment-${groupName}`}>
{showGrouped && groupName !== t("tasks.allTasks") && (
<tr key={`group-${groupName}`}>
<td
colSpan={colSpan}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 font-medium text-gray-800 dark:text-gray-200 text-sm"
>
{groupName} ({groupTasks.length} {t("tasks.tasks")})
</td>
</tr>
)}
{groupTasks.map((task) => (
<TaskRow
key={task.id}
task={task}
showTimeLeft={showTimeLeft}
showMaxWait={showMaxWait}
/>
))}
</Fragment>
))}
</tbody>
</table>
{filteredTasks.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>{t("tasks.noTasks")}</p>
</div>
)}
</div>
);
};
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="h-64 bg-gray-200 rounded-lg"></div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-blue-600">
{taskGroups.pending.length}
</div>
<div className="text-sm text-gray-600">{t("taskStatus.pending")}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-purple-600">
{taskGroups.in_progress.length}
</div>
<div className="text-sm text-gray-600">{t("taskStatus.in_progress")}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-green-600">
{taskGroups.completed.length}
</div>
<div className="text-sm text-gray-600">{t("taskStatus.completed")}</div>
</CardContent>
</Card>
</div>{" "}
{/* Search and Controls */}{" "}
<SearchBar
searchTerm={searchTerm}
onSearchChange={(e) => setSearchTerm(e.target.value)}
placeholder={t("tasks.searchPlaceholder")}
resultsCount={
filterTasks(taskGroups.pending).length +
filterTasks(taskGroups.in_progress).length +
filterTasks(taskGroups.completed).length
}
resultsText={t("tasks.tasks")}
filters={
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">
{t("tasks.sortBy")}:
</label>
<Select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="min-w-[120px]"
>
<option value="none">{t("common.none")}</option>
<option value="task_name">{t("tasks.taskName")}</option>
</Select>
</div>
{session?.user && (
<button
onClick={() => setMine(!mine)}
className={`
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
${mine
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>{t('projects.mine') || 'Tylko moje'}</span>
</button>
)}
</div>
}
/>{" "}
{/* Task Tables */}
<div className="space-y-8">
{/* Pending Tasks */}
<div>
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
{t("taskStatus.pending")} {t("tasks.tasks")}
<Badge variant="primary" size="md">
{taskGroups.pending.length}
</Badge>
</h2>
<p className="text-sm text-gray-600 mt-1">
{t("tasks.noTasksMessage")}
</p>
</div>
<TaskTable
tasks={taskGroups.pending}
showGrouped={groupBy === "task_name"}
showTimeLeft={false}
showMaxWait={true}
/>
</div>
{/* In Progress Tasks */}
<div>
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
{t("taskStatus.in_progress")} {t("tasks.tasks")}
<Badge variant="secondary" size="md">
{taskGroups.in_progress.length}
</Badge>
</h2>
<p className="text-sm text-gray-600 mt-1">
Zadania aktualnie w trakcie realizacji - pokazujący pozostały czas do ukończenia
</p>
</div>
<TaskTable
tasks={taskGroups.in_progress}
showGrouped={groupBy === "task_name"}
showTimeLeft={true}
showMaxWait={false}
/>
</div>
{/* Completed Tasks */}
<div>
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
{t("taskStatus.completed")} {t("tasks.tasks")}
<Badge variant="success" size="md">
{taskGroups.completed.length}
</Badge>
</h2>
<p className="text-sm text-gray-600 mt-1">
Ostatnio ukończone i anulowane zadania
</p>
</div>
<TaskTable
tasks={taskGroups.completed}
showGrouped={groupBy === "task_name"}
showTimeLeft={false}
showMaxWait={false}
/>
</div>
</div> {/* Comments Modal */}
<TaskCommentsModal
task={selectedTask}
isOpen={showCommentsModal}
onClose={handleCloseComments}
/>
</div>
);
}