React: تمرين عملي شامل

آخر تحديث: 2026-08-26

هذا الدرس هو الجزء الأخير من سلسلة دروس React المكونة من 30 درسًا. ستطبق كل ما تعلمته حتى الآن — موجه تطبيقات Next.js، واسترجاع البيانات، ومكتبات مكونات واجهة المستخدم، والمصادقة والتفويض، والنشر عبر CI/CD — لبناء تطبيق Kanban SaaS متعدد المستأجرين من الصفر. هذا ليس مجرد مشروع تجريبي؛ بل هو منتج حقيقي جاهز للنشر والتشغيل.


1. نظرة عامة على المشروع

(1) وصف الميزة

SaaS Kanban هي أداة خفيفة الوزن لإدارة المشاريع مصممة للفرق. وتشمل ميزاتها الأساسية ما يلي:

الوحدة الوظيفية الوصف التقنيات ذات الصلة
لوحة التحكم سحب البطاقات وإفلاتها بين الأعمدة @dnd-kit مكتبة السحب والإفلات
إدارة المهام إنشاء/تعديل/حذف/تعيين المهام إجراءات الخادم + قاعدة البيانات
متعدد المستأجرين مساحات عمل منفصلة لكل فريق عزل بيانات معرّف المستأجر
مصادقة المستخدم تسجيل الدخول عبر البريد الإلكتروني وكلمة المرور + 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، مكونات واجهة المستخدم، منطق التفاعل
طبقة واجهة برمجة التطبيقات (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). تتم إدارة المصادقة مركزيًّا باستخدام 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 { 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. البرمجة خطوة بخطوة

فيما يلي، سنقوم بتنفيذ الكود الأساسي لتطبيق «كانبان» خطوة بخطوة، مرتبةً حسب الوحدات الوظيفية. تتوافق كل خطوة مع كتلة وظيفية كاملة؛ ويمكنك نسخها إلى مشروعك بالترتيب وتشغيلها.

(1) تخطيط صفحة «كانبان» واسترجاع البيانات

يتمثل الهيكل الأساسي لصفحة «كانبان» في «تصميم مكون من ثلاثة أعمدة»: الشريط الجانبي الأيسر (قائمة كانبان) والمنطقة الرئيسية في الوسط (عناوين الأعمدة + بطاقات المهام). وهذا يمثل التسلسل الهرمي النموذجي لمكون الخادم + مكون العميل: حيث يقوم مكون الخادم في الطبقة الخارجية باسترداد بيانات كانبان، بينما يتولى مكون العميل في الطبقة الداخلية معالجة تفاعلات السحب والإفلات.

▶ المثال 2: استرداد البيانات من مكون خادم صفحة «كانبان»

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) أعمدة الكانبان ومكونات بطاقات المهام

يُعد كل عمود في نظام «كانبان» مكونًا مستقلًا يتضمن عنوان العمود، وقائمة بالمهام، ومؤشر لون العمود. تعرض بطاقات المهام العنوان، وعلامة الأولوية، وصورة المستخدم المكلف بالمهمة، وتاريخ الاستحقاق. يتم تنفيذ وظيفة السحب والإفلات باستخدام ربط useSortable من @dnd-kit.

▶ المثال 3: مكونات الأعمدة وبطاقات المهام

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 لإظهار توزيع المهام حسب الأولوية، مما يساعد الفرق على تكوين فهم واضح لحجم العمل الملقى على عاتقها.

▶ المثال 4: قوائم كانبان والرسوم البيانية الإحصائية

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

▶ المثال 5: فرز المهام عن طريق السحب والإفلات — إجراء الخادم

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
}


❓ أسئلة شائعة

س كم من الوقت سيستغرق إنجاز هذا المشروع؟
ج إذا اتبعت الخطوات الواردة في هذا الدرس، فسيحتاج المطور الذي يتمتع بخبرة شاملة (في كل من الواجهة الأمامية والخلفية) إلى حوالي 3–5 أيام (20–30 ساعة). أما المبتدئون فقد يحتاجون إلى 1–2 أسبوع. نوصي بإكمال المشروع على مراحل حسب الوحدات الوظيفية: ابدأ بتصميم قاعدة البيانات والمصادقة (اليوم الأول)، ثم انتقل إلى لوحة كانبان وتصور البيانات (اليومان الثاني والثالث)، وأخيرًا تعامل مع وظيفة السحب والإفلات والنشر (اليومان الرابع والخامس). يمكن اختبار كل وحدة بشكل مستقل بمجرد اكتمالها.
س كيف يتم تنفيذ عزل البيانات في نموذج تعدد المستأجرين؟
ج يعتمد نموذج تعدد المستأجرين في هذا المشروع على «لوحات كانبان المملوكة للمستخدمين». يرتبط كل سجل Board بـ ownerId، وتشمل جميع استعلامات البيانات مرشحًا ownerId (على سبيل المثال، where: { ownerId: session.user.id }). لا يلزم عزل متعدد المستأجرين على مستوى قاعدة البيانات (لا حاجة إلى «مخطط لكل مستأجر»); تكفي الاستعلامات الشرطية على مستوى طبقة التطبيق. إذا كان التعاون بين الفرق مطلوبًا (تشارك عدة مستخدمين لوحة كانبان واحدة)، يمكن إضافة نموذج ارتباط Team إلى Board.
س كيف أختار بين @dnd-kit و react-beautiful-dnd؟
ج كانت react-beautiful-dnd في السابق أكثر مكتبات السحب والإفلات شيوعًا في React، لكن شركة Atlassian أعلنت أنها لن تواصل صيانتها، كما أنها لم تعد تدعم الوضع الصارم (Strict Mode) في React 18 وما بعده. تُعد @dnd-kit حاليًّا الخيار الموصى به — فهي أخف وزنًا (حوالي 15 كيلوبايت)، وتتميز بتصميم واجهة برمجة تطبيقات (API) أكثر حداثة، وتدعم نطاقًا أوسع من سيناريوهات السحب والإفلات (الفرز عن طريق السحب، والإفلات والتحديد، وسحب الحاويات المتعددة، ومقابض السحب). نمط DndContext + SortableContext + useSortable المستخدم في هذا المشروع هو التركيبة الأكثر شيوعًا في @dnd-kit.
س كيف يمكنني نشر هذا المشروع في بيئة الإنتاج؟
ج نوصي باستخدام مزيج من Vercel و Supabase (PostgreSQL). توفر Supabase قاعدة بيانات PostgreSQL مجانية (500 ميجابايت)، بينما توفر Vercel إمكانية النشر بنقرة واحدة لـ Next.js. قم بتعيين متغيرات البيئة (DATABASE_URL، NEXTAUTH_SECRET، GOOGLE_CLIENT_ID، إلخ) في لوحة تحكم Vercel. قبل النشر، قم بتشغيل npx prisma generate وnpx prisma db push لتهيئة قاعدة البيانات. يمكنك إعادة استخدام تكوين CI/CD من القسم 28 من هذه الدورة التدريبية.
س كيف يمكن توسيع نطاق هذا المشروع ليتيح التعاون بين عدة مستخدمين؟
ج هناك ثلاثة اتجاهات للتوسع: ① التعاون في الوقت الفعلي — استخدام WebSockets (مثل Socket.io أو Supabase Realtime) لنشر التغييرات على المستخدمين الآخرين المتصلين بالإنترنت عندما يقوم أحد المستخدمين بسحب بطاقة؛ ② إدارة الأذونات — إضافة الحقل members إلى نموذج Board للتمييز بين أدوار المالك والمحرر والمشاهد؛ ③ نظام الإشعارات — إرسال إشعارات عبر البريد الإلكتروني أو المتصفح عند تخصيص البطاقات أو اقتراب المواعيد النهائية. خارطة طريق ترقية المكونات التقنية: Supabase Realtime + NextAuth.js RBAC + Web Push API.

📖 ملخص


📝 تمارين

  1. اتبع الخطوات الواردة في هذا الدرس لإعداد الهيكل الأساسي للمشروع: أنشئ مشروع Next.js، وقم بتكوين مخطط قاعدة بيانات Prisma، وقم بتشغيل npx prisma db push لتهيئة جداول البيانات. أنشئ قاعدة بيانات PostgreSQL مجانية في Supabase وقم بتكوين سلسلة الاتصال في الملف .env. تأكد من تنفيذ prisma db push بنجاح.
  2. تنفيذ وظيفة السحب والإفلات في لوحة كانبان الخاصة بـ @dnd-kit: تهيئة DndContext (PointerSensor + KeyboardSensor)؛ وتنفيذ الدالة handleDragEnd لمعالجة عمليات النقل بين الأعمدة والفرز داخل العمود نفسه؛ واستخدام واجهة برمجة التطبيقات (API) PUT لمزامنة نتائج السحب والإفلات مع قاعدة البيانات. بعد السحب، قم بتحديث الصفحة للتحقق من أن حالة لوحة كانبان قد تم حفظها بشكل صحيح.
  3. النشر والتشغيل: قم بنشر المشروع إلى مستودع GitHub واربطه بـ Vercel من أجل النشر التلقائي. أضف جميع متغيرات البيئة (DATABASE_URL، NEXTAUTH_SECRET، GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET) في لوحة تحكم Vercel. بمجرد اكتمال النشر، أنشئ لوحة اختبار، وأضف المهام، واختبر وظيفة السحب والإفلات للتحقق من أن جميع الميزات تعمل بشكل صحيح في بيئة الإنتاج.
Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%