404 Not Found

404 Not Found


nginx

包括的な実践演習:SaaSカンバンアプリケーション

このレッスンは、全30回にわたるReactチュートリアルシリーズの最終回です。これまで学んだ知識――Next.jsのApp Router、データフェッチ、UIコンポーネントライブラリ、認証と認可、CI/CDによるデプロイ――をすべて活用し、マルチテナント型のSaaSカンバンアプリケーションをゼロから構築します。これは単なるデモプロジェクトではなく、デプロイして運用できる本格的な製品です。


1. プロジェクトの概要

(1) 機能の説明

SaaS Kanbanは、チーム向けに設計された軽量なプロジェクト管理ツールです。その主な機能は以下の通りです:

機能モジュール 説明 関連技術
ダッシュボードパネル 列間でカードをドラッグ&ドロップ @dnd-kit ドラッグ&ドロップライブラリ
タスク管理 タスクの作成・編集・削除・割り当て サーバー操作 + データベース
マルチテナント チームごとに独立したワークスペース テナントIDによるデータ分離
ユーザー認証 メールアドレスとパスワードによるログイン + Google OAuth NextAuth.js
リアルタイム更新 ドラッグ後にサーバーへ自動的に同期 サーバーアクションによる楽観的更新
データ統計 かんばんステータス棒グラフ Recharts チャートライブラリ
デプロイ 自動化されたCI/CDパイプライン Vercel + GitHub Actions

(2) 技術アーキテクチャ

TEXT
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. 技術アーキテクチャ図

アーキテクチャ層 技術 役割
フロントエンド・プレゼンテーション層 Next.js + Ant Design SSR/SSG、UIコンポーネント、インタラクションロジック
API レイヤー ルートハンドラー + サーバーアクション データインターフェース、ビジネスロジック、フォーム送信
データアクセス層 Prisma ORM 型安全なデータベースクエリ
ストレージ層 PostgreSQL 永続データストレージ
認証レイヤー NextAuth.js メールアドレスとパスワード + Google OAuth
デプロイ層 Vercel + GitHub Actions CI/CDによる自動デプロイ

SaaS Kanbanの全体的なアーキテクチャは、フロントエンドのプレゼンテーション層(Next.js + Ant Design)、API層(Route Handler + Server Action)、データアクセス層(Prisma ORM)、ストレージ層(PostgreSQL)の4つの層で構成されています。認証はNextAuth.jsを使用して一元管理され、デプロイはVercelとGitHub Actionsによって自動化されています。

100%
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. プロジェクトのセットアップとデータベース設計

(1) プロジェクトを初期化する

BASH
# 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) データベーススキーマ

データモデル 主要フィールド 関係 説明
ユーザー メールアドレス、名前、画像 → ボード[], タスク[] ユーザーは複数のボードとタスクを持っている
ボード タイトル、説明 → 列[] カンバンには複数の列がある
タイトル、役職 → タスク[] この列には複数のタスクカードが含まれています
タスク タイトル、優先度、期限 → ユーザー(担当者) タスクをユーザーに割り当てることができます
アカウント/セッション プロバイダー、トークン → ユーザー NextAuthによる認証が必要
PRISMA
// 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
}

(1) ▶ サンプル:NextAuth.js の認証設定

TS
// 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 bcrypt from 'bcryptjs'
import { prisma } from './prisma'

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 = await bcrypt.compare(
          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. コーディングの手順

以下では、カンバンアプリケーションの中核となるコードを、機能モジュールごとに分類して段階的に実装していきます。各ステップは1つの機能ブロックに対応しており、順番通りに自分のプロジェクトにコピーして実行することができます。

(1) かんばんのページレイアウトとデータ取得

カンバンページの中核となる構造は「3列レイアウト」です。左側のサイドバー(カンバンリスト)と、中央のメインエリア(列ヘッダー+タスクカード)で構成されています。これは典型的な「サーバーコンポーネント+クライアントコンポーネント」の階層構造であり、外側のサーバーコンポーネントがカンバンデータを取得し、内側のクライアントコンポーネントがドラッグ&ドロップ操作を処理します。

(1) ▶ サンプル:カンバンページ・サーバーコンポーネントからのデータ取得

TSX
// 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) かんばんの列とタスクカードの構成要素

各カンバン列は、列ヘッダー、タスク一覧、列の色インジケーターを含む独立したコンポーネントです。タスクカードには、タイトル、優先度ラベル、担当者のアバター、および期限が表示されます。ドラッグ&ドロップ機能は、@dnd-kituseSortable フックを使用して実装されています。

(2) ▶ サンプル:列コンポーネントとタスクカード

TSX
// 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) かんばんリストとデータ統計

ボード一覧ページには、ユーザーが作成したすべてのボードが表示され、各ボードはカード形式で表示され、その名前、作成日、タスク数が表示されます。統計情報はRechartsの棒グラフを用いて優先度別のタスクの分布を示しており、チームが作業負荷を明確に把握するのに役立ちます。

(3) ▶ サンプル:かんばんリストと統計グラフ

TSX
// 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
▶ 試してみよう

(4) ▶ サンプル:ドラッグ&ドロップによるタスクの並べ替え — サーバーアクション

TSX
// 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
}
▶ 試してみよう

❓ よくある質問

Q このプロジェクトを完了するにはどれくらい時間がかかりますか?
A このレッスンの手順に従えば、フルスタック(フロントエンドとバックエンドの両方)の経験がある開発者であれば、約3~5日(20~30時間)かかります。初心者の方は1~2週間かかる場合があります。プロジェクトは機能モジュールごとに段階的に進めることをお勧めします。まずデータベース設計と認証機能から始め(1日目)、次にカンバンボードとデータ可視化に進み(2~3日目)、最後にドラッグ&ドロップ機能とデプロイに取り組みます(4~5日目)。各モジュールは、完成次第、個別にテストを行うことができます。
Q マルチテナントのデータ分離はどのように実装されていますか?
A 本プロジェクトのマルチテナントモデルは、「ユーザー所有のカンバンボード」に基づいています。各 Board レコードは ownerId と関連付けられており、すべてのデータクエリには ownerId フィルターが含まれます(例:where: { ownerId: session.user.id })。データベースレベルでのマルチテナント分離は不要です(「テナントごとのスキーマ」は必要ありません)。アプリケーション層での条件付きクエリで十分です。チームでの共同作業が必要な場合(複数のユーザーが単一のカンバンボードを共有する場合)、Teamの関連付けモデルをBoardに追加することができます。
Q @dnd-kit と react-beautiful-dnd のどちらを選べばいいですか?
A react-beautiful-dnd はかつて最も人気のある React ドラッグ&ドロップライブラリでしたが、Atlassian はそのメンテナンスを終了すると発表しており、React 18 以降では Strict Mode をサポートしなくなりました。@dnd-kitは現在、推奨される選択肢です。軽量(約15KB)で、よりモダンなAPI設計を採用しており、幅広いドラッグ&ドロップのシナリオ(ドラッグによる並べ替え、ドロップ&プレイス、マルチコンテナのドラッグ、ドラッグ可能なハンドル)に対応しています。このプロジェクトで使用されている DndContext + SortableContext + useSortable のパターンは、@dnd-kit において最も一般的な組み合わせです。
Q このプロジェクトを本番環境にデプロイするにはどうすればよいですか?
A VercelとSupabase(PostgreSQL)の組み合わせの使用をお勧めします。Supabaseは無料のPostgreSQLデータベース(500MB)を提供しており、VercelはNext.js向けのワンクリックデプロイ機能を提供しています。Vercelダッシュボードで環境変数(DATABASE_URLNEXTAUTH_SECRETGOOGLE_CLIENT_IDなど)を設定してください。デプロイする前に、npx prisma generatenpx prisma db pushを実行してデータベースを初期化してください。このコースの第28セクションにあるCI/CD設定を再利用できます。
Q このプロジェクトをマルチユーザーでの共同作業に対応させるにはどうすればよいですか?
A 3つの拡張の方向性があります:① リアルタイム共同作業――WebSockets(Socket.ioやSupabase Realtimeなど)を導入し、ユーザーがカードをドラッグした際に、その変更内容を他のオンラインユーザーに通知するようにします; ② 権限管理——Boardモデルにmembersフィールドを追加し、オーナー、編集者、閲覧者の役割を区別します;③ 通知システム——カードが割り当てられたり、期限が近づいたりした際に、メールまたはブラウザ通知を送信します。技術スタックのアップグレードロードマップ:Supabase Realtime + NextAuth.js RBAC + Web Push API。

📖 まとめ


📝 練習問題

  1. このレッスンの手順に従って、プロジェクトの骨格を設定します。具体的には、Next.js プロジェクトを作成し、Prisma のデータベーススキーマを設定し、npx prisma db push を実行してデータテーブルを初期化します。Supabase で無料の PostgreSQL データベースを作成し、.env ファイル内で接続文字列を設定します。さらに、prisma db push が正常に実行されることを確認してください。
  2. @dnd-kitのカンバンボードにドラッグ&ドロップ機能を実装する:DndContext(PointerSensor + KeyboardSensor)を設定し、列間の移動および同一列内での並べ替えを処理するhandleDragEnd関数を実装し、PUT APIを使用してドラッグ&ドロップの結果をデータベースに同期させる。ドラッグ操作後、ページを更新して、カンバンボードの状態が正しく保存されていることを確認してください。
  3. デプロイと公開:プロジェクトをGitHubリポジトリにプッシュし、Vercelに接続して自動デプロイを行います。Vercelダッシュボードにすべての環境変数(DATABASE_URL、NEXTAUTH_SECRET、GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET)を追加します。デプロイが完了したら、テスト用ボードを作成し、タスクを追加して、ドラッグ&ドロップ機能をテストし、本番環境で全ての機能が正常に動作していることを確認します。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%