React: Exercício prático abrangente
Última atualização: 2026-08-26
Esta aula é a última parte da nossa série de tutoriais sobre React, composta por 30 aulas. Você aplicará todo o conhecimento adquirido até agora — o Next.js App Router, a obtenção de dados, bibliotecas de componentes de interface do usuário, autenticação e autorização, e implantação de CI/CD — para criar do zero um aplicativo SaaS Kanban multilocatário. Este não é apenas um projeto de demonstração; é um produto real, pronto para ser implantado e colocado em operação.
1. Visão geral do projeto
(1) Descrição do recurso
O SaaS Kanban é uma ferramenta leve de gerenciamento de projetos desenvolvida para equipes. Seus principais recursos incluem:
| Módulo funcional | Descrição | Tecnologias relacionadas |
|---|---|---|
| Painel do Dashboard | Arrastar e soltar cartões entre colunas | @dnd-kit Biblioteca de arrastar e soltar |
| Gerenciamento de tarefas | Criar/Editar/Excluir/Atribuir tarefas | Ação do servidor + Banco de dados |
| Multilocatário | Espaços de trabalho separados para cada equipe | Isolamento de dados por ID de locatário |
| Autenticação de usuário | Login com e-mail e senha + Google OAuth | NextAuth.js |
| Atualizações em tempo real | Sincroniza automaticamente com o servidor após o arrastar | Atualização otimista por ação do servidor |
| Estatísticas de dados | Gráfico de barra de status do Kanban | Biblioteca de gráficos da Recharts |
| Implantação | Pipeline automatizado de CI/CD | Vercel + GitHub Actions |
(2) Arquitetura técnica
Project Root Directory/
├── app/ # App Router Routing
│ ├── (auth)/ # Pages Related to Certification
│ │ ├── login/ # Login Page
│ │ ├── register/ # Registration Page
│ │ └── layout.tsx # Authentication Page Layout
│ ├── (dashboard)/ # Dashboard(You must log in)
│ │ ├── board/ # Kanban Page
│ │ │ ├── [id]/ # Specific Kanban Board
│ │ │ └── page.tsx # List of Kanban Boards
│ │ ├── layout.tsx # Dashboard Layout(Sidebar)
│ │ └── page.tsx # Homepage Redirect
│ ├── api/ # Route Handler
│ │ ├── auth/ # NextAuth API
│ │ └── boards/ # Dashboard Data API
│ └── layout.tsx # Global Layout
├── components/ # Reusable Components
│ ├── board/ # Kanban Component
│ ├── ui/ # UI General Components
│ └── providers.tsx # Ant Design Provider
├── lib/ # Utility Functions
│ ├── prisma.ts # Database Client
│ └── auth.ts # NextAuth Layout
├── prisma/ # Database Schema
│ └── schema.prisma
├── middleware.ts # Route Guard
├── next.config.ts
└── package.json
2. Diagrama da arquitetura técnica
| Camada de arquitetura | Tecnologia | Responsabilidades |
|---|---|---|
| Camada de apresentação front-end | Next.js + Ant Design | SSR/SSG, componentes de interface do usuário, lógica de interação |
| Camada de API | Manipulador de rota + ação do servidor | Interfaces de dados, lógica de negócios, envios de formulários |
| Camada de acesso a dados | Prisma ORM | Consultas a bancos de dados com segurança de tipos |
| Camada de armazenamento | PostgreSQL | Armazenamento persistente de dados |
| Camada de autenticação | NextAuth.js | E-mail e senha + Google OAuth |
| Camada de implantação | Vercel + GitHub Actions | Implantação automatizada de CI/CD |
A arquitetura geral do SaaS Kanban consiste em quatro camadas: a camada de apresentação front-end (Next.js + Ant Design), a camada de API (Route Handler + Server Action), a camada de acesso a dados (Prisma ORM) e a camada de armazenamento (PostgreSQL). A autenticação é gerenciada centralmente por meio do NextAuth.js, e a implantação é automatizada via Vercel + GitHub Actions.
flowchart TD
A[Browser] --> B[Next.js App]
B --> C{Route Distribution}
C -->|Public Routing| D[Log In/Register]
C -->|Protected Routes| E[Kanban Page]
E --> F[Server Component<br/>Data Collection]
E --> G[Client Component<br/>Drag-and-Drop Interaction]
F --> H[Prisma ORM]
G --> I[Server Action<br/>Operation Synchronization]
H --> J[(PostgreSQL)]
I --> J
D --> K[NextAuth.js]
K --> J
B --> L[Middleware]
L -->|Not logged in| D
L -->|Logged in| E
B --> M[Vercel Deployment]
M --> N[GitHub Actions CI/CD]
3. Configuração do projeto e projeto do banco de dados
(1) Inicializar o projeto
# Create Next.js Project
npx create-next-app@latest saas-kanban --typescript --tailwind --app
# Install Core Dependencies
cd saas-kanban
# UI Component Library
npm install antd @ant-design/icons
# Drag-and-drop feature
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
# Certification
npm install next-auth @auth/prisma-adapter
# Database
npm install prisma @prisma/client
npx prisma init
# Charts
npm install recharts
(2) Esquema do banco de dados
| Modelo de dados | Campos-chave | Relações | Descrição |
|---|---|---|---|
| Usuário | e-mail, nome, imagem | → Quadro[], Tarefa[] | Um usuário possui vários quadros e tarefas |
| Quadro | título, descrição | → Coluna[] | O Kanban possui várias colunas |
| Coluna | título, posição | → Tarefa[] | A coluna contém vários cartões de tarefa |
| Tarefa | título, prioridade, data de vencimento | → Usuário (responsável) | As tarefas podem ser atribuídas a usuários |
| Conta/Sessão | provedor, token | → Usuário | Autenticação NextAuth necessária |
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// User Model
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
boards Board[] // User-Created Kanban Boards
tasks Task[] // Tasks Assigned to Users
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Kanban Model(Multi-tenancy achieves data isolation by associating tenants with users.)
model Board {
id String @id @default(cuid())
title String
color String @default("#1677ff")
ownerId String
owner User @relation(fields: [ownerId], references: [id])
columns Column[]
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Kanban Column
model Column {
id String @id @default(cuid())
title String
position Int // Sort Order
boardId String
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
tasks Task[]
}
// Task Cards
model Task {
id String @id @default(cuid())
title String
description String?
priority Priority @default(middle)
assigneeId String?
assignee User? @relation(fields: [assigneeId], references: [id])
columnId String
column Column @relation(fields: [columnId], references: [id], onDelete: Cascade)
boardId String
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
position Int // Sort Order Within a Row
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Priority {
urgent
high
middle
low
}
▶ Exemplo 1: Configuração da autenticação com NextAuth.js
// lib/auth.ts - NextAuth Complete Configuration
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import CredentialsProvider from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
function verifyPassword(plain: string, hashed: string): boolean {
// Production: use bcryptjs — npm i bcryptjs → bcrypt.compare(plain, hashed)
return plain === hashed
}
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
// Google OAuth
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
// Log in with your email and password
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null
const user = await prisma.user.findUnique({
where: { email: credentials.email as string }
})
if (!user || !user.password) return null
const isValid = verifyPassword(
credentials.password as string,
user.password
)
if (!isValid) return null
return user
},
}),
],
pages: {
signIn: '/login',
error: '/login',
},
callbacks: {
// Add users ID Injecting Tenant Information JWT
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
// Inject JWT info into session
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
}
return session
},
},
session: {
strategy: 'jwt',
},
})
// lib/prisma.ts - Database Client Singleton
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
4. Programação passo a passo
A seguir, vamos implementar o código principal do aplicativo Kanban passo a passo, organizado por módulos funcionais. Cada etapa corresponde a um bloco funcional completo; você pode copiá-los para o seu projeto na ordem indicada e executá-los.
(1) Layout da página Kanban e recuperação de dados
A estrutura central da página Kanban é um “layout de três colunas”: a barra lateral esquerda (lista Kanban) e a área principal central (cabeçalhos das colunas + cartões de tarefa). Trata-se de uma hierarquia típica de Componente de Servidor + Componente de Cliente: o Componente de Servidor, na camada externa, recupera os dados do Kanban, enquanto o Componente de Cliente, na camada interna, lida com as interações de arrastar e soltar.
▶ Exemplo 2: Recuperação de dados do componente de servidor da página Kanban
// app/(dashboard)/board/[id]/page.tsx
// Server Component:Get kanban board, columns, task data
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
import BoardClient from './BoardClient'
interface BoardPageProps {
params: { id: string }
}
async function BoardPage({ params }: BoardPageProps) {
const session = await auth()
// Redirect for Unauthenticated Users
if (!session?.user?.id) redirect('/login')
// Retrieve Kanban Data(Lists and Tasks)
const board = await prisma.board.findUnique({
where: {
id: params.id,
ownerId: session.user.id, // Multi-tenant Data Isolation
},
include: {
columns: {
orderBy: { position: 'asc' },
include: {
tasks: {
orderBy: { position: 'asc' },
include: { assignee: { select: { id: true, name: true, image: true } } },
},
},
},
},
})
if (!board) redirect('/board')
// Serialization Date Field(Server Component Forwarded to Client Component When needed)
const serializedBoard = JSON.parse(JSON.stringify(board))
return <BoardClient board={serializedBoard} />
}
export default BoardPage
// app/(dashboard)/board/[id]/BoardClient.tsx
// Client Component:Core of Drag-and-Drop Interaction
'use client'
import { useState, useCallback } from 'react'
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Layout } from 'antd'
import BoardColumn from './BoardColumn'
import TaskCard from './TaskCard'
const { Content } = Layout
interface BoardData {
id: string
title: string
columns: ColumnData[]
}
interface ColumnData {
id: string
title: string
tasks: TaskData[]
}
interface TaskData {
id: string
title: string
description?: string
priority: string
position: number
assignee?: { id: string; name: string; image?: string }
}
function BoardClient({ board }: { board: BoardData }) {
const [activeTask, setActiveTask] = useState<TaskData | null>(null)
const [columns, setColumns] = useState(board.columns)
// Configure Sensors(Pointer + Keyboard)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor)
)
// Drag to Start:Record tasks that have been dragged
const handleDragStart = useCallback((event: any) => {
const { active } = event
const task = columns
.flatMap(col => col.tasks)
.find(t => t.id === active.id)
setActiveTask(task || null)
}, [columns])
// Drag ended:Update the "Assigned To" column and sort order
const handleDragEnd = useCallback(async (event: any) => {
const { active, over } = event
if (!over || active.id === over.id) {
setActiveTask(null)
return
}
const sourceColumn = columns.find(col =>
col.tasks.some(t => t.id === active.id)
)
const targetColumn = columns.find(col =>
col.tasks.some(t => t.id === over.id) ||
col.id === over.id
)
if (!sourceColumn || !targetColumn) {
setActiveTask(null)
return
}
const task = sourceColumn.tasks.find(t => t.id === active.id)!
const newSourceTasks = sourceColumn.tasks.filter(t => t.id !== active.id)
if (sourceColumn.id === targetColumn.id) {
// Sort within the same column
const overIndex = targetColumn.tasks.findIndex(t => t.id === over.id)
const newTasks = [...newSourceTasks]
newTasks.splice(overIndex, 0, task)
setColumns(prev => prev.map(col =>
col.id === sourceColumn.id ? { ...col, tasks: newTasks } : col
))
} else {
// Cross-Row Movement
const overIndex = targetColumn.tasks.findIndex(t => t.id === over.id)
const newTargetTasks = [...targetColumn.tasks]
newTargetTasks.splice(overIndex, 0, task)
setColumns(prev => prev.map(col => {
if (col.id === sourceColumn.id) return { ...col, tasks: newSourceTasks }
if (col.id === targetColumn.id) return { ...col, tasks: newTargetTasks }
return col
}))
}
// Sync to the server
await fetch('/api/tasks/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskId: active.id,
targetColumnId: targetColumn.id,
position: targetColumn.tasks.findIndex(t => t.id === over.id),
}),
})
setActiveTask(null)
}, [columns])
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<Content style={{ display: 'flex', gap: 16, padding: 24, overflowX: 'auto' }}>
{columns.map(column => (
<BoardColumn key={column.id} column={column} />
))}
</Content>
<DragOverlay>
{activeTask ? <TaskCard task={activeTask} isOverlay /> : null}
</DragOverlay>
</DndContext>
)
}
export default BoardClient
(2) Colunas do Kanban e componentes dos cartões de tarefa
Cada coluna do Kanban é um componente independente que inclui um cabeçalho de coluna, uma lista de tarefas e um indicador de cor da coluna. Os cartões de tarefa exibem o título, o rótulo de prioridade, o avatar do responsável e a data de vencimento. A funcionalidade de arrastar e soltar é implementada usando o gancho useSortable do @dnd-kit.
▶ Exemplo 3: Componentes de coluna e cartões de tarefa
// board/BoardColumn.tsx - Kanban Column Components
'use client'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Card, Tag, Typography } from 'antd'
import TaskCard from './TaskCard'
const { Text } = Typography
interface ColumnProps {
column: {
id: string
title: string
tasks: any[]
}
}
const columnColors: Record<string, string> = {
'To-Do': '#f0f0f0',
'In progress': '#e6f4ff',
'Completed': '#f6ffed',
}
const columnHeaderColors: Record<string, string> = {
'To-Do': '#d9d9d9',
'In progress': '#1677ff',
'Completed': '#52c41a',
}
function BoardColumn({ column }: ColumnProps) {
// Set the placement area
const { setNodeRef, isOver } = useDroppable({ id: column.id })
return (
<div
ref={setNodeRef}
style={{
minWidth: 300,
maxWidth: 360,
flex: 1,
background: isOver ? '#e6f4ff' : (columnColors[column.title] || '#fafafa'),
borderRadius: 12,
padding: 12,
transition: 'background 0.2s',
}}
>
{/* Column Headings */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong style={{ fontSize: 16 }}>
{column.title}
</Text>
<Tag color={columnHeaderColors[column.title] || 'default'}>
{column.tasks.length}
</Tag>
</div>
{/* Sortable Task List */}
<SortableContext items={column.tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minHeight: 100 }}>
{column.tasks.map(task => (
<TaskCard key={task.id} task={task} />
))}
</div>
</SortableContext>
</div>
)
}
export default BoardColumn
// board/TaskCard.tsx - Drag-and-drop task cards
'use client'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Card, Tag, Avatar, Typography, Tooltip } from 'antd'
import { CalendarOutlined } from '@ant-design/icons'
const { Text } = Typography
const priorityColors: Record<string, string> = {
urgent: 'red',
high: 'orange',
middle: 'blue',
low: 'green',
}
const priorityLabels: Record<string, string> = {
urgent: 'Urgent',
high: 'High',
middle: 'in ',
low: 'Low',
}
interface TaskCardProps {
task: {
id: string
title: string
description?: string
priority: string
dueDate?: string
assignee?: { id: string; name: string; image?: string }
position: number
}
isOverlay?: boolean
}
function TaskCard({ task, isOverlay }: TaskCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: isOverlay ? 'grabbing' : 'grab',
}
return (
<Card
ref={setNodeRef}
style={style}
size="small"
{...attributes}
{...listeners}
hoverable
bodyStyle={{ padding: 12 }}
>
{/* Priority Labels */}
<div style={{ marginBottom: 8 }}>
<Tag color={priorityColors[task.priority]} style={{ fontSize: 11 }}>
{priorityLabels[task.priority]}
</Tag>
</div>
{/* Task Title */}
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 4 }}>
{task.title}
</Text>
{/* Task Description Preview */}
{task.description && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
{task.description.slice(0, 60)}...
</Text>
)}
{/* Footer Information:Expiration Date + Person in Charge */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{task.dueDate && (
<Tooltip title={new Date(task.dueDate).toLocaleDateString('zh-CN')}>
<Text type="secondary" style={{ fontSize: 11 }}>
<CalendarOutlined /> {new Date(task.dueDate).toLocaleDateString('zh-CN')}
</Text>
</Tooltip>
)}
{task.assignee && (
<Tooltip title={task.assignee.name}>
<Avatar src={task.assignee.image} size={24}>
{task.assignee.name?.[0]}
</Avatar>
</Tooltip>
)}
</div>
</Card>
)
}
export default TaskCard
// app/api/tasks/move/route.ts - Drag-and-Drop Synchronization API
import { prisma } from '@/lib/prisma'
export async function PUT(request: Request) {
try {
const { taskId, targetColumnId, position } = await request.json()
// Update the "Assigned To" column and sort order
const task = await prisma.task.update({
where: { id: taskId },
data: {
columnId: targetColumnId,
position,
},
})
return Response.json({ success: true, task })
} catch (error) {
return Response.json({ error: 'Failed to move task' }, { status: 500 })
}
}
(3) Listas Kanban e estatísticas de dados
A página da lista de quadros exibe todos os quadros criados pelos usuários, sendo que cada quadro é apresentado como um cartão que mostra seu nome, data de criação e número de tarefas. As estatísticas são apresentadas por meio de gráficos de barras do Recharts para mostrar a distribuição das tarefas por prioridade, ajudando as equipes a ter uma compreensão clara de sua carga de trabalho.
▶ Exemplo 4: Listas Kanban e gráficos estatísticos
// app/(dashboard)/page.tsx - Dashboard Home Page(List of Kanban Boards + Statistics)
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
import DashboardClient from './DashboardClient'
async function DashboardPage() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
// Get the user's list of boards
const boards = await prisma.board.findMany({
where: { ownerId: session.user.id },
include: {
_count: { select: { tasks: true } },
},
orderBy: { updatedAt: 'desc' },
})
// Get statistics on the number of tasks by priority
const taskPriorityCounts = await prisma.task.groupBy({
by: ['priority'],
where: {
board: { ownerId: session.user.id },
},
_count: true,
})
const stats = {
totalTasks: taskPriorityCounts.reduce((sum, item) => sum + item._count, 0),
priorityData: taskPriorityCounts.map(item => ({
priority: item.priority,
count: item._count,
})),
}
return (
<DashboardClient
boards={JSON.parse(JSON.stringify(boards))}
stats={JSON.parse(JSON.stringify(stats))}
/>
)
}
export default DashboardPage
// app/(dashboard)/DashboardClient.tsx
'use client'
import { Card, Row, Col, Statistic, Typography } from 'antd'
import { PlusOutlined, ProjectOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import Link from 'next/link'
const { Title } = Typography
const priorityLabels: Record<string, string> = {
urgent: 'Urgent',
high: 'High',
middle: 'in ',
low: 'Low',
}
const priorityColors: Record<string, string> = {
urgent: '#ff4d4f',
high: '#fa8c16',
middle: '#1677ff',
low: '#52c41a',
}
function DashboardClient({ boards, stats }: { boards: any[], stats: any }) {
const chartData = stats.priorityData.map((d: any) => ({
name: priorityLabels[d.priority] || d.priority,
count: d.count,
fill: priorityColors[d.priority] || '#1677ff',
}))
return (
<div style={{ padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={3} style={{ margin: 0 }}>My Board</Title>
<Link href="/board/new">
<Card
hoverable
style={{ width: 200, textAlign: 'center', borderStyle: 'dashed' }}
>
<PlusOutlined style={{ fontSize: 24, color: '#1677ff' }} />
<div style={{ marginTop: 8 }}>Create a Kanban Board</div>
</Card>
</Link>
</div>
{/* Statistical Overview */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic
title="Total Number of Tasks"
value={stats.totalTasks}
prefix={<ProjectOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Number of boards"
value={boards.length}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
</Row>
{/* Priority Distribution Chart */}
<Card title="Distribution of Task Priorities" style={{ marginBottom: 24 }}>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" name="Quantity" radius={[4, 4, 0, 0]}>
{chartData.map((entry: any, index: number) => (
<rect key={index} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* List of Kanban Boards */}
<Row gutter={[16, 16]}>
{boards.map((board: any) => (
<Col key={board.id} span={8}>
<Link href={`/board/${board.id}`}>
<Card
hoverable
style={{ borderTop: `3px solid ${board.color || '#1677ff'}` }}
>
<Card.Meta
title={board.title}
description={`${board._count.tasks} A task`}
/>
</Card>
</Link>
</Col>
))}
</Row>
</div>
)
}
export default DashboardClient
▶ Exemplo 5: Classificação de tarefas por arrastar e soltar — Ação do servidor
// app/actions/board-actions.ts - Server-Side Drag-and-Drop Sorting
'use server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function moveTask(taskId: string, targetColumnId: string, newPosition: number) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const task = await prisma.task.findUnique({
where: { id: taskId },
include: { board: true },
})
if (!task || task.board.userId !== session.user.id) {
throw new Error('Task not found or no permission')
}
await prisma.$transaction(async (tx) => {
await tx.task.updateMany({
where: { columnId: targetColumnId, position: { gte: newPosition } },
data: { position: { increment: 1 } },
})
await tx.task.update({
where: { id: taskId },
data: { columnId: targetColumnId, position: newPosition },
})
})
revalidatePath(`/board/${task.boardId}`)
}
export async function createTask(columnId: string, title: string, priority: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const column = await prisma.column.findUnique({
where: { id: columnId },
include: { board: true },
})
if (!column || column.board.userId !== session.user.id) {
throw new Error('Column not found or no permission')
}
const maxPosition = await prisma.task.findMany({
where: { columnId },
orderBy: { position: 'desc' },
take: 1,
select: { position: true },
})
const task = await prisma.task.create({
data: {
title,
priority: priority as any,
columnId,
boardId: column.boardId,
position: (maxPosition[0]?.position ?? -1) + 1,
},
})
revalidatePath(`/board/${column.boardId}`)
return task
}
❓ Perguntas Frequentes
P: Quanto tempo levará para concluir este projeto? R: Se você seguir as etapas desta lição, um desenvolvedor com experiência em full-stack (tanto front-end quanto back-end) levará cerca de 3 a 5 dias (20 a 30 horas). Iniciantes podem precisar de 1 a 2 semanas. Recomendamos concluir o projeto em etapas, por módulo funcional: comece com o projeto do banco de dados e a autenticação (Dia 1), depois passe para o quadro Kanban e a visualização de dados (Dias 2–3) e, por fim, dedique-se à funcionalidade de arrastar e soltar e à implantação (Dias 4–5). Cada módulo pode ser testado independentemente após sua conclusão.
P: Como é implementado o isolamento de dados em ambiente multilocatário? R: O modelo multilocatário neste projeto baseia-se em “quadros Kanban de propriedade do usuário”. Cada registro
Boardestá associado a umownerId, e todas as consultas de dados incluem um filtroownerId(por exemplo,where: { ownerId: session.user.id }). Não é necessário isolamento multilocatário no nível do banco de dados (não é necessário um “esquema por locatário”); consultas condicionais na camada de aplicação são suficientes. Se for necessária a colaboração em equipe (vários usuários compartilhando um único quadro Kanban), um modelo de associaçãoTeampode ser adicionado aoBoard.
P: Como faço para escolher entre @dnd-kit e react-beautiful-dnd? R: O react-beautiful-dnd costumava ser a biblioteca de arrastar e soltar mais popular do React, mas a Atlassian anunciou que não irá mais mantê-la, e ela não oferece mais suporte ao Modo Estrito no React 18+. O @dnd-kit é, atualmente, a opção recomendada — ele é mais leve (cerca de 15 KB), possui um design de API mais moderno e oferece suporte a uma gama mais ampla de cenários de arrastar e soltar (classificação por arrastar, soltar e posicionar, arrastar em vários contêineres e alças arrastáveis). O padrão
DndContext+SortableContext+useSortableusado neste projeto é a combinação mais comum no @dnd-kit.
P: Como faço para implantar este projeto em um ambiente de produção? R: Recomendamos usar a combinação do Vercel com o Supabase (PostgreSQL). O Supabase oferece um banco de dados PostgreSQL gratuito (500 MB), e o Vercel oferece implantação com um clique para o Next.js. Defina as variáveis de ambiente (
DATABASE_URL,NEXTAUTH_SECRET,GOOGLE_CLIENT_ID, etc.) no painel do Vercel. Antes da implantação, executenpx prisma generateenpx prisma db pushpara inicializar o banco de dados. Você pode reutilizar a configuração de CI/CD da Seção 28 deste curso.
P: Como esse projeto pode ser adaptado para colaboração entre vários usuários? R: Três direções de expansão: ① Colaboração em tempo real — introduzir WebSockets (como Socket.io ou Supabase Realtime) para transmitir alterações a outros usuários online quando um usuário arrastar um cartão; ② Gerenciamento de permissões — adicionar o campo
membersao modelo Board para distinguir entre as funções de Proprietário, Editor e Visualizador; ③ Sistema de notificações — enviar notificações por e-mail ou no navegador quando cartões forem atribuídos ou prazos estiverem se aproximando. Roteiro de atualização da pilha de tecnologia: Supabase Realtime + NextAuth.js RBAC + Web Push API.
📖 Resumo
- Esta aula integra os conhecimentos das 29 aulas anteriores: App Router, recuperação de dados, bibliotecas de componentes, autenticação e implantação de CI/CD
- Principais recursos do SaaS Kanban: quadros Kanban + funcionalidade de arrastar e soltar + isolamento de dados multilocatário + autenticação de usuários + análise de dados
- Pilha tecnológica: Next.js 14 + Ant Design 5 + Prisma + PostgreSQL + @dnd-kit + NextAuth.js + Recharts
- A multilocação garante o isolamento de dados por meio do campo
ownerId, com filtragem baseada em condições de consulta na camada de aplicação - As interações do tipo “arrastar e soltar” utilizam a combinação de três componentes do @dnd-kit: DndContext, SortableContext e useSortable
- O Componente Servidor é responsável pela recuperação de dados, o Componente Cliente é responsável pelas interações de arrastar e soltar, e a Ação/API do Servidor é responsável pela sincronização de dados
- Implantação recomendada: Vercel + Supabase PostgreSQL, com automação de CI/CD por meio do GitHub Actions
- Trata-se de um produto SaaS de verdade, pronto para ser lançado, e não de um projeto de demonstração.
📝 Exercícios
- Siga as etapas desta lição para configurar a estrutura básica do projeto: crie um projeto Next.js, configure o esquema do banco de dados Prisma e execute
npx prisma db pushpara inicializar as tabelas de dados. Crie um banco de dados PostgreSQL gratuito no Supabase e configure a string de conexão no arquivo.env. Verifique seprisma db pushé executado com sucesso. - Implementar a funcionalidade de arrastar e soltar no quadro kanban do @dnd-kit: configurar o DndContext (PointerSensor + KeyboardSensor); implementar a função
handleDragEndpara lidar com movimentos entre colunas e ordenação dentro da mesma coluna; e usar a API PUT para sincronizar os resultados do arrastar e soltar com o banco de dados. Após o arrastar, atualize a página para verificar se o estado do quadro kanban foi corretamente salvo. - Implantação e lançamento: envie o projeto para o repositório do GitHub e conecte-o ao Vercel para a implantação automática. Adicione todas as variáveis de ambiente (DATABASE_URL, NEXTAUTH_SECRET, GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET) no painel do Vercel. Assim que a implantação estiver concluída, crie um quadro de teste, adicione tarefas e teste a funcionalidade de arrastar e soltar para verificar se todos os recursos estão funcionando corretamente no ambiente de produção.