Phase 4: Core Features¶
Mục tiêu¶
Xây dựng các tính năng chính của TaskFlow: Dashboard, Projects, Tasks, Kanban.
Thời gian ước tính: 6 giờ
Step 1: Dashboard Page¶
1.1 Dashboard Layout Components¶
// src/components/layout/sidebar.tsx
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: '📊' },
{ href: '/projects', label: 'Projects', icon: '📁' },
{ href: '/settings', label: 'Settings', icon: '⚙️' },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="w-64 bg-white border-r">
<div className="p-4 border-b">
<h1 className="text-xl font-bold text-blue-600">TaskFlow</h1>
</div>
<nav className="p-4">
<ul className="space-y-2">
{navItems.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg ${
pathname === item.href
? 'bg-blue-50 text-blue-600'
: 'hover:bg-gray-50'
}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
</li>
))}
</ul>
</nav>
</aside>
);
}
// src/components/layout/header.tsx
import { UserMenu } from './user-menu';
export function Header() {
return (
<header className="h-16 bg-white border-b flex items-center justify-between px-6">
<div>
{/* Breadcrumb or search */}
</div>
<UserMenu />
</header>
);
}
1.2 Dashboard Page¶
// src/app/(dashboard)/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server';
import { StatsCard } from '@/components/dashboard/stats-card';
import { RecentTasks } from '@/components/dashboard/recent-tasks';
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
// Get user's organizations
const { data: memberships } = await supabase
.from('organization_members')
.select('organization_id')
.eq('user_id', user!.id);
const orgIds = memberships?.map(m => m.organization_id) || [];
// Get stats
const [projectsResult, tasksResult, myTasksResult] = await Promise.all([
supabase
.from('projects')
.select('*', { count: 'exact', head: true })
.in('organization_id', orgIds)
.eq('status', 'active'),
supabase
.from('tasks')
.select('*, project:projects!inner(organization_id)', { count: 'exact', head: true })
.in('project.organization_id', orgIds),
supabase
.from('tasks')
.select('*, project:projects!inner(organization_id)', { count: 'exact', head: true })
.eq('assignee_id', user!.id)
.neq('status', 'done'),
]);
// Get recent tasks
const { data: recentTasks } = await supabase
.from('tasks')
.select(`
id,
title,
status,
priority,
due_date,
project:projects(id, name)
`)
.eq('assignee_id', user!.id)
.order('updated_at', { ascending: false })
.limit(5);
return (
<div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-4 gap-4 mb-8">
<StatsCard
title="Active Projects"
value={projectsResult.count || 0}
icon="📁"
/>
<StatsCard
title="Total Tasks"
value={tasksResult.count || 0}
icon="📝"
/>
<StatsCard
title="My Open Tasks"
value={myTasksResult.count || 0}
icon="✅"
/>
<StatsCard
title="Due Today"
value={0}
icon="⏰"
/>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Recent Tasks</h2>
<RecentTasks tasks={recentTasks || []} />
</div>
</div>
);
}
// src/components/dashboard/stats-card.tsx
interface StatsCardProps {
title: string;
value: number;
icon: string;
}
export function StatsCard({ title, value, icon }: StatsCardProps) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3">
<span className="text-2xl">{icon}</span>
<div>
<p className="text-gray-500 text-sm">{title}</p>
<p className="text-2xl font-bold">{value}</p>
</div>
</div>
</div>
);
}
Step 2: Projects List¶
2.1 Projects Page¶
// src/app/(dashboard)/projects/page.tsx
import { createClient } from '@/lib/supabase/server';
import { ProjectCard } from '@/components/projects/project-card';
import { CreateProjectButton } from '@/components/projects/create-project-button';
export default async function ProjectsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
// Get user's projects through organization membership
const { data: projects } = await supabase
.from('projects')
.select(`
id,
name,
description,
status,
created_at,
organization:organizations(id, name),
tasks(count)
`)
.eq('status', 'active')
.order('created_at', { ascending: false });
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Projects</h1>
<CreateProjectButton />
</div>
<div className="grid grid-cols-3 gap-4">
{projects?.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
{(!projects || projects.length === 0) && (
<p className="col-span-3 text-center text-gray-500 py-12">
No projects yet. Create your first project!
</p>
)}
</div>
</div>
);
}
// src/components/projects/project-card.tsx
import Link from 'next/link';
interface ProjectCardProps {
project: {
id: string;
name: string;
description: string | null;
organization: { name: string };
tasks: { count: number }[];
};
}
export function ProjectCard({ project }: ProjectCardProps) {
const taskCount = project.tasks[0]?.count || 0;
return (
<Link
href={`/projects/${project.id}`}
className="block bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
>
<h3 className="text-lg font-semibold mb-2">{project.name}</h3>
<p className="text-gray-500 text-sm mb-4 line-clamp-2">
{project.description || 'No description'}
</p>
<div className="flex items-center justify-between text-sm text-gray-400">
<span>{project.organization.name}</span>
<span>{taskCount} tasks</span>
</div>
</Link>
);
}
2.2 Create Project¶
// src/components/projects/create-project-button.tsx
'use client';
import { useState } from 'react';
import { CreateProjectModal } from './create-project-modal';
export function CreateProjectButton() {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
+ New Project
</button>
{open && <CreateProjectModal onClose={() => setOpen(false)} />}
</>
);
}
// src/components/projects/create-project-modal.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
interface Props {
onClose: () => void;
}
export function CreateProjectModal({ onClose }: Props) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const supabase = createClient();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const { data: { user } } = await supabase.auth.getUser();
// Get user's first organization (simplified)
const { data: membership } = await supabase
.from('organization_members')
.select('organization_id')
.eq('user_id', user!.id)
.single();
if (!membership) {
throw new Error('You need to be part of an organization');
}
const { error } = await supabase.from('projects').insert({
name,
description,
organization_id: membership.organization_id,
created_by: user!.id,
});
if (error) throw error;
router.refresh();
onClose();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-semibold mb-4">Create Project</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{loading ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}
Step 3: Kanban Board¶
3.1 Project Detail Page (Kanban)¶
// src/app/(dashboard)/projects/[id]/page.tsx
import { createClient } from '@/lib/supabase/server';
import { notFound } from 'next/navigation';
import { KanbanBoard } from '@/components/kanban/kanban-board';
export default async function ProjectPage({
params,
}: {
params: { id: string };
}) {
const supabase = await createClient();
const { data: project } = await supabase
.from('projects')
.select('id, name, description')
.eq('id', params.id)
.single();
if (!project) notFound();
const { data: tasks } = await supabase
.from('tasks')
.select(`
id,
title,
status,
priority,
due_date,
position,
assignee:profiles!assignee_id(id, full_name, avatar_url)
`)
.eq('project_id', params.id)
.order('position');
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold">{project.name}</h1>
<p className="text-gray-500">{project.description}</p>
</div>
<KanbanBoard
projectId={params.id}
initialTasks={tasks || []}
/>
</div>
);
}
3.2 Kanban Board Component¶
// src/components/kanban/kanban-board.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { KanbanColumn } from './kanban-column';
import { CreateTaskButton } from './create-task-button';
const COLUMNS = [
{ id: 'todo', title: 'To Do' },
{ id: 'in_progress', title: 'In Progress' },
{ id: 'review', title: 'In Review' },
{ id: 'done', title: 'Done' },
];
interface Task {
id: string;
title: string;
status: string;
priority: string;
due_date: string | null;
assignee: { id: string; full_name: string; avatar_url: string | null } | null;
}
interface Props {
projectId: string;
initialTasks: Task[];
}
export function KanbanBoard({ projectId, initialTasks }: Props) {
const [tasks, setTasks] = useState(initialTasks);
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
const supabase = createClient();
const handleDragStart = (task: Task) => {
setDraggedTask(task);
};
const handleDrop = async (status: string) => {
if (!draggedTask || draggedTask.status === status) {
setDraggedTask(null);
return;
}
// Optimistic update
setTasks((prev) =>
prev.map((t) => (t.id === draggedTask.id ? { ...t, status } : t))
);
// Update in database
const { error } = await supabase
.from('tasks')
.update({ status })
.eq('id', draggedTask.id);
if (error) {
// Revert on error
setTasks((prev) =>
prev.map((t) =>
t.id === draggedTask.id ? { ...t, status: draggedTask.status } : t
)
);
}
setDraggedTask(null);
};
const handleTaskCreated = (task: Task) => {
setTasks((prev) => [...prev, task]);
};
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{COLUMNS.map((column) => (
<div key={column.id} className="flex-shrink-0 w-80">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-600">{column.title}</h3>
<span className="text-sm text-gray-400">
{tasks.filter((t) => t.status === column.id).length}
</span>
</div>
<KanbanColumn
status={column.id}
tasks={tasks.filter((t) => t.status === column.id)}
onDragStart={handleDragStart}
onDrop={() => handleDrop(column.id)}
isDragging={!!draggedTask}
/>
{column.id === 'todo' && (
<CreateTaskButton
projectId={projectId}
onCreated={handleTaskCreated}
/>
)}
</div>
))}
</div>
);
}
// src/components/kanban/kanban-column.tsx
'use client';
import { TaskCard } from './task-card';
interface Task {
id: string;
title: string;
status: string;
priority: string;
due_date: string | null;
assignee: { id: string; full_name: string; avatar_url: string | null } | null;
}
interface Props {
status: string;
tasks: Task[];
onDragStart: (task: Task) => void;
onDrop: () => void;
isDragging: boolean;
}
export function KanbanColumn({
status,
tasks,
onDragStart,
onDrop,
isDragging,
}: Props) {
return (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
className={`min-h-[200px] bg-gray-100 rounded-lg p-2 space-y-2 ${
isDragging ? 'ring-2 ring-blue-300' : ''
}`}
>
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onDragStart={() => onDragStart(task)}
/>
))}
</div>
);
}
// src/components/kanban/task-card.tsx
'use client';
import Link from 'next/link';
import { formatDistanceToNow } from 'date-fns';
interface Task {
id: string;
title: string;
priority: string;
due_date: string | null;
assignee: { full_name: string; avatar_url: string | null } | null;
}
interface Props {
task: Task;
onDragStart: () => void;
}
const priorityColors: Record<string, string> = {
low: 'bg-gray-100 text-gray-600',
medium: 'bg-blue-100 text-blue-600',
high: 'bg-orange-100 text-orange-600',
urgent: 'bg-red-100 text-red-600',
};
export function TaskCard({ task, onDragStart }: Props) {
return (
<div
draggable
onDragStart={onDragStart}
className="bg-white rounded-lg shadow p-3 cursor-grab active:cursor-grabbing"
>
<Link href={`/tasks/${task.id}`}>
<p className="font-medium text-sm mb-2">{task.title}</p>
</Link>
<div className="flex items-center justify-between">
<span
className={`text-xs px-2 py-0.5 rounded ${
priorityColors[task.priority]
}`}
>
{task.priority}
</span>
{task.assignee && (
<div
className="w-6 h-6 rounded-full bg-gray-300 flex items-center justify-center text-xs"
title={task.assignee.full_name}
>
{task.assignee.full_name?.[0] || '?'}
</div>
)}
</div>
{task.due_date && (
<p className="text-xs text-gray-400 mt-2">
Due {formatDistanceToNow(new Date(task.due_date), { addSuffix: true })}
</p>
)}
</div>
);
}
Step 4: Task CRUD¶
4.1 Create Task¶
// src/components/kanban/create-task-button.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
interface Props {
projectId: string;
onCreated: (task: any) => void;
}
export function CreateTaskButton({ projectId, onCreated }: Props) {
const [isAdding, setIsAdding] = useState(false);
const [title, setTitle] = useState('');
const [loading, setLoading] = useState(false);
const supabase = createClient();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
setLoading(true);
try {
const { data: { user } } = await supabase.auth.getUser();
const { data, error } = await supabase
.from('tasks')
.insert({
project_id: projectId,
title: title.trim(),
status: 'todo',
priority: 'medium',
created_by: user!.id,
})
.select(`
id,
title,
status,
priority,
due_date,
assignee:profiles!assignee_id(id, full_name, avatar_url)
`)
.single();
if (error) throw error;
onCreated(data);
setTitle('');
setIsAdding(false);
} catch (error) {
console.error('Error creating task:', error);
} finally {
setLoading(false);
}
};
if (!isAdding) {
return (
<button
onClick={() => setIsAdding(true)}
className="w-full mt-2 p-2 text-gray-500 hover:bg-gray-200 rounded text-sm"
>
+ Add Task
</button>
);
}
return (
<form onSubmit={handleSubmit} className="mt-2">
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Task title..."
className="w-full px-3 py-2 border rounded-lg text-sm"
onBlur={() => {
if (!title.trim()) setIsAdding(false);
}}
/>
<div className="flex gap-2 mt-2">
<button
type="submit"
disabled={loading}
className="px-3 py-1 bg-blue-600 text-white rounded text-sm"
>
Add
</button>
<button
type="button"
onClick={() => setIsAdding(false)}
className="px-3 py-1 border rounded text-sm"
>
Cancel
</button>
</div>
</form>
);
}
4.2 Task Detail Page¶
// src/app/(dashboard)/tasks/[id]/page.tsx
import { createClient } from '@/lib/supabase/server';
import { notFound } from 'next/navigation';
import { TaskDetail } from '@/components/tasks/task-detail';
import { TaskComments } from '@/components/tasks/task-comments';
export default async function TaskPage({
params,
}: {
params: { id: string };
}) {
const supabase = await createClient();
const { data: task } = await supabase
.from('tasks')
.select(`
*,
assignee:profiles!assignee_id(id, full_name, avatar_url, email),
project:projects(id, name),
comments:task_comments(
id,
content,
created_at,
user:profiles(id, full_name, avatar_url)
)
`)
.eq('id', params.id)
.single();
if (!task) notFound();
return (
<div className="max-w-3xl mx-auto">
<TaskDetail task={task} />
<TaskComments
taskId={params.id}
initialComments={task.comments || []}
/>
</div>
);
}
Step 5: File Attachments¶
5.1 Storage Bucket Setup¶
-- Run in Supabase SQL Editor
INSERT INTO storage.buckets (id, name, public)
VALUES ('attachments', 'attachments', false);
-- Storage policy
CREATE POLICY "Users can manage their attachments"
ON storage.objects FOR ALL
USING (bucket_id = 'attachments')
WITH CHECK (bucket_id = 'attachments');
5.2 File Upload Component¶
// src/components/tasks/file-upload.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
interface Props {
taskId: string;
onUpload: (attachment: any) => void;
}
export function FileUpload({ taskId, onUpload }: Props) {
const [uploading, setUploading] = useState(false);
const supabase = createClient();
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const { data: { user } } = await supabase.auth.getUser();
// Upload to storage
const filePath = `${user!.id}/${taskId}/${Date.now()}-${file.name}`;
const { error: uploadError } = await supabase.storage
.from('attachments')
.upload(filePath, file);
if (uploadError) throw uploadError;
// Save metadata
const { data, error } = await supabase
.from('task_attachments')
.insert({
task_id: taskId,
file_name: file.name,
file_path: filePath,
file_size: file.size,
mime_type: file.type,
uploaded_by: user!.id,
})
.select()
.single();
if (error) throw error;
onUpload(data);
} catch (error) {
console.error('Upload error:', error);
} finally {
setUploading(false);
}
};
return (
<label className="block">
<span className="text-sm text-blue-600 cursor-pointer hover:underline">
{uploading ? 'Uploading...' : '+ Attach file'}
</span>
<input
type="file"
onChange={handleUpload}
disabled={uploading}
className="hidden"
/>
</label>
);
}
Verification Checklist¶
- [ ] Dashboard showing stats and recent tasks
- [ ] Projects list with create functionality
- [ ] Kanban board with drag-and-drop
- [ ] Task creation and editing
- [ ] Task detail page with comments
- [ ] File attachments working
- [ ] All CRUD operations respecting RLS
Next Phase¶
Chuyển sang Phase 5: Queue Processing để implement background jobs.