commit
0c32c87ba7
25 changed files with 1028 additions and 140 deletions
|
|
@ -21,6 +21,7 @@ import ProfileSettings from "./components/Profile/ProfileSettings";
|
|||
import Layout from "./Layout";
|
||||
import { DataProvider } from "./contexts/DataContext";
|
||||
import { User } from "./entities/User";
|
||||
import TasksToday from "./components/Task/TasksToday";
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
|
|
@ -85,7 +86,7 @@ const App: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (currentUser && location.pathname === "/") {
|
||||
navigate("/tasks?type=today", { replace: true });
|
||||
navigate("/today", { replace: true });
|
||||
}
|
||||
}, [currentUser, location.pathname, navigate]);
|
||||
|
||||
|
|
@ -104,12 +105,13 @@ const App: React.FC = () => {
|
|||
{currentUser ? (
|
||||
<Layout
|
||||
currentUser={currentUser}
|
||||
setCurrentUser={setCurrentUser}
|
||||
setCurrentUser={setCurrentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/tasks" replace />} />
|
||||
<Route path="/" element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TasksToday />} />
|
||||
<Route path="/tasks" element={<Tasks />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
|
|
@ -119,7 +121,10 @@ const App: React.FC = () => {
|
|||
<Route path="/tag/:id" element={<TagDetails />} />
|
||||
<Route path="/notes" element={<Notes />} />
|
||||
<Route path="/note/:id" element={<NoteDetails />} />
|
||||
<Route path="/profile" element={<ProfileSettings currentUser={currentUser} />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<ProfileSettings currentUser={currentUser} />}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const Login: React.FC = () => {
|
|||
|
||||
if (response.ok) {
|
||||
console.log('Login successful:', data);
|
||||
navigate('/tasks?type=today&order_by=due_date%3Aasc');
|
||||
navigate('/today');
|
||||
} else {
|
||||
setError(data.errors[0] || 'Login failed. Please try again.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useLocation, useNavigate, Link } from "react-router-dom";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import {
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
FolderIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
} from "@heroicons/react/24/outline";
|
||||
import TaskList from "../Task/TaskList";
|
||||
import ProjectModal from "../Project/ProjectModal";
|
||||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||
|
|
@ -16,7 +17,6 @@ import { Task } from "../../entities/Task";
|
|||
const ProjectDetails: React.FC = () => {
|
||||
const { updateTask, deleteTask, updateProject, deleteProject } = useDataContext();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { areas } = useDataContext();
|
||||
|
|
@ -28,9 +28,7 @@ const ProjectDetails: React.FC = () => {
|
|||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
|
||||
const { title: stateTitle, icon: stateIcon } = location.state || {};
|
||||
const projectTitle = stateTitle || project?.name || "Project";
|
||||
const projectIcon = stateIcon;
|
||||
const projectTitle = project?.name || "Project";
|
||||
|
||||
const [isCompletedOpen, setIsCompletedOpen] = useState(false);
|
||||
|
||||
|
|
@ -129,8 +127,8 @@ const ProjectDetails: React.FC = () => {
|
|||
if (!updatedProject) return;
|
||||
|
||||
try {
|
||||
await updateProject(updatedProject.id!, updatedProject);
|
||||
setProject(updatedProject);
|
||||
const savedProject = await updateProject(updatedProject.id!, updatedProject);
|
||||
setProject(savedProject);
|
||||
setIsModalOpen(false);
|
||||
} catch (err) {
|
||||
console.error("Error saving project:", err);
|
||||
|
|
@ -179,7 +177,7 @@ const ProjectDetails: React.FC = () => {
|
|||
{/* Project Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center">
|
||||
<i className={`${projectIcon} text-xl mr-2`}></i>
|
||||
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
|
||||
{projectTitle}
|
||||
</h2>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { Project } from '../../entities/Project';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Area } from "../../entities/Area";
|
||||
import { Project } from "../../entities/Project";
|
||||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import TagInput from "../Tag/TagInput";
|
||||
import useFetchTags from "../../hooks/useFetchTags";
|
||||
|
||||
interface ProjectModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -24,13 +25,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
}) => {
|
||||
const [formData, setFormData] = useState<Project>(
|
||||
project || {
|
||||
name: '',
|
||||
description: '',
|
||||
name: "",
|
||||
description: "",
|
||||
area_id: null,
|
||||
active: true,
|
||||
tags: [],
|
||||
}
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<string[]>(project?.tags?.map(tag => tag.name) || []);
|
||||
|
||||
const { tags: availableTags, isLoading: isTagsLoading, isError: isTagsError } = useFetchTags();
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
|
@ -39,14 +45,20 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setFormData(project);
|
||||
setFormData({
|
||||
...project,
|
||||
tags: project.tags || [],
|
||||
});
|
||||
setTags(project.tags?.map(tag => tag.name) || []);
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
name: "",
|
||||
description: "",
|
||||
area_id: null,
|
||||
active: true,
|
||||
tags: [],
|
||||
});
|
||||
setTags([]);
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
|
|
@ -61,24 +73,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (event.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
|
|
@ -88,16 +100,17 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
>
|
||||
) => {
|
||||
const target = e.target;
|
||||
const { name, type } = target;
|
||||
const { name, type, value } = target;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checked = (target as HTMLInputElement).checked;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: checked,
|
||||
}));
|
||||
if (type === "checkbox") {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const checked = target.checked;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: checked,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const value = target.value;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
|
|
@ -105,10 +118,20 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleTagsChange = useCallback((newTags: string[]) => {
|
||||
setTags(newTags);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: newTags.map((name) => ({ name })),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave(formData);
|
||||
onSave({ ...formData, tags: tags.map(name => ({ name })) });
|
||||
showSuccessToast(
|
||||
project ? 'Project updated successfully!' : 'Project created successfully!'
|
||||
project
|
||||
? "Project updated successfully!"
|
||||
: "Project created successfully!"
|
||||
);
|
||||
handleClose();
|
||||
};
|
||||
|
|
@ -120,7 +143,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
const handleDeleteConfirm = () => {
|
||||
if (project && project.id && onDelete) {
|
||||
onDelete(project.id);
|
||||
showSuccessToast('Project deleted successfully!');
|
||||
showSuccessToast("Project deleted successfully!");
|
||||
setShowConfirmDialog(false);
|
||||
handleClose();
|
||||
}
|
||||
|
|
@ -131,29 +154,53 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
}, 300);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (isTagsLoading) {
|
||||
return (
|
||||
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
|
||||
Loading tags...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isTagsError) {
|
||||
return (
|
||||
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
|
||||
Error loading tags.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modal Overlay */}
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
|
||||
isClosing ? 'opacity-0' : 'opacity-100'
|
||||
isClosing ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`bg-white dark:bg-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl overflow-hidden transform transition-transform duration-300 ${
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
} h-screen sm:h-auto flex flex-col`}
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
maxHeight: "calc(100vh - 4rem)",
|
||||
}}
|
||||
>
|
||||
{/* Form */}
|
||||
<form className="flex flex-col flex-1">
|
||||
<fieldset className="flex flex-col flex-1">
|
||||
{/* Form Fields */}
|
||||
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto">
|
||||
{/* Project Name */}
|
||||
<div className="py-4">
|
||||
|
|
@ -178,13 +225,27 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
id="projectDescription"
|
||||
name="description"
|
||||
rows={4}
|
||||
value={formData.description || ''}
|
||||
value={formData.description || ""}
|
||||
onChange={handleChange}
|
||||
className="block w-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder="Enter project description (optional)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tags
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<TagInput
|
||||
onTagsChange={handleTagsChange}
|
||||
initialTags={tags}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Area */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
|
|
@ -193,7 +254,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
<select
|
||||
id="projectArea"
|
||||
name="area_id"
|
||||
value={formData.area_id || ''}
|
||||
value={formData.area_id || ""}
|
||||
onChange={handleChange}
|
||||
className="block w-full rounded-md shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
>
|
||||
|
|
@ -248,7 +309,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
{project ? 'Update Project' : 'Create Project'}
|
||||
{project ? "Update Project" : "Create Project"}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -256,6 +317,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog for Deletion */}
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="Delete Project"
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ interface SidebarNavProps {
|
|||
}
|
||||
|
||||
const navLinks = [
|
||||
{ path: '/tasks?type=today', title: 'Today', icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
|
||||
{ path: '/today', title: 'Today', icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
|
||||
{ path: '/tasks?type=upcoming', title: 'Upcoming', icon: <CalendarIcon className="h-5 w-5" />, query: 'type=upcoming' },
|
||||
{ path: '/tasks?type=next', title: 'Next Actions', icon: <ArrowRightCircleIcon className="h-5 w-5" />, query: 'type=next' },
|
||||
{ path: '/tasks?type=inbox', title: 'Inbox', icon: <InboxIcon className="h-5 w-5" />, query: 'type=inbox' },
|
||||
{ path: '/tasks?type=someday', title: 'Someday', icon: <ClockIcon className="h-5 w-5" />, query: 'type=someday' },
|
||||
{ path: '/tasks?type=waiting', title: 'Waiting for', icon: <PauseCircleIcon className="h-5 w-5" />, query: 'type=waiting' },
|
||||
// { path: '/tasks?type=someday', title: 'Someday', icon: <ClockIcon className="h-5 w-5" />, query: 'type=someday' },
|
||||
// { path: '/tasks?type=waiting', title: 'Waiting for', icon: <PauseCircleIcon className="h-5 w-5" />, query: 'type=waiting' },
|
||||
{ path: '/tasks?status=done', title: 'Completed', icon: <CheckCircleIcon className="h-5 w-5" />, query: 'status=done' },
|
||||
{ path: '/tasks', title: 'All Tasks', icon: <ListBulletIcon className="h-5 w-5" /> },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-1">
|
||||
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-2">
|
||||
{/* Tags without onTagRemove prop */}
|
||||
<TaskTags tags={task.tags || []} />
|
||||
{task.due_date && <TaskDueDate dueDate={task.due_date} />}
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
key={project.id}
|
||||
type="button"
|
||||
onClick={() => handleProjectSelection(project)}
|
||||
className="block w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
className="block w-full text-gray-500 dark:text-gray-300 text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-1 ${className}`}>
|
||||
<div className={`flex flex-wrap gap-2 ${className}`}>
|
||||
{tags.map((tag, index) => (
|
||||
<div
|
||||
key={tag.id || index}
|
||||
className="flex items-center bg-gray-200 text-gray-800 text-xs font-medium mr-2 px-2.5 py-1 rounded-md dark:bg-gray-700 dark:text-gray-200 cursor-pointer"
|
||||
className="flex items-center bg-gray-200 text-gray-800 text-xs font-medium px-2 py-1.5 rounded-md dark:bg-gray-700 dark:text-gray-200 cursor-pointer"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
183
app/frontend/components/Task/TasksToday.tsx
Normal file
183
app/frontend/components/Task/TasksToday.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import React from "react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ClipboardDocumentListIcon,
|
||||
ClockIcon,
|
||||
ArrowPathIcon,
|
||||
CalendarDaysIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { Task } from "../../entities/Task";
|
||||
import { Project } from "../../entities/Project";
|
||||
|
||||
import useFetchTasks from "../../hooks/useFetchTasks";
|
||||
import useFetchProjects from "../../hooks/useFetchProjects";
|
||||
import useManageTasks from "../../hooks/useManageTasks";
|
||||
|
||||
import NewTask from "./NewTask";
|
||||
import TaskList from "./TaskList";
|
||||
|
||||
const TasksToday: React.FC = () => {
|
||||
const {
|
||||
tasks,
|
||||
metrics,
|
||||
isLoading: loadingTasks,
|
||||
isError: errorTasks,
|
||||
mutate: mutateTasks,
|
||||
} = useFetchTasks({
|
||||
type: "today",
|
||||
});
|
||||
|
||||
const {
|
||||
projects,
|
||||
isLoading: loadingProjects,
|
||||
isError: errorProjects,
|
||||
} = useFetchProjects();
|
||||
|
||||
const { updateTask, deleteTask } = useManageTasks();
|
||||
|
||||
const handleTaskUpdate = (updatedTask: Task): void => {
|
||||
if (updatedTask.id === undefined) {
|
||||
console.error("Error updating task: Task ID is undefined.");
|
||||
return;
|
||||
}
|
||||
updateTask(updatedTask.id, updatedTask)
|
||||
.then(() => {
|
||||
mutateTasks();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error updating task:", error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTaskDelete = (taskId: number): void => {
|
||||
deleteTask(taskId)
|
||||
.then(() => {
|
||||
mutateTasks();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting task:", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (loadingTasks || loadingProjects) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
if (errorTasks) {
|
||||
return <p className="text-red-500">Error loading tasks.</p>;
|
||||
}
|
||||
|
||||
if (errorProjects) {
|
||||
return <p className="text-red-500">Error loading projects.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-4">
|
||||
<h2 className="text-2xl font-light flex items-center">
|
||||
<CalendarDaysIcon className="h-5 w-5 mr-2" /> Today
|
||||
</h2>
|
||||
<span className="ml-4 text-gray-500">
|
||||
{format(new Date(), "EEEE, MMMM d, yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Overview of Tasks */}
|
||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
{/* Total Open Tasks */}
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<ClipboardDocumentListIcon className="h-8 w-8 text-blue-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Backlog</p>
|
||||
<p className="text-2xl font-semibold">{metrics.total_open_tasks}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks Pending Over a Month */}
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<ClockIcon className="h-8 w-8 text-yellow-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Stale</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{metrics.tasks_pending_over_month}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks In Progress */}
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<ArrowPathIcon className="h-8 w-8 text-green-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">In Progress</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{metrics.tasks_in_progress_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks Due Today */}
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<CalendarDaysIcon className="h-8 w-8 text-red-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Due Today</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{metrics.tasks_due_today.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks Due Today */}
|
||||
{metrics.tasks_due_today.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xl font-medium mt-6 mb-2">Tasks Due Today</h3>
|
||||
<TaskList
|
||||
tasks={metrics.tasks_due_today}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tasks In Progress */}
|
||||
{metrics.tasks_in_progress.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xl font-medium mt-6 mb-2">Tasks In Progress</h3>
|
||||
<TaskList
|
||||
tasks={metrics.tasks_in_progress}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Suggested Tasks */}
|
||||
{metrics.suggested_tasks.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xl font-medium mt-6 mb-2">Suggested Tasks</h3>
|
||||
<TaskList
|
||||
tasks={metrics.suggested_tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback Message */}
|
||||
{tasks.length === 0 && (
|
||||
<p className="text-gray-500 text-center mt-4">
|
||||
No tasks available for today.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TasksToday;
|
||||
|
|
@ -77,7 +77,7 @@ const Tasks: React.FC = () => {
|
|||
|
||||
if (tasksResponse.ok) {
|
||||
const tasksData = await tasksResponse.json();
|
||||
setTasks(tasksData || []);
|
||||
setTasks(tasksData.tasks || []);
|
||||
} else {
|
||||
throw new Error("Failed to fetch tasks.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ interface DataContextProps {
|
|||
updateArea: (areaId: number, areaData: any) => Promise<void>;
|
||||
deleteArea: (areaId: number) => Promise<void>;
|
||||
createProject: (projectData: any) => Promise<Project>;
|
||||
updateProject: (projectId: number, projectData: any) => Promise<void>;
|
||||
updateProject: (projectId: number, projectData: any) => Promise<Project>;
|
||||
deleteProject: (projectId: number) => Promise<void>;
|
||||
createTag: (tagData: any) => Promise<void>;
|
||||
updateTag: (tagId: number, tagData: any) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Area } from "./Area";
|
||||
import { Tag } from "./Tag";
|
||||
|
||||
export interface Project {
|
||||
id?: number;
|
||||
|
|
@ -7,5 +8,6 @@ export interface Project {
|
|||
active: boolean;
|
||||
pin_to_sidebar?: boolean;
|
||||
area?: Area;
|
||||
area_id?: number | null;
|
||||
area_id?: number | null;
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export interface Task {
|
|||
note?: string;
|
||||
tags?: Tag[];
|
||||
project_id?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export type StatusType = 'not_started' | 'in_progress' | 'done' | 'archived';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import useSWR from 'swr';
|
||||
import { Task } from '../entities/Task';
|
||||
|
||||
interface UseFetchTasksOptions {
|
||||
type?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
interface Metrics {
|
||||
total_open_tasks: number;
|
||||
tasks_pending_over_month: number;
|
||||
tasks_in_progress_count: number;
|
||||
tasks_in_progress: Task[];
|
||||
tasks_due_today: Task[];
|
||||
suggested_tasks: Task[];
|
||||
}
|
||||
|
||||
interface UseFetchTasksResult {
|
||||
tasks: Task[];
|
||||
metrics: Metrics;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
mutate: () => void;
|
||||
}
|
||||
|
||||
const initialMetrics: Metrics = {
|
||||
total_open_tasks: 0,
|
||||
tasks_pending_over_month: 0,
|
||||
tasks_in_progress_count: 0,
|
||||
tasks_in_progress: [],
|
||||
tasks_due_today: [],
|
||||
suggested_tasks: [],
|
||||
};
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch tasks.');
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
|
||||
const useFetchTasks = (options?: UseFetchTasksOptions): UseFetchTasksResult => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options?.type) {
|
||||
params.append('type', options.type);
|
||||
}
|
||||
if (options?.tag) {
|
||||
params.append('tag', options.tag);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `/api/tasks${queryString ? `?${queryString}` : ''}`;
|
||||
const { data, error, mutate } = useSWR(url, fetcher);
|
||||
|
||||
return {
|
||||
tasks: data?.tasks || [],
|
||||
metrics: data?.metrics || initialMetrics,
|
||||
isLoading: !error && !data,
|
||||
isError: !!error,
|
||||
mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFetchTasks;
|
||||
|
|
@ -41,6 +41,7 @@ const useManageProjects = () => {
|
|||
const updatedProject: Project = await response.json();
|
||||
mutate('/api/projects', (current: Project[] = []) =>
|
||||
current.map((project) => (project.id === projectId ? updatedProject : project)), false);
|
||||
return updatedProject;
|
||||
} catch (error) {
|
||||
console.error('Error updating project:', error);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ class Project < ActiveRecord::Base
|
|||
belongs_to :area, optional: true
|
||||
has_many :tasks, dependent: :destroy
|
||||
has_many :notes, dependent: :destroy
|
||||
has_and_belongs_to_many :tags
|
||||
|
||||
scope :with_incomplete_tasks, -> { joins(:tasks).where.not(tasks: { status: Task.statuses[:done] }).distinct }
|
||||
scope :with_complete_tasks, -> { joins(:tasks).where(tasks: { status: Task.statuses[:done] }).distinct }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ class Tag < ActiveRecord::Base
|
|||
belongs_to :user
|
||||
has_and_belongs_to_many :tasks
|
||||
has_and_belongs_to_many :notes
|
||||
has_and_belongs_to_many :projects
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ class Task < ActiveRecord::Base
|
|||
enum priority: { low: 0, medium: 1, high: 2 }
|
||||
enum status: { not_started: 0, in_progress: 1, done: 2, archived: 3, waiting: 4 }
|
||||
|
||||
# Existing scopes
|
||||
scope :complete, -> { where(status: statuses[:done]) }
|
||||
scope :incomplete, -> { where.not(status: statuses[:done]) }
|
||||
scope :due_today, -> { incomplete.where('due_date <= ?', Date.today.end_of_day) }
|
||||
scope :due_today, -> { incomplete.where('DATE(due_date) < ?', Date.today) }
|
||||
scope :upcoming, -> { incomplete.where('due_date BETWEEN ? AND ?', Date.today, Date.today + 7.days) }
|
||||
scope :someday, -> { incomplete.where(due_date: nil) }
|
||||
scope :next_actions, -> { incomplete.where(due_date: nil, project_id: nil) }
|
||||
|
|
@ -26,5 +27,85 @@ class Task < ActiveRecord::Base
|
|||
scope :by_status, ->(status) { where(status: statuses[status]) }
|
||||
scope :by_priority, ->(priority) { where(priority: priorities[priority]) }
|
||||
|
||||
scope :order_by_priority, -> { order(priority: :desc) }
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||
|
||||
# New class method to filter tasks based on params
|
||||
def self.filter_by_params(params, user)
|
||||
tasks = user.tasks.includes(:project, :tags)
|
||||
|
||||
tasks = case params[:type]
|
||||
when 'today'
|
||||
tasks
|
||||
when 'upcoming'
|
||||
tasks.upcoming
|
||||
when 'next'
|
||||
tasks.next_actions
|
||||
when 'inbox'
|
||||
tasks.inbox
|
||||
when 'someday'
|
||||
tasks.someday
|
||||
when 'waiting'
|
||||
tasks.waiting_for
|
||||
else
|
||||
params[:status] == 'done' ? tasks.complete : tasks.incomplete
|
||||
end
|
||||
|
||||
tasks = tasks.with_tag(params[:tag]) if params[:tag]
|
||||
|
||||
tasks = tasks.apply_ordering(params[:order_by]) if params[:order_by]
|
||||
|
||||
tasks.left_joins(:tags).distinct
|
||||
end
|
||||
|
||||
scope :apply_ordering, lambda { |order_by|
|
||||
order_column, order_direction = order_by.split(':')
|
||||
order_direction ||= 'asc'
|
||||
order_direction = order_direction.downcase == 'desc' ? :desc : :asc
|
||||
|
||||
allowed_columns = %w[created_at updated_at name priority status due_date]
|
||||
raise ArgumentError, 'Invalid order column specified.' unless allowed_columns.include?(order_column)
|
||||
|
||||
if order_column == 'due_date'
|
||||
ordered_by_due_date(order_direction)
|
||||
else
|
||||
order("tasks.#{order_column} #{order_direction}")
|
||||
end
|
||||
}
|
||||
|
||||
def self.compute_metrics(user)
|
||||
total_open_tasks = user.tasks.incomplete.count
|
||||
|
||||
one_month_ago = Date.today - 30
|
||||
tasks_pending_over_month = user.tasks.incomplete.where('created_at < ?', one_month_ago).count
|
||||
|
||||
tasks_in_progress = user.tasks.incomplete.where(status: statuses[:in_progress])
|
||||
tasks_in_progress_count = tasks_in_progress.count
|
||||
|
||||
tasks_due_today = user.tasks.due_today
|
||||
|
||||
# Suggested tasks
|
||||
excluded_task_ids = tasks_in_progress.pluck(:id) + tasks_due_today.pluck(:id)
|
||||
suggested_tasks = user.tasks.incomplete
|
||||
.where(status: statuses[:not_started])
|
||||
.where.not(id: excluded_task_ids)
|
||||
.order_by_priority
|
||||
.limit(5)
|
||||
|
||||
{
|
||||
total_open_tasks: total_open_tasks,
|
||||
tasks_pending_over_month: tasks_pending_over_month,
|
||||
tasks_in_progress: tasks_in_progress,
|
||||
tasks_in_progress_count: tasks_in_progress_count,
|
||||
tasks_due_today: tasks_due_today,
|
||||
suggested_tasks: suggested_tasks
|
||||
}
|
||||
end
|
||||
|
||||
def as_json(options = {})
|
||||
super(options).merge(
|
||||
'due_date' => due_date&.strftime('%Y-%m-%d')
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@ require 'sinatra/namespace'
|
|||
class Sinatra::Application
|
||||
register Sinatra::Namespace
|
||||
|
||||
def update_project_tags(project, tags_data)
|
||||
return if tags_data.nil?
|
||||
|
||||
tag_names = tags_data.map { |tag| tag['name'] }.compact.reject(&:empty?).uniq
|
||||
|
||||
existing_tags = Tag.where(user: current_user, name: tag_names)
|
||||
new_tags = tag_names - existing_tags.pluck(:name)
|
||||
created_tags = new_tags.map { |name| Tag.create(name: name, user: current_user) }
|
||||
|
||||
project.tags = (existing_tags + created_tags).uniq
|
||||
end
|
||||
|
||||
namespace '/api' do
|
||||
before do
|
||||
content_type :json
|
||||
|
|
@ -18,6 +30,7 @@ class Sinatra::Application
|
|||
area_id_param = params[:area_id]
|
||||
|
||||
projects = current_user.projects
|
||||
.includes(:tags)
|
||||
.left_joins(:tasks, :area)
|
||||
.distinct
|
||||
.order('projects.name ASC')
|
||||
|
|
@ -32,18 +45,18 @@ class Sinatra::Application
|
|||
grouped_projects = projects.group_by(&:area)
|
||||
|
||||
{
|
||||
projects: projects.as_json(include: { tasks: {}, area: { only: :name } }),
|
||||
projects: projects.as_json(include: { tasks: {}, area: { only: :name }, tags: { only: %i[id name] } }),
|
||||
task_status_counts: task_status_counts,
|
||||
grouped_projects: grouped_projects.as_json(include: { area: { only: :name } })
|
||||
}.to_json
|
||||
end
|
||||
|
||||
get '/project/:id' do
|
||||
project = current_user.projects.includes(:tasks).find_by(id: params[:id])
|
||||
project = current_user.projects.includes(:tasks, :tags).find_by(id: params[:id])
|
||||
|
||||
halt 404, { error: 'Project not found' }.to_json unless project
|
||||
|
||||
project.as_json(include: { tasks: {}, area: { only: %i[id name] } }).to_json
|
||||
project.as_json(include: { tasks: {}, area: { only: %i[id name] }, tags: { only: %i[id name] } }).to_json
|
||||
end
|
||||
|
||||
post '/project' do
|
||||
|
|
@ -63,8 +76,9 @@ class Sinatra::Application
|
|||
)
|
||||
|
||||
if project.save
|
||||
update_project_tags(project, project_data['tags'])
|
||||
status 201
|
||||
project.as_json.to_json
|
||||
project.as_json(include: { tags: { only: %i[id name] } }).to_json
|
||||
else
|
||||
status 400
|
||||
{ error: 'There was a problem creating the project.', details: project.errors.full_messages }.to_json
|
||||
|
|
@ -92,7 +106,8 @@ class Sinatra::Application
|
|||
)
|
||||
|
||||
if project.save
|
||||
project.as_json.to_json
|
||||
update_project_tags(project, project_data['tags'])
|
||||
project.as_json(include: { tags: { only: %i[id name] } }).to_json
|
||||
else
|
||||
status 400
|
||||
{ error: 'There was a problem updating the project.', details: project.errors.full_messages }.to_json
|
||||
|
|
|
|||
|
|
@ -15,47 +15,31 @@ module Sinatra
|
|||
get '/api/tasks' do
|
||||
content_type :json
|
||||
|
||||
@tasks = current_user.tasks.includes(:project, :tags)
|
||||
|
||||
@tasks = case params[:type]
|
||||
when 'today'
|
||||
@tasks.due_today
|
||||
when 'upcoming'
|
||||
@tasks.upcoming
|
||||
when 'next'
|
||||
@tasks.next_actions
|
||||
when 'inbox'
|
||||
@tasks.inbox
|
||||
when 'someday'
|
||||
@tasks.someday
|
||||
when 'waiting'
|
||||
@tasks.waiting_for
|
||||
else
|
||||
params[:status] == 'done' ? @tasks.complete : @tasks.incomplete
|
||||
end
|
||||
|
||||
@tasks = @tasks.with_tag(params[:tag]) if params[:tag]
|
||||
|
||||
if params[:order_by]
|
||||
order_column, order_direction = params[:order_by].split(':')
|
||||
order_direction ||= 'asc'
|
||||
order_direction = order_direction.downcase == 'desc' ? :desc : :asc
|
||||
|
||||
allowed_columns = %w[created_at updated_at name priority status due_date]
|
||||
if allowed_columns.include?(order_column)
|
||||
@tasks = if order_column == 'due_date'
|
||||
@tasks.ordered_by_due_date(order_direction)
|
||||
else
|
||||
@tasks.order("tasks.#{order_column} #{order_direction}")
|
||||
end
|
||||
else
|
||||
halt 400, { error: 'Invalid order column specified.' }.to_json
|
||||
end
|
||||
begin
|
||||
tasks = Task.filter_by_params(params, current_user)
|
||||
rescue ArgumentError => e
|
||||
halt 400, { error: e.message }.to_json
|
||||
end
|
||||
|
||||
@tasks = @tasks.left_joins(:tags).distinct
|
||||
metrics = Task.compute_metrics(current_user)
|
||||
|
||||
@tasks.to_json(include: { tags: { only: %i[id name] }, project: { only: :name } })
|
||||
# Prepare the response
|
||||
response = {
|
||||
tasks: tasks.as_json(include: { tags: { only: %i[id name] }, project: { only: :name } }),
|
||||
metrics: {
|
||||
total_open_tasks: metrics[:total_open_tasks],
|
||||
tasks_pending_over_month: metrics[:tasks_pending_over_month],
|
||||
tasks_in_progress_count: metrics[:tasks_in_progress_count],
|
||||
tasks_in_progress: metrics[:tasks_in_progress].as_json(include: { tags: { only: %i[id name] },
|
||||
project: { only: :name } }),
|
||||
tasks_due_today: metrics[:tasks_due_today].as_json(include: { tags: { only: %i[id name] },
|
||||
project: { only: :name } }),
|
||||
suggested_tasks: metrics[:suggested_tasks].as_json(include: { tags: { only: %i[id name] },
|
||||
project: { only: :name } })
|
||||
}
|
||||
}
|
||||
|
||||
response.to_json
|
||||
end
|
||||
|
||||
post '/api/task' do
|
||||
|
|
@ -70,8 +54,8 @@ module Sinatra
|
|||
|
||||
task_attributes = {
|
||||
name: task_data['name'],
|
||||
priority: task_data['priority'] || 'medium',
|
||||
due_date: task_data['due_date'],
|
||||
priority: task_data['priority'],
|
||||
due_date: task_data['due_date'].presence,
|
||||
status: task_data['status'] || Task.statuses[:not_started],
|
||||
note: task_data['note'],
|
||||
user_id: current_user.id
|
||||
|
|
@ -117,7 +101,7 @@ module Sinatra
|
|||
priority: task_data['priority'],
|
||||
status: task_data['status'] || Task.statuses[:not_started],
|
||||
note: task_data['note'],
|
||||
due_date: task_data['due_date']
|
||||
due_date: task_data['due_date'].presence
|
||||
}
|
||||
|
||||
if task_data['project_id'] && !task_data['project_id'].to_s.strip.empty?
|
||||
|
|
|
|||
14
db/migrate/20241121113756_create_projects_tags.rb
Normal file
14
db/migrate/20241121113756_create_projects_tags.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class CreateProjectsTags < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :projects_tags, id: false do |t|
|
||||
t.integer :project_id, null: false
|
||||
t.integer :tag_id, null: false
|
||||
end
|
||||
|
||||
add_index :projects_tags, :project_id
|
||||
add_index :projects_tags, :tag_id
|
||||
|
||||
add_foreign_key :projects_tags, :projects
|
||||
add_foreign_key :projects_tags, :tags
|
||||
end
|
||||
end
|
||||
11
db/schema.rb
11
db/schema.rb
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_10_16_105827) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_11_21_113756) do
|
||||
create_table "areas", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.integer "user_id", null: false
|
||||
|
|
@ -51,6 +51,13 @@ ActiveRecord::Schema[7.1].define(version: 2024_10_16_105827) do
|
|||
t.index ["user_id"], name: "index_projects_on_user_id"
|
||||
end
|
||||
|
||||
create_table "projects_tags", id: false, force: :cascade do |t|
|
||||
t.integer "project_id", null: false
|
||||
t.integer "tag_id", null: false
|
||||
t.index ["project_id"], name: "index_projects_tags_on_project_id"
|
||||
t.index ["tag_id"], name: "index_projects_tags_on_tag_id"
|
||||
end
|
||||
|
||||
create_table "tags", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.integer "user_id", null: false
|
||||
|
|
@ -99,6 +106,8 @@ ActiveRecord::Schema[7.1].define(version: 2024_10_16_105827) do
|
|||
add_foreign_key "notes", "users", on_delete: :cascade
|
||||
add_foreign_key "projects", "areas", on_delete: :cascade
|
||||
add_foreign_key "projects", "users"
|
||||
add_foreign_key "projects_tags", "projects"
|
||||
add_foreign_key "projects_tags", "tags"
|
||||
add_foreign_key "tags", "users", on_delete: :cascade
|
||||
add_foreign_key "tags_tasks", "tags"
|
||||
add_foreign_key "tags_tasks", "tasks"
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@heroicons/react": "^2.1.5",
|
||||
"@yaireo/tagify": "^4.31.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
|
|
@ -4222,6 +4223,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"dependencies": {
|
||||
"@heroicons/react": "^2.1.5",
|
||||
"@yaireo/tagify": "^4.31.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue