بردِ کانبان
پیشنمایش تمامصفحهمدیریتِ کار با چهار ستون و شمارش، کارتِ هر کار با برچسب، چکلیست، پیوست، سررسید و عکسِ افراد؛ جابهجایی با کشیدنورهاکردن یا منوی «انتقال به»، جستوجو و حالتِ ستونِ خالی.
۱. نصب کامپوننتها
این بلاک از ۱۰ آیتم دیگ استفاده میکند: 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.jsonpnpm 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.jsonyarn 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.jsonbunx --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>
)
}