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="description" content="Todo App - Task management made simple" />
|
||||
<title>Todo App</title>
|
||||
<script type="module" crossorigin src="/assets/index-KBG7ug1d.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DH_ujkYf.css">
|
||||
<script type="module" crossorigin src="/assets/index-B4OukgoH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CVystegy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { InboxPage } from '@/pages/Inbox';
|
||||
import { TodayPage } from '@/pages/Today';
|
||||
import { UpcomingPage } from '@/pages/Upcoming';
|
||||
import { AdminPage } from '@/pages/Admin';
|
||||
import { ProjectPage } from '@/pages/Project';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -51,6 +52,8 @@ function AppRoutes() {
|
||||
<Route path="/today" element={<TodayPage />} />
|
||||
<Route path="/upcoming" element={<UpcomingPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/project/:id" element={<ProjectPage />} />
|
||||
<Route path="/project/:id/board" element={<ProjectPage />} />
|
||||
|
||||
{/* Redirects */}
|
||||
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { cn, getPriorityColor } from '@/lib/utils';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
@@ -18,10 +18,17 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
||||
const [description, setDescription] = useState('');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [priority, setPriority] = useState<Priority>('p4');
|
||||
const [assigneeId, setAssigneeId] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { createTask, projects } = useTasksStore();
|
||||
const { createTask, projects, users, fetchUsers } = useTasksStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && users.length === 0) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && inputRef.current) {
|
||||
@@ -43,6 +50,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
||||
parentId,
|
||||
dueDate: dueDate || undefined,
|
||||
priority,
|
||||
assigneeId: assigneeId || undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
@@ -50,6 +58,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
||||
setDescription('');
|
||||
setDueDate('');
|
||||
setPriority('p4');
|
||||
setAssigneeId('');
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
@@ -68,6 +77,7 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
||||
setDescription('');
|
||||
setDueDate('');
|
||||
setPriority('p4');
|
||||
setAssigneeId('');
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -141,6 +151,25 @@ export function AddTask({ projectId, sectionId, parentId, onClose, autoFocus = f
|
||||
<option value="p4">Priority 4</option>
|
||||
</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) */}
|
||||
{!projectId && projects.length > 0 && (
|
||||
<select
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
|
||||
Hash, Settings, LogOut, User, FolderPlus, Tag
|
||||
Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
|
||||
const PROJECT_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { projects, labels } = useTasksStore();
|
||||
const { projects, labels, createProject } = useTasksStore();
|
||||
const [projectsExpanded, setProjectsExpanded] = 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 () => {
|
||||
await logout();
|
||||
@@ -74,13 +112,16 @@ export function Sidebar() {
|
||||
>
|
||||
<span>Projects</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to="/projects/new"
|
||||
<button
|
||||
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" />
|
||||
</Link>
|
||||
</button>
|
||||
{projectsExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
@@ -97,7 +138,7 @@ export function Sidebar() {
|
||||
to={`/project/${project.id}`}
|
||||
className={cn(
|
||||
'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'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
@@ -109,6 +150,63 @@ export function Sidebar() {
|
||||
<span className="truncate">{project.name}</span>
|
||||
</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>
|
||||
|
||||
@@ -117,6 +117,16 @@ export function TaskItem({ task, onClick, showProject = false }: TaskItemProps)
|
||||
</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 */}
|
||||
{task.priority !== 'p4' && (
|
||||
<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(),
|
||||
getProjects: vi.fn(),
|
||||
getLabels: vi.fn(),
|
||||
getUsers: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
createProject: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -62,6 +64,7 @@ describe('useTasksStore', () => {
|
||||
tasks: [],
|
||||
projects: [],
|
||||
labels: [],
|
||||
users: [],
|
||||
isLoading: false,
|
||||
selectedTask: null,
|
||||
activeProjectId: null,
|
||||
@@ -74,6 +77,7 @@ describe('useTasksStore', () => {
|
||||
expect(state.tasks).toEqual([]);
|
||||
expect(state.projects).toEqual([]);
|
||||
expect(state.labels).toEqual([]);
|
||||
expect(state.users).toEqual([]);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.selectedTask).toBeNull();
|
||||
expect(state.activeProjectId).toBeNull();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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';
|
||||
|
||||
interface TasksState {
|
||||
tasks: Task[];
|
||||
projects: Project[];
|
||||
labels: Label[];
|
||||
users: User[];
|
||||
isLoading: boolean;
|
||||
|
||||
// Selected items
|
||||
@@ -17,11 +18,13 @@ interface TasksState {
|
||||
fetchTasks: (params?: Parameters<typeof api.getTasks>[0]) => Promise<void>;
|
||||
fetchProjects: () => Promise<void>;
|
||||
fetchLabels: () => Promise<void>;
|
||||
fetchUsers: () => Promise<void>;
|
||||
|
||||
createTask: (data: TaskCreate) => Promise<Task>;
|
||||
updateTask: (id: string, data: TaskUpdate) => Promise<void>;
|
||||
deleteTask: (id: string) => Promise<void>;
|
||||
toggleComplete: (id: string) => Promise<void>;
|
||||
createProject: (data: { name: string; color?: string; icon?: string }) => Promise<Project>;
|
||||
|
||||
setSelectedTask: (task: Task | null) => void;
|
||||
setActiveProject: (projectId: string | null) => void;
|
||||
@@ -32,6 +35,7 @@ export const useTasksStore = create<TasksState>((set, get) => ({
|
||||
tasks: [],
|
||||
projects: [],
|
||||
labels: [],
|
||||
users: [],
|
||||
isLoading: false,
|
||||
selectedTask: 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) => {
|
||||
const task = await api.createTask(data);
|
||||
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) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
if (!task) return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// <reference types="vitest" />
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
Reference in New Issue
Block a user