Next.js: المشروع النهائي: CRUD و Server Actions

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

إدارة المهام هي جوهر أي منصة SaaS — إنشاء المهام وتحريرها وحذفها وسحبها وإفلاتها لإعادة ترتيبها ورفع الملفات والبحث والتصفية. يمكّن نظام CRUD الشامل المستخدمين من إدارة سير العمل بكفاءة.

1. ما ستتعلمه



2. قصة حقيقية لمدير مشروع

(1) نقطة الألم: فريق مكون من 50 شخصا يهدر 3 ساعات يوميا في إدارة المهام

بوب هو مدير مشروع في شركة Acme Corp يدير 5 مشاريع وأكثر من 200 مهمة. يقضي الفريق وقتا كبيرا يوميا في: إنشاء المهام (ملء نماذج من 10 حقول بشكل متكرر)، والسحب والإفلات لتحديث الحالات (مثل لصق أوراق الملاحظات على لوحة بيضاء)، وطلب الملفات من الزملاء (تمريرها ذهابا وإيابا على WeChat)، والبحث عن المهام (التمرير عبر جداول Excel). حسبت أليس أنه في فريق مكون من 50 شخصا، يهدر كل شخص 36 دقيقة يوميا في إدارة مهام غير فعالة، مما ينتج عنه خسارة إنتاجية شهرية تبلغ 600 ساعة.

(2) Server Actions + التحديثات المتفائلة + حل للسحب والإفلات

تنفيذ CRUD بدون تحديث باستخدام Server Actions + useOptimistic للتغذية الراجعة الفورية + تبديل الحالة بالسحب والإفلات.

TSX
// سطر واحد من Server Action لإكمال إنشاء المهمة + تحديث التخزين المؤقت
<form action={createTask}>
  <input name="title" required />
  <input name="projectId" type="hidden" value={project.id} />
  <button type="submit">إنشاء مهمة</button>
</form>

(3) المكاسب

السيناريو قبل التحسين بعد التحسين تحسين الكفاءة
إنشاء مهمة 3 دقائق (نموذج كامل) 15 ثانية (إنشاء سريع) 12x
الفرز بالسحب والإفلات لوحة بيضاء مكتوبة يدويا النقر على عمود الحالة فوري
مشاركة الملفات إرسال الملفات عبر WeChat الرفع عبر Uploadthing 5x
البحث عن مهمة تصفح Excel بحث DataTable فوري 20x
الإنتاجية الشهرية 600 ساعة مهدرة ~50 ساعة مهدرة تقليل بنسبة 92%


3. CRUD المهام باستخدام Server Actions

(1) هندسة العمليات

100%
graph TB
    subgraph "Server Actions"
        A[createTask] --> B[التحقق عبر Zod]
        B --> C[إنشاء Prisma]
        C --> D[revalidateTag]
        D --> E[redirect]
    end

    subgraph "العميل"
        F[TaskForm] -->|action| A
        G[useOptimistic] -->|واجهة مستخدم فورية| F
        H[useActionState] -->|تأكيد| J[deleteTask]
    end

    subgraph "التخزين المؤقت"
        D --> K[revalidateTag<br/>"tasks"]
        K --> L[تحديث واجهة المستخدم]
    end

    style A fill:#cce5ff
    style G fill:#d4edda
    style K fill:#ffeeba

(2) مقارنة عمليات المهام

العملية Server Action التحقق سياسة التخزين المؤقت تغذية واجهة المستخدم
createTask "use server" Zod revalidateTag("tasks") useOptimistic
updateTask "use server" Zod جزئي revalidateTag("tasks") تحديث متفائل
deleteTask "use server" فحص التأكيد revalidateTag("tasks") useActionState
reorderTask "use server" التحقق من الرقم revalidateTag("tasks") إعادة ترتيب فورية

▶ مثال: تعريف Server Actions

المخرجات:

TEXT 📖 للعرض فقط
رسم توضيحي: createTask؛ التحقق عبر Zod؛ إنشاء Prisma؛ revalidateTag؛ redirect؛ TaskForm.
TYPESCRIPT
// src/lib/actions/task-actions.ts
"use server"

import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import { z } from "zod"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { TaskStatus, TaskPriority } from "@prisma/client"

const createTaskSchema = z.object({
  title: z.string().min(1, "العنوان مطلوب").max(200, "العنوان طويل جدا"),
  description: z.string().max(2000).optional(),
  projectId: z.string().min(1),
  assigneeId: z.string().optional(),
  priority: z.nativeEnum(TaskPriority).optional(),
  dueDate: z.string().optional(),
})

const updateTaskSchema = z.object({
  id: z.string().min(1),
  title: z.string().min(1).max(200).optional(),
  description: z.string().max(2000).optional(),
  status: z.nativeEnum(TaskStatus).optional(),
  priority: z.nativeEnum(TaskPriority).optional(),
  assigneeId: z.string().nullable().optional(),
  dueDate: z.string().nullable().optional(),
  order: z.number().int().optional(),
})

export async function createTask(formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) {
    throw new Error("غير مصرح")
  }

  const rawData = {
    title: formData.get("title") as string,
    description: formData.get("description") as string | undefined,
    projectId: formData.get("projectId") as string,
    assigneeId: formData.get("assigneeId") as string | undefined,
    priority: (formData.get("priority") as TaskPriority) || "MEDIUM",
    dueDate: formData.get("dueDate") as string | undefined,
  }

  const validated = createTaskSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      error: validated.error.flatten().fieldErrors,
      message: "فشل التحقق",
    }
  }

  const data = validated.data

  const maxOrder = await prisma.task.findFirst({
    where: { projectId: data.projectId, status: "TODO" },
    orderBy: { order: "desc" },
    select: { order: true },
  })

  await prisma.task.create({
    data: {
      title: data.title,
      description: data.description || null,
      status: "TODO",
      priority: data.priority || "MEDIUM",
      projectId: data.projectId,
      creatorId: session.user.id,
      assigneeId: data.assigneeId || null,
      dueDate: data.dueDate ? new Date(data.dueDate) : null,
      order: (maxOrder?.order ?? -1) + 1,
    },
  })

  revalidateTag("tasks")
  revalidateTag(`project-${data.projectId}`)
}

export async function updateTask(formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) {
    throw new Error("غير مصرح")
  }

  const rawData = {
    id: formData.get("id") as string,
    title: formData.get("title") as string | undefined,
    description: formData.get("description") as string | undefined,
    status: formData.get("status") as TaskStatus | undefined,
    priority: formData.get("priority") as TaskPriority | undefined,
    assigneeId: formData.get("assigneeId") as string | null | undefined,
    dueDate: formData.get("dueDate") as string | null | undefined,
  }

  const validated = updateTaskSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      error: validated.error.flatten().fieldErrors,
      message: "فشل التحقق",
    }
  }

  const data = validated.data
  const updateData: Record<string, unknown> = {}

  if (data.title !== undefined) updateData.title = data.title
  if (data.description !== undefined) updateData.description = data.description
  if (data.status !== undefined) updateData.status = data.status
  if (data.priority !== undefined) updateData.priority = data.priority
  if (data.assigneeId !== undefined) updateData.assigneeId = data.assigneeId
  if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ? new Date(data.dueDate) : null
  if (data.order !== undefined) updateData.order = data.order

  await prisma.task.update({
    where: { id: data.id },
    data: updateData,
  })

  revalidateTag("tasks")
}

export async function deleteTask(prevState: { message: string }, formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) {
    return { message: "غير مصرح" }
  }

  const taskId = formData.get("id") as string

  if (!taskId) {
    return { message: "معرف المهمة مطلوب" }
  }

  const task = await prisma.task.findUnique({
    where: { id: taskId },
    select: { creatorId: true, projectId: true },
  })

  if (!task) {
    return { message: "المهمة غير موجودة" }
  }

  if (task.creatorId !== session.user.id) {
    return { message: "يمكن للمنشئ فقط حذف هذه المهمة" }
  }

  await prisma.task.delete({ where: { id: taskId } })

  revalidateTag("tasks")
  revalidateTag(`project-${task.projectId}`)

  return { message: "تم حذف المهمة بنجاح" }
}

export async function reorderTask(taskId: string, newStatus: TaskStatus, newOrder: number) {
  const session = await auth()
  if (!session?.user?.id) {
    throw new Error("غير مصرح")
  }

  await prisma.task.update({
    where: { id: taskId },
    data: { status: newStatus, order: newOrder },
  })

  revalidateTag("tasks")
}

المخرجات:

TEXT 📖 للعرض فقط
يقبل حقول النموذج: title، description، projectId، assigneeId، priority.
يتحقق من الإدخال باستخدام Zod schema قبل المعالجة.
يكتب في قاعدة البيانات ويبطل صلاحية وسوم التخزين المؤقت ذات الصلة.


4. التحديثات المتفائلة ونوافذ التأكيد المنبثقة

(1) ثلاثة أوضاع لواجهة المستخدم

100%
graph LR
    subgraph "التحديث المتفائل"
        A1[نقر المستخدم] --> B1[تحديث فوري لواجهة المستخدم]
        B1 --> C1[Server Action]
        C1 --> D1{تراجع؟}
        D1 -->|نجاح| E1[إبقاء]
        D1 -->|خطأ| F1[استعادة]
    end

    subgraph "حالة الإجراء"
        A2[نقر المستخدم على حذف] --> B2[إظهار حوار التأكيد]
        B2 --> C2[إذا تم التأكيد<br/>استدعاء deleteTask]
        C2 --> D2[إظهار رسالة النتيجة]
    end

    subgraph "التقليدي"
        A3[إرسال النموذج] --> B3[أيقونة تحميل دوارة]
        B3 --> C3[Server Action]
        C3 --> D3[revalidateTag + تحديث]
    end

    style B1 fill:#d4edda
    style B2 fill:#ffeeba
    style B3 fill:#f8d7da

▶ مثال: useOptimistic — التحديثات المتفائلة

المخرجات:

TEXT 📖 للعرض فقط
يوضح الرسم التوضيحي الهندسة/التدفق الموصوف.
TSX
// src/components/tasks/task-list.tsx
"use client"

import { useOptimistic } from "react"
import { TaskCard } from "@/components/tasks/task-card"
import type { TaskWithAssignee } from "@/types"

interface TaskListProps {
  tasks: TaskWithAssignee[]
  status: "TODO" | "IN_PROGRESS" | "DONE"
  onStatusChange: (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => void
}

export function TaskList({ tasks, status, onStatusChange }: TaskListProps) {
  const [optimisticTasks, addOptimisticTask] = useOptimistic(
    tasks,
    (state, newTask: TaskWithAssignee) => {
      return [newTask, ...state]
    }
  )

  return (
    <div className="space-y-2">
      <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
        {status.replace("_", " ")}
        <span className="ml-2 text-xs">({optimisticTasks.length})</span>
      </h3>
      {optimisticTasks.length === 0 ? (
        <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
          لا توجد مهام
        </div>
      ) : (
        <div className="space-y-2">
          {optimisticTasks.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onStatusChange={onStatusChange}
            />
          ))}
        </div>
      )}
    </div>
  )
}

المخرجات:

TEXT 📖 للعرض فقط
يعرض واجهة مستخدم مكون ▶ مثال: useOptimistic — التحديثات المتفائلة كما هو موضح في القسم.

▶ مثال: useActionState لتأكيد الحذف

المخرجات:

TEXT 📖 للعرض فقط
يعرض المكون واجهة المستخدم الموصوفة في المتصفح.
TSX
// src/components/tasks/delete-task-button.tsx
"use client"

import { useActionState, useState } from "react"
import { deleteTask } from "@/lib/actions/task-actions"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"

interface DeleteTaskButtonProps {
  taskId: string
  taskTitle: string
}

export function DeleteTaskButton({ taskId, taskTitle }: DeleteTaskButtonProps) {
  const [open, setOpen] = useState(false)
  const [state, formAction, isPending] = useActionState(deleteTask, {
    message: "",
  })

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
          </svg>
        </Button>
      </DialogTrigger>
      <DialogContent>
        <form action={formAction}>
          <input type="hidden" name="id" value={taskId} />
          <DialogHeader>
            <DialogTitle>حذف المهمة</DialogTitle>
            <DialogDescription>
              هل أنت متأكد من رغبتك في حذف "{taskTitle}"؟ لا يمكن التراجع عن هذا الإجراء.
            </DialogDescription>
          </DialogHeader>
          {state.message && state.message !== "تم حذف المهمة بنجاح" && (
            <div className="my-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
              {state.message}
            </div>
          )}
          {state.message === "تم حذف المهمة بنجاح" && (
            <div className="my-4 rounded-md bg-green-50 p-3 text-sm text-green-700">
              {state.message}
            </div>
          )}
          <DialogFooter className="mt-4">
            <Button
              type="button"
              variant="outline"
              onClick={() => setOpen(false)}
            >
              إلغاء
            </Button>
            <Button
              type="submit"
              variant="destructive"
              disabled={isPending}
            >
              {isPending ? "جاري الحذف..." : "حذف"}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}

المخرجات:

TEXT 📖 للعرض فقط
نموذج تفاعلي مع إدارة حالة من جانب العميل.
المحتوى المرئي: حذف المهمة


5. الفرز بالسحب والإفلات (لوحة كانبان ثلاثية الأعمدة)

(1) هيكل كانبان

100%
graph LR
    subgraph "لوحة المهام"
        A["المهام<br/>(عمود)"] -->|سحب| B["قيد التنفيذ<br/>(عمود)"]
        B -->|سحب| C["مكتملة<br/>(عمود)"]
        C -->|سحب للخلف| B
        B -->|سحب للخلف| A
    end

    style A fill:#fef3c7
    style B fill:#dbeafe
    style C fill:#d1fae5

▶ مثال: تخطيط ثلاثي الأعمدة للوحة المهام

المخرجات:

TEXT 📖 للعرض فقط
رسم توضيحي للتخطيط وهيكل القوالب في دليل app.
TSX
// src/components/tasks/task-board.tsx
"use client"

import { useCallback } from "react"
import { TaskList } from "@/components/tasks/task-list"
import { reorderTask } from "@/lib/actions/task-actions"
import type { TaskWithAssignee } from "@/types"

interface TaskBoardProps {
  tasks: TaskWithAssignee[]
}

export function TaskBoard({ tasks }: TaskBoardProps) {
  const todoTasks = tasks.filter((t) => t.status === "TODO")
  const inProgressTasks = tasks.filter((t) => t.status === "IN_PROGRESS")
  const doneTasks = tasks.filter((t) => t.status === "DONE")

  const handleStatusChange = useCallback(
    async (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => {
      const targetList = tasks.filter((t) => t.status === newStatus)
      const maxOrder = targetList.length > 0
        ? Math.max(...targetList.map((t) => t.order))
        : 0

      try {
        await reorderTask(taskId, newStatus, maxOrder + 1)
      } catch (error) {
        console.error("فشل إعادة ترتيب المهمة:", error)
      }
    },
    [tasks]
  )

  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
      <div className="rounded-lg bg-muted/50 p-4">
        <TaskList
          tasks={todoTasks}
          status="TODO"
          onStatusChange={handleStatusChange}
        />
      </div>
      <div className="rounded-lg bg-muted/50 p-4">
        <TaskList
          tasks={inProgressTasks}
          status="IN_PROGRESS"
          onStatusChange={handleStatusChange}
        />
      </div>
      <div className="rounded-lg bg-muted/50 p-4">
        <TaskList
          tasks={doneTasks}
          status="DONE"
          onStatusChange={handleStatusChange}
        />
      </div>
    </div>
  )
}

المخرجات:

TEXT 📖 للعرض فقط
يعرض واجهة مستخدم مكون ▶ مثال: تخطيط ثلاثي الأعمدة للوحة المهام كما هو موضح في القسم.

▶ مثال: مكون بطاقة المهمة

المخرجات:

TEXT 📖 للعرض فقط
يعرض المكون واجهة المستخدم الموصوفة في المتصفح.
TSX
// src/components/tasks/task-card.tsx
"use client"

import { DeleteTaskButton } from "@/components/tasks/delete-task-button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import type { TaskWithAssignee } from "@/types"

interface TaskCardProps {
  task: TaskWithAssignee
  onStatusChange: (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => void
}

const statusActions: Record<string, ("TODO" | "IN_PROGRESS" | "DONE")[]> = {
  TODO: ["IN_PROGRESS"],
  IN_PROGRESS: ["TODO", "DONE"],
  DONE: ["IN_PROGRESS"],
}

const priorityColors: Record<string, string> = {
  LOW: "bg-gray-100 text-gray-700",
  MEDIUM: "bg-blue-100 text-blue-700",
  HIGH: "bg-orange-100 text-orange-700",
  URGENT: "bg-red-100 text-red-700",
}

export function TaskCard({ task, onStatusChange }: TaskCardProps) {
  const initials = task.assignee?.name
    ?.split(" ")
    .map((n) => n[0])
    .join("")
    .toUpperCase()

  return (
    <div className="group rounded-lg border bg-card p-3 shadow-sm transition-all hover:shadow-md">
      <div className="flex items-start justify-between">
        <h4 className="text-sm font-medium">{task.title}</h4>
        <DeleteTaskButton taskId={task.id} taskTitle={task.title} />
      </div>

      {task.description && (
        <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
          {task.description}
        </p>
      )}

      <div className="mt-3 flex items-center justify-between">
        <div className="flex items-center gap-2">
          <Badge
            variant="secondary"
            className={priorityColors[task.priority]}
          >
            {task.priority}
          </Badge>
          {task.dueDate && (
            <span className="text-xs text-muted-foreground">
              {new Date(task.dueDate).toLocaleDateString("en-US", {
                month: "short",
                day: "numeric",
              })}
            </span>
          )}
        </div>

        <div className="flex items-center gap-2">
          <div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
            {statusActions[task.status].map((action) => (
              <button
                key={action}
                onClick={() => onStatusChange(task.id, action)}
                className="rounded px-2 py-1 text-[10px] font-medium uppercase transition-colors hover:bg-accent"
              >
                {action === "TODO"
                  ? "\u21A9"
                  : action === "IN_PROGRESS"
                  ? "\u25B6"
                  : "\u2713"}
              </button>
            ))}
          </div>

          {task.assignee && (
            <Avatar className="h-6 w-6">
              <AvatarImage src={task.assignee.image ?? undefined} />
              <AvatarFallback className="text-[10px]">
                {initials}
              </AvatarFallback>
            </Avatar>
          )}
        </div>
      </div>

      {task._count?.comments > 0 && (
        <div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
          <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
          </svg>
          {task._count.comments}
        </div>
      )}
    </div>
  )
}

المخرجات:

TEXT 📖 للعرض فقط
يعرض واجهة مستخدم مكون ▶ مثال: مكون بطاقة المهمة كما هو موضح في القسم.


6. رفع الملفات (uploadthing)

(1) هندسة الرفع

100%
sequenceDiagram
    participant C as العميل
    participant U as UploadThing
    participant S as Server Action
    participant DB as قاعدة البيانات

    C->>U: رفع الملف (multipart)
    U-->>C: إرجاع fileUrl + بيانات وصفية
    C->>S: Server Action(formData مع fileUrl)
    S->>DB: إنشاء سجل Attachment
    DB-->>S: تم إنشاء المرفق
    S->>S: revalidateTag("tasks")
    S-->>C: نجاح

▶ مثال: إعداد UploadThing

المخرجات:

TEXT 📖 للعرض فقط
رسم توضيحي متسلسل يظهر تدفق الرسائل بين المشاركين.
TYPESCRIPT
// src/lib/uploadthing.ts
import { createUploadthing, type FileRouter } from "uploadthing/next"
import { auth } from "@/lib/auth"

const f = createUploadthing()

export const ourFileRouter = {
  taskAttachment: f({
    image: { maxFileSize: "4MB", maxFileCount: 5 },
    pdf: { maxFileSize: "8MB", maxFileCount: 3 },
    "text/*": { maxFileSize: "2MB", maxFileCount: 5 },
  })
    .middleware(async () => {
      const session = await auth()
      if (!session?.user) throw new Error("غير مصرح")
      return { userId: session.user.id }
    })
    .onUploadComplete(async ({ metadata, file }) => {
      return { uploadedBy: metadata.userId, fileUrl: file.url }
    }),
} satisfies FileRouter

export type OurFileRouter = typeof ourFileRouter

المخرجات:

TEXT 📖 للعرض فقط
يعرف أنواع TypeScript: FileRouter، OurFileRouter.
TYPESCRIPT
// src/app/api/uploadthing/core.ts
import { createRouteHandler } from "uploadthing/next"
import { ourFileRouter } from "@/lib/uploadthing"

export const { GET, POST } = createRouteHandler({
  router: ourFileRouter,
})


7. البحث والتصفية والترقيم

(1) هيكل معاملات بحث URL

100%
graph LR
    A[معاملات بحث URL] --> B[مكون الخادم]
    B --> C[استعلام Prisma]
    C --> D[نتائج مصفاة]
    D --> E[shadcn/ui DataTable]
    E -->|تفاعل المستخدم| F[تحديث معاملات URL]
    F -->|تنقل Next.js| A

    style A fill:#cce5ff
    style E fill:#d4edda
    style F fill:#ffeeba


8. مثال كامل: صفحة لوحة المهام الكاملة

TSX
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx
import { Suspense } from "react"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { notFound } from "next/navigation"
import { TaskBoard } from "@/components/tasks/task-board"
import { CreateTaskForm } from "@/components/tasks/create-task-form"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"

interface ProjectBoardPageProps {
  params: Promise<{ id: string }>
}

export default async function ProjectBoardPage({ params }: ProjectBoardPageProps) {
  const { id } = await params
  const session = await auth()

  const project = await prisma.project.findUnique({
    where: { id },
    include: {
      _count: { select: { tasks: true } },
    },
  })

  if (!project) {
    notFound()
  }

  const tasks = await prisma.task.findMany({
    where: { projectId: id },
    orderBy: { order: "asc" },
    include: {
      assignee: { select: { name: true, image: true } },
      _count: { select: { comments: true, attachments: true } },
    },
  })

  const statusCounts = {
    TODO: tasks.filter((t) => t.status === "TODO").length,
    IN_PROGRESS: tasks.filter((t) => t.status === "IN_PROGRESS").length,
    DONE: tasks.filter((t) => t.status === "DONE").length,
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <div className="flex items-center gap-3">
            <div
              className="h-4 w-4 rounded-full"
              style={{ backgroundColor: project.color }}
            />
            <h1 className="text-2xl font-bold">{project.name}</h1>
            <Badge variant="secondary">
              {project.status}
            </Badge>
          </div>
          <p className="mt-1 text-sm text-muted-foreground">
            {tasks.length} مهام · {statusCounts.TODO} مهام ·{" "}
            {statusCounts.IN_PROGRESS} قيد التنفيذ · {statusCounts.DONE} مكتملة
          </p>
        </div>
      </div>

      <Suspense fallback={<div className="h-12 animate-pulse rounded-lg bg-muted" />}>
        <CreateTaskForm projectId={project.id} members={[]} />
      </Suspense>

      <TaskBoard tasks={tasks} />
    </div>
  )
}

المخرجات المتوقعة (عند الوصول إلى /dashboard/projects/[id] في المتصفح):

TEXT 📖 للعرض فقط
→ لوحة كانبان ثلاثية الأعمدة: مهام (8) | قيد التنفيذ (5) | مكتملة (7)
→ النقر على زر "+" لإظهار نموذج الإنشاء السريع → إدخال عنوان → Enter → تظهر المهمة فورا في عمود المهام (تحديث متفائل)
→ النقر على زر ▶ في بطاقة المهمة → نقل المهمة إلى قيد التنفيذ
→ النقر على حذف → عرض حوار التأكيد → تأكيد الحذف → اختفت المهمة
→ سحب وإفلات الملفات في منطقة الرفع → عرض شريط التقدم → ربط المرفقات بالمهمة
→ البحث عن "auth" → تحديث معامل URL → تصفية DataTable لعرض المهام المطابقة

❓ أسئلة شائعة

س كيف أختار بين Server Actions و API Routes؟
ج استخدم Server Actions لإرسال النماذج وتحديث التخزين المؤقت (كود أقل، آمن من حيث الأنواع، لا حاجة لنقاط نهاية API). استخدم API Routes للتكامل مع أطراف خارجية وwebhooks وواجهات برمجة تطبيقات الجوال. Server Actions هي الطريقة الموصى بها للتعامل مع النماذج في Next.js، بينما تستخدم API Routes للعملاء غير المتصفحين.
س ما الفرق بين useOptimistic و useState العادي؟
ج useOptimistic هو hook جديد تم تقديمه في React 19، مصمم خصيصا لنمط التحديث المتفائل المستخدم مع Server Actions. يقوم بتحديث واجهة المستخدم فورا أثناء تنفيذ Server Action ويعود تلقائيا إلى البيانات الفعلية إذا فشل Server Action. استخدام useState مع التراجع اليدوي يتطلب كودا إضافيا أكثر ويكون عرضة للأخطاء.
س أيهما أفضل، uploadthing أم رفع الملفات المحلي؟
ج يوفر uploadthing شبكة CDN وتحسين الصور والتحقق الأمني، مما يجعله مناسبا لبيئات production. حل /uploads/ المحلي سهل التنفيذ ولكنه غير مناسب لـ production (لا يوجد CDN، مساحة تخزين محدودة، وصعوبة في النسخ الاحتياطي). يستخدم TaskFlow الرفع المحلي كحل للتطوير وينتقل إلى uploadthing في بيئة production.
س لماذا لا يستخدم ترقيم معاملات بحث URL الترقيم من جانب العميل؟
ج ترقيم معاملات بحث URL هو "ترقيم حقيقي" (ترقيم من جانب الخادم)، يقوم بتحميل بيانات الصفحة الحالية فقط وهو مناسب لمجموعات البيانات الكبيرة. الترقيم من جانب العميل يحمل جميع البيانات دفعة واحدة، مما يؤدي إلى بطء في التحميل الأولي وإهدار النطاق الترددي. نظرا لأن TaskFlow قد يحتوي على عشرات الآلاف من المهام، فإن الترقيم من جانب الخادم هو الخيار الوحيد القابل للتطبيق.
س ما دور التحقق عبر Zod في Server Action؟
ج يوفر طبقة حماية مزدوجة — التحقق من جانب المتصفح (HTML5 required + التحقق من الواجهة الأمامية) يمنع الأخطاء العرضية، بينما يمنع التحقق عبر Zod من جانب الخادم الطلبات الخبيثة. يوفر Zod أيضا safeParse الآمن من حيث الأنواع، والذي يولد رسائل خطأ type تلقائيا، مما يجعله أكثر موثوقية من التحقق اليدوي بـ if-else.
س لماذا نستخدم useActionState لحذف مهمة بدلا من استدعاء deleteTask مباشرة؟
ج useActionState هو hook مصمم لـ Server Actions في React 19. يوفر حالة isPending (لتعطيل الزر ومنع الإرسالات المكررة) ودالة action المغلفة (لمعالجة formData تلقائيا). مقارنة بالاستدعاء المباشر، فإن useActionState أكثر أمانا (حماية CSRF) ويوفر تغذية راجعة أكثر طبيعية لواجهة المستخدم.

📖 ملخص

📝 تمارين

  1. تمرين أساسي (⭐): نفذ نموذج إنشاء مهمة سريع (Title + projectId) في مشروع TaskFlow باستخدام Server Action وتحديث متفائل. التحقق: بعد النقر على "إنشاء"، تظهر المهمة فورا في عمود المهام بلوحة كانبان.

  2. تمرين متقدم (⭐⭐): أضف ميزة "عملية جماعية" إلى DataTable — بعد تحديد عدة صفوف، قم بتحديث حالتها جماعيا (TODO → IN_PROGRESS) أو حذفها جماعيا. استخدم useOptimistic لتنفيذ تحديثات متفائلة للتحديدات الجماعية. اكتب Server Action bulkUpdateTasks للتعامل مع معاملات المصفوفة.

  3. تحدي (⭐⭐⭐): نفذ فرز كامل بالسحب والإفلات (باستخدام @dnd-kit/core أو HTML5 Drag & Drop API الأصلي): اسمح بسحب بطاقات المهام بحرية بين الأعمدة الثلاثة؛ اعرض عنصرا نائبا أثناء السحب؛ استدع reorderTask بعد تحرير البطاقة لتحديث الحالة وترتيب الفرز؛ استخدم useOptimistic لتوفير تغذية راجعة فورية للفرز بعد السحب؛ تعامل مع تعارضات السحب (القفل المتفائل عندما يسحب مستخدمان نفس المهمة في نفس الوقت).

Web-Tutorial.com

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

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

100%