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

مدیریت کاربران

پیش‌نمایش تمام‌صفحه

جست‌وجو و فیلتر نقش، جدول کاربران با وضعیت، دعوت عضو و منوی عملیات.

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

این بلاک از ۱۷ آیتم دیگ استفاده می‌کند: action-baravatarbadgebuttoncontrol-sizedialogdropdown-menuempty-stateiconslabelpaginationpersiansearch-fieldselecttabletext-fieldtoast

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/action-bar.json 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/dialog.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/pagination.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/table.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/action-bar.json 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/dialog.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/pagination.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/table.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/action-bar.json 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/dialog.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/pagination.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/table.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/action-bar.json 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/dialog.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/pagination.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/table.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

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

user-management.tsx
"use client";

import * as React from "react";

import {
  AvatarGenderIcon,
  EditIcon,
  MoreHorizontalIcon,
  PlusIcon,
  TrashIcon,
  UsersIcon,
} from "@/components/icons";
import {
  ActionBar,
  ActionBarAction,
  ActionBarClose,
  ActionBarCount,
  ActionBarSeparator,
} from "@/components/ui/action-bar";
import { Avatar } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ControlSizeProvider } from "@/components/ui/control-size";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateTitle,
} from "@/components/ui/empty-state";
import { Label } from "@/components/ui/label";
import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination";
import { SearchField } from "@/components/ui/search-field";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Table,
  TableBody,
  TableCell,
  TableCellStack,
  TableCheckboxCell,
  TableHead,
  TableHeader,
  TableRow,
  TableSelectAllCheckbox,
} from "@/components/ui/table";
import { TextField } from "@/components/ui/text-field";
import { toast } from "@/components/ui/toast";
import {
  formatJalaliDateTime,
  formatRelativeTime,
  isValidEmail,
  toFaDigits,
  toSearchKey,
} from "@/lib/persian";
import { avatarPhoto } from "@/lib/stock-images";

type Role = "admin" | "editor" | "viewer";
type Status = "active" | "away" | "inactive";
const ROLES: Record<Role, string> = {
  admin: "مدیر",
  editor: "ویرایشگر",
  viewer: "بیننده",
};
const STATUS: Record<
  Status,
  { label: string; color: "success" | "warning" | "default" }
> = {
  active: { label: "فعال", color: "success" },
  away: { label: "مرخصی", color: "warning" },
  inactive: { label: "غیرفعال", color: "default" },
};

const USERS: {
  id: string;
  name: string;
  email: string;
  gender: "male" | "female";
  role: Role;
  lastSeen: string;
  status: Status;
}[] = [
  {
    id: "1",
    name: "آرمیتا رستگار",
    email: "armita@dig.ir",
    gender: "female" as const,
    role: "admin",
    lastSeen: "2026-10-05T09:40:00",
    status: "active",
  },
  {
    id: "2",
    name: "کیان مرادی",
    email: "kian@dig.ir",
    gender: "male" as const,
    role: "editor",
    lastSeen: "2026-10-05T08:15:00",
    status: "active",
  },
  {
    id: "3",
    name: "نیلوفر احمدی",
    email: "niloofar@dig.ir",
    gender: "female" as const,
    role: "viewer",
    lastSeen: "2026-09-20T10:00:00",
    status: "inactive",
  },
  {
    id: "4",
    name: "سامان کریمی",
    email: "saman@dig.ir",
    gender: "male" as const,
    role: "editor",
    lastSeen: "2026-10-04T16:20:00",
    status: "away",
  },
  {
    id: "5",
    name: "مهسا توکلی",
    email: "mahsa@dig.ir",
    gender: "female" as const,
    role: "viewer",
    lastSeen: "2026-10-03T11:45:00",
    status: "active",
  },
  {
    id: "6",
    name: "پرهام نوری",
    email: "parham@dig.ir",
    gender: "male" as const,
    role: "editor",
    lastSeen: "2026-10-01T13:10:00",
    status: "active",
  },
  {
    id: "7",
    name: "ترانه شریفی",
    email: "taraneh@dig.ir",
    gender: "female" as const,
    role: "admin",
    lastSeen: "2026-09-28T09:30:00",
    status: "away",
  },
  {
    id: "8",
    name: "بهراد صالحی",
    email: "behrad@dig.ir",
    gender: "male" as const,
    role: "viewer",
    lastSeen: "2026-09-25T18:05:00",
    status: "active",
  },
];
const PAGE_SIZE = 5;

type SortDescriptor = { column: string; direction: "ascending" | "descending" };

export default function UserManagementBlock() {
  const [users, setUsers] = React.useState(USERS);
  const [query, setQuery] = React.useState("");
  const [role, setRole] = React.useState<"all" | Role>("all");
  const [open, setOpen] = React.useState(false);
  const [selected, setSelected] = React.useState<Set<string>>(new Set());
  const [sort, setSort] = React.useState<SortDescriptor>({
    column: "lastSeen",
    direction: "descending",
  });
  const [page, setPage] = React.useState(1);

  const key = toSearchKey(query);
  const filtered = users
    .filter(
      (user) =>
        (role === "all" || user.role === role) &&
        toSearchKey(user.name + user.email).includes(key),
    )
    .sort((a, b) => {
      const order =
        sort.column === "name"
          ? a.name.localeCompare(b.name, "fa")
          : a.lastSeen.localeCompare(b.lastSeen);
      return sort.direction === "ascending" ? order : -order;
    });
  const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
  const currentPage = Math.min(page, pageCount);
  const rows = filtered.slice(
    (currentPage - 1) * PAGE_SIZE,
    currentPage * PAGE_SIZE,
  );
  const from = filtered.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1;
  const to = Math.min(currentPage * PAGE_SIZE, filtered.length);

  // حذف برگشت‌پذیر است (دعوتِ دوباره)؛ پس اسنک‌بارِ «برگردان» به‌جای دیالوگِ تأیید.
  function remove(ids: string[]) {
    const before = users;
    setUsers((list) => list.filter((user) => !ids.includes(user.id)));
    setSelected(new Set());
    toast(
      ids.length === 1
        ? "عضو از تیم حذف شد."
        : `${toFaDigits(ids.length)} عضو از تیم حذف شدند.`,
      { action: { label: "برگردان", onClick: () => setUsers(before) } },
    );
  }

  function bulk(message: string) {
    toast.success(message, { description: `${toFaDigits(selected.size)} عضو` });
    setSelected(new Set());
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-6 p-4 pb-28 sm:p-6 sm:pb-28">
        <ControlSizeProvider size="xl">
          {/* سلسله‌مراتب: عنوان و توضیح نزدیک، اکشنِ اصلی هم‌ردیفِ عنوان. */}
          <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-sm text-muted-foreground">
                {toFaDigits(users.length)} عضو؛ نقش هر نفر مشخص می‌کند به چه
                بخش‌هایی دسترسی دارد.
              </p>
            </div>
            <Dialog open={open} onOpenChange={setOpen}>
              <DialogTrigger asChild>
                <Button>
                  <PlusIcon />
                  دعوت عضو
                </Button>
              </DialogTrigger>
              <ControlSizeProvider size="xl" asChild>
                <DialogContent>
                  <DialogHeader>
                    <DialogTitle>دعوت عضو تازه</DialogTitle>
                    <DialogDescription>
                      لینک دعوت به ایمیل او فرستاده می‌شود.
                    </DialogDescription>
                  </DialogHeader>
                  <form
                    id="invite"
                    className="flex flex-col gap-4"
                    onSubmit={(event) => {
                      event.preventDefault();
                      setOpen(false);
                      toast.success("دعوت‌نامه ارسال شد.");
                    }}
                  >
                    <TextField
                      label="ایمیل"
                      name="email"
                      type="email"
                      dir="ltr"
                      required
                      validate={(value) =>
                        isValidEmail(value)
                          ? undefined
                          : "ایمیل معتبر نیست؛ مثل name@example.com"
                      }
                    />
                    <div className="flex flex-col gap-2">
                      <Label id="invite-role">نقش</Label>
                      <Select name="role" defaultValue="viewer">
                        <SelectTrigger
                          aria-labelledby="invite-role"
                          className="w-full"
                        >
                          <SelectValue />
                        </SelectTrigger>
                        <SelectContent>
                          {(Object.keys(ROLES) as Role[]).map((item) => (
                            <SelectItem key={item} value={item}>
                              {ROLES[item]}
                            </SelectItem>
                          ))}
                        </SelectContent>
                      </Select>
                    </div>
                  </form>
                  <DialogFooter>
                    <Button type="submit" form="invite">
                      ارسال دعوت
                    </Button>
                  </DialogFooter>
                </DialogContent>
              </ControlSizeProvider>
            </Dialog>
          </header>

          {/* جدول، نوارِ ابزار و صفحه‌بندی یک سطح‌اند: هر سه داخلِ همان کارت. */}
          {/* ابزارها بیرونِ قاب و ۳۲ پیکسل بالای جدول؛ فقط خودِ جدول قاب دارد، صفحه‌بندی زیرش. */}
          <section aria-label="فهرست اعضا" className="flex flex-col gap-8">
            <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
              <SearchField
                aria-label="جست‌وجوی عضو"
                placeholder="جست‌وجوی نام یا ایمیل"
                value={query}
                onValueChange={(value) => {
                  setQuery(value);
                  setPage(1);
                }}
                className="min-w-0 flex-1 sm:max-w-xs"
              />
              <Select
                value={role}
                onValueChange={(value) => {
                  setRole(value as "all" | Role);
                  setPage(1);
                }}
              >
                <SelectTrigger
                  aria-label="فیلتر نقش"
                  className="w-full sm:ms-auto sm:w-40"
                >
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="all">همهٔ نقش‌ها</SelectItem>
                  {(Object.keys(ROLES) as Role[]).map((item) => (
                    <SelectItem key={item} value={item}>
                      {ROLES[item]}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>

            {rows.length === 0 ? (
              <EmptyState variant="bordered" className="py-14">
                <UsersIcon aria-hidden className="size-8 text-placeholder" />
                <EmptyStateTitle>عضوی با این مشخصات نیست</EmptyStateTitle>
                <EmptyStateDescription>
                  نام دیگری جست‌وجو کنید یا فیلتر نقش را بردارید.
                </EmptyStateDescription>
              </EmptyState>
            ) : (
              <div className="flex flex-col gap-4">
                <div className="overflow-x-auto rounded-xl border border-border bg-card">
                  {/* table-fixed با عرضِ ستون‌های ثابت: مرتب‌سازی و فیلتر ستون‌ها را جابه‌جا نمی‌کند. */}
                  <Table stackOnMobile
                    layout="fixed"
                    className="min-w-[44rem]"
                    selectionMode="multiple"
                    selectedKeys={selected}
                    onSelectionChange={setSelected}
                    sortDescriptor={sort}
                    onSortChange={(descriptor) =>
                      setSort(descriptor as SortDescriptor)
                    }
                  >
                    <TableHeader className="bg-muted">
                      <TableRow>
                        <TableSelectAllCheckbox />
                        <TableHead sortKey="name" className="w-[34%]">
                          عضو
                        </TableHead>
                        <TableHead className="w-[14%]">نقش</TableHead>
                        <TableHead sortKey="lastSeen" className="w-[24%]">
                          آخرین فعالیت
                        </TableHead>
                        <TableHead className="w-[16%]">وضعیت</TableHead>
                        <TableHead align="end" className="w-16 pe-4">
                          <span className="sr-only">عملیات</span>
                        </TableHead>
                      </TableRow>
                    </TableHeader>
                    <TableBody>
                      {rows.map((user) => (
                        <TableRow key={user.id} value={user.id}>
                          <TableCheckboxCell />
                          <TableCell>
                            {/* آواتار همیشه آیکون است، نه حروفِ اول؛ alt نام را برای صفحه‌خوان نگه می‌دارد. */}
                            <TableCellStack
                              media={
                                <Avatar
                                  size="sm"
                                  alt={user.name}
                                  src={avatarPhoto(user.name, user.gender)}
                                  icon={
                                    <AvatarGenderIcon gender={user.gender} />
                                  }
                                  className="ring-1 ring-border"
                                />
                              }
                              title={user.name}
                              description={user.email}
                              descriptionDir="ltr"
                            />
                          </TableCell>
                          <TableCell>{ROLES[user.role]}</TableCell>
                          <TableCell>
                            <TableCellStack
                              title={formatRelativeTime(user.lastSeen)}
                              description={formatJalaliDateTime(user.lastSeen)}
                              className="[&_span:first-child]:font-normal"
                            />
                          </TableCell>
                          <TableCell>
                            <Badge
                              variant="soft"
                              color={STATUS[user.status].color}
                              className="gap-1.5"
                            >
                              <span
                                aria-hidden
                                className="size-1.5 rounded-full bg-current"
                              />
                              {STATUS[user.status].label}
                            </Badge>
                          </TableCell>
                          <TableCell align="end" className="pe-4">
                            <DropdownMenu>
                              <DropdownMenuTrigger asChild>
                                <Button
                                  variant="ghost"
                                  size="icon"
                                  aria-label={`عملیات ${user.name}`}
                                >
                                  <MoreHorizontalIcon />
                                </Button>
                              </DropdownMenuTrigger>
                              <DropdownMenuContent align="end">
                                <DropdownMenuItem>
                                  <EditIcon />
                                  تغییر نقش
                                </DropdownMenuItem>
                                <DropdownMenuItem>
                                  <UsersIcon />
                                  {user.status === "inactive"
                                    ? "فعال‌کردن"
                                    : "غیرفعال‌کردن"}
                                </DropdownMenuItem>
                                <DropdownMenuSeparator />
                                <DropdownMenuItem
                                  variant="destructive"
                                  onSelect={() => remove([user.id])}
                                >
                                  <TrashIcon />
                                  حذف از تیم
                                </DropdownMenuItem>
                              </DropdownMenuContent>
                            </DropdownMenu>
                          </TableCell>
                        </TableRow>
                      ))}
                    </TableBody>
                  </Table>
                </div>
                <footer className="flex flex-wrap items-center justify-between gap-3">
                  <span className="text-sm text-muted-foreground">
                    نمایش {toFaDigits(from)} تا {toFaDigits(to)} از{" "}
                    {toFaDigits(filtered.length)} عضو
                  </span>
                  <Pagination className="mx-0 w-auto">
                    <PaginationContent>
                      <PaginationItem>
                        <PaginationPrevious
                          href="#"
                          aria-disabled={currentPage === 1}
                          onClick={(event) => {
                            event.preventDefault();
                            setPage(Math.max(1, currentPage - 1));
                          }}
                        />
                      </PaginationItem>
                      {Array.from(
                        { length: pageCount },
                        (_, index) => index + 1,
                      ).map((number) => (
                        <PaginationItem key={number}>
                          <PaginationLink
                            href="#"
                            isActive={number === currentPage}
                            onClick={(event) => {
                              event.preventDefault();
                              setPage(number);
                            }}
                          >
                            {toFaDigits(number)}
                          </PaginationLink>
                        </PaginationItem>
                      ))}
                      <PaginationItem>
                        <PaginationNext
                          href="#"
                          aria-disabled={currentPage === pageCount}
                          onClick={(event) => {
                            event.preventDefault();
                            setPage(Math.min(pageCount, currentPage + 1));
                          }}
                        />
                      </PaginationItem>
                    </PaginationContent>
                  </Pagination>
                </footer>
              </div>
            )}
          </section>
        </ControlSizeProvider>

        {/* اقدام‌های گروهی نزدیکِ دست، فقط وقتی چیزی انتخاب شده. */}
        <ActionBar open={selected.size > 0} aria-label="اقدام‌های گروهی">
          <ActionBarCount>
            <strong>{toFaDigits(selected.size)}</strong> از{" "}
            {toFaDigits(users.length)} انتخاب شد
          </ActionBarCount>
          <ActionBarAction onClick={() => bulk("نقش اعضا تغییر کرد.")}>
            <EditIcon />
            تغییر نقش
          </ActionBarAction>
          <ActionBarAction onClick={() => bulk("اعضا غیرفعال شدند.")}>
            <UsersIcon />
            غیرفعال‌کردن
          </ActionBarAction>
          <ActionBarSeparator />
          <ActionBarAction
            variant="destructive"
            onClick={() => remove([...selected])}
          >
            <TrashIcon />
            حذف
          </ActionBarAction>
          <ActionBarClose onClick={() => setSelected(new Set())} />
        </ActionBar>
      </main>
    </ControlSizeProvider>
  );
}