Add kanban board view, project creation, and task assignment
This commit is contained in:
1
dist/assets/index-DH_ujkYf.css
vendored
1
dist/assets/index-DH_ujkYf.css
vendored
File diff suppressed because one or more lines are too long
11
dist/assets/index-KBG7ug1d.js
vendored
11
dist/assets/index-KBG7ug1d.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Todo App - Task management made simple" />
|
<meta name="description" content="Todo App - Task management made simple" />
|
||||||
<title>Todo App</title>
|
<title>Todo App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-KBG7ug1d.js"></script>
|
<script type="module" crossorigin src="/assets/index-B4OukgoH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DH_ujkYf.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CVystegy.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { InboxPage } from '@/pages/Inbox';
|
|||||||
import { TodayPage } from '@/pages/Today';
|
import { TodayPage } from '@/pages/Today';
|
||||||
import { UpcomingPage } from '@/pages/Upcoming';
|
import { UpcomingPage } from '@/pages/Upcoming';
|
||||||
import { AdminPage } from '@/pages/Admin';
|
import { AdminPage } from '@/pages/Admin';
|
||||||
|
import { ProjectPage } from '@/pages/Project';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -51,6 +52,8 @@ function AppRoutes() {
|
|||||||
<Route path="/today" element={<TodayPage />} />
|
<Route path="/today" element={<TodayPage />} />
|
||||||
<Route path="/upcoming" element={<UpcomingPage />} />
|
<Route path="/upcoming" element={<UpcomingPage />} />
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
<Route path="/project/:id" element={<ProjectPage />} />
|
||||||
|
<Route path="/project/:id/board" element={<ProjectPage />} />
|
||||||
|
|
||||||
{/* Redirects */}
|
{/* Redirects */}
|
||||||
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Plus, Calendar, Flag, Tag, X } from 'lucide-react';
|
import { Plus, Calendar, Flag, Tag, X, UserCircle } from 'lucide-react';
|
||||||
import type { Priority } from '@/types';
|
import type { Priority } from '@/types';
|
||||||
import { cn, getPriorityColor } from '@/lib/utils';
|
import { cn, getPriorityColor } from '@/lib/utils';
|
||||||
import { useTasksStore } from '@/stores/tasks';
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
@@ -18,10 +18,17 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
|||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [dueDate, setDueDate] = useState('');
|
const [dueDate, setDueDate] = useState('');
|
||||||
const [priority, setPriority] = useState<Priority>('p4');
|
const [priority, setPriority] = useState<Priority>('p4');
|
||||||
|
const [assigneeId, setAssigneeId] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const { createTask, projects } = useTasksStore();
|
const { createTask, projects, users, fetchUsers } = useTasksStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExpanded && users.length === 0) {
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isExpanded && inputRef.current) {
|
if (isExpanded && inputRef.current) {
|
||||||
@@ -43,6 +50,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
|||||||
parentId,
|
parentId,
|
||||||
dueDate: dueDate || undefined,
|
dueDate: dueDate || undefined,
|
||||||
priority,
|
priority,
|
||||||
|
assigneeId: assigneeId || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
@@ -50,6 +58,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
|||||||
setDescription('');
|
setDescription('');
|
||||||
setDueDate('');
|
setDueDate('');
|
||||||
setPriority('p4');
|
setPriority('p4');
|
||||||
|
setAssigneeId('');
|
||||||
|
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -68,6 +77,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
|||||||
setDescription('');
|
setDescription('');
|
||||||
setDueDate('');
|
setDueDate('');
|
||||||
setPriority('p4');
|
setPriority('p4');
|
||||||
|
setAssigneeId('');
|
||||||
onClose?.();
|
onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,6 +151,25 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
|||||||
<option value="p4">Priority 4</option>
|
<option value="p4">Priority 4</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{/* Assignee selector */}
|
||||||
|
{users.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={assigneeId}
|
||||||
|
onChange={(e) => setAssigneeId(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'appearance-none px-2 py-1 text-xs rounded border border-gray-200 bg-white cursor-pointer hover:bg-gray-50',
|
||||||
|
assigneeId ? 'text-blue-600' : 'text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Project selector (if not in a specific project context) */}
|
{/* Project selector (if not in a specific project context) */}
|
||||||
{!projectId && projects.length > 0 && (
|
{!projectId && projects.length > 0 && (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1,20 +1,58 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
|
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
|
||||||
Hash, Settings, LogOut, User, FolderPlus, Tag
|
Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useTasksStore } from '@/stores/tasks';
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
|
||||||
|
const PROJECT_COLORS = [
|
||||||
|
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6',
|
||||||
|
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
||||||
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
const { projects, labels } = useTasksStore();
|
const { projects, labels, createProject } = useTasksStore();
|
||||||
const [projectsExpanded, setProjectsExpanded] = useState(true);
|
const [projectsExpanded, setProjectsExpanded] = useState(true);
|
||||||
const [labelsExpanded, setLabelsExpanded] = useState(true);
|
const [labelsExpanded, setLabelsExpanded] = useState(true);
|
||||||
|
const [showNewProject, setShowNewProject] = useState(false);
|
||||||
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
|
const [newProjectColor, setNewProjectColor] = useState(PROJECT_COLORS[0]);
|
||||||
|
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||||
|
const newProjectInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showNewProject && newProjectInputRef.current) {
|
||||||
|
newProjectInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [showNewProject]);
|
||||||
|
|
||||||
|
const handleCreateProject = async () => {
|
||||||
|
if (!newProjectName.trim() || isCreatingProject) return;
|
||||||
|
setIsCreatingProject(true);
|
||||||
|
try {
|
||||||
|
const project = await createProject({ name: newProjectName.trim(), color: newProjectColor });
|
||||||
|
setNewProjectName('');
|
||||||
|
setNewProjectColor(PROJECT_COLORS[0]);
|
||||||
|
setShowNewProject(false);
|
||||||
|
navigate(`/project/${project.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create project:', error);
|
||||||
|
} finally {
|
||||||
|
setIsCreatingProject(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelNewProject = () => {
|
||||||
|
setShowNewProject(false);
|
||||||
|
setNewProjectName('');
|
||||||
|
setNewProjectColor(PROJECT_COLORS[0]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
@@ -74,13 +112,16 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
<span>Projects</span>
|
<span>Projects</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link
|
<button
|
||||||
to="/projects/new"
|
|
||||||
className="p-1 hover:bg-gray-200 rounded"
|
className="p-1 hover:bg-gray-200 rounded"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowNewProject(true);
|
||||||
|
setProjectsExpanded(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
</Link>
|
</button>
|
||||||
{projectsExpanded ? (
|
{projectsExpanded ? (
|
||||||
<ChevronDown className="w-3 h-3" />
|
<ChevronDown className="w-3 h-3" />
|
||||||
) : (
|
) : (
|
||||||
@@ -97,7 +138,7 @@ export function Sidebar() {
|
|||||||
to={`/project/${project.id}`}
|
to={`/project/${project.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
|
||||||
location.pathname === `/project/${project.id}`
|
location.pathname === `/project/${project.id}` || location.pathname === `/project/${project.id}/board`
|
||||||
? 'bg-blue-50 text-blue-600'
|
? 'bg-blue-50 text-blue-600'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
)}
|
)}
|
||||||
@@ -109,6 +150,63 @@ export function Sidebar() {
|
|||||||
<span className="truncate">{project.name}</span>
|
<span className="truncate">{project.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Inline create project form */}
|
||||||
|
{showNewProject && (
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreateProject();
|
||||||
|
}}
|
||||||
|
className="border border-gray-200 rounded-lg p-2 bg-white shadow-sm space-y-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={newProjectInputRef}
|
||||||
|
type="text"
|
||||||
|
value={newProjectName}
|
||||||
|
onChange={(e) => setNewProjectName(e.target.value)}
|
||||||
|
placeholder="Project name"
|
||||||
|
className="w-full text-sm text-gray-900 placeholder-gray-400 border border-gray-200 rounded px-2 py-1 outline-none focus:border-blue-400"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{PROJECT_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNewProjectColor(color)}
|
||||||
|
className={cn(
|
||||||
|
'w-5 h-5 rounded-full border-2 transition-all',
|
||||||
|
newProjectColor === color ? 'border-gray-700 scale-110' : 'border-transparent'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelNewProject}
|
||||||
|
className="px-2 py-1 text-xs text-gray-500 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newProjectName.trim() || isCreatingProject}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-xs font-medium rounded',
|
||||||
|
newProjectName.trim() && !isCreatingProject
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCreatingProject ? 'Adding...' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,6 +117,16 @@ export function TaskItem({ task, onClick, showProject = false }: TaskItemProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Assignee avatar */}
|
||||||
|
{task.assignee && (
|
||||||
|
<span
|
||||||
|
className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-[10px] font-medium"
|
||||||
|
title={task.assignee.name}
|
||||||
|
>
|
||||||
|
{task.assignee.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Priority flag */}
|
{/* Priority flag */}
|
||||||
{task.priority !== 'p4' && (
|
{task.priority !== 'p4' && (
|
||||||
<Flag
|
<Flag
|
||||||
|
|||||||
135
src/pages/Board.tsx
Normal file
135
src/pages/Board.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Calendar, Flag, Plus } from 'lucide-react';
|
||||||
|
import type { Task, Project, Section } from '@/types';
|
||||||
|
import { cn, formatDate, isOverdue, getPriorityColor } from '@/lib/utils';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
import { AddTask } from '@/components/AddTask';
|
||||||
|
|
||||||
|
interface BoardViewProps {
|
||||||
|
project: Project;
|
||||||
|
sections: Section[];
|
||||||
|
tasks: Task[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task }: { task: Task }) {
|
||||||
|
const { toggleComplete, setSelectedTask } = useTasksStore();
|
||||||
|
const overdue = !task.isCompleted && isOverdue(task.dueDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{/* Priority indicator */}
|
||||||
|
<span
|
||||||
|
className="mt-1 w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: getPriorityColor(task.priority) }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={cn(
|
||||||
|
'text-sm font-medium text-gray-900',
|
||||||
|
task.isCompleted && 'line-through text-gray-500'
|
||||||
|
)}>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex items-center gap-1 text-xs',
|
||||||
|
overdue ? 'text-red-500' : 'text-gray-500'
|
||||||
|
)}>
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{formatDate(task.dueDate)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.assignee && (
|
||||||
|
<span
|
||||||
|
className="w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-[10px] font-medium flex-shrink-0"
|
||||||
|
title={task.assignee.name}
|
||||||
|
>
|
||||||
|
{task.assignee.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoardColumn({
|
||||||
|
title,
|
||||||
|
tasks,
|
||||||
|
projectId,
|
||||||
|
sectionId,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
tasks: Task[];
|
||||||
|
projectId: string;
|
||||||
|
sectionId?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex-shrink-0 w-72 flex flex-col bg-gray-100 rounded-xl max-h-full">
|
||||||
|
{/* Column header */}
|
||||||
|
<div className="px-3 py-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">
|
||||||
|
{title}
|
||||||
|
<span className="ml-2 text-xs font-normal text-gray-400">{tasks.length}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-2">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<TaskCard key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add task */}
|
||||||
|
<div className="px-2 pb-2">
|
||||||
|
<AddTask projectId={projectId} sectionId={sectionId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardView({ project, sections, tasks, isLoading }: BoardViewProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-gray-500">Loading tasks...</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsectionedTasks = tasks.filter((t) => !t.sectionId);
|
||||||
|
const columns = [
|
||||||
|
{ title: 'No Section', tasks: unsectionedTasks, sectionId: undefined },
|
||||||
|
...sections.map((section) => ({
|
||||||
|
title: section.name,
|
||||||
|
tasks: tasks.filter((t) => t.sectionId === section.id),
|
||||||
|
sectionId: section.id,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4" style={{ minHeight: '60vh' }}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<BoardColumn
|
||||||
|
key={col.sectionId || 'unsectioned'}
|
||||||
|
title={col.title}
|
||||||
|
tasks={col.tasks}
|
||||||
|
projectId={project.id}
|
||||||
|
sectionId={col.sectionId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/pages/Project.tsx
Normal file
147
src/pages/Project.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { LayoutList, LayoutGrid, Plus } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
import { TaskItem } from '@/components/TaskItem';
|
||||||
|
import { AddTask } from '@/components/AddTask';
|
||||||
|
import { BoardView } from '@/pages/Board';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Project as ProjectType, Section } from '@/types';
|
||||||
|
|
||||||
|
export function ProjectPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { tasks, isLoading, fetchTasks, setSelectedTask } = useTasksStore();
|
||||||
|
const [project, setProject] = useState<ProjectType | null>(null);
|
||||||
|
const [sections, setSections] = useState<Section[]>([]);
|
||||||
|
|
||||||
|
const isBoardView = location.pathname.endsWith('/board');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
fetchTasks({ projectId: id, completed: false });
|
||||||
|
api.getProject(id).then((p) => {
|
||||||
|
setProject(p);
|
||||||
|
setSections(p.sections || []);
|
||||||
|
}).catch(console.error);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-gray-500">
|
||||||
|
Loading project...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleView = () => {
|
||||||
|
if (isBoardView) {
|
||||||
|
navigate(`/project/${id}`);
|
||||||
|
} else {
|
||||||
|
navigate(`/project/${id}/board`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group tasks by section
|
||||||
|
const unsectionedTasks = tasks.filter((t) => !t.sectionId);
|
||||||
|
const tasksBySection = sections.map((section) => ({
|
||||||
|
section,
|
||||||
|
tasks: tasks.filter((t) => t.sectionId === section.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ backgroundColor: project.color }}
|
||||||
|
/>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => !isBoardView || toggleView()}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||||
|
!isBoardView
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LayoutList className="w-4 h-4" />
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => isBoardView || toggleView()}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||||
|
isBoardView
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
Board
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isBoardView ? (
|
||||||
|
<BoardView
|
||||||
|
project={project}
|
||||||
|
sections={sections}
|
||||||
|
tasks={tasks}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* List view */
|
||||||
|
<div className="space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Loading tasks...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Unsectioned tasks */}
|
||||||
|
{unsectionedTasks.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{unsectionedTasks.map((task) => (
|
||||||
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add task for unsectioned */}
|
||||||
|
<AddTask projectId={id} />
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
{tasksBySection.map(({ section, tasks: sectionTasks }) => (
|
||||||
|
<div key={section.id}>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-2 px-3 py-1 border-b border-gray-200">
|
||||||
|
{section.name}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sectionTasks.map((task) => (
|
||||||
|
<TaskItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<AddTask projectId={id} sectionId={section.id} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ vi.mock('@/lib/api', () => ({
|
|||||||
getTasks: vi.fn(),
|
getTasks: vi.fn(),
|
||||||
getProjects: vi.fn(),
|
getProjects: vi.fn(),
|
||||||
getLabels: vi.fn(),
|
getLabels: vi.fn(),
|
||||||
|
getUsers: vi.fn(),
|
||||||
createTask: vi.fn(),
|
createTask: vi.fn(),
|
||||||
updateTask: vi.fn(),
|
updateTask: vi.fn(),
|
||||||
deleteTask: vi.fn(),
|
deleteTask: vi.fn(),
|
||||||
|
createProject: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ describe('useTasksStore', () => {
|
|||||||
tasks: [],
|
tasks: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
|
users: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
selectedTask: null,
|
selectedTask: null,
|
||||||
activeProjectId: null,
|
activeProjectId: null,
|
||||||
@@ -74,6 +77,7 @@ describe('useTasksStore', () => {
|
|||||||
expect(state.tasks).toEqual([]);
|
expect(state.tasks).toEqual([]);
|
||||||
expect(state.projects).toEqual([]);
|
expect(state.projects).toEqual([]);
|
||||||
expect(state.labels).toEqual([]);
|
expect(state.labels).toEqual([]);
|
||||||
|
expect(state.users).toEqual([]);
|
||||||
expect(state.isLoading).toBe(false);
|
expect(state.isLoading).toBe(false);
|
||||||
expect(state.selectedTask).toBeNull();
|
expect(state.selectedTask).toBeNull();
|
||||||
expect(state.activeProjectId).toBeNull();
|
expect(state.activeProjectId).toBeNull();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Task, TaskCreate, TaskUpdate, Project, Label } from '@/types';
|
import type { Task, TaskCreate, TaskUpdate, Project, Label, User } from '@/types';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
interface TasksState {
|
interface TasksState {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
labels: Label[];
|
labels: Label[];
|
||||||
|
users: User[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
||||||
// Selected items
|
// Selected items
|
||||||
@@ -17,11 +18,13 @@ interface TasksState {
|
|||||||
fetchTasks: (params?: Parameters<typeof api.getTasks>[0]) => Promise<void>;
|
fetchTasks: (params?: Parameters<typeof api.getTasks>[0]) => Promise<void>;
|
||||||
fetchProjects: () => Promise<void>;
|
fetchProjects: () => Promise<void>;
|
||||||
fetchLabels: () => Promise<void>;
|
fetchLabels: () => Promise<void>;
|
||||||
|
fetchUsers: () => Promise<void>;
|
||||||
|
|
||||||
createTask: (data: TaskCreate) => Promise<Task>;
|
createTask: (data: TaskCreate) => Promise<Task>;
|
||||||
updateTask: (id: string, data: TaskUpdate) => Promise<void>;
|
updateTask: (id: string, data: TaskUpdate) => Promise<void>;
|
||||||
deleteTask: (id: string) => Promise<void>;
|
deleteTask: (id: string) => Promise<void>;
|
||||||
toggleComplete: (id: string) => Promise<void>;
|
toggleComplete: (id: string) => Promise<void>;
|
||||||
|
createProject: (data: { name: string; color?: string; icon?: string }) => Promise<Project>;
|
||||||
|
|
||||||
setSelectedTask: (task: Task | null) => void;
|
setSelectedTask: (task: Task | null) => void;
|
||||||
setActiveProject: (projectId: string | null) => void;
|
setActiveProject: (projectId: string | null) => void;
|
||||||
@@ -32,6 +35,7 @@ export const useTasksStore = create<TasksState>((set, get) => ({
|
|||||||
tasks: [],
|
tasks: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
|
users: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
selectedTask: null,
|
selectedTask: null,
|
||||||
activeProjectId: null,
|
activeProjectId: null,
|
||||||
@@ -66,6 +70,15 @@ export const useTasksStore = create<TasksState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchUsers: async () => {
|
||||||
|
try {
|
||||||
|
const users = await api.getUsers();
|
||||||
|
set({ users });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createTask: async (data) => {
|
createTask: async (data) => {
|
||||||
const task = await api.createTask(data);
|
const task = await api.createTask(data);
|
||||||
set((state) => ({ tasks: [task, ...state.tasks] }));
|
set((state) => ({ tasks: [task, ...state.tasks] }));
|
||||||
@@ -90,6 +103,12 @@ export const useTasksStore = create<TasksState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createProject: async (data) => {
|
||||||
|
const project = await api.createProject(data);
|
||||||
|
set((state) => ({ projects: [...state.projects, project] }));
|
||||||
|
return project;
|
||||||
|
},
|
||||||
|
|
||||||
toggleComplete: async (id) => {
|
toggleComplete: async (id) => {
|
||||||
const task = get().tasks.find((t) => t.id === id);
|
const task = get().tasks.find((t) => t.id === id);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|||||||
Reference in New Issue
Block a user