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