tududi/frontend/components/Tasks.tsx
Chris 03f38f05dc
Setup intelligence (#84)
* Add next suggestions and remove console logs

* Add pomodoro timer

* Add pomodoro switch in settings

* Fix pomodoro setting

* Add timezones to settings

* Fix an issue with password reset

* Cleanup

* Sort tags alphabetically

* Clean up today's view

* Add an indicator for repeatedly added to today

* Refactor tags

* Add due date today item

* Move recurrence to the subtitle area

* Fix today layout

* Add a badge to Inbox items

* Move inbox badge to sidebar

* Add quotes and progress bar

* Add translations for quotes

* Fix test issues

* Add helper script for docker local

* Set up overdue tasks

* Add  linux/arm/v7 build to deploy script

* Add  linux/arm/v7 build to deploy script pt2

* Fix an issue with helmet and SSL

* Add volume db persistence

* Fix cog icon issues
2025-06-27 14:02:18 +03:00

359 lines
No EOL
12 KiB
TypeScript

import React, { useEffect, useState, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import TaskList from "./Task/TaskList";
import NewTask from "./Task/NewTask";
import { Task } from "../entities/Task";
import { Project } from "../entities/Project";
import { getTitleAndIcon } from "./Task/getTitleAndIcon";
import { getDescription } from "./Task/getDescription";
import { createTask, toggleTaskToday } from "../utils/tasksService";
import { useToast } from "./Shared/ToastContext";
import {
TagIcon,
XMarkIcon,
ChevronDownIcon,
ChevronDoubleDownIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/solid";
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
// Helper function to get search placeholder by language
const getSearchPlaceholder = (language: string): string => {
const placeholders: Record<string, string> = {
en: 'Search tasks...',
el: 'Αναζήτηση εργασιών...',
es: 'Buscar tareas...',
de: 'Aufgaben suchen...',
jp: 'タスクを検索...',
ua: 'Пошук завдань...'
};
return placeholders[language] || 'Search tasks...';
};
const Tasks: React.FC = () => {
const { t, i18n } = useTranslation();
const { showSuccessToast } = useToast();
const [tasks, setTasks] = useState<Task[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
const [orderBy, setOrderBy] = useState<string>("due_date:asc");
const [taskSearchQuery, setTaskSearchQuery] = useState<string>("");
const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const navigate = useNavigate();
const query = new URLSearchParams(location.search);
const { title: stateTitle, icon: stateIcon } = location.state || {};
const { title, icon } =
stateTitle && stateIcon
? { title: stateTitle, icon: stateIcon }
: getTitleAndIcon(query, projects, t);
const IconComponent =
typeof icon === "string" ? React.createElement(icon) : icon;
const tag = query.get("tag");
const status = query.get("status");
useEffect(() => {
const savedOrderBy = localStorage.getItem("order_by") || "due_date:asc";
setOrderBy(savedOrderBy);
const params = new URLSearchParams(location.search);
if (!params.get("order_by")) {
params.set("order_by", savedOrderBy);
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
}, { replace: true });
}
}, [location.pathname]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownOpen]);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const tagId = query.get("tag");
const [tasksResponse, projectsResponse] = await Promise.all([
fetch(`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ""}`),
fetch("/api/projects"),
]);
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
setTasks(tasksData.tasks || []);
} else {
throw new Error("Failed to fetch tasks.");
}
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
setProjects(projectsData?.projects || []);
} else {
throw new Error("Failed to fetch projects.");
}
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
};
fetchData();
}, [location]);
const handleRemoveTag = () => {
const params = new URLSearchParams(location.search);
params.delete("tag");
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
};
const handleTaskCreate = async (taskData: Partial<Task>) => {
try {
const newTask = await createTask(taskData as Task);
// Add the new task optimistically to avoid race conditions
setTasks((prevTasks) => [newTask, ...prevTasks]);
// Show success toast with task link
const taskLink = (
<span>
{t('task.created', 'Task')} <a href={`/task/${newTask.uuid}`} className="text-green-200 underline hover:text-green-100">{newTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
</span>
);
showSuccessToast(taskLink);
} catch (error) {
console.error("Error creating task:", error);
setError("Error creating task.");
throw error; // Re-throw to allow proper error handling
}
};
const handleTaskUpdate = async (updatedTask: Task) => {
try {
const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTask),
});
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
} else {
const errorData = await response.json();
console.error("Failed to update task:", errorData.error);
setError("Failed to update task.");
}
} catch (error) {
console.error("Error updating task:", error);
setError("Error updating task.");
}
};
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
method: "DELETE",
});
if (response.ok) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
} else {
const errorData = await response.json();
console.error("Failed to delete task:", errorData.error);
setError("Failed to delete task.");
}
} catch (error) {
console.error("Error deleting task:", error);
setError("Error deleting task.");
}
};
const handleToggleToday = async (taskId: number): Promise<void> => {
try {
const updatedTask = await toggleTaskToday(taskId);
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === taskId ? updatedTask : task
)
);
} catch (error) {
console.error("Error toggling task today status:", error);
setError("Error toggling task today status.");
}
};
const handleSortChange = (order: string) => {
setOrderBy(order);
localStorage.setItem("order_by", order);
const params = new URLSearchParams(location.search);
params.set("order_by", order);
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
}, { replace: true });
setDropdownOpen(false);
};
const description = getDescription(query, projects, t);
const isNewTaskAllowed = () => {
return status !== "done";
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(taskSearchQuery.toLowerCase())
);
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4">
<div className="flex items-center mb-2 sm:mb-0">
{IconComponent && <IconComponent className="h-6 w-6 mr-2" />}
<h2 className="text-2xl font-light">{title}</h2>
{tag && (
<div className="ml-4 flex items-center space-x-2">
<button
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={handleRemoveTag}
>
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
<span className="text-xs text-gray-700 dark:text-gray-300">
{capitalize(tag)}
</span>
<XMarkIcon className="h-4 w-4 text-gray-500 dark:text-gray-300 hover:text-red-500" />
</button>
</div>
)}
</div>
<div className="relative inline-block text-left" ref={dropdownRef}>
<button
type="button"
className="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
id="menu-button"
aria-expanded={dropdownOpen}
aria-haspopup="true"
onClick={() => setDropdownOpen(!dropdownOpen)}
>
<ChevronDoubleDownIcon className="h-5 w-5 text-gray-500 mr-2" />{" "}
{t(`sort.${orderBy.split(":")[0]}`, capitalize(orderBy.split(":")[0].replace("_", " ")))}
<ChevronDownIcon className="h-5 w-5 ml-2 text-gray-500 dark:text-gray-300" />
</button>
{dropdownOpen && (
<div
className="origin-top-right absolute left-0 sm:right-0 sm:left-auto mt-2 w-full sm:w-56 max-w-full rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
>
<div className="py-1 max-h-60 overflow-y-auto" role="none">
{[
"due_date:asc",
"name:asc",
"priority:desc",
"status:desc",
"created_at:desc",
].map((order) => (
<button
key={order}
onClick={() => handleSortChange(order)}
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
role="menuitem"
>
{t(`sort.${order.split(":")[0]}`, capitalize(order.split(":")[0].replace("_", " ")))}
</button>
))}
</div>
</div>
)}
</div>
</div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder={getSearchPlaceholder(i18n.language)}
value={taskSearchQuery}
onChange={(e) => setTaskSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{loading ? (
<p>{t('common.loading', 'Loading...')}</p>
) : error ? (
<p className="text-red-500">{error}</p>
) : (
<>
{/* New Task Form */}
{isNewTaskAllowed() && (
<NewTask
onTaskCreate={async (taskName: string) =>
await handleTaskCreate({ name: taskName, status: "not_started" })
}
/>
)}
{filteredTasks.length > 0 ? (
<TaskList
tasks={filteredTasks}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={projects}
onToggleToday={handleToggleToday}
/>
) : (
<p className="text-gray-500 text-center mt-4">
{t('tasks.noTasksAvailable', 'Δεν υπάρχουν διαθέσιμες εργασίες.')}
</p>
)}
</>
)}
</div>
</div>
);
};
export default Tasks;