# Datatable 15 - Task Manager Table

> A grouped task table with collapsible status sections, drag-and-drop between groups, an add task dialog, and filters. Useful for task trackers and project management dashboards.

A shadcn/ui block from the [@shadcn-dashboard](https://shadcndashboard.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcn-dashboard/datatable-15).

- Page, with a live preview: https://shadcn.exchange/r/shadcn-dashboard/datatable-15
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcn-dashboard/datatable-15-light-a069ed0b94b747f3.png
- Registry item: https://shadcndashboard.dev/r/datatable-15.json
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/shadcn-dashboard/datatable-15.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@dnd-kit/core`, `lucide-react`
- Registry items: `avatar`, `badge`, `button`, `checkbox`, `card`, `dialog`, `dropdown-menu`, `input`, `input-group`, `label`, `select`, `table`

## Files

### `src/components/shadcn-dashboard/blocks/datatable/datatable-15/page.tsx`

```tsx
import TaskBoardTable from "@/components/shadcn-dashboard/blocks/datatable/datatable-15/datatable"
const page = () => {
  return (
    <TaskBoardTable/>
  )
}

export default page
```

### `src/components/shadcn-dashboard/blocks/datatable/datatable-15/datatable.tsx`

```tsx
"use client";

import { useMemo, useState } from "react";
import {
  DndContext,
  KeyboardSensor,
  PointerSensor,
  useDraggable,
  useDroppable,
  useSensor,
  useSensors,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  ChevronRightIcon,
  CheckCircle2Icon,
  CircleDashedIcon,
  FlaskConicalIcon,
  LoaderIcon,
  type LucideIcon,
  FlagIcon,
  GripVerticalIcon,
  UserIcon,
  ListFilterIcon,
  MessageSquareIcon,
  MoreVerticalIcon,
  PlusIcon,
  Trash2Icon,
  RotateCcwIcon,
  SearchIcon,
  XIcon,
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuCheckboxItem,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";

type TaskStatus = "todo" | "in-progress" | "in-review" | "done";
type TaskPriority = "Urgent" | "High" | "Medium" | "Low";

interface Task {
  id: string;
  title: string;
  assignee: string;
  avatar: string;
  priority: TaskPriority;
  due: string;
  overdue?: boolean;
  label: string;
  comments: number;
  status: TaskStatus;
}

const AVATAR_BASE =
  "https://images.shadcnspace.com/assets/shadcn-dashboard/profile";
const ALL_PEOPLE = "All members";
const VISIBLE_ROWS = 3;

const statusMeta: Record<TaskStatus, { label: string; pill: string }> = {
  todo: { label: "To Do", pill: "bg-muted text-muted-foreground" },
  "in-progress": { label: "In Progress", pill: "bg-sky-400/10 text-sky-400" },
  "in-review": { label: "In Review", pill: "bg-amber-300/10 text-amber-400" },
  done: { label: "Done", pill: "bg-teal-400/10 text-teal-500" },
};

const statusOrder: TaskStatus[] = ["todo", "in-progress", "in-review", "done"];

const priorityIcon: Record<TaskPriority, { icon: LucideIcon; color: string }> =
  {
    Urgent: { icon: FlagIcon, color: "text-red-500" },
    High: { icon: FlagIcon, color: "text-orange-400" },
    Medium: { icon: FlagIcon, color: "text-sky-400" },
    Low: { icon: FlagIcon, color: "text-muted-foreground" },
  };

const statusIcon: Record<TaskStatus, { icon: LucideIcon; color: string }> = {
  todo: { icon: CircleDashedIcon, color: "text-muted-foreground" },
  "in-progress": { icon: LoaderIcon, color: "text-sky-400" },
  "in-review": { icon: FlaskConicalIcon, color: "text-amber-300" },
  done: { icon: CheckCircle2Icon, color: "text-teal-400" },
};

const priorities: TaskPriority[] = ["Urgent", "High", "Medium", "Low"];

const initialTasks: Task[] = [
  {
    id: "TSK-401",
    title: "Redesign pricing page layout",
    assignee: "Nora Bennett",
    avatar: `${AVATAR_BASE}/user-9.webp`,
    priority: "High",
    due: "Nov 14",
    label: "Design",
    comments: 4,
    status: "todo",
  },
  {
    id: "TSK-402",
    title: "Write onboarding email series",
    assignee: "Ethan Walker",
    avatar: `${AVATAR_BASE}/user-6.webp`,
    priority: "Medium",
    due: "Nov 20",
    label: "Content",
    comments: 0,
    status: "todo",
  },
  {
    id: "TSK-403",
    title: "Audit mobile checkout flow",
    assignee: "Sofia Marin",
    avatar: `${AVATAR_BASE}/user-2.webp`,
    priority: "Urgent",
    due: "Nov 10",
    label: "Research",
    comments: 2,
    status: "todo",
  },
  {
    id: "TSK-404",
    title: "Update brand color tokens",
    assignee: "Kenji Tanaka",
    avatar: `${AVATAR_BASE}/user-7.webp`,
    priority: "Low",
    due: "Nov 25",
    label: "Design",
    comments: 1,
    status: "todo",
  },
  {
    id: "TSK-405",
    title: "Fix broken invoice PDF export",
    assignee: "Marcus Cole",
    avatar: `${AVATAR_BASE}/user-3.webp`,
    priority: "Urgent",
    due: "Nov 6",
    overdue: true,
    label: "Bug",
    comments: 7,
    status: "in-progress",
  },
  {
    id: "TSK-406",
    title: "Build team activity timeline",
    assignee: "Nora Bennett",
    avatar: `${AVATAR_BASE}/user-9.webp`,
    priority: "High",
    due: "Nov 16",
    label: "Feature",
    comments: 3,
    status: "in-progress",
  },
  {
    id: "TSK-407",
    title: "Migrate docs to new search",
    assignee: "Sofia Marin",
    avatar: `${AVATAR_BASE}/user-2.webp`,
    priority: "Medium",
    due: "Nov 21",
    label: "Docs",
    comments: 0,
    status: "in-progress",
  },
  {
    id: "TSK-408",
    title: "Review analytics dashboard copy",
    assignee: "Ethan Walker",
    avatar: `${AVATAR_BASE}/user-6.webp`,
    priority: "Medium",
    due: "Nov 8",
    overdue: true,
    label: "Content",
    comments: 5,
    status: "in-review",
  },
  {
    id: "TSK-409",
    title: "Accessibility pass on forms",
    assignee: "Kenji Tanaka",
    avatar: `${AVATAR_BASE}/user-7.webp`,
    priority: "High",
    due: "Nov 13",
    label: "Bug",
    comments: 2,
    status: "in-review",
  },
  {
    id: "TSK-410",
    title: "Launch referral program page",
    assignee: "Marcus Cole",
    avatar: `${AVATAR_BASE}/user-3.webp`,
    priority: "High",
    due: "Nov 3",
    label: "Feature",
    comments: 6,
    status: "done",
  },
  {
    id: "TSK-411",
    title: "Archive legacy API endpoints",
    assignee: "Nora Bennett",
    avatar: `${AVATAR_BASE}/user-9.webp`,
    priority: "Low",
    due: "Oct 29",
    label: "Docs",
    comments: 1,
    status: "done",
  },
  {
    id: "TSK-412",
    title: "Customer interview synthesis",
    assignee: "Sofia Marin",
    avatar: `${AVATAR_BASE}/user-2.webp`,
    priority: "Medium",
    due: "Oct 27",
    label: "Research",
    comments: 3,
    status: "done",
  },
];

const people = Array.from(new Set(initialTasks.map((t) => t.assignee)));

interface NewTask {
  title: string;
  assignee: string;
  priority: TaskPriority;
  due: string;
}

const emptyDraft: NewTask = {
  title: "",
  assignee: people[0],
  priority: "Medium",
  due: "",
};

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("");

const TaskRow = ({
  task,
  selected,
  onToggleSelect,
  onMove,
}: {
  task: Task;
  selected: boolean;
  onToggleSelect: (id: string) => void;
  onMove: (id: string, status: TaskStatus) => void;
}) => {
  const { attributes, listeners, setNodeRef, transform, isDragging } =
    useDraggable({ id: task.id });
  const PriorityIcon = priorityIcon[task.priority].icon;
  const StatusIcon = statusIcon[task.status].icon;

  return (
    <TableRow
      ref={setNodeRef}
      style={{
        transform: transform
          ? `translate3d(${transform.x}px, ${transform.y}px, 0)`
          : undefined,
      }}
      className={cn(
        "group relative bg-card",
        selected && "bg-muted/50",
        isDragging && "z-10 opacity-80 shadow-lg",
      )}
    >
      <TableCell className="w-16 pr-0">
        <div className="flex items-center gap-2">
          <button
            type="button"
            aria-label={`Drag ${task.title}`}
            className="cursor-grab touch-none text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 active:cursor-grabbing"
            {...attributes}
            {...listeners}
          >
            <GripVerticalIcon className="size-4" />
          </button>
          <Checkbox
            checked={selected}
            onCheckedChange={() => onToggleSelect(task.id)}
            aria-label={`Select ${task.title}`}
            className="cursor-pointer"
          />
        </div>
      </TableCell>
      <TableCell>
        <div className="flex items-center gap-2.5">
          <StatusIcon
            className={cn("size-4 shrink-0", statusIcon[task.status].color)}
          />
          <span className="text-sm font-medium">{task.title}</span>

          <Badge> {task.label}</Badge>
        </div>
      </TableCell>
      <TableCell>
        <Avatar className="size-6" title={task.assignee}>
          <AvatarImage src={task.avatar} alt={task.assignee} />
          <AvatarFallback className="text-[10px]">
            {initials(task.assignee)}
          </AvatarFallback>
        </Avatar>
      </TableCell>
      <TableCell>
        <div
          className={cn(
            "text-sm",
            task.overdue ? "text-red-500" : "text-muted-foreground",
          )}
        >
          {task.due}
        </div>
      </TableCell>
      <TableCell>
        <div className="flex items-center gap-1.5 text-sm">
          <PriorityIcon
            className={cn("size-4", priorityIcon[task.priority].color)}
          />
          <span>{task.priority}</span>
        </div>
      </TableCell>
      <TableCell>
        <div className="flex items-center gap-1.5 text-sm text-foreground">
          <MessageSquareIcon className="size-4 text-muted-foreground" />
          <span>{task.comments}</span>
        </div>
      </TableCell>
      <TableCell className="text-right">
        <DropdownMenu>
          <DropdownMenuTrigger
            render={
              <Button
                variant="ghost"
                size="icon-sm"
                aria-label="Task actions"
                className="cursor-pointer opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
              >
                <MoreVerticalIcon className="size-4" />
              </Button>
            }
          />
          <DropdownMenuContent align="end">
            <DropdownMenuGroup>
              <DropdownMenuLabel>Move to</DropdownMenuLabel>
              {statusOrder
                .filter((s) => s !== task.status)
                .map((s) => {
                  const Icon = statusIcon[s].icon;
                  return (
                    <DropdownMenuItem
                      className="cursor-pointer"
                      key={s}
                      onClick={() => onMove(task.id, s)}
                    >
                      <Icon className={cn("size-4", statusIcon[s].color)} />
                      {statusMeta[s].label}
                    </DropdownMenuItem>
                  );
                })}
            </DropdownMenuGroup>
            <DropdownMenuSeparator />
            <DropdownMenuItem className="cursor-pointer" onClick={() => {}}>
              Copy task ID
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </TableCell>
    </TableRow>
  );
};

const GroupSection = ({
  status,
  tasks,
  collapsed,
  expanded,
  onToggleCollapse,
  onToggleExpand,
  onMove,
  selectedIds,
  onToggleSelect,
}: {
  status: TaskStatus;
  tasks: Task[];
  collapsed: boolean;
  expanded: boolean;
  onToggleCollapse: () => void;
  onToggleExpand: () => void;
  onMove: (id: string, status: TaskStatus) => void;
  selectedIds: string[];
  onToggleSelect: (id: string) => void;
}) => {
  const { setNodeRef, isOver } = useDroppable({ id: status });
  const meta = statusMeta[status];
  const Icon = statusIcon[status].icon;
  const visible = expanded ? tasks : tasks.slice(0, VISIBLE_ROWS);
  const hidden = tasks.length - visible.length;

  return (
    <TableBody
      ref={setNodeRef}
      className={cn(
        isOver && "bg-primary/5 outline-1 -outline-offset-1 outline-primary/40",
      )}
    >
      <TableRow className="hover:bg-transparent">
        <TableCell colSpan={7} className="pt-5 pb-2">
          <div className="flex items-center gap-2">
            <Button
              variant="ghost"
              size="icon-xs"
              className="cursor-pointer"
              aria-label={collapsed ? "Expand group" : "Collapse group"}
              aria-expanded={!collapsed}
              onClick={onToggleCollapse}
            >
              <ChevronRightIcon
                className={cn(
                  "size-4 transition-transform",
                  !collapsed && "rotate-90",
                )}
              />
            </Button>
            <span
              className={cn(
                "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-semibold tracking-wide uppercase",
                meta.pill,
              )}
            >
              <Icon className="size-3.5" />
              {meta.label}
            </span>
            <span className="text-sm text-muted-foreground">
              {tasks.length}
            </span>
          </div>
        </TableCell>
      </TableRow>
      {!collapsed &&
        visible.map((task) => (
          <TaskRow
            key={task.id}
            task={task}
            selected={selectedIds.includes(task.id)}
            onToggleSelect={onToggleSelect}
            onMove={onMove}
          />
        ))}
      {!collapsed && tasks.length === 0 && (
        <TableRow>
          <TableCell
            colSpan={7}
            className="py-5 text-center text-xs text-muted-foreground"
          >
            No tasks here — drop one to move it to {meta.label}
          </TableCell>
        </TableRow>
      )}
      {!collapsed && tasks.length > VISIBLE_ROWS && (
        <TableRow>
          <TableCell colSpan={7} className="py-1">
            <Button
              variant="link"
              size="sm"
              className="cursor-pointer"
              onClick={onToggleExpand}
            >
              {expanded ? "Show less" : `View ${hidden} more`}
            </Button>
          </TableCell>
        </TableRow>
      )}
    </TableBody>
  );
};

export default function TaskBoardTable() {
  const [tasks, setTasks] = useState<Task[]>(initialTasks);
  const [query, setQuery] = useState("");
  const [priorityFilter, setPriorityFilter] = useState<TaskPriority | null>(
    null,
  );
  const [person, setPerson] = useState(ALL_PEOPLE);
  const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null);
  const [overdueOnly, setOverdueOnly] = useState(false);
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
  const [dialogOpen, setDialogOpen] = useState(false);
  const [newTask, setNewTask] = useState<NewTask>(emptyDraft);
  const [newStatus, setNewStatus] = useState<TaskStatus>("todo");

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
    useSensor(KeyboardSensor),
  );

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    return tasks.filter(
      (t) =>
        (!priorityFilter || t.priority === priorityFilter) &&
        (!statusFilter || t.status === statusFilter) &&
        (!overdueOnly || t.overdue) &&
        (person === ALL_PEOPLE || t.assignee === person) &&
        (!q ||
          t.title.toLowerCase().includes(q) ||
          t.id.toLowerCase().includes(q)),
    );
  }, [tasks, priorityFilter, statusFilter, overdueOnly, person, query]);

  const grouped = useMemo(
    () =>
      statusOrder.map((status) => ({
        status,
        tasks: filtered.filter((t) => t.status === status),
      })),
    [filtered],
  );

  const hasFilters =
    priorityFilter !== null ||
    statusFilter !== null ||
    overdueOnly ||
    person !== ALL_PEOPLE;
  const isDirty =
    query !== "" ||
    hasFilters ||
    tasks.some(
      (t) => t.status !== initialTasks.find((i) => i.id === t.id)?.status,
    );

  const moveTask = (id: string, status: TaskStatus) =>
    setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t)));

  const toggleSelect = (id: string) =>
    setSelectedIds((prev) =>
      prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
    );

  const addTask = (status: TaskStatus, draft: NewTask) =>
    setTasks((prev) => [
      ...prev,
      {
        id: `TSK-${Date.now()}`,
        title: draft.title,
        assignee: draft.assignee,
        avatar: initialTasks.find((t) => t.assignee === draft.assignee)!.avatar,
        priority: draft.priority,
        due: draft.due
          ? new Date(draft.due + "T00:00:00").toLocaleDateString("en-US", {
              month: "short",
              day: "numeric",
            })
          : "No date",
        label: "General",
        comments: 0,
        status,
      },
    ]);

  const moveSelected = (status: TaskStatus) => {
    setTasks((prev) =>
      prev.map((t) => (selectedIds.includes(t.id) ? { ...t, status } : t)),
    );
    setSelectedIds([]);
  };

  const deleteSelected = () => {
    setTasks((prev) => prev.filter((t) => !selectedIds.includes(t.id)));
    setSelectedIds([]);
  };

  const handleDragEnd = ({ active, over }: DragEndEvent) => {
    if (over) moveTask(String(active.id), over.id as TaskStatus);
  };

  const reset = () => {
    setTasks(initialTasks);
    setQuery("");
    setPriorityFilter(null);
    setPerson(ALL_PEOPLE);
    setStatusFilter(null);
    setOverdueOnly(false);
    setSelectedIds([]);
  };

  return (
    <section className="py-8 px-4 max-w-7xl w-full mx-auto">
      <Card className="w-full py-0 gap-0 ring-0 border border-border">
        <CardHeader className="flex items-center justify-between flex-wrap gap-4 p-3.5">
          <div>
            <CardTitle>Task Manager</CardTitle>
            <CardDescription>
              Track, filter and manage tasks across every stage.
            </CardDescription>
          </div>
          <CardAction>
            <Button onClick={reset} disabled={!isDirty}>
              <RotateCcwIcon />
              Reset board
            </Button>
          </CardAction>
        </CardHeader>

        <div className="flex flex-wrap items-center gap-3 border-t border-border p-3.5">
          <InputGroup className="w-full sm:w-64">
            <InputGroupAddon align="inline-start">
              <SearchIcon className="size-4" />
            </InputGroupAddon>
            <InputGroupInput
              placeholder="Search tasks or IDs"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
            />
            {query && (
              <InputGroupAddon align="inline-end">
                <InputGroupButton
                  size="icon-xs"
                  aria-label="Clear search"
                  onClick={() => setQuery("")}
                >
                  <XIcon className="size-3.5" />
                </InputGroupButton>
              </InputGroupAddon>
            )}
          </InputGroup>

          <DropdownMenu>
            <DropdownMenuTrigger
              render={
                <Button
                  variant="outline"
                  className="relative cursor-pointer sm:ml-auto"
                >
                  <ListFilterIcon className="size-4" />
                  Filters
                  {hasFilters && (
                    <span className="absolute -top-1 -right-1 size-2 rounded-full bg-primary" />
                  )}
                </Button>
              }
            />
            <DropdownMenuContent align="end" className="w-60">
              <DropdownMenuGroup>
                <DropdownMenuLabel>Filter tasks</DropdownMenuLabel>
              </DropdownMenuGroup>
              <DropdownMenuSeparator />

              <DropdownMenuSub>
                <DropdownMenuSubTrigger className="cursor-pointer">
                  <FlagIcon className="size-4" />
                  Priority
                  {priorityFilter && (
                    <span className="ml-auto text-xs text-muted-foreground">
                      {priorityFilter}
                    </span>
                  )}
                </DropdownMenuSubTrigger>
                <DropdownMenuSubContent className="w-44">
                  <DropdownMenuRadioGroup
                    value={priorityFilter ?? "all"}
                    onValueChange={(v) =>
                      setPriorityFilter(
                        v === "all" ? null : (v as TaskPriority),
                      )
                    }
                  >
                    <DropdownMenuRadioItem
                      className="cursor-pointer"
                      value="all"
                    >
                      All priorities
                    </DropdownMenuRadioItem>
                    {priorities.map((p) => (
                      <DropdownMenuRadioItem
                        className="cursor-pointer"
                        key={p}
                        value={p}
                      >
                        {(() => {
                          const Icon = priorityIcon[p].icon;
                          return (
                            <Icon
                              className={cn("size-4", priorityIcon[p].color)}
                            />
                          );
                        })()}
                        {p}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuSubContent>
              </DropdownMenuSub>

              <DropdownMenuSub>
                <DropdownMenuSubTrigger className="cursor-pointer">
                  <CircleDashedIcon className="size-4" />
                  Status
                  {statusFilter && (
                    <span className="ml-auto text-xs text-muted-foreground">
                      {statusMeta[statusFilter].label}
                    </span>
                  )}
                </DropdownMenuSubTrigger>
                <DropdownMenuSubContent className="w-44">
                  <DropdownMenuRadioGroup
                    value={statusFilter ?? "all"}
                    onValueChange={(v) =>
                      setStatusFilter(v === "all" ? null : (v as TaskStatus))
                    }
                  >
                    <DropdownMenuRadioItem
                      className="cursor-pointer"
                      value="all"
                    >
                      All statuses
                    </DropdownMenuRadioItem>
                    {statusOrder.map((st) => (
                      <DropdownMenuRadioItem
                        className="cursor-pointer"
                        key={st}
                        value={st}
                      >
                        {(() => {
                          const Icon = statusIcon[st].icon;
                          return (
                            <Icon
                              className={cn("size-4", statusIcon[st].color)}
                            />
                          );
                        })()}
                        {statusMeta[st].label}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuSubContent>
              </DropdownMenuSub>

              <DropdownMenuSub>
                <DropdownMenuSubTrigger className="cursor-pointer">
                  <UserIcon className="size-4" />
                  Member
                  {person !== ALL_PEOPLE && (
                    <span className="ml-auto max-w-20 truncate text-xs text-muted-foreground">
                      {person}
                    </span>
                  )}
                </DropdownMenuSubTrigger>
                <DropdownMenuSubContent className="w-48">
                  <DropdownMenuRadioGroup
                    value={person}
                    onValueChange={(v) => setPerson(v)}
                  >
                    <DropdownMenuRadioItem
                      className="cursor-pointer"
                      value={ALL_PEOPLE}
                    >
                      {ALL_PEOPLE}
                    </DropdownMenuRadioItem>
                    {people.map((p) => (
                      <DropdownMenuRadioItem
                        className="cursor-pointer"
                        key={p}
                        value={p}
                      >
                        {p}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuSubContent>
              </DropdownMenuSub>

              <DropdownMenuSeparator />
              <DropdownMenuCheckboxItem
                className="cursor-pointer"
                checked={overdueOnly}
                onCheckedChange={(v) => setOverdueOnly(!!v)}
              >
                Overdue only
              </DropdownMenuCheckboxItem>
              <DropdownMenuSeparator />
              <DropdownMenuItem
                className="cursor-pointer"
                disabled={!hasFilters}
                onClick={() => {
                  setPriorityFilter(null);
                  setStatusFilter(null);
                  setPerson(ALL_PEOPLE);
                  setOverdueOnly(false);
                }}
              >
                <XIcon className="size-4" />
                Clear filters
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>

          <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
            <DialogTrigger
              render={
                <Button className="cursor-pointer" variant={"outline"}>
                  <PlusIcon className="size-4" />
                  Add task
                </Button>
              }
            />
            <DialogContent>
              <DialogHeader>
                <DialogTitle>Add task</DialogTitle>
                <DialogDescription>
                  Create a new task and assign it to a teammate.
                </DialogDescription>
              </DialogHeader>
              <div className="grid gap-4">
                <div className="grid gap-1.5">
                  <Label htmlFor="new-task-name">Task name</Label>
                  <Input
                    id="new-task-name"
                    placeholder="Enter task name"
                    value={newTask.title}
                    onChange={(e) =>
                      setNewTask({ ...newTask, title: e.target.value })
                    }
                  />
                </div>
                <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                  <div className="grid gap-1.5">
                    <Label>Assignee</Label>
                    <Select
                      value={newTask.assignee}
                      onValueChange={(v) =>
                        setNewTask({ ...newTask, assignee: v ?? people[0] })
                      }
                    >
                      <SelectTrigger className="w-full cursor-pointer">
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {people.map((p) => (
                          <SelectItem
                            className="cursor-pointer"
                            key={p}
                            value={p}
                          >
                            {p}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>
                  <div className="grid gap-1.5">
                    <Label htmlFor="new-task-due">Due date</Label>
                    <Input
                      id="new-task-due"
                      type="date"
                      value={newTask.due}
                      onChange={(e) =>
                        setNewTask({ ...newTask, due: e.target.value })
                      }
                    />
                  </div>
                  <div className="grid gap-1.5">
                    <Label>Priority</Label>
                    <Select
                      value={newTask.priority}
                      onValueChange={(v) =>
                        setNewTask({
                          ...newTask,
                          priority: (v ?? "Medium") as TaskPriority,
                        })
                      }
                    >
                      <SelectTrigger className="w-full cursor-pointer">
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {priorities.map((p) => (
                          <SelectItem
                            className="cursor-pointer"
                            key={p}
                            value={p}
                          >
                            {p}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>
                  <div className="grid gap-1.5">
                    <Label>Status</Label>
                    <Select
                      value={newStatus}
                      onValueChange={(v) =>
                        setNewStatus((v ?? "todo") as TaskStatus)
                      }
                    >
                      <SelectTrigger className="w-full cursor-pointer">
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {statusOrder.map((st) => (
                          <SelectItem
                            className="cursor-pointer"
                            key={st}
                            value={st}
                          >
                            {statusMeta[st].label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>
                </div>
              </div>
              <DialogFooter>
                <Button
                  className="cursor-pointer"
                  disabled={!newTask.title.trim()}
                  onClick={() => {
                    addTask(newStatus, {
                      ...newTask,
                      title: newTask.title.trim(),
                    });
                    setNewTask(emptyDraft);
                    setNewStatus("todo");
                    setDialogOpen(false);
                  }}
                >
                  Add task
                </Button>
              </DialogFooter>
            </DialogContent>
          </Dialog>
        </div>

        {selectedIds.length > 0 && (
          <div className="flex flex-wrap items-center gap-3 border-t border-border bg-muted/40 px-3.5 py-2">
            <span className="text-sm font-medium">
              {selectedIds.length} selected
            </span>
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    variant="outline"
                    size="sm"
                    className="cursor-pointer"
                  >
                    Move to
                  </Button>
                }
              />
              <DropdownMenuContent align="start">
                {statusOrder.map((st) => {
                  const Icon = statusIcon[st].icon;
                  return (
                    <DropdownMenuItem
                      className="cursor-pointer"
                      key={st}
                      onClick={() => moveSelected(st)}
                    >
                      <Icon className={cn("size-4", statusIcon[st].color)} />
                      {statusMeta[st].label}
                    </DropdownMenuItem>
                  );
                })}
              </DropdownMenuContent>
            </DropdownMenu>
            <Button
              variant="destructive"
              size="sm"
              className="cursor-pointer"
              onClick={deleteSelected}
            >
              <Trash2Icon className="size-4" />
              Delete
            </Button>
            <Button
              variant="ghost"
              size="sm"
              className="ml-auto cursor-pointer"
              onClick={() => setSelectedIds([])}
            >
              Clear
            </Button>
          </div>
        )}

        <CardContent className="p-0">
          <Card className="overflow-hidden p-0 ring-0 rounded-none">
            <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
              <Table className="min-w-4xl">
                <TableHeader>
                  <TableRow>
                    <TableHead className="w-8" />
                    <TableHead>Name</TableHead>
                    <TableHead>Assignee</TableHead>
                    <TableHead>Due date</TableHead>
                    <TableHead>Priority</TableHead>
                    <TableHead>Comments</TableHead>
                    <TableHead className="text-right" />
                  </TableRow>
                </TableHeader>
                {grouped.map(({ status, tasks: groupTasks }) => (
                  <GroupSection
                    key={status}
                    status={status}
                    tasks={groupTasks}
                    collapsed={!!collapsed[status]}
                    expanded={!!expanded[status]}
                    onMove={moveTask}
                    selectedIds={selectedIds}
                    onToggleSelect={toggleSelect}
                    onToggleCollapse={() =>
                      setCollapsed((c) => ({ ...c, [status]: !c[status] }))
                    }
                    onToggleExpand={() =>
                      setExpanded((e) => ({ ...e, [status]: !e[status] }))
                    }
                  />
                ))}
              </Table>
            </DndContext>
          </Card>
        </CardContent>
      </Card>
    </section>
  );
}
```
