Add kanban board view, project creation, and task assignment

This commit is contained in:
2026-01-28 17:57:57 +00:00
parent ff70948a54
commit 094e29d838
12 changed files with 459 additions and 26 deletions

View File

@@ -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 />} />

View File

@@ -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

View File

@@ -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>

View File

@@ -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
View 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
View 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>
);
}

View File

@@ -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();

View File

@@ -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;