بلاک‌ها · پنل مدیریت

مدیریتِ کار با چهار ستون و شمارش، کارتِ هر کار با برچسب، چک‌لیست، پیوست، سررسید و عکسِ افراد؛ جابه‌جایی با کشیدن‌ورهاکردن یا منوی «انتقال به»، جست‌وجو و حالتِ ستونِ خالی.

۱. نصب کامپوننت‌ها

این بلاک از ۱۰ آیتم دیگ استفاده می‌کند: avatarbadgebuttoncontrol-sizedropdown-menuiconspersianprogresssearch-fieldtoast

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/progress.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

کد را در app/<route>/page.tsx بگذارید و متن‌ها، داده و هندلرها را با محصول خودتان عوض کنید.

kanban.tsx
"use client"

import * as React from "react"

import { ClockIcon, MoreHorizontalIcon, PaperclipIcon, PlusIcon } from "@/components/icons"
import { Avatar, AvatarGroup } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Progress } from "@/components/ui/progress"
import { SearchField } from "@/components/ui/search-field"
import { toast } from "@/components/ui/toast"
import { formatJalali, toFaDigits, toSearchKey } from "@/lib/persian"
import { stockImages } from "@/lib/stock-images"

type ColumnId = "todo" | "doing" | "review" | "done"
type Task = {
  id: string
  title: string
  tag: { label: string; color: "violet" | "info" | "warning" | "success" | "destructive" }
  column: ColumnId
  due: string
  people: (keyof typeof stockImages)[]
  checklist?: [number, number]
  files?: number
}

const COLUMNS: { id: ColumnId; title: string; tone: string }[] = [
  { id: "todo", title: "برای انجام", tone: "var(--dig-neutral-400)" },
  { id: "doing", title: "در حال انجام", tone: "var(--dig-blue-500)" },
  { id: "review", title: "بازبینی", tone: "var(--dig-amber-500)" },
  { id: "done", title: "انجام‌شده", tone: "var(--dig-green-500)" },
]

const INITIAL: Task[] = [
  { id: "t1", title: "پژوهشِ کاربر برای صفحهٔ پرداخت", tag: { label: "پژوهش", color: "violet" }, column: "todo", due: "2026-10-12", people: ["personWoman1"], files: 2 },
  { id: "t2", title: "طراحی حالتِ خالی داشبورد", tag: { label: "طراحی", color: "info" }, column: "todo", due: "2026-10-14", people: ["personMan2", "personWoman2"] },
  { id: "t3", title: "پیاده‌سازی فیلترِ تاریخ در گزارش‌ها", tag: { label: "توسعه", color: "warning" }, column: "doing", due: "2026-10-09", people: ["personMan1"], checklist: [3, 5] },
  { id: "t4", title: "متنِ ایمیلِ خوشامد", tag: { label: "محتوا", color: "success" }, column: "doing", due: "2026-10-08", people: ["personWoman3"], checklist: [1, 4] },
  { id: "t5", title: "رفعِ خطای ورود در سافاری", tag: { label: "باگ", color: "destructive" }, column: "review", due: "2026-10-07", people: ["personMan1", "personWoman1"], files: 1 },
  { id: "t6", title: "به‌روزرسانی کتابخانهٔ آیکون", tag: { label: "طراحی", color: "info" }, column: "done", due: "2026-10-03", people: ["personWoman2"], checklist: [6, 6] },
]

/**
 * بردِ کانبان: چهار ستون با شمارش، کارتِ هر کار با برچسب، چک‌لیست، پیوست، سررسید و
 * افراد. جابه‌جایی با کشیدن‌ورهاکردن یا از منوی «انتقال به» (برای صفحه‌کلید و
 * صفحه‌خوان)؛ جست‌وجو بیرون از برد و هم‌ردیفِ کنش‌ها.
 */
export default function KanbanBlock() {
  const [tasks, setTasks] = React.useState(INITIAL)
  const [query, setQuery] = React.useState("")
  const [dragging, setDragging] = React.useState<string | null>(null)
  const [over, setOver] = React.useState<ColumnId | null>(null)
  const key = toSearchKey(query)

  const move = (id: string, column: ColumnId) => {
    const task = tasks.find((item) => item.id === id)
    if (!task || task.column === column) return
    setTasks((list) => list.map((item) => (item.id === id ? { ...item, column } : item)))
    toast.success(`«${task.title}» به «${COLUMNS.find((col) => col.id === column)?.title}» رفت.`)
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="flex min-h-svh flex-col gap-8 bg-background p-4 sm:p-6">
        <header className="flex flex-wrap items-end justify-between gap-x-4 gap-y-8">
          <div className="flex flex-col gap-1">
            <h1 className="text-2xl font-bold">نسخهٔ ۲ اپ</h1>
            <p className="text-muted-foreground">اسپرینتِ مهر · {toFaDigits(tasks.length)} کار</p>
          </div>
          <div className="flex flex-wrap items-center gap-3">
            <AvatarGroup>
              {(["personMan1", "personWoman1", "personMan2", "personWoman2"] as const).map((name) => (
                <Avatar key={name} alt={stockImages[name].alt} src={stockImages[name].src} className="ring-2 ring-background" />
              ))}
            </AvatarGroup>
            <SearchField aria-label="جست‌وجو در کارها" placeholder="جست‌وجوی کار" value={query} onValueChange={setQuery} className="w-full sm:w-64" />
            <Button>
              <PlusIcon />
              کارِ تازه
            </Button>
          </div>
        </header>

        <div className="-mx-4 flex flex-1 gap-4 overflow-x-auto px-4 pb-4 sm:-mx-6 sm:px-6">
          {COLUMNS.map((column) => {
            const items = tasks.filter((task) => task.column === column.id && toSearchKey(task.title).includes(key))
            return (
              <section
                key={column.id}
                aria-labelledby={`kanban-${column.id}`}
                onDragOver={(event) => {
                  event.preventDefault()
                  setOver(column.id)
                }}
                onDragLeave={() => setOver((current) => (current === column.id ? null : current))}
                onDrop={(event) => {
                  event.preventDefault()
                  if (dragging) move(dragging, column.id)
                  setDragging(null)
                  setOver(null)
                }}
                className={`flex w-72 shrink-0 flex-col gap-3 rounded-xl p-3 transition-colors ${over === column.id ? "bg-[color-mix(in_oklab,var(--brand-secondary)_10%,var(--muted))]" : "bg-muted"}`}
              >
                <div className="flex items-center justify-between gap-2 px-1 py-1">
                  <h2 id={`kanban-${column.id}`} className="flex items-center gap-2 text-sm font-semibold">
                    <span aria-hidden className="size-2 rounded-full" style={{ background: column.tone }} />
                    {column.title}
                    <span className="text-muted-foreground tabular-nums">{toFaDigits(items.length)}</span>
                  </h2>
                  <Button variant="ghost" size="icon" aria-label={`افزودنِ کار به ${column.title}`}>
                    <PlusIcon />
                  </Button>
                </div>

                <ul className="flex flex-col gap-3">
                  {items.map((task) => {
                    const done = task.column === "done"
                    return (
                      <li
                        key={task.id}
                        draggable
                        onDragStart={() => setDragging(task.id)}
                        onDragEnd={() => setDragging(null)}
                        className={`flex cursor-grab flex-col gap-3 rounded-lg border border-border bg-card p-4 transition-shadow hover:shadow-card active:cursor-grabbing ${dragging === task.id ? "ring-2 ring-brand-secondary" : ""}`}
                      >
                        <div className="flex items-start justify-between gap-2">
                          <Badge variant="soft" color={task.tag.color}>
                            {task.tag.label}
                          </Badge>
                          <DropdownMenu>
                            <DropdownMenuTrigger asChild>
                              <Button variant="ghost" size="icon" aria-label={`گزینه‌های ${task.title}`} className="-me-2 -mt-2">
                                <MoreHorizontalIcon />
                              </Button>
                            </DropdownMenuTrigger>
                            <DropdownMenuContent align="end">
                              <DropdownMenuLabel>انتقال به</DropdownMenuLabel>
                              {COLUMNS.filter((col) => col.id !== task.column).map((col) => (
                                <DropdownMenuItem key={col.id} onSelect={() => move(task.id, col.id)}>
                                  {col.title}
                                </DropdownMenuItem>
                              ))}
                              <DropdownMenuSeparator />
                              <DropdownMenuItem variant="destructive" onSelect={() => {
                                  setTasks((list) => list.filter((item) => item.id !== task.id))
                                  toast(`«${task.title}» حذف شد.`, { action: { label: "برگردان", onClick: () => setTasks((list) => (list.some((item) => item.id === task.id) ? list : [...list, task])) } })
                                }}>
                                حذفِ کار
                              </DropdownMenuItem>
                            </DropdownMenuContent>
                          </DropdownMenu>
                        </div>
                        <p className={done ? "text-sm leading-6 font-medium text-muted-foreground line-through" : "text-sm leading-6 font-medium"}>{task.title}</p>
                        {task.checklist && (
                          <div className="flex items-center gap-2">
                            <Progress value={(task.checklist[0] / task.checklist[1]) * 100} size="sm" aria-label="پیشرفتِ چک‌لیست" className="flex-1" />
                            <span className="text-xs text-muted-foreground tabular-nums">
                              {toFaDigits(task.checklist[0])}/{toFaDigits(task.checklist[1])}
                            </span>
                          </div>
                        )}
                        <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
                          <span className="flex items-center gap-3">
                            <span className="flex items-center gap-1">
                              <ClockIcon aria-hidden className="size-3.5" />
                              {formatJalali(new Date(task.due), "short")}
                            </span>
                            {task.files && (
                              <span className="flex items-center gap-1">
                                <PaperclipIcon aria-hidden className="size-3.5" />
                                {toFaDigits(task.files)}
                              </span>
                            )}
                          </span>
                          <AvatarGroup>
                            {task.people.map((name) => (
                              <Avatar key={name} size="sm" alt={stockImages[name].alt} src={stockImages[name].src} className="ring-2 ring-card" />
                            ))}
                          </AvatarGroup>
                        </div>
                      </li>
                    )
                  })}
                </ul>
                {items.length === 0 && (
                  <p className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
                    {query ? "کاری با این نام نیست" : "کاری را این‌جا رها کنید"}
                  </p>
                )}
              </section>
            )
          })}
        </div>
      </main>
    </ControlSizeProvider>
  )
}