commit
f38f63977f
13 changed files with 470 additions and 212 deletions
|
|
@ -12,7 +12,16 @@ import ConfirmDialog from "../Shared/ConfirmDialog";
|
|||
import { useDataContext } from "../../contexts/DataContext";
|
||||
import NewTask from "../Task/NewTask";
|
||||
import { Project } from "../../entities/Project";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { PriorityType, Task } from "../../entities/Task";
|
||||
|
||||
type PriorityStyles = Record<PriorityType, string> & { default: string };
|
||||
|
||||
const priorityStyles: PriorityStyles = {
|
||||
high: 'bg-red-500',
|
||||
medium: 'bg-yellow-500',
|
||||
low: 'bg-green-500',
|
||||
default: 'bg-gray-400',
|
||||
};
|
||||
|
||||
const ProjectDetails: React.FC = () => {
|
||||
const { updateTask, deleteTask, updateProject, deleteProject } = useDataContext();
|
||||
|
|
@ -21,7 +30,7 @@ const ProjectDetails: React.FC = () => {
|
|||
|
||||
const { areas } = useDataContext();
|
||||
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [project, setProject] = useState<Project | undefined>(undefined);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -56,14 +65,15 @@ const ProjectDetails: React.FC = () => {
|
|||
fetchProject();
|
||||
}, [id]);
|
||||
|
||||
const handleTaskCreate = async (taskData: Partial<Task>) => {
|
||||
if (!project?.id) {
|
||||
console.error("Project ID is missing");
|
||||
const handleTaskCreate = async (taskName: string) => {
|
||||
if (!project || project.id === undefined) {
|
||||
console.error("Cannot create task: Project or Project ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
const taskPayload = {
|
||||
...taskData,
|
||||
name: taskName,
|
||||
status: "not_started",
|
||||
project_id: project.id,
|
||||
};
|
||||
|
||||
|
|
@ -124,10 +134,13 @@ const ProjectDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleSaveProject = async (updatedProject: Project) => {
|
||||
if (!updatedProject) return;
|
||||
if (!updatedProject || updatedProject.id === undefined) {
|
||||
console.error("Cannot save project: Project or Project ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedProject = await updateProject(updatedProject.id!, updatedProject);
|
||||
const savedProject = await updateProject(updatedProject.id, updatedProject);
|
||||
setProject(savedProject);
|
||||
setIsModalOpen(false);
|
||||
} catch (err) {
|
||||
|
|
@ -136,10 +149,13 @@ const ProjectDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!project) return;
|
||||
if (!project || project.id === undefined) {
|
||||
console.error("Cannot delete project: Project or Project ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteProject(project.id!);
|
||||
await deleteProject(project.id);
|
||||
navigate("/projects");
|
||||
} catch (err) {
|
||||
console.error("Error deleting project:", err);
|
||||
|
|
@ -164,6 +180,14 @@ const ProjectDetails: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">Project not found.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeTasks = tasks.filter(task => task.status !== 'done');
|
||||
const completedTasks = tasks.filter(task => task.status === 'done');
|
||||
|
||||
|
|
@ -177,12 +201,21 @@ 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" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
|
||||
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100 mr-2">
|
||||
{projectTitle}
|
||||
</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}`}
|
||||
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"
|
||||
|
|
@ -190,6 +223,7 @@ const ProjectDetails: React.FC = () => {
|
|||
<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"
|
||||
|
|
@ -200,11 +234,11 @@ const ProjectDetails: React.FC = () => {
|
|||
</div>
|
||||
|
||||
{/* Project Area */}
|
||||
{project?.area && (
|
||||
{project.area && (
|
||||
<div className="flex items-center mb-4">
|
||||
<Squares2X2Icon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<Link
|
||||
to={`/projects/?area_id=${project?.area.id}`}
|
||||
to={`/projects/?area_id=${project.area.id}`}
|
||||
className="text-gray-600 dark:text-gray-400 hover:underline"
|
||||
>
|
||||
{project.area.name.toUpperCase()}
|
||||
|
|
@ -213,7 +247,7 @@ const ProjectDetails: React.FC = () => {
|
|||
)}
|
||||
|
||||
{/* Project Description */}
|
||||
{project?.description && (
|
||||
{project.description && (
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
||||
{project.description}
|
||||
</p>
|
||||
|
|
@ -221,13 +255,7 @@ const ProjectDetails: React.FC = () => {
|
|||
|
||||
{/* New Task Form */}
|
||||
<NewTask
|
||||
onTaskCreate={(taskName: string) =>
|
||||
handleTaskCreate({
|
||||
name: taskName,
|
||||
status: "not_started",
|
||||
project_id: project?.id,
|
||||
})
|
||||
}
|
||||
onTaskCreate={handleTaskCreate}
|
||||
/>
|
||||
|
||||
{/* Active Tasks */}
|
||||
|
|
@ -250,7 +278,7 @@ const ProjectDetails: React.FC = () => {
|
|||
onClick={toggleCompleted}
|
||||
className="flex items-center justify-between w-full px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-md focus:outline-none"
|
||||
>
|
||||
<span className="text-xl font-semibold">Completed Tasks</span>
|
||||
<span className="text-sm uppercase font-medium">Completed Tasks</span>
|
||||
<svg
|
||||
className={`w-6 h-6 transform transition-transform duration-200 ${
|
||||
isCompletedOpen ? "rotate-180" : "rotate-0"
|
||||
|
|
@ -291,14 +319,15 @@ const ProjectDetails: React.FC = () => {
|
|||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveProject}
|
||||
project={project || undefined}
|
||||
project={project}
|
||||
areas={areas}
|
||||
/>
|
||||
|
||||
{/* Confirm Delete Dialog */}
|
||||
{isConfirmDialogOpen && (
|
||||
<ConfirmDialog
|
||||
title="Delete Project"
|
||||
message={`Are you sure you want to delete the project "${project?.name}"?`}
|
||||
message={`Are you sure you want to delete the project "${project.name}"?`}
|
||||
onConfirm={handleDeleteProject}
|
||||
onCancel={() => setIsConfirmDialogOpen(false)}
|
||||
/>
|
||||
|
|
@ -308,4 +337,17 @@ const ProjectDetails: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const priorityLabel = (priority: PriorityType) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'High';
|
||||
case 'medium':
|
||||
return 'Medium';
|
||||
case 'low':
|
||||
return 'Low';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export default ProjectDetails;
|
||||
|
|
|
|||
137
app/frontend/components/Project/ProjectItem.tsx
Normal file
137
app/frontend/components/Project/ProjectItem.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import { Project } from "../../entities/Project";
|
||||
|
||||
interface ProjectItemProps {
|
||||
project: Project;
|
||||
viewMode: "cards" | "list";
|
||||
color: string;
|
||||
getCompletionPercentage: (projectId: number | undefined) => number;
|
||||
activeDropdown: number | null;
|
||||
setActiveDropdown: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
handleEditProject: (project: Project) => void;
|
||||
setProjectToDelete: React.Dispatch<React.SetStateAction<Project | null>>;
|
||||
setIsConfirmDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const getProjectInitials = (name: string) => {
|
||||
const words = name
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter((word) => word.length > 0);
|
||||
if (words.length === 1) {
|
||||
return name.toUpperCase();
|
||||
}
|
||||
return words.map((word) => word[0].toUpperCase()).join("");
|
||||
};
|
||||
|
||||
const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||
project,
|
||||
viewMode,
|
||||
color,
|
||||
getCompletionPercentage,
|
||||
activeDropdown,
|
||||
setActiveDropdown,
|
||||
handleEditProject,
|
||||
setProjectToDelete,
|
||||
setIsConfirmDialogOpen,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
viewMode === "cards"
|
||||
? "bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col"
|
||||
: "bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-row items-center p-4"
|
||||
}`}
|
||||
style={{
|
||||
minHeight: viewMode === "cards" ? "250px" : "auto",
|
||||
maxHeight: viewMode === "cards" ? "250px" : "auto",
|
||||
}}
|
||||
>
|
||||
{viewMode === "cards" && (
|
||||
<div
|
||||
className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg"
|
||||
style={{ height: "140px" }}
|
||||
>
|
||||
<span className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20">
|
||||
{getProjectInitials(project.name)}
|
||||
</span>
|
||||
<div
|
||||
className={`absolute top-2 left-2 w-3 h-3 rounded-full ${color}`}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex justify-between items-start ${
|
||||
viewMode === "cards" ? "p-4 flex-1" : "flex-1"
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
className={`${
|
||||
viewMode === "cards"
|
||||
? "text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline line-clamp-2"
|
||||
: "text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
|
||||
}`}
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
<div className="relative">
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-400 focus:outline-none"
|
||||
onClick={() =>
|
||||
setActiveDropdown(
|
||||
activeDropdown === project.id ? null : project.id ?? null
|
||||
)
|
||||
}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{activeDropdown === project.id && (
|
||||
<div className="absolute right-0 mt-2 w-28 bg-white dark:bg-gray-700 shadow-lg rounded-md z-10">
|
||||
<button
|
||||
onClick={() => handleEditProject(project)}
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setProjectToDelete(project);
|
||||
setIsConfirmDialogOpen(true);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === "cards" && (
|
||||
<div className="absolute bottom-4 left-0 right-0 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${getCompletionPercentage(project?.id)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{getCompletionPercentage(project?.id)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectItem;
|
||||
|
|
@ -5,6 +5,9 @@ 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";
|
||||
|
||||
interface ProjectModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -30,12 +33,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
area_id: null,
|
||||
active: true,
|
||||
tags: [],
|
||||
priority: "low",
|
||||
}
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<string[]>(project?.tags?.map(tag => tag.name) || []);
|
||||
const [tags, setTags] = useState<string[]>(
|
||||
project?.tags?.map((tag) => tag.name) || []
|
||||
);
|
||||
|
||||
const { tags: availableTags, isLoading: isTagsLoading, isError: isTagsError } = useFetchTags();
|
||||
const {
|
||||
tags: availableTags,
|
||||
isLoading: isTagsLoading,
|
||||
isError: isTagsError,
|
||||
} = useFetchTags();
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
|
@ -49,7 +59,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
...project,
|
||||
tags: project.tags || [],
|
||||
});
|
||||
setTags(project.tags?.map(tag => tag.name) || []);
|
||||
setTags(project.tags?.map((tag) => tag.name) || []);
|
||||
} else {
|
||||
setFormData({
|
||||
name: "",
|
||||
|
|
@ -127,7 +137,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave({ ...formData, tags: tags.map(name => ({ name })) });
|
||||
onSave({ ...formData, tags: tags.map((name) => ({ name })) });
|
||||
showSuccessToast(
|
||||
project
|
||||
? "Project updated successfully!"
|
||||
|
|
@ -154,7 +164,14 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
}, 300);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleToggleActive = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
active: !prev.active,
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
|
@ -232,6 +249,18 @@ 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-3">
|
||||
Priority
|
||||
</label>
|
||||
<PriorityDropdown
|
||||
value={formData.priority || "medium"}
|
||||
onChange={(value: PriorityType) =>
|
||||
setFormData({ ...formData, priority: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
|
|
@ -269,13 +298,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
{/* Active Checkbox */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="active"
|
||||
name="active"
|
||||
checked={formData.active}
|
||||
onChange={handleChange}
|
||||
className="h-5 w-5 appearance-none border border-gray-300 rounded-md bg-white dark:bg-gray-700 checked:bg-blue-600 checked:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
<Switch
|
||||
isChecked={formData.active}
|
||||
onToggle={handleToggleActive}
|
||||
/>
|
||||
<label
|
||||
htmlFor="active"
|
||||
|
|
|
|||
|
|
@ -1,43 +1,46 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Project } from "../entities/Project";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
MagnifyingGlassIcon,
|
||||
FolderIcon
|
||||
FolderIcon,
|
||||
Squares2X2Icon,
|
||||
Bars3Icon,
|
||||
} 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 { PriorityType, StatusType } from "../entities/Task";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import ProjectItem from "./Project/ProjectItem";
|
||||
|
||||
const getProjectInitials = (name: string) => {
|
||||
const words = name
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter((word) => word.length > 0);
|
||||
if (words.length === 1) {
|
||||
return name.toUpperCase();
|
||||
type ProjectTaskCounts = Record<StatusType, number>;
|
||||
|
||||
const getPriorityStyles = (priority: PriorityType) => {
|
||||
switch (priority) {
|
||||
case "low":
|
||||
return { color: "bg-green-500" };
|
||||
case "medium":
|
||||
return { color: "bg-yellow-500" };
|
||||
case "high":
|
||||
return { color: "bg-red-500" };
|
||||
default:
|
||||
return { color: "bg-gray-500" };
|
||||
}
|
||||
return words.map((word) => word[0].toUpperCase()).join("");
|
||||
};
|
||||
|
||||
const Projects: React.FC = () => {
|
||||
const { areas, createProject, updateProject, deleteProject } =
|
||||
useDataContext();
|
||||
const [taskStatusCounts, setTaskStatusCounts] = useState<Record<number, any>>(
|
||||
{}
|
||||
);
|
||||
const { areas, createProject, updateProject, deleteProject } = useDataContext();
|
||||
const [taskStatusCounts, setTaskStatusCounts] = useState<Record<number, ProjectTaskCounts>>({});
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState<boolean>(false);
|
||||
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [viewMode, setViewMode] = useState<"cards" | "list">("cards");
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const activeFilter = searchParams.get("active") || "all";
|
||||
const areaFilter = searchParams.get("area_id") || "";
|
||||
|
||||
|
|
@ -55,14 +58,14 @@ const Projects: React.FC = () => {
|
|||
|
||||
const getCompletionPercentage = (projectId: number | undefined) => {
|
||||
if (!projectId) return 0;
|
||||
const taskStatus = taskStatusCounts[projectId] || {};
|
||||
const totalTasks =
|
||||
(taskStatus.done || 0) +
|
||||
(taskStatus.not_started || 0) +
|
||||
(taskStatus.in_progress || 0);
|
||||
|
||||
const taskStatus = taskStatusCounts[projectId] || {
|
||||
not_started: 0,
|
||||
in_progress: 0,
|
||||
done: 0,
|
||||
archived: 0,
|
||||
};
|
||||
const totalTasks = taskStatus.done + taskStatus.not_started + taskStatus.in_progress;
|
||||
if (totalTasks === 0) return 0;
|
||||
|
||||
return Math.round((taskStatus.done / totalTasks) * 100);
|
||||
};
|
||||
|
||||
|
|
@ -89,9 +92,7 @@ const Projects: React.FC = () => {
|
|||
mutate();
|
||||
};
|
||||
|
||||
const handleActiveFilterChange = (
|
||||
e: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
const handleActiveFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newActiveFilter = e.target.value;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
|
|
@ -100,7 +101,6 @@ const Projects: React.FC = () => {
|
|||
} else {
|
||||
params.set("active", newActiveFilter);
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
|
|
@ -159,51 +159,79 @@ const Projects: React.FC = () => {
|
|||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Filters for Active Status and Area */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:space-x-4 mb-6">
|
||||
<div className="mb-4 md:mb-0 w-full md:w-1/3">
|
||||
<label
|
||||
htmlFor="activeFilter"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
{/* View Mode and Filters */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 space-y-4 md:space-y-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setViewMode("cards")}
|
||||
className={`p-2 rounded-md focus:outline-none ${
|
||||
viewMode === "cards"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
aria-label="Card View"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="activeFilter"
|
||||
value={activeFilter}
|
||||
onChange={handleActiveFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
<Squares2X2Icon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`p-2 rounded-md focus:outline-none ${
|
||||
viewMode === "list"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
aria-label="List View"
|
||||
>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
<Bars3Icon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/3">
|
||||
<label
|
||||
htmlFor="areaFilter"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Area
|
||||
</label>
|
||||
<select
|
||||
id="areaFilter"
|
||||
value={areaFilter}
|
||||
onChange={handleAreaFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Areas</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id.toString()}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:space-x-4">
|
||||
<div className="w-full md:w-auto">
|
||||
<label
|
||||
htmlFor="activeFilter"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="activeFilter"
|
||||
value={activeFilter}
|
||||
onChange={handleActiveFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-auto">
|
||||
<label
|
||||
htmlFor="areaFilter"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Area
|
||||
</label>
|
||||
<select
|
||||
id="areaFilter"
|
||||
value={areaFilter}
|
||||
onChange={handleAreaFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Areas</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id.toString()}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar with Icon */}
|
||||
{/* 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" />
|
||||
|
|
@ -217,8 +245,14 @@ const Projects: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Projects Grid/List */}
|
||||
<div
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{Object.keys(groupedProjects).length === 0 ? (
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
No projects found.
|
||||
|
|
@ -226,93 +260,34 @@ const Projects: React.FC = () => {
|
|||
) : (
|
||||
Object.keys(groupedProjects).map((areaName) => (
|
||||
<React.Fragment key={areaName}>
|
||||
<h3 className="col-span-full text-md uppercase font-light text-gray-800 dark:text-gray-200 mb-2 mt-6">
|
||||
{areaName}
|
||||
</h3>
|
||||
|
||||
{groupedProjects[areaName].map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative"
|
||||
style={{ minHeight: "280px", maxHeight: "280px" }}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg"
|
||||
style={{ height: "160px" }}
|
||||
>
|
||||
<span className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20">
|
||||
{getProjectInitials(project.name)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start p-4">
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
className="text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline line-clamp-2"
|
||||
style={{ minHeight: "3.3rem", maxHeight: "3.3rem" }}
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
<div className="relative">
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-400 focus:outline-none"
|
||||
onClick={() =>
|
||||
setActiveDropdown(
|
||||
activeDropdown === project.id
|
||||
? null
|
||||
: project.id ?? null
|
||||
)
|
||||
}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{activeDropdown === project.id && (
|
||||
<div className="absolute right-0 mt-2 w-28 bg-white dark:bg-gray-700 shadow-lg rounded-md z-10">
|
||||
<button
|
||||
onClick={() => handleEditProject(project)}
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setProjectToDelete(project);
|
||||
setIsConfirmDialogOpen(true);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-0 right-0 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${getCompletionPercentage(project?.id)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{getCompletionPercentage(project?.id)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{viewMode === "cards" && (
|
||||
<h3 className="col-span-full text-md uppercase font-light text-gray-800 dark:text-gray-200 mb-2 mt-6">
|
||||
{areaName}
|
||||
</h3>
|
||||
)}
|
||||
{groupedProjects[areaName].map((project) => {
|
||||
const { color } = getPriorityStyles(project.priority || "low");
|
||||
return (
|
||||
<ProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
viewMode={viewMode}
|
||||
color={color}
|
||||
getCompletionPercentage={getCompletionPercentage}
|
||||
activeDropdown={activeDropdown}
|
||||
setActiveDropdown={setActiveDropdown}
|
||||
handleEditProject={handleEditProject}
|
||||
setProjectToDelete={setProjectToDelete}
|
||||
setIsConfirmDialogOpen={setIsConfirmDialogOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Modal */}
|
||||
{isProjectModalOpen && (
|
||||
<ProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
|
|
@ -326,7 +301,6 @@ const Projects: React.FC = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{isConfirmDialogOpen && (
|
||||
<ConfirmDialog
|
||||
title="Delete Project"
|
||||
|
|
|
|||
30
app/frontend/components/Shared/Switch.tsx
Normal file
30
app/frontend/components/Shared/Switch.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Switch.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface SwitchProps {
|
||||
isChecked: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const Switch: React.FC<SwitchProps> = ({ isChecked, onToggle }) => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 cursor-pointer transition-all duration-300 ${
|
||||
isChecked ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div
|
||||
className={`bg-white w-4 h-4 rounded-full shadow-md transform transition-transform duration-300 ${
|
||||
isChecked ? 'translate-x-6' : ''
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Switch;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Area } from "./Area";
|
||||
import { Tag } from "./Tag";
|
||||
import { PriorityType } from "./Task";
|
||||
|
||||
export interface Project {
|
||||
id?: number;
|
||||
|
|
@ -10,4 +11,5 @@ export interface Project {
|
|||
area?: Area;
|
||||
area_id?: number | null;
|
||||
tags?: Tag[];
|
||||
priority?: PriorityType;
|
||||
}
|
||||
|
|
@ -27,11 +27,11 @@ const useFetchProjects = (options?: UseFetchProjectsOptions): UseFetchProjectsRe
|
|||
let url = '/api/projects';
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options?.activeFilter !== undefined) {
|
||||
if (options?.activeFilter !== undefined && options.activeFilter !== "all") {
|
||||
params.append('active', String(options.activeFilter));
|
||||
}
|
||||
if (options?.areaFilter !== undefined) {
|
||||
params.append('area', options.areaFilter);
|
||||
params.append('area_id', options.areaFilter);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ class Project < ActiveRecord::Base
|
|||
has_many :notes, dependent: :destroy
|
||||
has_and_belongs_to_many :tags
|
||||
|
||||
enum priority: { low: 0, medium: 1, high: 2 }
|
||||
|
||||
scope :with_incomplete_tasks, -> { joins(:tasks).where.not(tasks: { status: Task.statuses[:done] }).distinct }
|
||||
scope :with_complete_tasks, -> { joins(:tasks).where(tasks: { status: Task.statuses[:done] }).distinct }
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class Task < ActiveRecord::Base
|
|||
# Existing scopes
|
||||
scope :complete, -> { where(status: statuses[:done]) }
|
||||
scope :incomplete, -> { where.not(status: statuses[:done]) }
|
||||
scope :due_today, -> { incomplete.where('DATE(due_date) < ?', Date.today) }
|
||||
scope :due_today, -> { incomplete.where('DATE(due_date) <= ?', Date.today) }
|
||||
scope :upcoming, -> { incomplete.where('due_date BETWEEN ? AND ?', Date.today, Date.today + 7.days) }
|
||||
scope :someday, -> { incomplete.where(due_date: nil) }
|
||||
scope :next_actions, -> { incomplete.where(due_date: nil, project_id: nil) }
|
||||
|
|
@ -85,13 +85,27 @@ class Task < ActiveRecord::Base
|
|||
|
||||
tasks_due_today = user.tasks.due_today
|
||||
|
||||
# Suggested tasks
|
||||
# Exclude tasks that are in progress or due today
|
||||
excluded_task_ids = tasks_in_progress.pluck(:id) + tasks_due_today.pluck(:id)
|
||||
suggested_tasks = user.tasks.incomplete
|
||||
.where(status: statuses[:not_started])
|
||||
.where.not(id: excluded_task_ids)
|
||||
.order_by_priority
|
||||
.limit(5)
|
||||
|
||||
# Fetch suggested tasks not in projects, 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)
|
||||
|
||||
# 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)
|
||||
|
||||
{
|
||||
total_open_tasks: total_open_tasks,
|
||||
|
|
@ -99,7 +113,7 @@ class Task < ActiveRecord::Base
|
|||
tasks_in_progress: tasks_in_progress,
|
||||
tasks_in_progress_count: tasks_in_progress_count,
|
||||
tasks_due_today: tasks_due_today,
|
||||
suggested_tasks: suggested_tasks
|
||||
suggested_tasks: (tasks_without_projects + tasks_in_projects)
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class Sinatra::Application
|
|||
|
||||
get '/projects' do
|
||||
active_param = params[:active]
|
||||
is_active = active_param == 'true' unless active_param.nil?
|
||||
is_active = active_param == 'true' unless active_param.nil? || active_param == 'all'
|
||||
|
||||
pin_to_sidebar_param = params[:pin_to_sidebar]
|
||||
is_pinned = pin_to_sidebar_param == 'true' unless pin_to_sidebar_param.nil?
|
||||
|
|
@ -37,7 +37,7 @@ class Sinatra::Application
|
|||
|
||||
projects = projects.where(active: is_active) unless is_active.nil?
|
||||
projects = projects.where(pin_to_sidebar: is_pinned) unless is_pinned.nil?
|
||||
projects = projects.where(area_id: area_id_param) if area_id_param
|
||||
projects = projects.where(area_id: area_id_param) unless area_id_param.blank?
|
||||
task_status_counts = projects.each_with_object({}) do |project, counts|
|
||||
counts[project.id] = project.task_status_counts
|
||||
end
|
||||
|
|
@ -67,12 +67,15 @@ class Sinatra::Application
|
|||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
project_data['priority'] = Project.priorities[project_data['priority']] if project_data['priority'].is_a?(String)
|
||||
|
||||
project = current_user.projects.new(
|
||||
name: project_data['name'],
|
||||
description: project_data['description'] || '',
|
||||
area_id: project_data['area_id'],
|
||||
active: true,
|
||||
pin_to_sidebar: false
|
||||
pin_to_sidebar: false,
|
||||
priority: project_data['priority']
|
||||
)
|
||||
|
||||
if project.save
|
||||
|
|
@ -102,7 +105,8 @@ class Sinatra::Application
|
|||
description: project_data['description'],
|
||||
area_id: project_data['area_id'],
|
||||
active: project_data['active'],
|
||||
pin_to_sidebar: project_data['pin_to_sidebar']
|
||||
pin_to_sidebar: project_data['pin_to_sidebar'],
|
||||
priority: project_data ['priority']
|
||||
)
|
||||
|
||||
if project.save
|
||||
|
|
|
|||
5
db/migrate/20241126095028_add_priority_to_projects.rb
Normal file
5
db/migrate/20241126095028_add_priority_to_projects.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class AddPriorityToProjects < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :projects, :priority, :integer
|
||||
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_21_113756) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_11_26_095028) do
|
||||
create_table "areas", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.integer "user_id", null: false
|
||||
|
|
@ -47,6 +47,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_11_21_113756) do
|
|||
t.text "description"
|
||||
t.boolean "active", default: false
|
||||
t.boolean "pin_to_sidebar", default: false
|
||||
t.integer "priority"
|
||||
t.index ["area_id"], name: "index_projects_on_area_id"
|
||||
t.index ["user_id"], name: "index_projects_on_user_id"
|
||||
end
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue