Bỏ qua

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.