Merge pull request #50 from chrisvel/add-state-management
Add global state management library
This commit is contained in:
commit
88d51f6d25
48 changed files with 1387 additions and 1590 deletions
|
|
@ -19,7 +19,6 @@ import Notes from "./components/Notes";
|
||||||
import NoteDetails from "./components/Note/NoteDetails";
|
import NoteDetails from "./components/Note/NoteDetails";
|
||||||
import ProfileSettings from "./components/Profile/ProfileSettings";
|
import ProfileSettings from "./components/Profile/ProfileSettings";
|
||||||
import Layout from "./Layout";
|
import Layout from "./Layout";
|
||||||
import { DataProvider } from "./contexts/DataContext";
|
|
||||||
import { User } from "./entities/User";
|
import { User } from "./entities/User";
|
||||||
import TasksToday from "./components/Task/TasksToday";
|
import TasksToday from "./components/Task/TasksToday";
|
||||||
|
|
||||||
|
|
@ -101,7 +100,7 @@ const App: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider>
|
<>
|
||||||
{currentUser ? (
|
{currentUser ? (
|
||||||
<Layout
|
<Layout
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
|
@ -131,7 +130,7 @@ const App: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<Login />
|
<Login />
|
||||||
)}
|
)}
|
||||||
</DataProvider>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,13 @@ import { Area } from "./entities/Area";
|
||||||
import { Tag } from "./entities/Tag";
|
import { Tag } from "./entities/Tag";
|
||||||
import { Project } from "./entities/Project";
|
import { Project } from "./entities/Project";
|
||||||
import { Task } from "./entities/Task";
|
import { Task } from "./entities/Task";
|
||||||
import { useDataContext } from "./contexts/DataContext";
|
|
||||||
import { User } from "./entities/User";
|
import { User } from "./entities/User";
|
||||||
|
import { useStore } from "./store/useStore";
|
||||||
|
import { fetchNotes, createNote, updateNote } from "./utils/notesService";
|
||||||
|
import { fetchAreas, createArea, updateArea } from "./utils/areasService";
|
||||||
|
import { fetchTags, createTag, updateTag } from "./utils/tagsService";
|
||||||
|
import { fetchProjects, createProject, updateProject } from "./utils/projectsService";
|
||||||
|
import { fetchTasks, createTask, updateTask } from "./utils/tasksService";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
|
|
@ -43,27 +48,47 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
const [newTask, setNewTask] = useState<Task | null>(null);
|
const [newTask, setNewTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
tags,
|
notesStore: {
|
||||||
areas,
|
|
||||||
notes,
|
notes,
|
||||||
|
setNotes,
|
||||||
|
setLoading: setNotesLoading,
|
||||||
|
setError: setNotesError,
|
||||||
|
isLoading: isNotesLoading,
|
||||||
|
isError: isNotesError,
|
||||||
|
},
|
||||||
|
areasStore: {
|
||||||
|
areas,
|
||||||
|
setAreas,
|
||||||
|
setLoading: setAreasLoading,
|
||||||
|
setError: setAreasError,
|
||||||
|
isLoading: isAreasLoading,
|
||||||
|
isError: isAreasError,
|
||||||
|
},
|
||||||
|
tasksStore: {
|
||||||
|
tasks,
|
||||||
|
setTasks,
|
||||||
|
setLoading: setTasksLoading,
|
||||||
|
setError: setTasksError,
|
||||||
|
isLoading: isTasksLoading,
|
||||||
|
isError: isTasksError,
|
||||||
|
},
|
||||||
|
projectsStore: {
|
||||||
projects,
|
projects,
|
||||||
isLoading,
|
setProjects,
|
||||||
isError,
|
setLoading: setProjectsLoading,
|
||||||
createNote,
|
setError: setProjectsError,
|
||||||
updateNote,
|
isLoading: isProjectsLoading,
|
||||||
deleteNote,
|
isError: isProjectsError,
|
||||||
createArea,
|
},
|
||||||
updateArea,
|
tagsStore: {
|
||||||
deleteArea,
|
tags,
|
||||||
createTag,
|
setTags,
|
||||||
updateTag,
|
setLoading: setTagsLoading,
|
||||||
deleteTag,
|
setError: setTagsError,
|
||||||
createProject,
|
isLoading: isTagsLoading,
|
||||||
updateProject,
|
isError: isTagsError,
|
||||||
deleteProject,
|
},
|
||||||
createTask,
|
} = useStore();
|
||||||
updateTask,
|
|
||||||
} = useDataContext();
|
|
||||||
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(
|
||||||
window.innerWidth >= 1024
|
window.innerWidth >= 1024
|
||||||
|
|
@ -77,6 +102,37 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadNotes = async () => {
|
||||||
|
setNotesLoading(true);
|
||||||
|
try {
|
||||||
|
const notesData = await fetchNotes();
|
||||||
|
setNotes(notesData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching notes:", error);
|
||||||
|
setNotesError(true);
|
||||||
|
} finally {
|
||||||
|
setNotesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAreas = async () => {
|
||||||
|
setAreasLoading(true);
|
||||||
|
try {
|
||||||
|
const areasData = await fetchAreas();
|
||||||
|
setAreas(areasData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching areas:", error);
|
||||||
|
setAreasError(true);
|
||||||
|
} finally {
|
||||||
|
setAreasLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNotes();
|
||||||
|
loadAreas();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openNoteModal = (note: Note | null = null) => {
|
const openNoteModal = (note: Note | null = null) => {
|
||||||
setSelectedNote(note);
|
setSelectedNote(note);
|
||||||
setIsNoteModalOpen(true);
|
setIsNoteModalOpen(true);
|
||||||
|
|
@ -127,20 +183,11 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
const handleSaveNote = async (noteData: Note) => {
|
const handleSaveNote = async (noteData: Note) => {
|
||||||
try {
|
try {
|
||||||
if (noteData.id) {
|
if (noteData.id) {
|
||||||
await updateNote(noteData.id, {
|
await updateNote(noteData.id, noteData);
|
||||||
title: noteData.title,
|
|
||||||
content: noteData.content,
|
|
||||||
tags: noteData.tags?.map((tag) => tag.name),
|
|
||||||
project_id: noteData.project?.id,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await createNote({
|
await createNote(noteData);
|
||||||
title: noteData.title,
|
|
||||||
content: noteData.content,
|
|
||||||
tags: noteData.tags?.map((tag) => tag.name),
|
|
||||||
project_id: noteData.project?.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
loadNotes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving note:", error);
|
console.error("Error saving note:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -154,12 +201,27 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
} else {
|
} else {
|
||||||
await createTask(taskData);
|
await createTask(taskData);
|
||||||
}
|
}
|
||||||
|
const { tasks } = await fetchTasks();
|
||||||
|
setTasks(tasks);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving task:", error);
|
console.error("Error saving task:", error);
|
||||||
}
|
}
|
||||||
closeTaskModal();
|
closeTaskModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateProject = async (name: string): Promise<Project> => {
|
||||||
|
try {
|
||||||
|
const newProject = await createProject({
|
||||||
|
name,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
return newProject;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating project:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveProject = async (projectData: Project) => {
|
const handleSaveProject = async (projectData: Project) => {
|
||||||
try {
|
try {
|
||||||
if (projectData.id) {
|
if (projectData.id) {
|
||||||
|
|
@ -167,29 +229,22 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
} else {
|
} else {
|
||||||
await createProject(projectData);
|
await createProject(projectData);
|
||||||
}
|
}
|
||||||
|
const projectsData = await fetchProjects();
|
||||||
|
setProjects(projectsData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving project:", error);
|
console.error("Error saving project:", error);
|
||||||
}
|
}
|
||||||
closeProjectModal();
|
closeProjectModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateProject = async (name: string): Promise<Project> => {
|
const handleSaveArea = async (areaData: Partial<Area>) => {
|
||||||
try {
|
|
||||||
const newProject = await createProject({ name });
|
|
||||||
return newProject;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating project:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveArea = async (areaData: Area) => {
|
|
||||||
try {
|
try {
|
||||||
if (areaData.id) {
|
if (areaData.id) {
|
||||||
await updateArea(areaData.id, areaData);
|
await updateArea(areaData.id, areaData);
|
||||||
} else {
|
} else {
|
||||||
await createArea(areaData);
|
await createArea(areaData);
|
||||||
}
|
}
|
||||||
|
loadAreas();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving area:", error);
|
console.error("Error saving area:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +258,8 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
} else {
|
} else {
|
||||||
await createTag(tagData);
|
await createTag(tagData);
|
||||||
}
|
}
|
||||||
|
const tagsData = await fetchTags();
|
||||||
|
setTags(tagsData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving tag:", error);
|
console.error("Error saving tag:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -211,6 +268,19 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
|
|
||||||
const mainContentMarginLeft = isSidebarOpen ? "ml-72" : "ml-0";
|
const mainContentMarginLeft = isSidebarOpen ? "ml-72" : "ml-0";
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isNotesLoading ||
|
||||||
|
isAreasLoading ||
|
||||||
|
isTasksLoading ||
|
||||||
|
isProjectsLoading ||
|
||||||
|
isTagsLoading;
|
||||||
|
const isError =
|
||||||
|
isNotesError ||
|
||||||
|
isAreasError ||
|
||||||
|
isTasksError ||
|
||||||
|
isProjectsError ||
|
||||||
|
isTagsError;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
|
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
|
||||||
|
|
@ -402,4 +472,3 @@ const Layout: React.FC<LayoutProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Layout;
|
export default Layout;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useDataContext } from '../../contexts/DataContext';
|
import { useStore } from '../../store/useStore';
|
||||||
|
import { Area } from '../../entities/Area';
|
||||||
|
|
||||||
const AreaDetails: React.FC = () => {
|
const AreaDetails: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { areas, isLoading, isError } = useDataContext();
|
const { areas } = useStore((state) => state.areasStore);
|
||||||
const [area, setArea] = useState<any | null>(null);
|
const [area, setArea] = useState<Area | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const foundArea = areas.find((a) => a.id === Number(id));
|
if (!areas.length) setIsLoading(true);
|
||||||
|
const foundArea = areas.find((a: Area) => a.id === Number(id));
|
||||||
setArea(foundArea || null);
|
setArea(foundArea || null);
|
||||||
|
if (!foundArea) {
|
||||||
|
setIsError(true);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
}, [id, areas]);
|
}, [id, areas]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -25,7 +33,7 @@ const AreaDetails: React.FC = () => {
|
||||||
if (isError || !area) {
|
if (isError || !area) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||||
<div className="textpro-red-500 text-lg">
|
<div className="text-red-500 text-lg">
|
||||||
{isError ? 'Error loading area details.' : 'Area not found.'}
|
{isError ? 'Error loading area details.' : 'Area not found.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,21 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Area } from '../../entities/Area';
|
import { Area } from '../../entities/Area';
|
||||||
import { useDataContext } from '../../contexts/DataContext';
|
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { useToast } from '../Shared/ToastContext';
|
import { useToast } from '../Shared/ToastContext';
|
||||||
|
|
||||||
interface AreaModalProps {
|
interface AreaModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (areaData: Area) => void;
|
onSave: (areaData: Partial<Area>) => Promise<void>;
|
||||||
area?: Area | null;
|
area?: Area | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave }) => {
|
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave }) => {
|
||||||
const { createArea, updateArea } = useDataContext();
|
|
||||||
const [formData, setFormData] = useState<Area>({
|
const [formData, setFormData] = useState<Area>({
|
||||||
id: area?.id || 0,
|
id: area?.id || 0,
|
||||||
name: area?.name || '',
|
name: area?.name || '',
|
||||||
description: area?.description || '',
|
description: area?.description || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
|
@ -60,6 +58,7 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
}
|
}
|
||||||
|
|
@ -88,14 +87,8 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (formData.id && formData.id !== 0) {
|
await onSave(formData);
|
||||||
await updateArea(formData.id, formData);
|
showSuccessToast(`Area ${formData.id ? 'updated' : 'created'} successfully!`);
|
||||||
showSuccessToast('Area updated successfully!');
|
|
||||||
} else {
|
|
||||||
await createArea(formData);
|
|
||||||
showSuccessToast('Area created successfully!');
|
|
||||||
}
|
|
||||||
onSave(formData);
|
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|
@ -116,17 +109,12 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div
|
<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 ${
|
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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-md overflow-hidden transform transition-transform duration-300 ${
|
className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-md overflow-hidden transform transition-transform duration-300 ${isClosing ? 'scale-95' : 'scale-100'} h-screen sm:h-auto flex flex-col`}
|
||||||
isClosing ? 'scale-95' : 'scale-100'
|
|
||||||
} h-screen sm:h-auto flex flex-col`}
|
|
||||||
style={{
|
style={{
|
||||||
maxHeight: 'calc(100vh - 4rem)',
|
maxHeight: 'calc(100vh - 4rem)',
|
||||||
}}
|
}}
|
||||||
|
|
@ -181,9 +169,7 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
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 ${
|
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 ${isSubmitting ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? 'Submitting...'
|
? 'Submitting...'
|
||||||
|
|
@ -196,7 +182,6 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
|
|
@ -7,17 +7,34 @@ import {
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import ConfirmDialog from './Shared/ConfirmDialog';
|
import ConfirmDialog from './Shared/ConfirmDialog';
|
||||||
import AreaModal from './Area/AreaModal';
|
import AreaModal from './Area/AreaModal';
|
||||||
import { useDataContext } from '../contexts/DataContext';
|
import { useStore } from '../store/useStore';
|
||||||
|
import { fetchAreas, createArea, updateArea, deleteArea } from '../utils/areasService';
|
||||||
import { Area } from '../entities/Area';
|
import { Area } from '../entities/Area';
|
||||||
|
|
||||||
const Areas: React.FC = () => {
|
const Areas: React.FC = () => {
|
||||||
const { areas, isLoading, isError, createArea, updateArea, deleteArea } = useDataContext();
|
const { areas, setAreas, setLoading, setError } = useStore((state) => state.areasStore);
|
||||||
|
|
||||||
const [isAreaModalOpen, setIsAreaModalOpen] = useState<boolean>(false);
|
const [isAreaModalOpen, setIsAreaModalOpen] = useState<boolean>(false);
|
||||||
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
||||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||||
const [areaToDelete, setAreaToDelete] = useState<Area | null>(null);
|
const [areaToDelete, setAreaToDelete] = useState<Area | null>(null);
|
||||||
|
|
||||||
const handleSaveArea = async (areaData: Area) => {
|
useEffect(() => {
|
||||||
|
const loadAreas = async () => {
|
||||||
|
try {
|
||||||
|
const areasData = await fetchAreas();
|
||||||
|
setAreas(areasData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching areas:', error);
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAreas();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveArea = async (areaData: Partial<Area>) => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (areaData.id) {
|
if (areaData.id) {
|
||||||
await updateArea(areaData.id, {
|
await updateArea(areaData.id, {
|
||||||
|
|
@ -30,9 +47,13 @@ const Areas: React.FC = () => {
|
||||||
description: areaData.description,
|
description: areaData.description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const updatedAreas = await fetchAreas();
|
||||||
|
setAreas(updatedAreas);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving area:', error);
|
console.error('Error saving area:', error);
|
||||||
|
setError(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
setIsAreaModalOpen(false);
|
setIsAreaModalOpen(false);
|
||||||
setSelectedArea(null);
|
setSelectedArea(null);
|
||||||
}
|
}
|
||||||
|
|
@ -56,12 +77,18 @@ const Areas: React.FC = () => {
|
||||||
const handleDeleteArea = async () => {
|
const handleDeleteArea = async () => {
|
||||||
if (!areaToDelete) return;
|
if (!areaToDelete) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await deleteArea(areaToDelete.id!);
|
await deleteArea(areaToDelete.id!);
|
||||||
|
const updatedAreas = await fetchAreas();
|
||||||
|
setAreas(updatedAreas);
|
||||||
setIsConfirmDialogOpen(false);
|
setIsConfirmDialogOpen(false);
|
||||||
setAreaToDelete(null);
|
setAreaToDelete(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting area:', error);
|
console.error('Error deleting area:', error);
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -70,26 +97,8 @@ const Areas: React.FC = () => {
|
||||||
setAreaToDelete(null);
|
setAreaToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
<div className="flex justify-center px-4 lg:px-2">
|
||||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
|
||||||
Loading areas...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="text-red-500 p-4">
|
|
||||||
An error occurred while fetching areas.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center px-4 lg:px-2 ">
|
|
||||||
<div className="w-full max-w-5xl">
|
<div className="w-full max-w-5xl">
|
||||||
{/* Areas Header */}
|
{/* Areas Header */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|
@ -99,6 +108,12 @@ const Areas: React.FC = () => {
|
||||||
Areas
|
Areas
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateArea}
|
||||||
|
className="bg-blue-500 text-white rounded-md px-4 py-2 hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Add Area
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Areas List */}
|
{/* Areas List */}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,62 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { PencilSquareIcon, TrashIcon, TagIcon, DocumentTextIcon } from '@heroicons/react/24/solid';
|
import { PencilSquareIcon, TrashIcon, TagIcon, DocumentTextIcon } from '@heroicons/react/24/solid';
|
||||||
import { useDataContext } from '../../contexts/DataContext';
|
|
||||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||||
import NoteModal from './NoteModal';
|
import NoteModal from './NoteModal';
|
||||||
import { Note } from '../../entities/Note';
|
import { Note } from '../../entities/Note';
|
||||||
|
import { fetchNotes, deleteNote as apiDeleteNote, updateNote as apiUpdateNote } from '../../utils/notesService';
|
||||||
|
|
||||||
const NoteDetails: React.FC = () => {
|
const NoteDetails: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { notes, deleteNote, isLoading, isError } = useDataContext();
|
|
||||||
const [note, setNote] = useState<Note | null>(null);
|
const [note, setNote] = useState<Note | null>(null);
|
||||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const foundNote = notes.find((n) => n.id === Number(id));
|
const fetchNote = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const notes = await fetchNotes();
|
||||||
|
const foundNote = notes.find((n: Note) => n.id === Number(id));
|
||||||
setNote(foundNote || null);
|
setNote(foundNote || null);
|
||||||
}, [id, notes]);
|
if (!foundNote) {
|
||||||
|
setIsError(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setIsError(true);
|
||||||
|
console.error('Error fetching note:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchNote();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
const handleDeleteNote = async () => {
|
const handleDeleteNote = async () => {
|
||||||
if (!noteToDelete) return;
|
if (!noteToDelete) return;
|
||||||
try {
|
try {
|
||||||
await deleteNote(noteToDelete.id!);
|
await apiDeleteNote(noteToDelete.id!);
|
||||||
navigate('/notes');
|
navigate('/notes');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting note:', err);
|
console.error('Error deleting note:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveNote = (updatedNote: Note) => {
|
const handleSaveNote = async (updatedNote: Note) => {
|
||||||
|
try {
|
||||||
|
if (updatedNote.id !== undefined) {
|
||||||
|
await apiUpdateNote(updatedNote.id, updatedNote);
|
||||||
setNote(updatedNote);
|
setNote(updatedNote);
|
||||||
|
} else {
|
||||||
|
console.error("Error: Note ID is undefined.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving note:', err);
|
||||||
|
}
|
||||||
setIsNoteModalOpen(false);
|
setIsNoteModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -66,7 +90,7 @@ const NoteDetails: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center px-4 lg:px-2 ">
|
<div className="flex justify-center px-4 lg:px-2">
|
||||||
<div className="w-full max-w-5xl">
|
<div className="w-full max-w-5xl">
|
||||||
{/* Header Section with Title and Action Buttons */}
|
{/* Header Section with Title and Action Buttons */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|
@ -76,7 +100,6 @@ const NoteDetails: React.FC = () => {
|
||||||
{note.title}
|
{note.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
|
|
@ -97,7 +120,6 @@ const NoteDetails: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card with Tags and Metadata */}
|
{/* Card with Tags and Metadata */}
|
||||||
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
|
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
|
||||||
{/* Note Tags */}
|
{/* Note Tags */}
|
||||||
|
|
@ -111,23 +133,25 @@ const NoteDetails: React.FC = () => {
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
|
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
|
||||||
<span className="text-xs text-gray-700 dark:text-gray-300">{tag.name}</span>
|
<span className="text-xs text-gray-700 dark:text-gray-300">
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Note Metadata */}
|
{/* Note Metadata */}
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
<p>Created on: {new Date(note.created_at || '').toLocaleDateString()}</p>
|
<p>Created on: {new Date(note.created_at || '').toLocaleDateString()}</p>
|
||||||
<p>Last updated: {new Date(note.updated_at || '').toLocaleDateString()}</p>
|
<p>Last updated: {new Date(note.updated_at || '').toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note Project */}
|
{/* Note Project */}
|
||||||
{note.project && (
|
{note.project && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Project</h3>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Project
|
||||||
|
</h3>
|
||||||
<Link
|
<Link
|
||||||
to={`/project/${note.project.id}`}
|
to={`/project/${note.project.id}`}
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
|
@ -137,14 +161,12 @@ const NoteDetails: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note Content */}
|
{/* Note Content */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-line">
|
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-line">
|
||||||
{note.content}
|
{note.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* NoteModal for editing */}
|
{/* NoteModal for editing */}
|
||||||
{isNoteModalOpen && (
|
{isNoteModalOpen && (
|
||||||
<NoteModal
|
<NoteModal
|
||||||
|
|
@ -154,7 +176,6 @@ const NoteDetails: React.FC = () => {
|
||||||
note={note}
|
note={note}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ConfirmDialog */}
|
{/* ConfirmDialog */}
|
||||||
{isConfirmDialogOpen && noteToDelete && (
|
{isConfirmDialogOpen && noteToDelete && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Note } from '../../entities/Note';
|
import { Note } from '../../entities/Note';
|
||||||
import { useDataContext } from '../../contexts/DataContext';
|
|
||||||
import { useToast } from '../Shared/ToastContext';
|
import { useToast } from '../Shared/ToastContext';
|
||||||
import TagInput from '../Tag/TagInput';
|
import TagInput from '../Tag/TagInput';
|
||||||
import { Tag } from '../../entities/Tag';
|
import { Tag } from '../../entities/Tag';
|
||||||
|
import { fetchTags } from '../../utils/tagsService';
|
||||||
|
|
||||||
interface NoteModalProps {
|
interface NoteModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
note?: Note | null;
|
note?: Note | null;
|
||||||
onSave: (noteData: Note) => void;
|
onSave: (noteData: Note) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave }) => {
|
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave }) => {
|
||||||
const { createNote, updateNote } = useDataContext();
|
|
||||||
const [formData, setFormData] = useState<Note>({
|
const [formData, setFormData] = useState<Note>({
|
||||||
id: note?.id || 0,
|
id: note?.id || 0,
|
||||||
title: note?.title || '',
|
title: note?.title || '',
|
||||||
|
|
@ -30,14 +29,18 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
||||||
const { showSuccessToast, showErrorToast } = useToast();
|
const { showSuccessToast, showErrorToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
const loadTags = async () => {
|
||||||
fetch('/api/tags')
|
try {
|
||||||
.then((response) => response.json())
|
const data = await fetchTags();
|
||||||
.then((data: Tag[]) => setAvailableTags(data))
|
setAvailableTags(data);
|
||||||
.catch((error) => {
|
} catch (error) {
|
||||||
console.error('Failed to fetch tags', error);
|
console.error('Failed to fetch tags', error);
|
||||||
showErrorToast('Failed to load available tags.');
|
showErrorToast('Failed to load available tags.');
|
||||||
});
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
loadTags();
|
||||||
}
|
}
|
||||||
}, [isOpen, showErrorToast]);
|
}, [isOpen, showErrorToast]);
|
||||||
|
|
||||||
|
|
@ -78,6 +81,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
}
|
}
|
||||||
|
|
@ -114,14 +118,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (formData.id && formData.id !== 0) {
|
const noteTags: Tag[] = tags.map(tagName => ({ name: tagName }));
|
||||||
await updateNote(formData.id, { ...formData, tags });
|
await onSave({ ...formData, tags: noteTags });
|
||||||
showSuccessToast('Note updated successfully!');
|
showSuccessToast(formData.id && formData.id !== 0 ? 'Note updated successfully!' : 'Note created successfully!');
|
||||||
} else {
|
|
||||||
await createNote({ ...formData, tags });
|
|
||||||
showSuccessToast('Note created successfully!');
|
|
||||||
}
|
|
||||||
onSave(formData);
|
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|
@ -142,7 +141,6 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div
|
<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 ${
|
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'
|
||||||
|
|
@ -231,7 +229,6 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,53 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { BookOpenIcon, PencilSquareIcon, TrashIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
import {
|
||||||
|
BookOpenIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
TrashIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
} from '@heroicons/react/24/solid';
|
||||||
import NoteModal from './Note/NoteModal';
|
import NoteModal from './Note/NoteModal';
|
||||||
import ConfirmDialog from './Shared/ConfirmDialog';
|
import ConfirmDialog from './Shared/ConfirmDialog';
|
||||||
import { useDataContext } from '../contexts/DataContext';
|
|
||||||
import { Note } from '../entities/Note';
|
import { Note } from '../entities/Note';
|
||||||
|
import {
|
||||||
|
fetchNotes,
|
||||||
|
createNote,
|
||||||
|
updateNote,
|
||||||
|
deleteNote as apiDeleteNote,
|
||||||
|
} from '../utils/notesService';
|
||||||
|
|
||||||
const Notes: React.FC = () => {
|
const Notes: React.FC = () => {
|
||||||
const { notes, createNote, updateNote, deleteNote, isLoading, isError } = useDataContext();
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadNotes = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const fetchedNotes = await fetchNotes();
|
||||||
|
setNotes(fetchedNotes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading notes:', error);
|
||||||
|
setIsError(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadNotes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDeleteNote = async () => {
|
const handleDeleteNote = async () => {
|
||||||
if (!noteToDelete) return;
|
if (!noteToDelete) return;
|
||||||
try {
|
try {
|
||||||
await deleteNote(noteToDelete.id!);
|
await apiDeleteNote(noteToDelete.id!);
|
||||||
|
setNotes((prev) => prev.filter((note) => note.id !== noteToDelete.id));
|
||||||
setIsConfirmDialogOpen(false);
|
setIsConfirmDialogOpen(false);
|
||||||
setNoteToDelete(null);
|
setNoteToDelete(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -32,11 +62,17 @@ const Notes: React.FC = () => {
|
||||||
|
|
||||||
const handleSaveNote = async (noteData: Note) => {
|
const handleSaveNote = async (noteData: Note) => {
|
||||||
try {
|
try {
|
||||||
|
let updatedNotes;
|
||||||
if (noteData.id) {
|
if (noteData.id) {
|
||||||
await updateNote(noteData.id, noteData);
|
await updateNote(noteData.id, noteData);
|
||||||
|
updatedNotes = notes.map((note) =>
|
||||||
|
note.id === noteData.id ? noteData : note
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await createNote(noteData);
|
const newNote = await createNote(noteData);
|
||||||
|
updatedNotes = [...notes, newNote];
|
||||||
}
|
}
|
||||||
|
setNotes(updatedNotes);
|
||||||
setIsNoteModalOpen(false);
|
setIsNoteModalOpen(false);
|
||||||
setSelectedNote(null);
|
setSelectedNote(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -69,13 +105,15 @@ const Notes: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center px-4 lg:px-2 ">
|
<div className="flex justify-center px-4 lg:px-2">
|
||||||
<div className="w-full max-w-5xl">
|
<div className="w-full max-w-5xl">
|
||||||
{/* Notes Header */}
|
{/* Notes Header */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<BookOpenIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
|
<BookOpenIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
|
||||||
<h2 className="text-2xl font-light text-gray-900 dark:text-white">Notes</h2>
|
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
|
||||||
|
Notes
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -99,7 +137,10 @@ const Notes: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{filteredNotes.map((note) => (
|
{filteredNotes.map((note) => (
|
||||||
<li key={note.id} className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center">
|
<li
|
||||||
|
key={note.id}
|
||||||
|
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
|
||||||
|
>
|
||||||
<div className="flex-grow overflow-hidden pr-4">
|
<div className="flex-grow overflow-hidden pr-4">
|
||||||
<Link
|
<Link
|
||||||
to={`/note/${note.id}`}
|
to={`/note/${note.id}`}
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,19 @@ import {
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
Squares2X2Icon,
|
Squares2X2Icon
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import TaskList from "../Task/TaskList";
|
import TaskList from "../Task/TaskList";
|
||||||
import ProjectModal from "../Project/ProjectModal";
|
import ProjectModal from "../Project/ProjectModal";
|
||||||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||||
import { useDataContext } from "../../contexts/DataContext";
|
import { useStore } from "../../store/useStore";
|
||||||
import NewTask from "../Task/NewTask";
|
import NewTask from "../Task/NewTask";
|
||||||
import { Project } from "../../entities/Project";
|
import { Project } from "../../entities/Project";
|
||||||
import { PriorityType, Task } from "../../entities/Task";
|
import { PriorityType, Task } from "../../entities/Task";
|
||||||
|
import { fetchProjectById, updateProject, deleteProject } from "../../utils/projectsService";
|
||||||
|
import { createTask, updateTask, deleteTask } from "../../utils/tasksService";
|
||||||
|
import { fetchAreas } from "../../utils/areasService";
|
||||||
|
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
type PriorityStyles = Record<PriorityType, string> & { default: string };
|
type PriorityStyles = Record<PriorityType, string> & { default: string };
|
||||||
|
|
||||||
|
|
@ -24,11 +28,10 @@ const priorityStyles: PriorityStyles = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectDetails: React.FC = () => {
|
const ProjectDetails: React.FC = () => {
|
||||||
const { updateTask, deleteTask, updateProject, deleteProject } = useDataContext();
|
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { areas } = useDataContext();
|
const areas = useStore((state) => state.areasStore.areas);
|
||||||
|
|
||||||
const [project, setProject] = useState<Project | undefined>(undefined);
|
const [project, setProject] = useState<Project | undefined>(undefined);
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
|
@ -36,71 +39,51 @@ const ProjectDetails: React.FC = () => {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||||
|
|
||||||
const projectTitle = project?.name || "Project";
|
|
||||||
|
|
||||||
const [isCompletedOpen, setIsCompletedOpen] = useState(false);
|
const [isCompletedOpen, setIsCompletedOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProject = async () => {
|
const loadProjectData = async () => {
|
||||||
try {
|
if (!id) {
|
||||||
const response = await fetch(`/api/project/${id}`, {
|
console.error("Project ID is missing.");
|
||||||
credentials: "include",
|
return;
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
setProject(data);
|
|
||||||
setTasks(data.tasks || []);
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || "Failed to fetch project.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
fetchAreas();
|
||||||
|
const projectData = await fetchProjectById(id);
|
||||||
|
setProject(projectData);
|
||||||
|
setTasks(projectData.tasks || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError((error as Error).message);
|
console.error("Error fetching project data:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchProject();
|
loadProjectData();
|
||||||
}, [id]);
|
}, [id, fetchAreas]);
|
||||||
|
|
||||||
const handleTaskCreate = async (taskName: string) => {
|
const handleTaskCreate = async (taskName: string) => {
|
||||||
if (!project || project.id === undefined) {
|
if (!project) {
|
||||||
console.error("Cannot create task: Project or Project ID is missing");
|
console.error("Cannot create task: Project is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskPayload = {
|
try {
|
||||||
|
const newTask = await createTask({
|
||||||
name: taskName,
|
name: taskName,
|
||||||
status: "not_started",
|
status: "not_started",
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/task`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(taskPayload),
|
|
||||||
});
|
});
|
||||||
|
setTasks((prevTasks) => [...prevTasks, newTask]);
|
||||||
const newTask = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
setTasks([...tasks, newTask]);
|
|
||||||
} else {
|
|
||||||
throw new Error(newTask.error || "Failed to create task");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error creating task:", err);
|
console.error("Error creating task:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTaskUpdate = async (updatedTask: Task) => {
|
const handleTaskUpdate = async (updatedTask: Task) => {
|
||||||
if (updatedTask.id === undefined) {
|
if (!updatedTask.id) {
|
||||||
console.error("Cannot update task: Task ID is missing");
|
console.error("Cannot update task: Task ID is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +100,7 @@ const ProjectDetails: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTaskDelete = async (taskId: number | undefined) => {
|
const handleTaskDelete = async (taskId: number | undefined) => {
|
||||||
if (taskId === undefined) {
|
if (!taskId) {
|
||||||
console.error("Cannot delete task: Task ID is missing");
|
console.error("Cannot delete task: Task ID is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -134,8 +117,8 @@ const ProjectDetails: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveProject = async (updatedProject: Project) => {
|
const handleSaveProject = async (updatedProject: Project) => {
|
||||||
if (!updatedProject || updatedProject.id === undefined) {
|
if (!updatedProject.id) {
|
||||||
console.error("Cannot save project: Project or Project ID is missing");
|
console.error("Cannot save project: Project ID is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,8 +132,8 @@ const ProjectDetails: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProject = async () => {
|
const handleDeleteProject = async () => {
|
||||||
if (!project || project.id === undefined) {
|
if (!project?.id) {
|
||||||
console.error("Cannot delete project: Project or Project ID is missing");
|
console.error("Cannot delete project: Project ID is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,8 +171,8 @@ const ProjectDetails: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTasks = tasks.filter(task => task.status !== 'done');
|
const activeTasks = tasks?.filter((task) => task.status !== 'done') || []; //TODO: Also add archived
|
||||||
const completedTasks = tasks.filter(task => task.status === 'done');
|
const completedTasks = tasks?.filter((task) => task.status === 'done');
|
||||||
|
|
||||||
const toggleCompleted = () => {
|
const toggleCompleted = () => {
|
||||||
setIsCompletedOpen(!isCompletedOpen);
|
setIsCompletedOpen(!isCompletedOpen);
|
||||||
|
|
@ -201,29 +184,27 @@ const ProjectDetails: React.FC = () => {
|
||||||
{/* Project Header */}
|
{/* Project Header */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
|
<FolderIcon className="h-6 w-6 text-gray-500 mr-3" />
|
||||||
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100 mr-2">
|
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100 mr-2">
|
||||||
{projectTitle}
|
{project.name}
|
||||||
</h2>
|
</h2>
|
||||||
{/* Priority Circle placed after the title */}
|
|
||||||
{project.priority && (
|
{project.priority && (
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${priorityStyles[project.priority] || priorityStyles.default}`}
|
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${
|
||||||
|
priorityStyles[project.priority] || priorityStyles.default
|
||||||
|
}`}
|
||||||
title={`Priority: ${priorityLabel(project.priority)}`}
|
title={`Priority: ${priorityLabel(project.priority)}`}
|
||||||
aria-label={`Priority: ${priorityLabel(project.priority)}`}
|
aria-label={`Priority: ${priorityLabel(project.priority)}`}
|
||||||
></div>
|
></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{/* Edit Project Button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleEditProject}
|
onClick={handleEditProject}
|
||||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||||
>
|
>
|
||||||
<PencilSquareIcon className="h-5 w-5" />
|
<PencilSquareIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Delete Project Button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsConfirmDialogOpen(true)}
|
onClick={() => setIsConfirmDialogOpen(true)}
|
||||||
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
||||||
|
|
@ -233,9 +214,8 @@ const ProjectDetails: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Area */}
|
|
||||||
{project.area && (
|
{project.area && (
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-2">
|
||||||
<Squares2X2Icon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
<Squares2X2Icon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||||
<Link
|
<Link
|
||||||
to={`/projects/?area_id=${project.area.id}`}
|
to={`/projects/?area_id=${project.area.id}`}
|
||||||
|
|
@ -246,19 +226,22 @@ const ProjectDetails: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Project Description */}
|
{project.due_date_at && (
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<CalendarDaysIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||||
|
{project.due_date_at}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
<p className="flex items-center text-gray-700 dark:text-gray-300 mb-6">
|
||||||
|
<InformationCircleIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New Task Form */}
|
<NewTask onTaskCreate={handleTaskCreate} />
|
||||||
<NewTask
|
|
||||||
onTaskCreate={handleTaskCreate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Active Tasks */}
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{activeTasks.length > 0 ? (
|
{activeTasks.length > 0 ? (
|
||||||
<TaskList
|
<TaskList
|
||||||
|
|
@ -272,7 +255,6 @@ const ProjectDetails: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapsible Completed Tasks */}
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<button
|
<button
|
||||||
onClick={toggleCompleted}
|
onClick={toggleCompleted}
|
||||||
|
|
@ -298,7 +280,7 @@ const ProjectDetails: React.FC = () => {
|
||||||
|
|
||||||
{isCompletedOpen && (
|
{isCompletedOpen && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{completedTasks.length > 0 ? (
|
{completedTasks && completedTasks.length > 0 ? (
|
||||||
<TaskList
|
<TaskList
|
||||||
tasks={completedTasks}
|
tasks={completedTasks}
|
||||||
onTaskUpdate={handleTaskUpdate}
|
onTaskUpdate={handleTaskUpdate}
|
||||||
|
|
@ -314,7 +296,6 @@ const ProjectDetails: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<ProjectModal
|
<ProjectModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
|
@ -323,7 +304,6 @@ const ProjectDetails: React.FC = () => {
|
||||||
areas={areas}
|
areas={areas}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Confirm Delete Dialog */}
|
|
||||||
{isConfirmDialogOpen && (
|
{isConfirmDialogOpen && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete Project"
|
title="Delete Project"
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import { Project } from "../../entities/Project";
|
||||||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||||
import { useToast } from "../Shared/ToastContext";
|
import { useToast } from "../Shared/ToastContext";
|
||||||
import TagInput from "../Tag/TagInput";
|
import TagInput from "../Tag/TagInput";
|
||||||
import useFetchTags from "../../hooks/useFetchTags";
|
|
||||||
import PriorityDropdown from "../Shared/PriorityDropdown";
|
import PriorityDropdown from "../Shared/PriorityDropdown";
|
||||||
import { PriorityType } from "../../entities/Task";
|
import { PriorityType } from "../../entities/Task";
|
||||||
import Switch from "../Shared/Switch";
|
import Switch from "../Shared/Switch";
|
||||||
|
import { useStore } from "../../store/useStore";
|
||||||
|
import { fetchTags } from "../../utils/tagsService";
|
||||||
|
|
||||||
interface ProjectModalProps {
|
interface ProjectModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -34,6 +35,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
active: true,
|
active: true,
|
||||||
tags: [],
|
tags: [],
|
||||||
priority: "low",
|
priority: "low",
|
||||||
|
due_date_at: "",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -41,23 +43,21 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
project?.tags?.map((tag) => tag.name) || []
|
project?.tags?.map((tag) => tag.name) || []
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const { tagsStore } = useStore();
|
||||||
tags: availableTags,
|
const { tags: availableTags } = tagsStore;
|
||||||
isLoading: isTagsLoading,
|
|
||||||
isError: isTagsError,
|
|
||||||
} = useFetchTags();
|
|
||||||
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
|
||||||
const { showSuccessToast, showErrorToast } = useToast();
|
const { showSuccessToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (project) {
|
if (project) {
|
||||||
setFormData({
|
setFormData({
|
||||||
...project,
|
...project,
|
||||||
tags: project.tags || [],
|
tags: project.tags || [],
|
||||||
|
due_date_at: project.due_date_at || "",
|
||||||
});
|
});
|
||||||
setTags(project.tags?.map((tag) => tag.name) || []);
|
setTags(project.tags?.map((tag) => tag.name) || []);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -67,11 +67,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
area_id: null,
|
area_id: null,
|
||||||
active: true,
|
active: true,
|
||||||
tags: [],
|
tags: [],
|
||||||
|
priority: "low",
|
||||||
|
due_date_at: "",
|
||||||
});
|
});
|
||||||
setTags([]);
|
setTags([]);
|
||||||
}
|
}
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableTags.length === 0) {
|
||||||
|
fetchTags();
|
||||||
|
}
|
||||||
|
}, [availableTags.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -176,35 +184,13 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
|
|
||||||
if (!isOpen) return null;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Modal Overlay */}
|
|
||||||
<div
|
<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 ${
|
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
|
<div
|
||||||
ref={modalRef}
|
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 ${
|
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 ${
|
||||||
|
|
@ -214,12 +200,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
maxHeight: "calc(100vh - 4rem)",
|
maxHeight: "calc(100vh - 4rem)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Form */}
|
|
||||||
<form className="flex flex-col flex-1">
|
<form className="flex flex-col flex-1">
|
||||||
<fieldset 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">
|
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto">
|
||||||
{/* Project Name */}
|
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -233,7 +216,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Description
|
Description
|
||||||
|
|
@ -249,6 +231,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pb-3">
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Due Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="due_date_at"
|
||||||
|
value={formData.due_date_at || ""}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
Priority
|
Priority
|
||||||
|
|
@ -261,7 +256,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Tags
|
Tags
|
||||||
|
|
@ -275,7 +269,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Area */}
|
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Area (optional)
|
Area (optional)
|
||||||
|
|
@ -296,7 +289,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Checkbox */}
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Switch
|
<Switch
|
||||||
isChecked={formData.active}
|
isChecked={formData.active}
|
||||||
|
|
@ -311,7 +303,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="p-3 flex-shrink-0 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-2">
|
<div className="p-3 flex-shrink-0 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-2">
|
||||||
{project && onDelete && (
|
{project && onDelete && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -342,7 +333,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confirmation Dialog for Deletion */}
|
|
||||||
{showConfirmDialog && (
|
{showConfirmDialog && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete Project"
|
title="Delete Project"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Project } from "../entities/Project";
|
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
|
|
@ -8,8 +7,11 @@ import {
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import ConfirmDialog from "./Shared/ConfirmDialog";
|
import ConfirmDialog from "./Shared/ConfirmDialog";
|
||||||
import ProjectModal from "./Project/ProjectModal";
|
import ProjectModal from "./Project/ProjectModal";
|
||||||
import { useDataContext } from "../contexts/DataContext";
|
import { useStore } from "../store/useStore";
|
||||||
import useFetchProjects from "../hooks/useFetchProjects";
|
import { fetchProjects, createProject, updateProject, deleteProject } from "../utils/projectsService";
|
||||||
|
import { fetchAreas } from "../utils/areasService";
|
||||||
|
|
||||||
|
import { Project } from "../entities/Project";
|
||||||
import { PriorityType, StatusType } from "../entities/Task";
|
import { PriorityType, StatusType } from "../entities/Task";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import ProjectItem from "./Project/ProjectItem";
|
import ProjectItem from "./Project/ProjectItem";
|
||||||
|
|
@ -30,7 +32,10 @@ const getPriorityStyles = (priority: PriorityType) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const Projects: React.FC = () => {
|
const Projects: React.FC = () => {
|
||||||
const { areas, createProject, updateProject, deleteProject } = useDataContext();
|
const { areas, setAreas, setLoading: setAreasLoading, setError: setAreasError } = useStore((state) => state.areasStore);
|
||||||
|
const { projects, setProjects, setLoading: setProjectsLoading, setError: setProjectsError } = useStore((state) => state.projectsStore);
|
||||||
|
const { isLoading, isError } = useStore((state) => state.projectsStore);
|
||||||
|
|
||||||
const [taskStatusCounts, setTaskStatusCounts] = useState<Record<number, ProjectTaskCounts>>({});
|
const [taskStatusCounts, setTaskStatusCounts] = useState<Record<number, ProjectTaskCounts>>({});
|
||||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState<boolean>(false);
|
const [isProjectModalOpen, setIsProjectModalOpen] = useState<boolean>(false);
|
||||||
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||||
|
|
@ -44,17 +49,79 @@ const Projects: React.FC = () => {
|
||||||
const activeFilter = searchParams.get("active") || "all";
|
const activeFilter = searchParams.get("active") || "all";
|
||||||
const areaFilter = searchParams.get("area_id") || "";
|
const areaFilter = searchParams.get("area_id") || "";
|
||||||
|
|
||||||
const {
|
useEffect(() => {
|
||||||
projects,
|
const loadAreas = async () => {
|
||||||
taskStatusCounts: fetchedTaskStatusCounts,
|
try {
|
||||||
isLoading,
|
const areasData = await fetchAreas();
|
||||||
isError,
|
setAreas(areasData);
|
||||||
mutate,
|
} catch (error) {
|
||||||
} = useFetchProjects({ activeFilter, areaFilter });
|
console.error("Failed to fetch areas:", error);
|
||||||
|
setAreasError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
loadAreas();
|
||||||
setTaskStatusCounts(fetchedTaskStatusCounts || {});
|
}, []);
|
||||||
}, [fetchedTaskStatusCounts]);
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const projectsData = await fetchProjects(activeFilter, areaFilter);
|
||||||
|
setProjects(projectsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch projects:", error);
|
||||||
|
setProjectsError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProjects();
|
||||||
|
}, [activeFilter, areaFilter]);
|
||||||
|
|
||||||
|
const handleSaveProject = async (project: Project) => {
|
||||||
|
setProjectsLoading(true);
|
||||||
|
try {
|
||||||
|
if (project.id) {
|
||||||
|
await updateProject(project.id, project);
|
||||||
|
} else {
|
||||||
|
await createProject(project);
|
||||||
|
}
|
||||||
|
const updatedProjects = await fetchProjects(activeFilter, areaFilter);
|
||||||
|
setProjects(updatedProjects);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving project:", error);
|
||||||
|
setProjectsError(true);
|
||||||
|
} finally {
|
||||||
|
setProjectsLoading(false);
|
||||||
|
setIsProjectModalOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditProject = (project: Project) => {
|
||||||
|
setProjectToEdit(project);
|
||||||
|
setIsProjectModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = async () => {
|
||||||
|
if (!projectToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (projectToDelete.id !== undefined) {
|
||||||
|
setProjectsLoading(true);
|
||||||
|
await deleteProject(projectToDelete.id);
|
||||||
|
const updatedProjects = await fetchProjects(activeFilter, areaFilter);
|
||||||
|
setProjects(updatedProjects);
|
||||||
|
} else {
|
||||||
|
console.error("Cannot delete project: ID is undefined.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting project:", error);
|
||||||
|
setProjectsError(true);
|
||||||
|
} finally {
|
||||||
|
setProjectsLoading(false);
|
||||||
|
setIsConfirmDialogOpen(false);
|
||||||
|
setProjectToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getCompletionPercentage = (projectId: number | undefined) => {
|
const getCompletionPercentage = (projectId: number | undefined) => {
|
||||||
if (!projectId) return 0;
|
if (!projectId) return 0;
|
||||||
|
|
@ -69,29 +136,6 @@ const Projects: React.FC = () => {
|
||||||
return Math.round((taskStatus.done / totalTasks) * 100);
|
return Math.round((taskStatus.done / totalTasks) * 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveProject = async (project: Project) => {
|
|
||||||
if (project.id) {
|
|
||||||
await updateProject(project.id, project);
|
|
||||||
} else {
|
|
||||||
await createProject(project);
|
|
||||||
}
|
|
||||||
setIsProjectModalOpen(false);
|
|
||||||
mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditProject = (project: Project) => {
|
|
||||||
setProjectToEdit(project);
|
|
||||||
setIsProjectModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteProject = async () => {
|
|
||||||
if (!projectToDelete) return;
|
|
||||||
await deleteProject(projectToDelete.id!);
|
|
||||||
setIsConfirmDialogOpen(false);
|
|
||||||
setProjectToDelete(null);
|
|
||||||
mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActiveFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleActiveFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const newActiveFilter = e.target.value;
|
const newActiveFilter = e.target.value;
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
@ -121,6 +165,16 @@ const Projects: React.FC = () => {
|
||||||
project.name.toLowerCase().includes(searchQuery.toLowerCase())
|
project.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const groupedProjects = filteredProjects.reduce<Record<string, Project[]>>(
|
||||||
|
(acc, project) => {
|
||||||
|
const areaName = project.area ? project.area.name : "Uncategorized";
|
||||||
|
if (!acc[areaName]) acc[areaName] = [];
|
||||||
|
acc[areaName].push(project);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||||
|
|
@ -139,16 +193,6 @@ const Projects: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupedProjects = filteredProjects.reduce<Record<string, Project[]>>(
|
|
||||||
(acc, project) => {
|
|
||||||
const areaName = project.area ? project.area.name : "Uncategorized";
|
|
||||||
if (!acc[areaName]) acc[areaName] = [];
|
|
||||||
acc[areaName].push(project);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center px-4 lg:px-2">
|
<div className="flex justify-center px-4 lg:px-2">
|
||||||
<div className="w-full max-w-6xl">
|
<div className="w-full max-w-6xl">
|
||||||
|
|
@ -222,7 +266,7 @@ const Projects: React.FC = () => {
|
||||||
>
|
>
|
||||||
<option value="">All Areas</option>
|
<option value="">All Areas</option>
|
||||||
{areas.map((area) => (
|
{areas.map((area) => (
|
||||||
<option key={area.id} value={area.id.toString()}>
|
<option key={area.id} value={area.id?.toString()}>
|
||||||
{area.name}
|
{area.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|
@ -250,7 +294,7 @@ const Projects: React.FC = () => {
|
||||||
className={`${
|
className={`${
|
||||||
viewMode === "cards"
|
viewMode === "cards"
|
||||||
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||||
: "flex flex-col space-y-4"
|
: "flex flex-col space-y-1"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{Object.keys(groupedProjects).length === 0 ? (
|
{Object.keys(groupedProjects).length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// Switch.tsx
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface SwitchProps {
|
interface SwitchProps {
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
||||||
};
|
};
|
||||||
|
|
||||||
const addNewTag = (tag: string) => {
|
const addNewTag = (tag: string) => {
|
||||||
if (tags.length >= 10) { // Example limit
|
if (tags.length >= 10) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,43 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { PencilSquareIcon, TrashIcon, TagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
import { PencilSquareIcon, TrashIcon, TagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
||||||
import ConfirmDialog from './Shared/ConfirmDialog';
|
import ConfirmDialog from './Shared/ConfirmDialog';
|
||||||
import TagModal from './Tag/TagModal';
|
import TagModal from './Tag/TagModal';
|
||||||
import { useDataContext } from '../contexts/DataContext';
|
|
||||||
import { Tag } from '../entities/Tag';
|
import { Tag } from '../entities/Tag';
|
||||||
|
import { fetchTags, createTag, updateTag, deleteTag as apiDeleteTag } from '../utils/tagsService';
|
||||||
|
|
||||||
const Tags: React.FC = () => {
|
const Tags: React.FC = () => {
|
||||||
const { tags, createTag, updateTag, deleteTag, isLoading, isError } = useDataContext();
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
|
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
|
||||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||||
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
|
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isError, setIsError] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTags = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const fetchedTags = await fetchTags();
|
||||||
|
setTags(fetchedTags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch tags:', error);
|
||||||
|
setIsError(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDeleteTag = async () => {
|
const handleDeleteTag = async () => {
|
||||||
if (!tagToDelete) return;
|
if (!tagToDelete) return;
|
||||||
try {
|
try {
|
||||||
await deleteTag(tagToDelete.id!);
|
await apiDeleteTag(tagToDelete.id!);
|
||||||
|
setTags((prev) => prev.filter((tag) => tag.id !== tagToDelete.id));
|
||||||
setIsConfirmDialogOpen(false);
|
setIsConfirmDialogOpen(false);
|
||||||
setTagToDelete(null);
|
setTagToDelete(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -32,17 +52,20 @@ const Tags: React.FC = () => {
|
||||||
|
|
||||||
const handleSaveTag = async (tagData: Tag) => {
|
const handleSaveTag = async (tagData: Tag) => {
|
||||||
try {
|
try {
|
||||||
|
let updatedTags;
|
||||||
if (tagData.id) {
|
if (tagData.id) {
|
||||||
await updateTag(tagData.id, tagData);
|
await updateTag(tagData.id, tagData);
|
||||||
|
updatedTags = tags.map((tag) => (tag.id === tagData.id ? tagData : tag));
|
||||||
} else {
|
} else {
|
||||||
await createTag(tagData);
|
const newTag = await createTag(tagData);
|
||||||
|
updatedTags = [...tags, newTag];
|
||||||
}
|
}
|
||||||
|
setTags(updatedTags);
|
||||||
|
setIsTagModalOpen(false);
|
||||||
|
setSelectedTag(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save tag:', err);
|
console.error('Failed to save tag:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsTagModalOpen(false);
|
|
||||||
setSelectedTag(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openConfirmDialog = (tag: Tag) => {
|
const openConfirmDialog = (tag: Tag) => {
|
||||||
|
|
@ -55,8 +78,7 @@ const Tags: React.FC = () => {
|
||||||
setTagToDelete(null);
|
setTagToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTags = tags.filter(
|
const filteredTags = tags.filter((tag) =>
|
||||||
(tag) =>
|
|
||||||
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
|
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -71,11 +93,11 @@ const Tags: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <div className="text-red-500 p-4">Error loading tags</div>;
|
return <div className="text-red-500 p-4">Error loading tags.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center px-4 lg:px-2 ">
|
<div className="flex justify-center px-4 lg:px-2">
|
||||||
<div className="w-full max-w-5xl">
|
<div className="w-full max-w-5xl">
|
||||||
{/* Tags Header */}
|
{/* Tags Header */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
|
@ -109,7 +131,6 @@ const Tags: React.FC = () => {
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
|
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
|
||||||
>
|
>
|
||||||
{/* Tag Content */}
|
|
||||||
<div className="flex-grow overflow-hidden pr-4">
|
<div className="flex-grow overflow-hidden pr-4">
|
||||||
<Link
|
<Link
|
||||||
to={`/tag/${tag.id}`}
|
to={`/tag/${tag.id}`}
|
||||||
|
|
@ -119,7 +140,6 @@ const Tags: React.FC = () => {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditTag(tag)}
|
onClick={() => handleEditTag(tag)}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ interface TaskListProps {
|
||||||
const TaskList: React.FC<TaskListProps> = ({
|
const TaskList: React.FC<TaskListProps> = ({
|
||||||
tasks,
|
tasks,
|
||||||
onTaskUpdate,
|
onTaskUpdate,
|
||||||
onTaskCreate,
|
|
||||||
onTaskDelete,
|
onTaskDelete,
|
||||||
projects,
|
projects,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||||
import { useToast } from "../Shared/ToastContext";
|
import { useToast } from "../Shared/ToastContext";
|
||||||
import TagInput from "../Tag/TagInput";
|
import TagInput from "../Tag/TagInput";
|
||||||
import { Project } from "../../entities/Project";
|
import { Project } from "../../entities/Project";
|
||||||
import { Tag } from "../../entities/Tag";
|
import { useStore } from "../../store/useStore";
|
||||||
import useFetchTags from "../../hooks/useFetchTags";
|
import { fetchTags } from '../../utils/tagsService';
|
||||||
|
|
||||||
interface TaskModalProps {
|
interface TaskModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -30,9 +30,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
}) => {
|
}) => {
|
||||||
const [formData, setFormData] = useState<Task>(task);
|
const [formData, setFormData] = useState<Task>(task);
|
||||||
const [tags, setTags] = useState<string[]>(
|
const [tags, setTags] = useState<string[]>(task.tags?.map((tag) => tag.name) || []);
|
||||||
task.tags?.map((tag) => tag.name) || []
|
|
||||||
);
|
|
||||||
const [filteredProjects, setFilteredProjects] = useState<Project[]>(projects);
|
const [filteredProjects, setFilteredProjects] = useState<Project[]>(projects);
|
||||||
const [newProjectName, setNewProjectName] = useState<string>("");
|
const [newProjectName, setNewProjectName] = useState<string>("");
|
||||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||||
|
|
@ -40,26 +38,42 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
|
||||||
const { showSuccessToast, showErrorToast } = useToast();
|
const { showSuccessToast, showErrorToast } = useToast();
|
||||||
|
const { tagsStore } = useStore();
|
||||||
const { tags: availableTags, isLoading, isError } = useFetchTags();
|
const { tags: availableTags, setTags: setAvailableTags, setLoading: setTagsLoading, setError: setTagsError } = tagsStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormData(task);
|
setFormData(task);
|
||||||
setTags(task.tags?.map((tag) => tag.name) || []);
|
setTags(task.tags?.map((tag) => tag.name) || []);
|
||||||
}, [task]);
|
|
||||||
|
const currentProject = projects.find((project) => project.id === task.project_id);
|
||||||
|
setNewProjectName(currentProject ? currentProject.name : '');
|
||||||
|
}, [task, projects]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTags = async () => {
|
||||||
|
setTagsLoading(true);
|
||||||
|
try {
|
||||||
|
if (availableTags.length === 0) {
|
||||||
|
const fetchedTags = await fetchTags();
|
||||||
|
setAvailableTags(fetchedTags);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setTagsError(true);
|
||||||
|
console.error("Error fetching tags:", error);
|
||||||
|
} finally {
|
||||||
|
setTagsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTags();
|
||||||
|
}, [availableTags.length, setAvailableTags, setTagsError, setTagsLoading]);
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
|
||||||
>
|
|
||||||
) => {
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
...prev,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTagsChange = useCallback((newTags: string[]) => {
|
const handleTagsChange = useCallback((newTags: string[]) => {
|
||||||
|
|
@ -75,7 +89,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
setNewProjectName(query);
|
setNewProjectName(query);
|
||||||
setDropdownOpen(true);
|
setDropdownOpen(true);
|
||||||
setFilteredProjects(
|
setFilteredProjects(
|
||||||
projects.filter((project) => project.name.toLowerCase().includes(query))
|
projects.filter((project) =>
|
||||||
|
project.name.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -137,10 +153,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||||
modalRef.current &&
|
|
||||||
!modalRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -168,26 +181,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-40">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
|
|
||||||
Loading tags...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-40">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
|
|
||||||
Error loading tags.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|
@ -200,14 +193,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-3xl overflow-hidden transform transition-transform duration-300 ${
|
className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-3xl 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`}
|
} h-screen sm:h-auto flex flex-col`}
|
||||||
style={{
|
style={{ maxHeight: "calc(100vh - 4rem)" }}
|
||||||
maxHeight: "calc(100vh - 4rem)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<form className="flex flex-col flex-1">
|
<form className="flex flex-col flex-1">
|
||||||
<fieldset className="flex flex-col flex-1">
|
<fieldset className="flex flex-col flex-1">
|
||||||
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto">
|
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto">
|
||||||
{/* Task Name */}
|
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -220,8 +210,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
placeholder="Add Task Name"
|
placeholder="Add Task Name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Tags
|
Tags
|
||||||
|
|
@ -234,8 +222,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project */}
|
|
||||||
<div className="pb-3 relative">
|
<div className="pb-3 relative">
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
Project
|
Project
|
||||||
|
|
@ -280,8 +266,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status and Priority */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 pb-3 sm:grid-flow-col">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 pb-3 sm:grid-flow-col">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
|
@ -319,8 +303,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note */}
|
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
Note
|
Note
|
||||||
|
|
@ -336,8 +318,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task Actions */}
|
|
||||||
<div className="p-3 flex-shrink-0">
|
<div className="p-3 flex-shrink-0">
|
||||||
<TaskActions
|
<TaskActions
|
||||||
taskId={task.id}
|
taskId={task.id}
|
||||||
|
|
@ -350,7 +330,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showConfirmDialog && (
|
{showConfirmDialog && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete Task"
|
title="Delete Task"
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,101 @@
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
ClipboardDocumentListIcon,
|
ClipboardDocumentListIcon,
|
||||||
ClockIcon,
|
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
|
ClockIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { fetchTasks, updateTask, deleteTask } from "../../utils/tasksService";
|
||||||
|
import { fetchProjects } from "../../utils/projectsService";
|
||||||
import { Task } from "../../entities/Task";
|
import { Task } from "../../entities/Task";
|
||||||
import { Project } from "../../entities/Project";
|
import { useStore } from "../../store/useStore";
|
||||||
|
|
||||||
import useFetchTasks from "../../hooks/useFetchTasks";
|
|
||||||
import useFetchProjects from "../../hooks/useFetchProjects";
|
|
||||||
import useManageTasks from "../../hooks/useManageTasks";
|
|
||||||
|
|
||||||
import NewTask from "./NewTask";
|
|
||||||
import TaskList from "./TaskList";
|
import TaskList from "./TaskList";
|
||||||
|
import { Metrics } from "../../entities/Metrics";
|
||||||
|
|
||||||
const TasksToday: React.FC = () => {
|
const TasksToday: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
metrics,
|
setTasks,
|
||||||
isLoading: loadingTasks,
|
setLoading: setTasksLoading,
|
||||||
isError: errorTasks,
|
setError: setTasksError,
|
||||||
mutate: mutateTasks,
|
} = useStore((state) => state.tasksStore);
|
||||||
} = useFetchTasks({
|
|
||||||
type: "today",
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
projects,
|
projects,
|
||||||
isLoading: loadingProjects,
|
setProjects,
|
||||||
isError: errorProjects,
|
setLoading: setProjectsLoading,
|
||||||
} = useFetchProjects();
|
setError: setProjectsError,
|
||||||
|
} = useStore((state) => state.projectsStore);
|
||||||
|
|
||||||
const { updateTask, deleteTask } = useManageTasks();
|
const [metrics, setMetrics] = React.useState<Metrics>({
|
||||||
|
total_open_tasks: 0,
|
||||||
|
tasks_pending_over_month: 0,
|
||||||
|
tasks_in_progress_count: 0,
|
||||||
|
tasks_in_progress: [],
|
||||||
|
tasks_due_today: [],
|
||||||
|
suggested_tasks: [],
|
||||||
|
});
|
||||||
|
|
||||||
const handleTaskUpdate = (updatedTask: Task): void => {
|
useEffect(() => {
|
||||||
if (updatedTask.id === undefined) {
|
const loadData = async () => {
|
||||||
console.error("Error updating task: Task ID is undefined.");
|
try {
|
||||||
return;
|
// setProjectsLoading(true);
|
||||||
|
const projectsData = await fetchProjects();
|
||||||
|
setProjects(projectsData);
|
||||||
|
|
||||||
|
const { tasks: fetchedTasks, metrics } = await fetchTasks("?type=today");
|
||||||
|
setTasks(fetchedTasks);
|
||||||
|
setMetrics(metrics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading data:", error);
|
||||||
|
setProjectsError(true);
|
||||||
|
setTasksError(true);
|
||||||
|
} finally {
|
||||||
|
// setProjectsLoading(false);
|
||||||
|
// setTasksLoading(false);
|
||||||
}
|
}
|
||||||
updateTask(updatedTask.id, updatedTask)
|
};
|
||||||
.then(() => {
|
|
||||||
mutateTasks();
|
loadData();
|
||||||
})
|
}, [setProjects, setProjectsLoading, setProjectsError, setTasks, setTasksLoading, setTasksError]);
|
||||||
.catch((error) => {
|
|
||||||
|
const handleTaskUpdate = async (updatedTask: Task): Promise<void> => {
|
||||||
|
if (!updatedTask.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setTasksLoading(true);
|
||||||
|
await updateTask(updatedTask.id, updatedTask);
|
||||||
|
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
|
||||||
|
setTasks(updatedTasks);
|
||||||
|
setMetrics(metrics);
|
||||||
|
} catch (error) {
|
||||||
console.error("Error updating task:", error);
|
console.error("Error updating task:", error);
|
||||||
});
|
setTasksError(true);
|
||||||
|
} finally {
|
||||||
|
setTasksLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTaskDelete = (taskId: number): void => {
|
const handleTaskDelete = async (taskId: number): Promise<void> => {
|
||||||
deleteTask(taskId)
|
try {
|
||||||
.then(() => {
|
setTasksLoading(true);
|
||||||
mutateTasks();
|
await deleteTask(taskId);
|
||||||
})
|
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
|
||||||
.catch((error) => {
|
setTasks(updatedTasks);
|
||||||
|
setMetrics(metrics);
|
||||||
|
} catch (error) {
|
||||||
console.error("Error deleting task:", error);
|
console.error("Error deleting task:", error);
|
||||||
});
|
setTasksError(true);
|
||||||
|
} finally {
|
||||||
|
setTasksLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadingTasks || loadingProjects) {
|
const todayDate = format(new Date(), "yyyy-MM-dd");
|
||||||
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 (
|
return (
|
||||||
<div className="flex justify-center px-4 lg:px-2">
|
<div className="flex justify-center px-4 lg:px-2">
|
||||||
<div className="w-full max-w-5xl">
|
<div className="w-full max-w-5xl">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<h2 className="text-2xl font-light flex items-center">
|
<h2 className="text-2xl font-light flex items-center">
|
||||||
<CalendarDaysIcon className="h-5 w-5 mr-2" /> Today
|
<CalendarDaysIcon className="h-5 w-5 mr-2" /> Today
|
||||||
|
|
@ -85,29 +105,17 @@ const TasksToday: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overview of Tasks */}
|
|
||||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-4 gap-4">
|
<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">
|
<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" />
|
<ClipboardDocumentListIcon className="h-8 w-8 text-blue-500 mr-4" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 dark:text-gray-400">Backlog</p>
|
<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">
|
<p className="text-2xl font-semibold">
|
||||||
{metrics.tasks_pending_over_month}
|
{metrics.total_open_tasks}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks In Progress */}
|
|
||||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
<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" />
|
<ArrowPathIcon className="h-8 w-8 text-green-500 mr-4" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -118,7 +126,6 @@ const TasksToday: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks Due Today */}
|
|
||||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
<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" />
|
<CalendarDaysIcon className="h-8 w-8 text-red-500 mr-4" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -128,12 +135,21 @@ const TasksToday: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks Due Today */}
|
|
||||||
{metrics.tasks_due_today.length > 0 && (
|
{metrics.tasks_due_today.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-xl font-medium mt-6 mb-2">Tasks Due Today</h3>
|
<h3 className="text-xl font-medium mt-6 mb-2">Due Today</h3>
|
||||||
<TaskList
|
<TaskList
|
||||||
tasks={metrics.tasks_due_today}
|
tasks={metrics.tasks_due_today}
|
||||||
onTaskUpdate={handleTaskUpdate}
|
onTaskUpdate={handleTaskUpdate}
|
||||||
|
|
@ -143,10 +159,9 @@ const TasksToday: React.FC = () => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tasks In Progress */}
|
|
||||||
{metrics.tasks_in_progress.length > 0 && (
|
{metrics.tasks_in_progress.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-xl font-medium mt-6 mb-2">Tasks In Progress</h3>
|
<h3 className="text-xl font-medium mt-6 mb-2">In Progress</h3>
|
||||||
<TaskList
|
<TaskList
|
||||||
tasks={metrics.tasks_in_progress}
|
tasks={metrics.tasks_in_progress}
|
||||||
onTaskUpdate={handleTaskUpdate}
|
onTaskUpdate={handleTaskUpdate}
|
||||||
|
|
@ -156,10 +171,9 @@ const TasksToday: React.FC = () => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suggested Tasks */}
|
|
||||||
{metrics.suggested_tasks.length > 0 && (
|
{metrics.suggested_tasks.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-xl font-medium mt-6 mb-2">Suggested Tasks</h3>
|
<h3 className="text-xl font-medium mt-6 mb-2">Suggested</h3>
|
||||||
<TaskList
|
<TaskList
|
||||||
tasks={metrics.suggested_tasks}
|
tasks={metrics.suggested_tasks}
|
||||||
onTaskUpdate={handleTaskUpdate}
|
onTaskUpdate={handleTaskUpdate}
|
||||||
|
|
@ -169,7 +183,6 @@ const TasksToday: React.FC = () => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fallback Message */}
|
|
||||||
{tasks.length === 0 && (
|
{tasks.length === 0 && (
|
||||||
<p className="text-gray-500 text-center mt-4">
|
<p className="text-gray-500 text-center mt-4">
|
||||||
No tasks available for today.
|
No tasks available for today.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronDoubleDownIcon,
|
ChevronDoubleDownIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
|
@ -22,8 +23,9 @@ const Tasks: React.FC = () => {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
|
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
|
||||||
const [orderBy, setOrderBy] = useState<string>("due_date:asc");
|
const [orderBy, setOrderBy] = useState<string>("due_date:asc");
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const [taskSearchQuery, setTaskSearchQuery] = useState<string>("");
|
||||||
|
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const query = new URLSearchParams(location.search);
|
const query = new URLSearchParams(location.search);
|
||||||
|
|
@ -198,6 +200,10 @@ const Tasks: React.FC = () => {
|
||||||
return status !== "done";
|
return status !== "done";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredTasks = tasks.filter((task) =>
|
||||||
|
task.name.toLowerCase().includes(taskSearchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center px-4 lg:px-2">
|
<div className="flex justify-center px-4 lg:px-2">
|
||||||
<div className="w-full max-w-5xl">
|
<div className="w-full max-w-5xl">
|
||||||
|
|
@ -268,10 +274,25 @@ const Tasks: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<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="Search tasks..."
|
||||||
|
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 ? (
|
{loading ? (
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
|
|
@ -288,9 +309,9 @@ const Tasks: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Task List */}
|
{/* Task List */}
|
||||||
{tasks.length > 0 ? (
|
{filteredTasks.length > 0 ? (
|
||||||
<TaskList
|
<TaskList
|
||||||
tasks={tasks}
|
tasks={filteredTasks}
|
||||||
onTaskCreate={handleTaskCreate}
|
onTaskCreate={handleTaskCreate}
|
||||||
onTaskUpdate={handleTaskUpdate}
|
onTaskUpdate={handleTaskUpdate}
|
||||||
onTaskDelete={handleTaskDelete}
|
onTaskDelete={handleTaskDelete}
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import React, { createContext, useContext } from 'react';
|
|
||||||
import useFetchTags from '../hooks/useFetchTags';
|
|
||||||
import useFetchAreas from '../hooks/useFetchAreas';
|
|
||||||
import useFetchProjects from '../hooks/useFetchProjects';
|
|
||||||
import useManageAreas from '../hooks/useManageAreas';
|
|
||||||
import useManageNotes from '../hooks/useManageNotes';
|
|
||||||
import useManageProjects from '../hooks/useManageProjects';
|
|
||||||
import useManageTags from '../hooks/useManageTags';
|
|
||||||
import useManageTasks from '../hooks/useManageTasks';
|
|
||||||
import { Project } from '../entities/Project';
|
|
||||||
|
|
||||||
interface DataContextProps {
|
|
||||||
tasks: any[];
|
|
||||||
tags: any[];
|
|
||||||
areas: any[];
|
|
||||||
notes: any[];
|
|
||||||
projects: Project[];
|
|
||||||
isLoading: boolean;
|
|
||||||
isError: boolean;
|
|
||||||
createNote: (noteData: any) => Promise<void>;
|
|
||||||
updateNote: (noteId: number, noteData: any) => Promise<void>;
|
|
||||||
deleteNote: (noteId: number) => Promise<void>;
|
|
||||||
createArea: (areaData: any) => Promise<void>;
|
|
||||||
updateArea: (areaId: number, areaData: any) => Promise<void>;
|
|
||||||
deleteArea: (areaId: number) => Promise<void>;
|
|
||||||
createProject: (projectData: any) => Promise<Project>;
|
|
||||||
updateProject: (projectId: number, projectData: any) => Promise<Project>;
|
|
||||||
deleteProject: (projectId: number) => Promise<void>;
|
|
||||||
createTag: (tagData: any) => Promise<void>;
|
|
||||||
updateTag: (tagId: number, tagData: any) => Promise<void>;
|
|
||||||
deleteTag: (tagId: number) => Promise<void>;
|
|
||||||
createTask: (taskData: any) => Promise<void>;
|
|
||||||
updateTask: (taskId: number, taskData: any) => Promise<void>;
|
|
||||||
deleteTask: (taskId: number) => Promise<void>;
|
|
||||||
mutateTags: () => void;
|
|
||||||
mutateAreas: () => void;
|
|
||||||
mutateNotes: () => void;
|
|
||||||
mutateProjects: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DataContext = createContext<DataContextProps | undefined>(undefined);
|
|
||||||
|
|
||||||
export const useDataContext = () => {
|
|
||||||
const context = useContext(DataContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useDataContext must be used within a DataProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
||||||
const { tags, isLoading: isLoadingTags, isError: isErrorTags, mutate: mutateTags } = useFetchTags();
|
|
||||||
const { areas, isLoading: isLoadingAreas, isError: isErrorAreas, mutate: mutateAreas } = useFetchAreas();
|
|
||||||
const {
|
|
||||||
projects,
|
|
||||||
isLoading: isLoadingProjects,
|
|
||||||
isError: isErrorProjects,
|
|
||||||
mutate: mutateProjects,
|
|
||||||
} = useFetchProjects();
|
|
||||||
const { createArea, updateArea, deleteArea } = useManageAreas();
|
|
||||||
const { createProject, updateProject, deleteProject } = useManageProjects();
|
|
||||||
const { createTag, updateTag, deleteTag } = useManageTags();
|
|
||||||
const {
|
|
||||||
tasks,
|
|
||||||
isLoading: isLoadingTasks,
|
|
||||||
isError: isErrorTasks,
|
|
||||||
createTask,
|
|
||||||
updateTask,
|
|
||||||
deleteTask,
|
|
||||||
} = useManageTasks();
|
|
||||||
const {
|
|
||||||
notes,
|
|
||||||
isLoading: isLoadingNotes,
|
|
||||||
isError: isErrorNotes,
|
|
||||||
createNote,
|
|
||||||
updateNote,
|
|
||||||
deleteNote,
|
|
||||||
mutate: mutateNotes,
|
|
||||||
} = useManageNotes();
|
|
||||||
|
|
||||||
const isLoading = isLoadingTags || isLoadingAreas || isLoadingNotes || isLoadingTasks || isLoadingProjects;
|
|
||||||
const isError = isErrorTags || isErrorAreas || isErrorNotes || isErrorTasks || isErrorProjects;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataContext.Provider
|
|
||||||
value={{
|
|
||||||
tasks,
|
|
||||||
tags,
|
|
||||||
areas,
|
|
||||||
notes,
|
|
||||||
projects,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
createNote,
|
|
||||||
updateNote,
|
|
||||||
deleteNote,
|
|
||||||
createArea,
|
|
||||||
updateArea,
|
|
||||||
deleteArea,
|
|
||||||
createProject,
|
|
||||||
updateProject,
|
|
||||||
deleteProject,
|
|
||||||
createTag,
|
|
||||||
updateTag,
|
|
||||||
deleteTag,
|
|
||||||
createTask,
|
|
||||||
updateTask,
|
|
||||||
deleteTask,
|
|
||||||
mutateTags,
|
|
||||||
mutateAreas,
|
|
||||||
mutateNotes,
|
|
||||||
mutateProjects,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DataContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
10
app/frontend/entities/Metrics.ts
Normal file
10
app/frontend/entities/Metrics.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Task } from "./Task";
|
||||||
|
|
||||||
|
export 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[];
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Area } from "./Area";
|
import { Area } from "./Area";
|
||||||
import { Tag } from "./Tag";
|
import { Tag } from "./Tag";
|
||||||
import { PriorityType } from "./Task";
|
import { PriorityType, Task } from "./Task";
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|
@ -12,4 +12,6 @@ export interface Project {
|
||||||
area_id?: number | null;
|
area_id?: number | null;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
priority?: PriorityType;
|
priority?: PriorityType;
|
||||||
|
tasks?: Task[];
|
||||||
|
due_date_at?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface UseFetchResult<T> {
|
|
||||||
data: T | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useFetch = <T,>(url: string, options?: RequestInit): UseFetchResult<T> => {
|
|
||||||
const [data, setData] = useState<T | null>(null);
|
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to fetch data.');
|
|
||||||
}
|
|
||||||
const result: T = await response.json();
|
|
||||||
if (isMounted) {
|
|
||||||
setData(result);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (isMounted) {
|
|
||||||
if (err.name !== 'AbortError') {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMounted) setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [url, JSON.stringify(options)]);
|
|
||||||
|
|
||||||
return { data, loading, error };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useFetch;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import useSWR from 'swr';
|
|
||||||
import { Area } from '../entities/Area';
|
|
||||||
import { fetcher } from '../utils/fetcher';
|
|
||||||
|
|
||||||
const useFetchAreas = () => {
|
|
||||||
const { data, error, mutate } = useSWR<Area[]>('/api/areas?active=true', fetcher);
|
|
||||||
|
|
||||||
return {
|
|
||||||
areas: data || [],
|
|
||||||
isLoading: !error && !data,
|
|
||||||
isError: !!error,
|
|
||||||
mutate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useFetchAreas;
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import useFetch from './useFetch';
|
|
||||||
import { Note } from '../entities/Note';
|
|
||||||
|
|
||||||
const useFetchNotes = () => {
|
|
||||||
const { data, loading, error } = useFetch<Note[]>('/api/notes', {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { notes: data || [], loading, error };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useFetchNotes;
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Project } from '../entities/Project';
|
|
||||||
|
|
||||||
interface UseFetchProjectsOptions {
|
|
||||||
activeFilter?: string;
|
|
||||||
areaFilter?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseFetchProjectsResult {
|
|
||||||
projects: Project[];
|
|
||||||
taskStatusCounts?: any;
|
|
||||||
isLoading: boolean;
|
|
||||||
isError: boolean;
|
|
||||||
mutate: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useFetchProjects = (options?: UseFetchProjectsOptions): UseFetchProjectsResult => {
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [taskStatusCounts, setTaskStatusCounts] = useState<any>();
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
const [isError, setIsError] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setIsError(false);
|
|
||||||
try {
|
|
||||||
let url = '/api/projects';
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
if (options?.activeFilter !== undefined && options.activeFilter !== "all") {
|
|
||||||
params.append('active', String(options.activeFilter));
|
|
||||||
}
|
|
||||||
if (options?.areaFilter !== undefined) {
|
|
||||||
params.append('area_id', options.areaFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.toString()) {
|
|
||||||
url += `?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.projects) {
|
|
||||||
setProjects(data.projects);
|
|
||||||
setTaskStatusCounts(data.taskStatusCounts);
|
|
||||||
} else {
|
|
||||||
setProjects(data);
|
|
||||||
setTaskStatusCounts(undefined);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to fetch projects.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching projects:', error);
|
|
||||||
setIsError(true);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchProjects();
|
|
||||||
}, [options?.activeFilter, options?.areaFilter]);
|
|
||||||
|
|
||||||
return { projects, taskStatusCounts, isLoading, isError, mutate: fetchProjects };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useFetchProjects;
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import useSWR from 'swr';
|
|
||||||
import { fetcher } from '../utils/fetcher';
|
|
||||||
|
|
||||||
const useFetchTags = () => {
|
|
||||||
const { data, error, mutate } = useSWR('/api/tags', fetcher);
|
|
||||||
|
|
||||||
return {
|
|
||||||
tags: data || [],
|
|
||||||
isLoading: !data && !error,
|
|
||||||
isError: !!error,
|
|
||||||
mutate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useFetchTags;
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useSWRConfig } from 'swr';
|
|
||||||
import { Area } from '../entities/Area';
|
|
||||||
|
|
||||||
const useManageAreas = () => {
|
|
||||||
const { mutate } = useSWRConfig();
|
|
||||||
|
|
||||||
const createArea = useCallback(async (areaData: Partial<Area>) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/areas', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(areaData),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to create area.');
|
|
||||||
}
|
|
||||||
const newArea: Area = await response.json();
|
|
||||||
mutate('/api/areas?active=true', (current: Area[] = []) => [...current, newArea], false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating area:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [mutate]);
|
|
||||||
|
|
||||||
const updateArea = useCallback(async (areaId: number, areaData: Partial<Area>) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/areas/${areaId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(areaData),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to update area.');
|
|
||||||
}
|
|
||||||
const updatedArea: Area = await response.json();
|
|
||||||
mutate('/api/areas?active=true', (current: Area[] = []) =>
|
|
||||||
current.map(area => (area.id === areaId ? updatedArea : area)),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating area:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [mutate]);
|
|
||||||
|
|
||||||
const deleteArea = useCallback(async (areaId: number) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/areas/${areaId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to delete area.');
|
|
||||||
}
|
|
||||||
mutate('/api/areas?active=true', (current: Area[] = []) =>
|
|
||||||
current.filter(area => area.id !== areaId),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting area:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [mutate]);
|
|
||||||
|
|
||||||
return { createArea, updateArea, deleteArea };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useManageAreas;
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import useSWR from 'swr';
|
|
||||||
import { Note } from '../entities/Note';
|
|
||||||
import { fetcher } from '../utils/fetcher';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
const useManageNotes = () => {
|
|
||||||
const { data, error, mutate } = useSWR<Note[]>('/api/notes', fetcher);
|
|
||||||
|
|
||||||
const createNote = useCallback(
|
|
||||||
async (noteData: Partial<Note>) => {
|
|
||||||
const noteDataToSend = {
|
|
||||||
...noteData,
|
|
||||||
tags: noteData.tags?.map((tag) => tag.name) || [],
|
|
||||||
};
|
|
||||||
const response = await fetch('/api/note', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(noteDataToSend),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to create note.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const newNote: Note = await response.json();
|
|
||||||
mutate([...(data || []), newNote], false);
|
|
||||||
},
|
|
||||||
[mutate, data]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateNote = useCallback(
|
|
||||||
async (noteId: number, noteData: Partial<Note>) => {
|
|
||||||
const noteDataToSend = {
|
|
||||||
...noteData,
|
|
||||||
tags: noteData.tags?.map((tag) => tag.name) || [],
|
|
||||||
};
|
|
||||||
const response = await fetch(`/api/note/${noteId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(noteDataToSend),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to update note.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedNote: Note = await response.json();
|
|
||||||
mutate((data || []).map((note) => (note.id === noteId ? updatedNote : note)), false);
|
|
||||||
},
|
|
||||||
[mutate, data]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteNote = useCallback(
|
|
||||||
async (noteId: number) => {
|
|
||||||
const response = await fetch(`/api/note/${noteId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to delete note.');
|
|
||||||
}
|
|
||||||
|
|
||||||
mutate((data || []).filter((note) => note.id !== noteId), false);
|
|
||||||
},
|
|
||||||
[mutate, data]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
notes: data || [],
|
|
||||||
isLoading: !error && !data,
|
|
||||||
isError: error,
|
|
||||||
createNote,
|
|
||||||
updateNote,
|
|
||||||
deleteNote,
|
|
||||||
mutate
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useManageNotes;
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useSWRConfig } from 'swr';
|
|
||||||
import { Project } from '../entities/Project';
|
|
||||||
|
|
||||||
const useManageProjects = () => {
|
|
||||||
const { mutate } = useSWRConfig();
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
|
|
||||||
const createProject = async (projectData: Partial<Project>): Promise<Project> => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/project', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(projectData),
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const newProject = await response.json();
|
|
||||||
setProjects((prevProjects) => [...prevProjects, newProject]);
|
|
||||||
return newProject;
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to create project.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating project:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProject = useCallback(async (projectId: number, projectData: Partial<Project>) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/project/${projectId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(projectData),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to update project.');
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}, [mutate]);
|
|
||||||
|
|
||||||
const deleteProject = useCallback(async (projectId: number) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/project/${projectId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to delete project.');
|
|
||||||
}
|
|
||||||
mutate('/api/projects', (current: Project[] = []) =>
|
|
||||||
current.filter((project) => project.id !== projectId), false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting project:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [mutate]);
|
|
||||||
|
|
||||||
return { projects, createProject, updateProject, deleteProject };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useManageProjects;
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useSWRConfig } from 'swr';
|
|
||||||
import { Tag } from '../entities/Tag';
|
|
||||||
|
|
||||||
const useManageTags = () => {
|
|
||||||
const { mutate } = useSWRConfig();
|
|
||||||
|
|
||||||
const createTag = useCallback(async (tagData: Partial<Tag>) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/tag', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(tagData),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to create tag.');
|
|
||||||
}
|
|
||||||
const newTag: Tag = await response.json();
|
|
||||||
mutate('/api/tags', (current: Tag[] = []) => [...current, newTag], false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating tag:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [mutate]);
|
|
||||||
|
|
||||||
const updateTag = useCallback(async (tagId: number, tagData: Partial<Tag>) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/tag/${tagId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(tagData),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to update tag.');
|
|
||||||
}
|
|
||||||
const updatedTag: Tag = await response.json();
|
|
||||||
mutate('/api/tags', (current: Tag[] = []) =>
|
|
||||||
current.map(tag => (tag.id === tagId ? updatedTag : tag)), false
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating tag:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [mutate]);
|
|
||||||
|
|
||||||
const deleteTag = useCallback(async (tagId: number) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/tag/${tagId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to delete tag.');
|
|
||||||
}
|
|
||||||
mutate('/api/tags', (current: Tag[] = []) =>
|
|
||||||
current.filter(tag => tag.id !== tagId), false
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting tag:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [mutate]);
|
|
||||||
|
|
||||||
return { createTag, updateTag, deleteTag };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useManageTags;
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Task } from '../entities/Task';
|
|
||||||
|
|
||||||
const useManageTasks = () => {
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
||||||
const [isError, setIsError] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const fetchTasks = async (query: string = '') => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setIsError(false);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/tasks${query}`, {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setTasks(data);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to fetch tasks.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setIsError(true);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createTask = async (taskData: Partial<Task>) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/task', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(taskData),
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const newTask = await response.json();
|
|
||||||
setTasks((prevTasks) => [newTask, ...prevTasks]);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to create task.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating task:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTask = async (taskId: number, taskData: Partial<Task>) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/task/${taskId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(taskData),
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const updatedTask = await response.json();
|
|
||||||
setTasks((prevTasks) =>
|
|
||||||
prevTasks.map((task) => (task.id === taskId ? updatedTask : task))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to update task.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating task:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTask = async (taskId: number) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/task/${taskId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to delete task.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting task:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutateTasks = fetchTasks;
|
|
||||||
|
|
||||||
return { tasks, isLoading, isError, fetchTasks, mutateTasks, createTask, updateTask, deleteTask };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useManageTasks;
|
|
||||||
102
app/frontend/store/useStore.ts
Normal file
102
app/frontend/store/useStore.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { Project } from "../entities/Project";
|
||||||
|
import { Area } from "../entities/Area";
|
||||||
|
import { Note } from "../entities/Note";
|
||||||
|
import { Task } from "../entities/Task";
|
||||||
|
import { Tag } from "../entities/Tag";
|
||||||
|
|
||||||
|
interface NotesStore {
|
||||||
|
notes: Note[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
setNotes: (notes: Note[]) => void;
|
||||||
|
setLoading: (isLoading: boolean) => void;
|
||||||
|
setError: (isError: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AreasStore {
|
||||||
|
areas: Area[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
setAreas: (areas: Area[]) => void;
|
||||||
|
setLoading: (isLoading: boolean) => void;
|
||||||
|
setError: (isError: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectsStore {
|
||||||
|
projects: Project[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
setProjects: (projects: Project[]) => void;
|
||||||
|
setLoading: (isLoading: boolean) => void;
|
||||||
|
setError: (isError: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagsStore {
|
||||||
|
tags: Tag[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
setTags: (tags: Tag[]) => void;
|
||||||
|
setLoading: (isLoading: boolean) => void;
|
||||||
|
setError: (isError: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TasksStore {
|
||||||
|
tasks: Task[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
setTasks: (tasks: Task[]) => void;
|
||||||
|
setLoading: (isLoading: boolean) => void;
|
||||||
|
setError: (isError: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoreState {
|
||||||
|
notesStore: NotesStore;
|
||||||
|
areasStore: AreasStore;
|
||||||
|
projectsStore: ProjectsStore;
|
||||||
|
tagsStore: TagsStore;
|
||||||
|
tasksStore: TasksStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<StoreState>((set) => ({
|
||||||
|
notesStore: {
|
||||||
|
notes: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
setNotes: (notes) => set((state) => ({ notesStore: { ...state.notesStore, notes } })),
|
||||||
|
setLoading: (isLoading) => set((state) => ({ notesStore: { ...state.notesStore, isLoading } })),
|
||||||
|
setError: (isError) => set((state) => ({ notesStore: { ...state.notesStore, isError } })),
|
||||||
|
},
|
||||||
|
areasStore: {
|
||||||
|
areas: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
setAreas: (areas) => set((state) => ({ areasStore: { ...state.areasStore, areas } })),
|
||||||
|
setLoading: (isLoading) => set((state) => ({ areasStore: { ...state.areasStore, isLoading } })),
|
||||||
|
setError: (isError) => set((state) => ({ areasStore: { ...state.areasStore, isError } })),
|
||||||
|
},
|
||||||
|
projectsStore: {
|
||||||
|
projects: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
setProjects: (projects) => set((state) => ({ projectsStore: { ...state.projectsStore, projects } })),
|
||||||
|
setLoading: (isLoading) => set((state) => ({ projectsStore: { ...state.projectsStore, isLoading } })),
|
||||||
|
setError: (isError) => set((state) => ({ projectsStore: { ...state.projectsStore, isError } })),
|
||||||
|
},
|
||||||
|
tagsStore: {
|
||||||
|
tags: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
setTags: (tags) => set((state) => ({ tagsStore: { ...state.tagsStore, tags } })),
|
||||||
|
setLoading: (isLoading) => set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })),
|
||||||
|
setError: (isError) => set((state) => ({ tagsStore: { ...state.tagsStore, isError } })),
|
||||||
|
},
|
||||||
|
tasksStore: {
|
||||||
|
tasks: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
setTasks: (tasks) => set((state) => ({ tasksStore: { ...state.tasksStore, tasks } })),
|
||||||
|
setLoading: (isLoading) => set((state) => ({ tasksStore: { ...state.tasksStore, isLoading } })),
|
||||||
|
setError: (isError) => set((state) => ({ tasksStore: { ...state.tasksStore, isError } })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
40
app/frontend/utils/areasService.ts
Normal file
40
app/frontend/utils/areasService.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Area } from "../entities/Area";
|
||||||
|
|
||||||
|
export const fetchAreas = async (): Promise<Area[]> => {
|
||||||
|
const response = await fetch("/api/areas?active=true");
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch areas.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
|
||||||
|
const response = await fetch('/api/areas', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(areaData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to create area.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateArea = async (areaId: number, areaData: Partial<Area>): Promise<Area> => {
|
||||||
|
const response = await fetch(`/api/areas/${areaId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(areaData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update area.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteArea = async (areaId: number): Promise<void> => {
|
||||||
|
const response = await fetch(`/api/areas/${areaId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete area.');
|
||||||
|
};
|
||||||
40
app/frontend/utils/notesService.ts
Normal file
40
app/frontend/utils/notesService.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Note } from "../entities/Note";
|
||||||
|
|
||||||
|
export const fetchNotes = async (): Promise<Note[]> => {
|
||||||
|
const response = await fetch("/api/notes");
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch notes.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNote = async (noteData: Note): Promise<Note> => {
|
||||||
|
const response = await fetch('/api/notes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(noteData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to create note.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateNote = async (noteId: number, noteData: Note): Promise<Note> => {
|
||||||
|
const response = await fetch(`/api/notes/${noteId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(noteData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update note.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteNote = async (noteId: number): Promise<void> => {
|
||||||
|
const response = await fetch(`/api/notes/${noteId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete note.');
|
||||||
|
};
|
||||||
63
app/frontend/utils/projectsService.ts
Normal file
63
app/frontend/utils/projectsService.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { Project } from "../entities/Project";
|
||||||
|
|
||||||
|
export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Promise<Project[]> => {
|
||||||
|
let url = `/api/projects`;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (activeFilter !== "all") params.append("active", activeFilter);
|
||||||
|
if (areaFilter) params.append("area_id", areaFilter);
|
||||||
|
if (params.toString()) url += `?${params.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch projects.');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.projects || data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchProjectById = async (projectId: string): Promise<Project> => {
|
||||||
|
const response = await fetch(`/api/project/${projectId}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch project details.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createProject = async (projectData: Partial<Project>): Promise<Project> => {
|
||||||
|
const response = await fetch('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(projectData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to create project.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProject = async (projectId: number, projectData: Partial<Project>): Promise<Project> => {
|
||||||
|
const response = await fetch(`/api/project/${projectId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(projectData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update project.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProject = async (projectId: number): Promise<void> => {
|
||||||
|
const response = await fetch(`/api/project/${projectId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete project.');
|
||||||
|
};
|
||||||
40
app/frontend/utils/tagsService.ts
Normal file
40
app/frontend/utils/tagsService.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Tag } from "../entities/Tag";
|
||||||
|
|
||||||
|
export const fetchTags = async (): Promise<Tag[]> => {
|
||||||
|
const response = await fetch("/api/tags");
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch tags.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTag = async (tagData: Tag): Promise<Tag> => {
|
||||||
|
const response = await fetch('/api/tag', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(tagData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to create tag.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTag = async (tagId: number, tagData: Tag): Promise<Tag> => {
|
||||||
|
const response = await fetch(`/api/tag/${tagId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(tagData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update tag.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTag = async (tagId: number): Promise<void> => {
|
||||||
|
const response = await fetch(`/api/tag/${tagId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete tag.');
|
||||||
|
};
|
||||||
52
app/frontend/utils/tasksService.ts
Normal file
52
app/frontend/utils/tasksService.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Metrics } from "../entities/Metrics";
|
||||||
|
import { Task } from "../entities/Task";
|
||||||
|
|
||||||
|
export const fetchTasks = async (query = ''): Promise<{ tasks: Task[]; metrics: Metrics }> => {
|
||||||
|
const response = await fetch(`/api/tasks${query}`);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch tasks.');
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(result.tasks)) {
|
||||||
|
throw new Error('Resulting tasks are not an array.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.metrics) {
|
||||||
|
throw new Error('Metrics data is not included.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tasks: result.tasks, metrics: result.metrics };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTask = async (taskData: Task): Promise<Task> => {
|
||||||
|
const response = await fetch('/api/task', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(taskData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to create task.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTask = async (taskId: number, taskData: Task): Promise<Task> => {
|
||||||
|
const response = await fetch(`/api/task/${taskId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(taskData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update task.');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTask = async (taskId: number): Promise<void> => {
|
||||||
|
const response = await fetch(`/api/task/${taskId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete task.');
|
||||||
|
};
|
||||||
|
|
@ -32,4 +32,8 @@ class Project < ActiveRecord::Base
|
||||||
completed_tasks = counts[:total] - counts[:not_started]
|
completed_tasks = counts[:total] - counts[:not_started]
|
||||||
(completed_tasks.to_f / counts[:total] * 100).round
|
(completed_tasks.to_f / counts[:total] * 100).round
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def due_date_at
|
||||||
|
self[:due_date_at]&.strftime('%Y-%m-%d')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -76,47 +76,103 @@ class Task < ActiveRecord::Base
|
||||||
|
|
||||||
def self.compute_metrics(user)
|
def self.compute_metrics(user)
|
||||||
total_open_tasks = user.tasks.incomplete.count
|
total_open_tasks = user.tasks.incomplete.count
|
||||||
|
|
||||||
one_month_ago = Date.today - 30
|
one_month_ago = Date.today - 30
|
||||||
tasks_pending_over_month = user.tasks.incomplete.where('created_at < ?', one_month_ago).count
|
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 = user.tasks.incomplete.where(status: statuses[:in_progress]).order(priority: :desc)
|
||||||
tasks_in_progress_count = tasks_in_progress.count
|
tasks_in_progress_count = tasks_in_progress.count
|
||||||
|
|
||||||
tasks_due_today = user.tasks.due_today
|
# Calculate tasks due today including those due via projects
|
||||||
|
tasks_due_today = user.tasks.incomplete.joins(:project)
|
||||||
|
.where('tasks.due_date <= ? OR projects.due_date_at <= ?', Date.today, Date.today)
|
||||||
|
.distinct
|
||||||
|
|
||||||
# Exclude tasks that are in progress or due today
|
# Gather an array of IDs to be excluded from suggested tasks
|
||||||
excluded_task_ids = tasks_in_progress.pluck(:id) + tasks_due_today.pluck(:id)
|
excluded_task_ids = tasks_in_progress.pluck(:id) + tasks_due_today.pluck(:id)
|
||||||
|
|
||||||
# Fetch suggested tasks not in projects, ordered by task priority
|
# Gather tasks in projects expiring starting today, order by task priority
|
||||||
|
tasks_in_expiring_projects = user.tasks.incomplete
|
||||||
|
.joins(:project)
|
||||||
|
.where('projects.due_date_at >= ?', Date.today)
|
||||||
|
.where(projects: { active: true }) # Only active projects
|
||||||
|
.where.not(id: excluded_task_ids)
|
||||||
|
.order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC'))
|
||||||
|
.limit(5)
|
||||||
|
|
||||||
|
# Gather tasks not assigned to projects expiring today, ordered by task priority
|
||||||
tasks_without_projects = user.tasks.incomplete
|
tasks_without_projects = user.tasks.incomplete
|
||||||
.where(status: statuses[:not_started], project_id: nil)
|
.where(status: statuses[:not_started], project_id: nil)
|
||||||
|
.or(user.tasks.where(project_id: nil, status: statuses[:not_started]))
|
||||||
.where.not(id: excluded_task_ids)
|
.where.not(id: excluded_task_ids)
|
||||||
.order(priority: :desc)
|
.order(priority: :desc)
|
||||||
.limit(3)
|
.limit(5)
|
||||||
|
|
||||||
# Fetch suggested tasks in projects, ordered by task priority and project priority
|
# Combine both list of suggested tasks
|
||||||
tasks_in_projects = user.tasks.incomplete
|
|
||||||
.where(status: statuses[:not_started])
|
|
||||||
.where.not(project_id: nil)
|
|
||||||
.where.not(id: excluded_task_ids)
|
|
||||||
.joins('LEFT JOIN projects ON tasks.project_id = projects.id')
|
|
||||||
.order(
|
|
||||||
Arel.sql('tasks.priority DESC, projects.priority DESC')
|
|
||||||
)
|
|
||||||
.distinct
|
|
||||||
.limit(3)
|
|
||||||
|
|
||||||
|
suggested_tasks = sort_suggested_tasks(tasks_in_expiring_projects + tasks_without_projects)
|
||||||
{
|
{
|
||||||
total_open_tasks: total_open_tasks,
|
total_open_tasks: total_open_tasks,
|
||||||
tasks_pending_over_month: tasks_pending_over_month,
|
tasks_pending_over_month: tasks_pending_over_month,
|
||||||
tasks_in_progress: tasks_in_progress,
|
tasks_in_progress: tasks_in_progress,
|
||||||
tasks_in_progress_count: tasks_in_progress_count,
|
tasks_in_progress_count: tasks_in_progress_count,
|
||||||
tasks_due_today: tasks_due_today,
|
tasks_due_today: tasks_due_today,
|
||||||
suggested_tasks: (tasks_without_projects + tasks_in_projects)
|
suggested_tasks: suggested_tasks
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.sort_suggested_tasks(tasks)
|
||||||
|
tasks.sort_by do |task|
|
||||||
|
# Parse or default the task due date
|
||||||
|
task_due_date = if task.due_date.is_a?(String)
|
||||||
|
Date.parse(task.due_date)
|
||||||
|
else
|
||||||
|
task.due_date || Date.new(9999, 12, 31)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parse or default the project due date
|
||||||
|
project_due_date = if (task.project&.due_date_at).is_a?(String)
|
||||||
|
Date.parse(task&.project&.due_date_at)
|
||||||
|
else
|
||||||
|
task.project&.due_date_at || Date.new(9999, 12, 31)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Priority in descending order (sorted values should be negative for sort_by)
|
||||||
|
priority_value = -(Task.priorities.fetch(task.priority, -1))
|
||||||
|
|
||||||
|
# Determine sorting flags based on various criteria
|
||||||
|
is_high_priority_proj_with_due_date = (task.priority == 'high' && task&.project&.due_date_at) ? 0 : 1
|
||||||
|
is_high_priority_with_due_date = (task.priority == 'high' && task.due_date) ? 0 : 1
|
||||||
|
is_high_priority = (task.priority == 'high' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1
|
||||||
|
|
||||||
|
is_medium_priority_proj_with_due_date = (task.priority == 'medium' && task&.project&.due_date_at) ? 0 : 1
|
||||||
|
is_medium_priority_with_due_date = (task.priority == 'medium' && task.due_date) ? 0 : 1
|
||||||
|
is_medium_priority = (task.priority == 'medium' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1
|
||||||
|
|
||||||
|
is_low_priority_proj_with_due_date = (task.priority == 'low' && task&.project&.due_date_at) ? 0 : 1
|
||||||
|
is_low_priority_with_due_date = (task.priority == 'low' && task.due_date) ? 0 : 1
|
||||||
|
is_low_priority = (task.priority == 'low' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1
|
||||||
|
|
||||||
|
# Primary sorting criteria
|
||||||
|
[
|
||||||
|
is_high_priority_proj_with_due_date,
|
||||||
|
is_high_priority_with_due_date,
|
||||||
|
is_high_priority,
|
||||||
|
|
||||||
|
is_medium_priority_proj_with_due_date,
|
||||||
|
is_medium_priority_with_due_date,
|
||||||
|
is_medium_priority,
|
||||||
|
|
||||||
|
is_low_priority_proj_with_due_date,
|
||||||
|
is_low_priority_with_due_date,
|
||||||
|
is_low_priority,
|
||||||
|
|
||||||
|
task_due_date,
|
||||||
|
project_due_date,
|
||||||
|
priority_value
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def as_json(options = {})
|
def as_json(options = {})
|
||||||
super(options).merge(
|
super(options).merge(
|
||||||
'due_date' => due_date&.strftime('%Y-%m-%d')
|
'due_date' => due_date&.strftime('%Y-%m-%d')
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,8 @@ class Sinatra::Application
|
||||||
area_id: project_data['area_id'],
|
area_id: project_data['area_id'],
|
||||||
active: true,
|
active: true,
|
||||||
pin_to_sidebar: false,
|
pin_to_sidebar: false,
|
||||||
priority: project_data['priority']
|
priority: project_data['priority'],
|
||||||
|
due_date_at: project_data['due_date_at']
|
||||||
)
|
)
|
||||||
|
|
||||||
if project.save
|
if project.save
|
||||||
|
|
@ -106,7 +107,8 @@ class Sinatra::Application
|
||||||
area_id: project_data['area_id'],
|
area_id: project_data['area_id'],
|
||||||
active: project_data['active'],
|
active: project_data['active'],
|
||||||
pin_to_sidebar: project_data['pin_to_sidebar'],
|
pin_to_sidebar: project_data['pin_to_sidebar'],
|
||||||
priority: project_data ['priority']
|
priority: project_data ['priority'],
|
||||||
|
due_date_at: project_data['due_date_at']
|
||||||
)
|
)
|
||||||
|
|
||||||
if project.save
|
if project.save
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,6 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="/js/bundle.js"></script>
|
<script src="http://localhost:8080/js/bundle.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
5
db/migrate/20250224162915_add_due_date_at_to_projects.rb
Normal file
5
db/migrate/20250224162915_add_due_date_at_to_projects.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddDueDateAtToProjects < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_column :projects, :due_date_at, :datetime
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2024_11_26_095028) do
|
ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do
|
||||||
create_table "areas", force: :cascade do |t|
|
create_table "areas", force: :cascade do |t|
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.integer "user_id", null: false
|
t.integer "user_id", null: false
|
||||||
|
|
@ -48,6 +48,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_11_26_095028) do
|
||||||
t.boolean "active", default: false
|
t.boolean "active", default: false
|
||||||
t.boolean "pin_to_sidebar", default: false
|
t.boolean "pin_to_sidebar", default: false
|
||||||
t.integer "priority"
|
t.integer "priority"
|
||||||
|
t.datetime "due_date_at"
|
||||||
t.index ["area_id"], name: "index_projects_on_area_id"
|
t.index ["area_id"], name: "index_projects_on_area_id"
|
||||||
t.index ["user_id"], name: "index_projects_on_user_id"
|
t.index ["user_id"], name: "index_projects_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
37
package-lock.json
generated
37
package-lock.json
generated
|
|
@ -17,7 +17,8 @@
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"react-tagify": "^1.0.7",
|
"react-tagify": "^1.0.7",
|
||||||
"swr": "^2.2.5",
|
"swr": "^2.2.5",
|
||||||
"tagify": "^0.1.1"
|
"tagify": "^0.1.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.7",
|
"@babel/core": "^7.25.7",
|
||||||
|
|
@ -2545,7 +2546,7 @@
|
||||||
"version": "15.7.13",
|
"version": "15.7.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
|
||||||
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
|
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
|
||||||
"dev": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.9.16",
|
"version": "6.9.16",
|
||||||
|
|
@ -2563,7 +2564,7 @@
|
||||||
"version": "18.3.10",
|
"version": "18.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz",
|
||||||
"integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==",
|
"integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
|
|
@ -4170,7 +4171,7 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"dev": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
|
|
@ -10297,6 +10298,34 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"react-tagify": "^1.0.7",
|
"react-tagify": "^1.0.7",
|
||||||
"swr": "^2.2.5",
|
"swr": "^2.2.5",
|
||||||
"tagify": "^0.1.1"
|
"tagify": "^0.1.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue