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 ProfileSettings from "./components/Profile/ProfileSettings";
|
||||
import Layout from "./Layout";
|
||||
import { DataProvider } from "./contexts/DataContext";
|
||||
import { User } from "./entities/User";
|
||||
import TasksToday from "./components/Task/TasksToday";
|
||||
|
||||
|
|
@ -101,7 +100,7 @@ const App: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<DataProvider>
|
||||
<>
|
||||
{currentUser ? (
|
||||
<Layout
|
||||
currentUser={currentUser}
|
||||
|
|
@ -131,7 +130,7 @@ const App: React.FC = () => {
|
|||
) : (
|
||||
<Login />
|
||||
)}
|
||||
</DataProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,13 @@ import { Area } from "./entities/Area";
|
|||
import { Tag } from "./entities/Tag";
|
||||
import { Project } from "./entities/Project";
|
||||
import { Task } from "./entities/Task";
|
||||
import { useDataContext } from "./contexts/DataContext";
|
||||
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 {
|
||||
currentUser: User;
|
||||
|
|
@ -43,27 +48,47 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const [newTask, setNewTask] = useState<Task | null>(null);
|
||||
|
||||
const {
|
||||
tags,
|
||||
areas,
|
||||
notes,
|
||||
projects,
|
||||
isLoading,
|
||||
isError,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
createArea,
|
||||
updateArea,
|
||||
deleteArea,
|
||||
createTag,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
createTask,
|
||||
updateTask,
|
||||
} = useDataContext();
|
||||
notesStore: {
|
||||
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,
|
||||
setProjects,
|
||||
setLoading: setProjectsLoading,
|
||||
setError: setProjectsError,
|
||||
isLoading: isProjectsLoading,
|
||||
isError: isProjectsError,
|
||||
},
|
||||
tagsStore: {
|
||||
tags,
|
||||
setTags,
|
||||
setLoading: setTagsLoading,
|
||||
setError: setTagsError,
|
||||
isLoading: isTagsLoading,
|
||||
isError: isTagsError,
|
||||
},
|
||||
} = useStore();
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(
|
||||
window.innerWidth >= 1024
|
||||
|
|
@ -77,6 +102,37 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
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) => {
|
||||
setSelectedNote(note);
|
||||
setIsNoteModalOpen(true);
|
||||
|
|
@ -127,20 +183,11 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const handleSaveNote = async (noteData: Note) => {
|
||||
try {
|
||||
if (noteData.id) {
|
||||
await updateNote(noteData.id, {
|
||||
title: noteData.title,
|
||||
content: noteData.content,
|
||||
tags: noteData.tags?.map((tag) => tag.name),
|
||||
project_id: noteData.project?.id,
|
||||
});
|
||||
await updateNote(noteData.id, noteData);
|
||||
} else {
|
||||
await createNote({
|
||||
title: noteData.title,
|
||||
content: noteData.content,
|
||||
tags: noteData.tags?.map((tag) => tag.name),
|
||||
project_id: noteData.project?.id,
|
||||
});
|
||||
await createNote(noteData);
|
||||
}
|
||||
loadNotes();
|
||||
} catch (error) {
|
||||
console.error("Error saving note:", error);
|
||||
}
|
||||
|
|
@ -154,12 +201,27 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
} else {
|
||||
await createTask(taskData);
|
||||
}
|
||||
const { tasks } = await fetchTasks();
|
||||
setTasks(tasks);
|
||||
} catch (error) {
|
||||
console.error("Error saving task:", error);
|
||||
}
|
||||
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) => {
|
||||
try {
|
||||
if (projectData.id) {
|
||||
|
|
@ -167,29 +229,22 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
} else {
|
||||
await createProject(projectData);
|
||||
}
|
||||
const projectsData = await fetchProjects();
|
||||
setProjects(projectsData);
|
||||
} catch (error) {
|
||||
console.error("Error saving project:", error);
|
||||
}
|
||||
closeProjectModal();
|
||||
};
|
||||
|
||||
const handleCreateProject = async (name: string): Promise<Project> => {
|
||||
try {
|
||||
const newProject = await createProject({ name });
|
||||
return newProject;
|
||||
} catch (error) {
|
||||
console.error("Error creating project:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveArea = async (areaData: Area) => {
|
||||
const handleSaveArea = async (areaData: Partial<Area>) => {
|
||||
try {
|
||||
if (areaData.id) {
|
||||
await updateArea(areaData.id, areaData);
|
||||
} else {
|
||||
await createArea(areaData);
|
||||
}
|
||||
loadAreas();
|
||||
} catch (error) {
|
||||
console.error("Error saving area:", error);
|
||||
}
|
||||
|
|
@ -203,6 +258,8 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
} else {
|
||||
await createTag(tagData);
|
||||
}
|
||||
const tagsData = await fetchTags();
|
||||
setTags(tagsData);
|
||||
} catch (error) {
|
||||
console.error("Error saving tag:", error);
|
||||
}
|
||||
|
|
@ -211,6 +268,19 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
|
||||
const mainContentMarginLeft = isSidebarOpen ? "ml-72" : "ml-0";
|
||||
|
||||
const isLoading =
|
||||
isNotesLoading ||
|
||||
isAreasLoading ||
|
||||
isTasksLoading ||
|
||||
isProjectsLoading ||
|
||||
isTagsLoading;
|
||||
const isError =
|
||||
isNotesError ||
|
||||
isAreasError ||
|
||||
isTasksError ||
|
||||
isProjectsError ||
|
||||
isTagsError;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
|
||||
|
|
@ -357,7 +427,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={() => {}}
|
||||
projects={projects}
|
||||
projects={projects}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -402,4 +472,3 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
};
|
||||
|
||||
export default Layout;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
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 { id } = useParams<{ id: string }>();
|
||||
const { areas, isLoading, isError } = useDataContext();
|
||||
const [area, setArea] = useState<any | null>(null);
|
||||
const { areas } = useStore((state) => state.areasStore);
|
||||
const [area, setArea] = useState<Area | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
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);
|
||||
if (!foundArea) {
|
||||
setIsError(true);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [id, areas]);
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -25,7 +33,7 @@ const AreaDetails: React.FC = () => {
|
|||
if (isError || !area) {
|
||||
return (
|
||||
<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.'}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -50,4 +58,4 @@ const AreaDetails: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default AreaDetails;
|
||||
export default AreaDetails;
|
||||
|
|
@ -1,29 +1,27 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { useDataContext } from '../../contexts/DataContext';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
|
||||
interface AreaModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (areaData: Area) => void;
|
||||
onSave: (areaData: Partial<Area>) => Promise<void>;
|
||||
area?: Area | null;
|
||||
}
|
||||
|
||||
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave }) => {
|
||||
const { createArea, updateArea } = useDataContext();
|
||||
const [formData, setFormData] = useState<Area>({
|
||||
id: area?.id || 0,
|
||||
name: area?.name || '',
|
||||
description: area?.description || '',
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
|
@ -60,6 +58,7 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
|||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
|
@ -88,14 +87,8 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
if (formData.id && formData.id !== 0) {
|
||||
await updateArea(formData.id, formData);
|
||||
showSuccessToast('Area updated successfully!');
|
||||
} else {
|
||||
await createArea(formData);
|
||||
showSuccessToast('Area created successfully!');
|
||||
}
|
||||
onSave(formData);
|
||||
await onSave(formData);
|
||||
showSuccessToast(`Area ${formData.id ? 'updated' : 'created'} successfully!`);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
|
@ -116,88 +109,80 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
|||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
|
||||
isClosing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
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 ${isClosing ? 'scale-95' : 'scale-100'} h-screen sm:h-auto flex flex-col`}
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
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 ${
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
} h-screen sm:h-auto flex flex-col`}
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
<form 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">
|
||||
{/* Area Name */}
|
||||
<div className="py-4">
|
||||
<input
|
||||
type="text"
|
||||
id="areaName"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder="Enter area name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Area Description */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="areaDescription"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="block w-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder="Enter area description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
<form 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">
|
||||
{/* Area Name */}
|
||||
<div className="py-4">
|
||||
<input
|
||||
type="text"
|
||||
id="areaName"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder="Enter area name"
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
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 ${
|
||||
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Submitting...'
|
||||
: formData.id && formData.id !== 0
|
||||
? 'Update Area'
|
||||
: 'Create Area'}
|
||||
</button>
|
||||
{/* Area Description */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="areaDescription"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="block w-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder="Enter area description"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && <div className="text-red-500">{error}</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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
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 ${isSubmitting ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Submitting...'
|
||||
: formData.id && formData.id !== 0
|
||||
? 'Update Area'
|
||||
: 'Create Area'}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreaModal;
|
||||
export default AreaModal;
|
||||
|
|
@ -1,23 +1,40 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
Squares2X2Icon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
} from '@heroicons/react/24/solid';
|
||||
import ConfirmDialog from './Shared/ConfirmDialog';
|
||||
import AreaModal from './Area/AreaModal';
|
||||
import { useDataContext } from '../contexts/DataContext';
|
||||
import AreaModal from './Area/AreaModal';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { fetchAreas, createArea, updateArea, deleteArea } from '../utils/areasService';
|
||||
import { Area } from '../entities/Area';
|
||||
|
||||
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 [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
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 {
|
||||
if (areaData.id) {
|
||||
await updateArea(areaData.id, {
|
||||
|
|
@ -30,9 +47,13 @@ const Areas: React.FC = () => {
|
|||
description: areaData.description,
|
||||
});
|
||||
}
|
||||
const updatedAreas = await fetchAreas();
|
||||
setAreas(updatedAreas);
|
||||
} catch (error) {
|
||||
console.error('Error saving area:', error);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsAreaModalOpen(false);
|
||||
setSelectedArea(null);
|
||||
}
|
||||
|
|
@ -56,12 +77,18 @@ const Areas: React.FC = () => {
|
|||
const handleDeleteArea = async () => {
|
||||
if (!areaToDelete) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteArea(areaToDelete.id!);
|
||||
const updatedAreas = await fetchAreas();
|
||||
setAreas(updatedAreas);
|
||||
setIsConfirmDialogOpen(false);
|
||||
setAreaToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -70,26 +97,8 @@ const Areas: React.FC = () => {
|
|||
setAreaToDelete(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<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="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-5xl">
|
||||
{/* Areas Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
|
@ -99,6 +108,12 @@ const Areas: React.FC = () => {
|
|||
Areas
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateArea}
|
||||
className="bg-blue-500 text-white rounded-md px-4 py-2 hover:bg-blue-600"
|
||||
>
|
||||
Add Area
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Areas List */}
|
||||
|
|
@ -174,4 +189,4 @@ const Areas: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Areas;
|
||||
export default Areas;
|
||||
|
|
@ -1,43 +1,67 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { PencilSquareIcon, TrashIcon, TagIcon, DocumentTextIcon } from '@heroicons/react/24/solid';
|
||||
import { useDataContext } from '../../contexts/DataContext';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
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 { id } = useParams<{ id: string }>();
|
||||
const { notes, deleteNote, isLoading, isError } = useDataContext();
|
||||
const [note, setNote] = useState<Note | null>(null);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const foundNote = notes.find((n) => n.id === Number(id));
|
||||
setNote(foundNote || null);
|
||||
}, [id, notes]);
|
||||
const fetchNote = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const notes = await fetchNotes();
|
||||
const foundNote = notes.find((n: Note) => n.id === Number(id));
|
||||
setNote(foundNote || null);
|
||||
if (!foundNote) {
|
||||
setIsError(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setIsError(true);
|
||||
console.error('Error fetching note:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchNote();
|
||||
}, [id]);
|
||||
|
||||
const handleDeleteNote = async () => {
|
||||
if (!noteToDelete) return;
|
||||
try {
|
||||
await deleteNote(noteToDelete.id!);
|
||||
navigate('/notes');
|
||||
await apiDeleteNote(noteToDelete.id!);
|
||||
navigate('/notes');
|
||||
} catch (err) {
|
||||
console.error('Error deleting note:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNote = (updatedNote: Note) => {
|
||||
setNote(updatedNote);
|
||||
setIsNoteModalOpen(false);
|
||||
const handleSaveNote = async (updatedNote: Note) => {
|
||||
try {
|
||||
if (updatedNote.id !== undefined) {
|
||||
await apiUpdateNote(updatedNote.id, updatedNote);
|
||||
setNote(updatedNote);
|
||||
} else {
|
||||
console.error("Error: Note ID is undefined.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving note:', err);
|
||||
}
|
||||
setIsNoteModalOpen(false);
|
||||
};
|
||||
|
||||
const handleEditNote = () => {
|
||||
setIsNoteModalOpen(true);
|
||||
setIsNoteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenConfirmDialog = (note: Note) => {
|
||||
|
|
@ -66,7 +90,7 @@ const NoteDetails: React.FC = () => {
|
|||
}
|
||||
|
||||
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">
|
||||
{/* Header Section with Title and Action Buttons */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
|
@ -76,7 +100,6 @@ const NoteDetails: React.FC = () => {
|
|||
{note.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
|
|
@ -97,7 +120,6 @@ const NoteDetails: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card with Tags and Metadata */}
|
||||
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
|
||||
{/* 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"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Metadata */}
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Created on: {new Date(note.created_at || '').toLocaleDateString()}</p>
|
||||
<p>Last updated: {new Date(note.updated_at || '').toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
{/* Note Project */}
|
||||
{note.project && (
|
||||
<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
|
||||
to={`/project/${note.project.id}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
|
|
@ -137,14 +161,12 @@ const NoteDetails: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note Content */}
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-line">
|
||||
{note.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* NoteModal for editing */}
|
||||
{isNoteModalOpen && (
|
||||
<NoteModal
|
||||
|
|
@ -154,7 +176,6 @@ const NoteDetails: React.FC = () => {
|
|||
note={note}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ConfirmDialog */}
|
||||
{isConfirmDialogOpen && noteToDelete && (
|
||||
<ConfirmDialog
|
||||
|
|
@ -172,4 +193,4 @@ const NoteDetails: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default NoteDetails;
|
||||
export default NoteDetails;
|
||||
|
|
@ -1,19 +1,18 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Note } from '../../entities/Note';
|
||||
import { useDataContext } from '../../contexts/DataContext';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import TagInput from '../Tag/TagInput';
|
||||
import { Tag } from '../../entities/Tag';
|
||||
import { fetchTags } from '../../utils/tagsService';
|
||||
|
||||
interface NoteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
note?: Note | null;
|
||||
onSave: (noteData: Note) => void;
|
||||
onSave: (noteData: Note) => Promise<void>;
|
||||
}
|
||||
|
||||
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave }) => {
|
||||
const { createNote, updateNote } = useDataContext();
|
||||
const [formData, setFormData] = useState<Note>({
|
||||
id: note?.id || 0,
|
||||
title: note?.title || '',
|
||||
|
|
@ -30,14 +29,18 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const data = await fetchTags();
|
||||
setAvailableTags(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tags', error);
|
||||
showErrorToast('Failed to load available tags.');
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
fetch('/api/tags')
|
||||
.then((response) => response.json())
|
||||
.then((data: Tag[]) => setAvailableTags(data))
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch tags', error);
|
||||
showErrorToast('Failed to load available tags.');
|
||||
});
|
||||
loadTags();
|
||||
}
|
||||
}, [isOpen, showErrorToast]);
|
||||
|
||||
|
|
@ -78,6 +81,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
|
@ -114,14 +118,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
if (formData.id && formData.id !== 0) {
|
||||
await updateNote(formData.id, { ...formData, tags });
|
||||
showSuccessToast('Note updated successfully!');
|
||||
} else {
|
||||
await createNote({ ...formData, tags });
|
||||
showSuccessToast('Note created successfully!');
|
||||
}
|
||||
onSave(formData);
|
||||
const noteTags: Tag[] = tags.map(tagName => ({ name: tagName }));
|
||||
await onSave({ ...formData, tags: noteTags });
|
||||
showSuccessToast(formData.id && formData.id !== 0 ? 'Note updated successfully!' : 'Note created successfully!');
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
|
@ -142,97 +141,95 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
|
||||
isClosing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
|
||||
isClosing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
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-2xl overflow-hidden transform transition-transform duration-300 ${
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
} h-screen sm:h-auto flex flex-col`}
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
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-2xl overflow-hidden transform transition-transform duration-300 ${
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
} h-screen sm:h-auto flex flex-col`}
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
<form 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="py-4">
|
||||
<input
|
||||
type="text"
|
||||
id="noteTitle"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder="Enter note title"
|
||||
<form 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="py-4">
|
||||
<input
|
||||
type="text"
|
||||
id="noteTitle"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder="Enter note title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tags
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<TagInput
|
||||
onTagsChange={handleTagsChange}
|
||||
initialTags={tags}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tags
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<TagInput
|
||||
onTagsChange={handleTagsChange}
|
||||
initialTags={tags}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pb-3 flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="noteContent"
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleChange}
|
||||
rows={20}
|
||||
className="block w-full h-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder="Enter note content"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="p-3 flex-shrink-0 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className={`px-4 py-2 text-md 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
|
||||
? 'Submitting...'
|
||||
: formData.id && formData.id !== 0
|
||||
? 'Update Note'
|
||||
: 'Create Note'}
|
||||
</button>
|
||||
<div className="pb-3 flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="noteContent"
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleChange}
|
||||
rows={20}
|
||||
className="block w-full h-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder="Enter note content"
|
||||
></textarea>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="p-3 flex-shrink-0 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className={`px-4 py-2 text-md 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
|
||||
? 'Submitting...'
|
||||
: formData.id && formData.id !== 0
|
||||
? 'Update Note'
|
||||
: 'Create Note'}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteModal;
|
||||
export default NoteModal;
|
||||
|
|
@ -1,23 +1,53 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 ConfirmDialog from './Shared/ConfirmDialog';
|
||||
import { useDataContext } from '../contexts/DataContext';
|
||||
import { Note } from '../entities/Note';
|
||||
import {
|
||||
fetchNotes,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote as apiDeleteNote,
|
||||
} from '../utils/notesService';
|
||||
|
||||
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 [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
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 () => {
|
||||
if (!noteToDelete) return;
|
||||
try {
|
||||
await deleteNote(noteToDelete.id!);
|
||||
await apiDeleteNote(noteToDelete.id!);
|
||||
setNotes((prev) => prev.filter((note) => note.id !== noteToDelete.id));
|
||||
setIsConfirmDialogOpen(false);
|
||||
setNoteToDelete(null);
|
||||
} catch (err) {
|
||||
|
|
@ -32,11 +62,17 @@ const Notes: React.FC = () => {
|
|||
|
||||
const handleSaveNote = async (noteData: Note) => {
|
||||
try {
|
||||
let updatedNotes;
|
||||
if (noteData.id) {
|
||||
await updateNote(noteData.id, noteData);
|
||||
updatedNotes = notes.map((note) =>
|
||||
note.id === noteData.id ? noteData : note
|
||||
);
|
||||
} else {
|
||||
await createNote(noteData);
|
||||
const newNote = await createNote(noteData);
|
||||
updatedNotes = [...notes, newNote];
|
||||
}
|
||||
setNotes(updatedNotes);
|
||||
setIsNoteModalOpen(false);
|
||||
setSelectedNote(null);
|
||||
} catch (err) {
|
||||
|
|
@ -69,13 +105,15 @@ const Notes: React.FC = () => {
|
|||
}
|
||||
|
||||
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">
|
||||
{/* Notes Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
|
||||
|
|
@ -99,7 +137,10 @@ const Notes: React.FC = () => {
|
|||
) : (
|
||||
<ul className="space-y-2">
|
||||
{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">
|
||||
<Link
|
||||
to={`/note/${note.id}`}
|
||||
|
|
@ -161,4 +202,4 @@ const Notes: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Notes;
|
||||
export default Notes;
|
||||
|
|
@ -4,15 +4,19 @@ import {
|
|||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
FolderIcon,
|
||||
Squares2X2Icon,
|
||||
Squares2X2Icon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import TaskList from "../Task/TaskList";
|
||||
import ProjectModal from "../Project/ProjectModal";
|
||||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||
import { useDataContext } from "../../contexts/DataContext";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import NewTask from "../Task/NewTask";
|
||||
import { Project } from "../../entities/Project";
|
||||
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 };
|
||||
|
||||
|
|
@ -24,11 +28,10 @@ const priorityStyles: PriorityStyles = {
|
|||
};
|
||||
|
||||
const ProjectDetails: React.FC = () => {
|
||||
const { updateTask, deleteTask, updateProject, deleteProject } = useDataContext();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { areas } = useDataContext();
|
||||
|
||||
const areas = useStore((state) => state.areasStore.areas);
|
||||
|
||||
const [project, setProject] = useState<Project | undefined>(undefined);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
|
|
@ -36,71 +39,51 @@ const ProjectDetails: React.FC = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
|
||||
const projectTitle = project?.name || "Project";
|
||||
|
||||
const [isCompletedOpen, setIsCompletedOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProject = async () => {
|
||||
const loadProjectData = async () => {
|
||||
if (!id) {
|
||||
console.error("Project ID is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/project/${id}`, {
|
||||
credentials: "include",
|
||||
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.");
|
||||
}
|
||||
fetchAreas();
|
||||
const projectData = await fetchProjectById(id);
|
||||
setProject(projectData);
|
||||
setTasks(projectData.tasks || []);
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
console.error("Error fetching project data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProject();
|
||||
}, [id]);
|
||||
|
||||
loadProjectData();
|
||||
}, [id, fetchAreas]);
|
||||
|
||||
const handleTaskCreate = async (taskName: string) => {
|
||||
if (!project || project.id === undefined) {
|
||||
console.error("Cannot create task: Project or Project ID is missing");
|
||||
if (!project) {
|
||||
console.error("Cannot create task: Project is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
const taskPayload = {
|
||||
name: taskName,
|
||||
status: "not_started",
|
||||
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),
|
||||
const newTask = await createTask({
|
||||
name: taskName,
|
||||
status: "not_started",
|
||||
project_id: project.id,
|
||||
});
|
||||
|
||||
const newTask = await response.json();
|
||||
if (response.ok) {
|
||||
setTasks([...tasks, newTask]);
|
||||
} else {
|
||||
throw new Error(newTask.error || "Failed to create task");
|
||||
}
|
||||
setTasks((prevTasks) => [...prevTasks, newTask]);
|
||||
} catch (err) {
|
||||
console.error("Error creating task:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskUpdate = async (updatedTask: Task) => {
|
||||
if (updatedTask.id === undefined) {
|
||||
if (!updatedTask.id) {
|
||||
console.error("Cannot update task: Task ID is missing");
|
||||
return;
|
||||
}
|
||||
|
|
@ -117,7 +100,7 @@ const ProjectDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleTaskDelete = async (taskId: number | undefined) => {
|
||||
if (taskId === undefined) {
|
||||
if (!taskId) {
|
||||
console.error("Cannot delete task: Task ID is missing");
|
||||
return;
|
||||
}
|
||||
|
|
@ -134,11 +117,11 @@ const ProjectDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleSaveProject = async (updatedProject: Project) => {
|
||||
if (!updatedProject || updatedProject.id === undefined) {
|
||||
console.error("Cannot save project: Project or Project ID is missing");
|
||||
if (!updatedProject.id) {
|
||||
console.error("Cannot save project: Project ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const savedProject = await updateProject(updatedProject.id, updatedProject);
|
||||
setProject(savedProject);
|
||||
|
|
@ -149,8 +132,8 @@ const ProjectDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!project || project.id === undefined) {
|
||||
console.error("Cannot delete project: Project or Project ID is missing");
|
||||
if (!project?.id) {
|
||||
console.error("Cannot delete project: Project ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -188,9 +171,9 @@ const ProjectDetails: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
const activeTasks = tasks.filter(task => task.status !== 'done');
|
||||
const completedTasks = 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 toggleCompleted = () => {
|
||||
setIsCompletedOpen(!isCompletedOpen);
|
||||
};
|
||||
|
|
@ -201,29 +184,27 @@ const ProjectDetails: React.FC = () => {
|
|||
{/* Project Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<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">
|
||||
{projectTitle}
|
||||
{project.name}
|
||||
</h2>
|
||||
{/* Priority Circle placed after the title */}
|
||||
{project.priority && (
|
||||
<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)}`}
|
||||
aria-label={`Priority: ${priorityLabel(project.priority)}`}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{/* Edit Project Button */}
|
||||
<button
|
||||
onClick={handleEditProject}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Delete Project Button */}
|
||||
<button
|
||||
onClick={() => setIsConfirmDialogOpen(true)}
|
||||
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>
|
||||
|
||||
{/* 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" />
|
||||
<Link
|
||||
to={`/projects/?area_id=${project.area.id}`}
|
||||
|
|
@ -246,19 +226,22 @@ const ProjectDetails: React.FC = () => {
|
|||
</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 && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* New Task Form */}
|
||||
<NewTask
|
||||
onTaskCreate={handleTaskCreate}
|
||||
/>
|
||||
<NewTask onTaskCreate={handleTaskCreate} />
|
||||
|
||||
{/* Active Tasks */}
|
||||
<div className="mt-2">
|
||||
{activeTasks.length > 0 ? (
|
||||
<TaskList
|
||||
|
|
@ -272,7 +255,6 @@ const ProjectDetails: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsible Completed Tasks */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={toggleCompleted}
|
||||
|
|
@ -298,7 +280,7 @@ const ProjectDetails: React.FC = () => {
|
|||
|
||||
{isCompletedOpen && (
|
||||
<div className="mt-4">
|
||||
{completedTasks.length > 0 ? (
|
||||
{completedTasks && completedTasks.length > 0 ? (
|
||||
<TaskList
|
||||
tasks={completedTasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
|
|
@ -314,7 +296,6 @@ const ProjectDetails: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<ProjectModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
|
|
@ -323,7 +304,6 @@ const ProjectDetails: React.FC = () => {
|
|||
areas={areas}
|
||||
/>
|
||||
|
||||
{/* Confirm Delete Dialog */}
|
||||
{isConfirmDialogOpen && (
|
||||
<ConfirmDialog
|
||||
title="Delete Project"
|
||||
|
|
@ -350,4 +330,4 @@ const priorityLabel = (priority: PriorityType) => {
|
|||
}
|
||||
};
|
||||
|
||||
export default ProjectDetails;
|
||||
export default ProjectDetails;
|
||||
|
|
@ -4,10 +4,11 @@ import { Project } from "../../entities/Project";
|
|||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import TagInput from "../Tag/TagInput";
|
||||
import useFetchTags from "../../hooks/useFetchTags";
|
||||
import PriorityDropdown from "../Shared/PriorityDropdown";
|
||||
import { PriorityType } from "../../entities/Task";
|
||||
import Switch from "../Shared/Switch";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import { fetchTags } from "../../utils/tagsService";
|
||||
|
||||
interface ProjectModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -34,6 +35,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
active: true,
|
||||
tags: [],
|
||||
priority: "low",
|
||||
due_date_at: "",
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -41,23 +43,21 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
project?.tags?.map((tag) => tag.name) || []
|
||||
);
|
||||
|
||||
const {
|
||||
tags: availableTags,
|
||||
isLoading: isTagsLoading,
|
||||
isError: isTagsError,
|
||||
} = useFetchTags();
|
||||
const { tagsStore } = useStore();
|
||||
const { tags: availableTags } = tagsStore;
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setFormData({
|
||||
...project,
|
||||
tags: project.tags || [],
|
||||
due_date_at: project.due_date_at || "",
|
||||
});
|
||||
setTags(project.tags?.map((tag) => tag.name) || []);
|
||||
} else {
|
||||
|
|
@ -67,11 +67,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
area_id: null,
|
||||
active: true,
|
||||
tags: [],
|
||||
priority: "low",
|
||||
due_date_at: "",
|
||||
});
|
||||
setTags([]);
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
useEffect(() => {
|
||||
if (availableTags.length === 0) {
|
||||
fetchTags();
|
||||
}
|
||||
}, [availableTags.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
|
|
@ -176,35 +184,13 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (isTagsLoading) {
|
||||
return (
|
||||
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
|
||||
Loading tags...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isTagsError) {
|
||||
return (
|
||||
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
|
||||
Error loading tags.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modal Overlay */}
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
|
||||
isClosing ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`bg-white dark:bg-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl overflow-hidden transform transition-transform duration-300 ${
|
||||
|
|
@ -214,12 +200,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
maxHeight: "calc(100vh - 4rem)",
|
||||
}}
|
||||
>
|
||||
{/* Form */}
|
||||
<form className="flex flex-col flex-1">
|
||||
<fieldset className="flex flex-col flex-1">
|
||||
{/* Form Fields */}
|
||||
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto">
|
||||
{/* Project Name */}
|
||||
<div className="py-4">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -233,7 +216,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
|
|
@ -249,6 +231,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
></textarea>
|
||||
</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">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Priority
|
||||
|
|
@ -261,7 +256,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tags
|
||||
|
|
@ -275,7 +269,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Area */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Area (optional)
|
||||
|
|
@ -296,7 +289,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Active Checkbox */}
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
isChecked={formData.active}
|
||||
|
|
@ -311,7 +303,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
</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">
|
||||
{project && onDelete && (
|
||||
<button
|
||||
|
|
@ -342,7 +333,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog for Deletion */}
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="Delete Project"
|
||||
|
|
@ -355,4 +345,4 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default ProjectModal;
|
||||
export default ProjectModal;
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Project } from "../entities/Project";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
FolderIcon,
|
||||
|
|
@ -8,8 +7,11 @@ import {
|
|||
} from "@heroicons/react/24/solid";
|
||||
import ConfirmDialog from "./Shared/ConfirmDialog";
|
||||
import ProjectModal from "./Project/ProjectModal";
|
||||
import { useDataContext } from "../contexts/DataContext";
|
||||
import useFetchProjects from "../hooks/useFetchProjects";
|
||||
import { useStore } from "../store/useStore";
|
||||
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 { useSearchParams } from "react-router-dom";
|
||||
import ProjectItem from "./Project/ProjectItem";
|
||||
|
|
@ -30,7 +32,10 @@ const getPriorityStyles = (priority: PriorityType) => {
|
|||
};
|
||||
|
||||
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 [isProjectModalOpen, setIsProjectModalOpen] = useState<boolean>(false);
|
||||
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||
|
|
@ -44,17 +49,79 @@ const Projects: React.FC = () => {
|
|||
const activeFilter = searchParams.get("active") || "all";
|
||||
const areaFilter = searchParams.get("area_id") || "";
|
||||
|
||||
const {
|
||||
projects,
|
||||
taskStatusCounts: fetchedTaskStatusCounts,
|
||||
isLoading,
|
||||
isError,
|
||||
mutate,
|
||||
} = useFetchProjects({ activeFilter, areaFilter });
|
||||
useEffect(() => {
|
||||
const loadAreas = async () => {
|
||||
try {
|
||||
const areasData = await fetchAreas();
|
||||
setAreas(areasData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch areas:", error);
|
||||
setAreasError(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTaskStatusCounts(fetchedTaskStatusCounts || {});
|
||||
}, [fetchedTaskStatusCounts]);
|
||||
loadAreas();
|
||||
}, []);
|
||||
|
||||
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) => {
|
||||
if (!projectId) return 0;
|
||||
|
|
@ -69,29 +136,6 @@ const Projects: React.FC = () => {
|
|||
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 newActiveFilter = e.target.value;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
|
@ -121,6 +165,16 @@ const Projects: React.FC = () => {
|
|||
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) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-6xl">
|
||||
|
|
@ -222,7 +266,7 @@ const Projects: React.FC = () => {
|
|||
>
|
||||
<option value="">All Areas</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id.toString()}>
|
||||
<option key={area.id} value={area.id?.toString()}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
|
|
@ -250,7 +294,7 @@ const Projects: React.FC = () => {
|
|||
className={`${
|
||||
viewMode === "cards"
|
||||
? "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 ? (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Switch.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface SwitchProps {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
};
|
||||
|
||||
const addNewTag = (tag: string) => {
|
||||
if (tags.length >= 10) { // Example limit
|
||||
if (tags.length >= 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,43 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PencilSquareIcon, TrashIcon, TagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
||||
import ConfirmDialog from './Shared/ConfirmDialog';
|
||||
import TagModal from './Tag/TagModal';
|
||||
import { useDataContext } from '../contexts/DataContext';
|
||||
import { Tag } from '../entities/Tag';
|
||||
import { fetchTags, createTag, updateTag, deleteTag as apiDeleteTag } from '../utils/tagsService';
|
||||
|
||||
const Tags: React.FC = () => {
|
||||
const { tags, createTag, updateTag, deleteTag, isLoading, isError } = useDataContext();
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
|
||||
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 () => {
|
||||
if (!tagToDelete) return;
|
||||
try {
|
||||
await deleteTag(tagToDelete.id!);
|
||||
await apiDeleteTag(tagToDelete.id!);
|
||||
setTags((prev) => prev.filter((tag) => tag.id !== tagToDelete.id));
|
||||
setIsConfirmDialogOpen(false);
|
||||
setTagToDelete(null);
|
||||
} catch (err) {
|
||||
|
|
@ -32,17 +52,20 @@ const Tags: React.FC = () => {
|
|||
|
||||
const handleSaveTag = async (tagData: Tag) => {
|
||||
try {
|
||||
let updatedTags;
|
||||
if (tagData.id) {
|
||||
await updateTag(tagData.id, tagData);
|
||||
updatedTags = tags.map((tag) => (tag.id === tagData.id ? tagData : tag));
|
||||
} else {
|
||||
await createTag(tagData);
|
||||
const newTag = await createTag(tagData);
|
||||
updatedTags = [...tags, newTag];
|
||||
}
|
||||
setTags(updatedTags);
|
||||
setIsTagModalOpen(false);
|
||||
setSelectedTag(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to save tag:', err);
|
||||
}
|
||||
|
||||
setIsTagModalOpen(false);
|
||||
setSelectedTag(null);
|
||||
};
|
||||
|
||||
const openConfirmDialog = (tag: Tag) => {
|
||||
|
|
@ -55,9 +78,8 @@ const Tags: React.FC = () => {
|
|||
setTagToDelete(null);
|
||||
};
|
||||
|
||||
const filteredTags = tags.filter(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const filteredTags = tags.filter((tag) =>
|
||||
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -71,11 +93,11 @@ const Tags: React.FC = () => {
|
|||
}
|
||||
|
||||
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 (
|
||||
<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">
|
||||
{/* Tags Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
|
|
@ -109,7 +131,6 @@ const Tags: React.FC = () => {
|
|||
key={tag.id}
|
||||
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">
|
||||
<Link
|
||||
to={`/tag/${tag.id}`}
|
||||
|
|
@ -119,7 +140,6 @@ const Tags: React.FC = () => {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditTag(tag)}
|
||||
|
|
@ -167,4 +187,4 @@ const Tags: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Tags;
|
||||
export default Tags;
|
||||
|
|
@ -28,7 +28,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
onTaskUpdate(updatedTask);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
|
||||
const handleDelete = () => {
|
||||
if (task.id) {
|
||||
onTaskDelete(task.id);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ interface TaskListProps {
|
|||
const TaskList: React.FC<TaskListProps> = ({
|
||||
tasks,
|
||||
onTaskUpdate,
|
||||
onTaskCreate,
|
||||
onTaskDelete,
|
||||
projects,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import ConfirmDialog from "../Shared/ConfirmDialog";
|
|||
import { useToast } from "../Shared/ToastContext";
|
||||
import TagInput from "../Tag/TagInput";
|
||||
import { Project } from "../../entities/Project";
|
||||
import { Tag } from "../../entities/Tag";
|
||||
import useFetchTags from "../../hooks/useFetchTags";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import { fetchTags } from '../../utils/tagsService';
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -30,36 +30,50 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
onCreateProject,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Task>(task);
|
||||
const [tags, setTags] = useState<string[]>(
|
||||
task.tags?.map((tag) => tag.name) || []
|
||||
);
|
||||
const [tags, setTags] = useState<string[]>(task.tags?.map((tag) => tag.name) || []);
|
||||
const [filteredProjects, setFilteredProjects] = useState<Project[]>(projects);
|
||||
const [newProjectName, setNewProjectName] = useState<string>("");
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { tags: availableTags, isLoading, isError } = useFetchTags();
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { tagsStore } = useStore();
|
||||
const { tags: availableTags, setTags: setAvailableTags, setLoading: setTagsLoading, setError: setTagsError } = tagsStore;
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(task);
|
||||
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 = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
>
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleTagsChange = useCallback((newTags: string[]) => {
|
||||
|
|
@ -75,7 +89,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
setNewProjectName(query);
|
||||
setDropdownOpen(true);
|
||||
setFilteredProjects(
|
||||
projects.filter((project) => project.name.toLowerCase().includes(query))
|
||||
projects.filter((project) =>
|
||||
project.name.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -111,7 +127,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowConfirmDialog(true);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
|
|
@ -137,10 +153,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
modalRef.current &&
|
||||
!modalRef.current.contains(event.target as Node)
|
||||
) {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
|
@ -168,26 +181,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
|
||||
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 (
|
||||
<>
|
||||
<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 ${
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
} h-screen sm:h-auto flex flex-col`}
|
||||
style={{
|
||||
maxHeight: "calc(100vh - 4rem)",
|
||||
}}
|
||||
style={{ maxHeight: "calc(100vh - 4rem)" }}
|
||||
>
|
||||
<form 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">
|
||||
{/* Task Name */}
|
||||
<div className="py-4">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -220,8 +210,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
placeholder="Add Task Name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tags
|
||||
|
|
@ -234,8 +222,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<div className="pb-3 relative">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Project
|
||||
|
|
@ -280,8 +266,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status and Priority */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 pb-3 sm:grid-flow-col">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Note */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Note
|
||||
|
|
@ -336,8 +318,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Actions */}
|
||||
<div className="p-3 flex-shrink-0">
|
||||
<TaskActions
|
||||
taskId={task.id}
|
||||
|
|
@ -350,7 +330,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="Delete Task"
|
||||
|
|
@ -363,4 +342,4 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default TaskModal;
|
||||
export default TaskModal;
|
||||
|
|
@ -1,81 +1,101 @@
|
|||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ClipboardDocumentListIcon,
|
||||
ClockIcon,
|
||||
ArrowPathIcon,
|
||||
CalendarDaysIcon,
|
||||
CalendarDaysIcon,
|
||||
ClockIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { fetchTasks, updateTask, deleteTask } from "../../utils/tasksService";
|
||||
import { fetchProjects } from "../../utils/projectsService";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { Project } from "../../entities/Project";
|
||||
|
||||
import useFetchTasks from "../../hooks/useFetchTasks";
|
||||
import useFetchProjects from "../../hooks/useFetchProjects";
|
||||
import useManageTasks from "../../hooks/useManageTasks";
|
||||
|
||||
import NewTask from "./NewTask";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import TaskList from "./TaskList";
|
||||
import { Metrics } from "../../entities/Metrics";
|
||||
|
||||
const TasksToday: React.FC = () => {
|
||||
const {
|
||||
tasks,
|
||||
metrics,
|
||||
isLoading: loadingTasks,
|
||||
isError: errorTasks,
|
||||
mutate: mutateTasks,
|
||||
} = useFetchTasks({
|
||||
type: "today",
|
||||
});
|
||||
|
||||
setTasks,
|
||||
setLoading: setTasksLoading,
|
||||
setError: setTasksError,
|
||||
} = useStore((state) => state.tasksStore);
|
||||
const {
|
||||
projects,
|
||||
isLoading: loadingProjects,
|
||||
isError: errorProjects,
|
||||
} = useFetchProjects();
|
||||
setProjects,
|
||||
setLoading: setProjectsLoading,
|
||||
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 => {
|
||||
if (updatedTask.id === undefined) {
|
||||
console.error("Error updating task: Task ID is undefined.");
|
||||
return;
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [setProjects, setProjectsLoading, setProjectsError, setTasks, setTasksLoading, setTasksError]);
|
||||
|
||||
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);
|
||||
setTasksError(true);
|
||||
} finally {
|
||||
setTasksLoading(false);
|
||||
}
|
||||
updateTask(updatedTask.id, updatedTask)
|
||||
.then(() => {
|
||||
mutateTasks();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error updating task:", error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTaskDelete = (taskId: number): void => {
|
||||
deleteTask(taskId)
|
||||
.then(() => {
|
||||
mutateTasks();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting task:", error);
|
||||
});
|
||||
const handleTaskDelete = async (taskId: number): Promise<void> => {
|
||||
try {
|
||||
setTasksLoading(true);
|
||||
await deleteTask(taskId);
|
||||
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
|
||||
setTasks(updatedTasks);
|
||||
setMetrics(metrics);
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
setTasksError(true);
|
||||
} finally {
|
||||
setTasksLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingTasks || loadingProjects) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
if (errorTasks) {
|
||||
return <p className="text-red-500">Error loading tasks.</p>;
|
||||
}
|
||||
|
||||
if (errorProjects) {
|
||||
return <p className="text-red-500">Error loading projects.</p>;
|
||||
}
|
||||
const todayDate = format(new Date(), "yyyy-MM-dd");
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-4">
|
||||
<h2 className="text-2xl font-light flex items-center">
|
||||
<CalendarDaysIcon className="h-5 w-5 mr-2" /> Today
|
||||
|
|
@ -85,29 +105,17 @@ const TasksToday: React.FC = () => {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Overview of Tasks */}
|
||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
{/* Total Open Tasks */}
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<ClipboardDocumentListIcon className="h-8 w-8 text-blue-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Backlog</p>
|
||||
<p className="text-2xl font-semibold">{metrics.total_open_tasks}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks Pending Over a Month */}
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<ClockIcon className="h-8 w-8 text-yellow-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Stale</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{metrics.tasks_pending_over_month}
|
||||
{metrics.total_open_tasks}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks In Progress */}
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<ArrowPathIcon className="h-8 w-8 text-green-500 mr-4" />
|
||||
<div>
|
||||
|
|
@ -118,7 +126,6 @@ const TasksToday: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks Due Today */}
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<CalendarDaysIcon className="h-8 w-8 text-red-500 mr-4" />
|
||||
<div>
|
||||
|
|
@ -128,12 +135,21 @@ const TasksToday: React.FC = () => {
|
|||
</p>
|
||||
</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>
|
||||
|
||||
{/* Tasks Due Today */}
|
||||
{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
|
||||
tasks={metrics.tasks_due_today}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
|
|
@ -143,10 +159,9 @@ const TasksToday: React.FC = () => {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Tasks In Progress */}
|
||||
{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
|
||||
tasks={metrics.tasks_in_progress}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
|
|
@ -156,10 +171,9 @@ const TasksToday: React.FC = () => {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Suggested Tasks */}
|
||||
{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
|
||||
tasks={metrics.suggested_tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
|
|
@ -169,7 +183,6 @@ const TasksToday: React.FC = () => {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback Message */}
|
||||
{tasks.length === 0 && (
|
||||
<p className="text-gray-500 text-center mt-4">
|
||||
No tasks available for today.
|
||||
|
|
@ -180,4 +193,4 @@ const TasksToday: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default TasksToday;
|
||||
export default TasksToday;
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
XMarkIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronDoubleDownIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
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 [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
|
||||
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 navigate = useNavigate();
|
||||
const query = new URLSearchParams(location.search);
|
||||
|
|
@ -198,6 +200,10 @@ const Tasks: React.FC = () => {
|
|||
return status !== "done";
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
task.name.toLowerCase().includes(taskSearchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-5xl">
|
||||
|
|
@ -268,10 +274,25 @@ const Tasks: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Description */}
|
||||
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</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 ? (
|
||||
<p>Loading...</p>
|
||||
) : error ? (
|
||||
|
|
@ -288,9 +309,9 @@ const Tasks: React.FC = () => {
|
|||
)}
|
||||
|
||||
{/* Task List */}
|
||||
{tasks.length > 0 ? (
|
||||
{filteredTasks.length > 0 ? (
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
tasks={filteredTasks}
|
||||
onTaskCreate={handleTaskCreate}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
|
|
@ -308,4 +329,4 @@ const Tasks: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Tasks;
|
||||
export default Tasks;
|
||||
|
|
@ -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 { Tag } from "./Tag";
|
||||
import { PriorityType } from "./Task";
|
||||
import { PriorityType, Task } from "./Task";
|
||||
|
||||
export interface Project {
|
||||
id?: number;
|
||||
|
|
@ -12,4 +12,6 @@ export interface Project {
|
|||
area_id?: number | null;
|
||||
tags?: Tag[];
|
||||
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.to_f / counts[:total] * 100).round
|
||||
end
|
||||
|
||||
def due_date_at
|
||||
self[:due_date_at]&.strftime('%Y-%m-%d')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -76,47 +76,103 @@ class Task < ActiveRecord::Base
|
|||
|
||||
def self.compute_metrics(user)
|
||||
total_open_tasks = user.tasks.incomplete.count
|
||||
|
||||
one_month_ago = Date.today - 30
|
||||
tasks_pending_over_month = user.tasks.incomplete.where('created_at < ?', one_month_ago).count
|
||||
|
||||
tasks_in_progress = user.tasks.incomplete.where(status: statuses[:in_progress])
|
||||
tasks_in_progress = user.tasks.incomplete.where(status: statuses[:in_progress]).order(priority: :desc)
|
||||
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)
|
||||
|
||||
# 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
|
||||
.where(status: statuses[:not_started], project_id: nil)
|
||||
.where.not(id: excluded_task_ids)
|
||||
.order(priority: :desc)
|
||||
.limit(3)
|
||||
.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)
|
||||
.order(priority: :desc)
|
||||
.limit(5)
|
||||
|
||||
# Fetch suggested tasks in projects, ordered by task priority and project priority
|
||||
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)
|
||||
# Combine both list of suggested tasks
|
||||
|
||||
suggested_tasks = sort_suggested_tasks(tasks_in_expiring_projects + tasks_without_projects)
|
||||
{
|
||||
total_open_tasks: total_open_tasks,
|
||||
tasks_pending_over_month: tasks_pending_over_month,
|
||||
tasks_in_progress: tasks_in_progress,
|
||||
tasks_in_progress_count: tasks_in_progress_count,
|
||||
tasks_due_today: tasks_due_today,
|
||||
suggested_tasks: (tasks_without_projects + tasks_in_projects)
|
||||
suggested_tasks: suggested_tasks
|
||||
}
|
||||
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 = {})
|
||||
super(options).merge(
|
||||
'due_date' => due_date&.strftime('%Y-%m-%d')
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ class Sinatra::Application
|
|||
area_id: project_data['area_id'],
|
||||
active: true,
|
||||
pin_to_sidebar: false,
|
||||
priority: project_data['priority']
|
||||
priority: project_data['priority'],
|
||||
due_date_at: project_data['due_date_at']
|
||||
)
|
||||
|
||||
if project.save
|
||||
|
|
@ -106,7 +107,8 @@ class Sinatra::Application
|
|||
area_id: project_data['area_id'],
|
||||
active: project_data['active'],
|
||||
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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/js/bundle.js"></script>
|
||||
<script src="http://localhost:8080/js/bundle.js"></script>
|
||||
</body>
|
||||
</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.
|
||||
|
||||
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|
|
||||
t.string "name"
|
||||
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 "pin_to_sidebar", default: false
|
||||
t.integer "priority"
|
||||
t.datetime "due_date_at"
|
||||
t.index ["area_id"], name: "index_projects_on_area_id"
|
||||
t.index ["user_id"], name: "index_projects_on_user_id"
|
||||
end
|
||||
|
|
|
|||
37
package-lock.json
generated
37
package-lock.json
generated
|
|
@ -17,7 +17,8 @@
|
|||
"react-router-dom": "^6.26.2",
|
||||
"react-tagify": "^1.0.7",
|
||||
"swr": "^2.2.5",
|
||||
"tagify": "^0.1.1"
|
||||
"tagify": "^0.1.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.7",
|
||||
|
|
@ -2545,7 +2546,7 @@
|
|||
"version": "15.7.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
|
||||
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.9.16",
|
||||
|
|
@ -2563,7 +2564,7 @@
|
|||
"version": "18.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz",
|
||||
"integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
|
|
@ -4170,7 +4171,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.1",
|
||||
|
|
@ -10297,6 +10298,34 @@
|
|||
"funding": {
|
||||
"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-tagify": "^1.0.7",
|
||||
"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