Bladeren bron

feat: implement admin crud

Mohammad Mahdi Salimi 2 weken geleden
bovenliggende
commit
ecfb29f116

+ 7 - 0
src/components/layout/data/sidebar-data.ts

@@ -5,6 +5,7 @@ import {
   Shield,
   Star,
   Tags,
+  UserCog,
   Users,
 } from 'lucide-react'
 import Logo from '@/assets/logo.svg'
@@ -81,6 +82,12 @@ export const sidebarData: SidebarData = {
     {
       title: 'دسترسی',
       items: [
+        {
+          title: 'مدیران',
+          url: '/admins',
+          icon: UserCog,
+          permission: PERMISSIONS.admins.view,
+        },
         {
           title: 'نقش‌ها',
           url: '/roles',

+ 11 - 0
src/core/react-query/keys.ts

@@ -60,6 +60,17 @@ export const queryKeys = {
     all: ['permissions'] as const,
   },
 
+  admins: {
+    all: ['admins'] as const,
+
+    lists: () => [...queryKeys.admins.all, 'list'] as const,
+    list: (params: unknown) => [...queryKeys.admins.lists(), params] as const,
+
+    details: () => [...queryKeys.admins.all, 'detail'] as const,
+    detail: (id: number | string) =>
+      [...queryKeys.admins.details(), id] as const,
+  },
+
   roles: {
     all: ['roles'] as const,
 

+ 339 - 0
src/features/admins/components/admins-action-dialog.tsx

@@ -0,0 +1,339 @@
+import { useEffect, useMemo } from 'react'
+import { z } from 'zod'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { toast } from 'sonner'
+import { getApiErrorMessage } from '@/lib/api-error'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useAdmin } from '@/hooks/admins/useAdmin'
+import { useCreateAdmin } from '@/hooks/admins/useCreateAdmin'
+import { useUpdateAdmin } from '@/hooks/admins/useUpdateAdmin'
+import { useRoles } from '@/hooks/roles/useRoles'
+import { useCan } from '@/hooks/use-can'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import {
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  FormLabel,
+  FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { PasswordInput } from '@/components/password-input'
+import { useAdminsContext } from './admins-provider'
+
+const schema = z.object({
+  name: z.string().trim().min(1, 'نام الزامی است'),
+  username: z.string().trim().min(1, 'نام کاربری الزامی است'),
+  password: z.string(),
+  status: z.enum(['active', 'inactive']),
+  roles: z.array(z.number()).min(1, 'حداقل یک نقش را انتخاب کنید'),
+})
+
+type FormValues = z.infer<typeof schema>
+
+const emptyValues: FormValues = {
+  name: '',
+  username: '',
+  password: '',
+  status: 'active',
+  roles: [],
+}
+
+export function AdminsActionDialog() {
+  const { open, setOpen, currentRow, setCurrentRow } = useAdminsContext()
+  const isAdd = open === 'add'
+  const isEdit = open === 'edit'
+  const isView = open === 'view'
+  const opened = isAdd || isEdit || isView
+  const readOnly = isView
+  const { can } = useCan()
+  const canReadRoles = can(PERMISSIONS.roles.view)
+
+  const adminQuery = useAdmin(opened && !isAdd ? currentRow?.id : undefined)
+  const rolesQuery = useRoles(
+    { page: 1, per_page: 100 },
+    opened && canReadRoles
+  )
+  const createMutation = useCreateAdmin()
+  const updateMutation = useUpdateAdmin()
+  const isPending = createMutation.isPending || updateMutation.isPending
+
+  const form = useForm<FormValues>({
+    resolver: zodResolver(schema),
+    defaultValues: emptyValues,
+  })
+
+  const roleOptions = useMemo(() => {
+    const options = new Map<number, string>()
+
+    for (const role of rolesQuery.data?.data.roles ?? []) {
+      options.set(role.id, role.name)
+    }
+
+    for (const role of adminQuery.data?.role_details ?? []) {
+      if (!options.has(role.id)) options.set(role.id, role.name)
+    }
+
+    return [...options.entries()].map(([id, name]) => ({ id, name }))
+  }, [rolesQuery.data, adminQuery.data])
+
+  useEffect(() => {
+    if (!opened) return
+
+    if (isAdd) {
+      form.reset(emptyValues)
+      return
+    }
+
+    if (!adminQuery.data) return
+
+    const status = adminQuery.data.status === 'inactive' ? 'inactive' : 'active'
+
+    form.reset({
+      name: adminQuery.data.name,
+      username: adminQuery.data.username,
+      password: '',
+      status,
+      roles: adminQuery.data.role_details.map((role) => role.id),
+    })
+  }, [opened, isAdd, adminQuery.data, form])
+
+  const handleClose = () => {
+    setOpen(null)
+    setCurrentRow(null)
+    form.reset(emptyValues)
+  }
+
+  const onSubmit = (values: FormValues) => {
+    if (readOnly) return
+
+    if (isAdd && !values.password.trim()) {
+      form.setError('password', { message: 'رمز عبور الزامی است' })
+      return
+    }
+
+    const payload = {
+      name: values.name.trim(),
+      username: values.username.trim(),
+      status: values.status,
+      roles: values.roles,
+      ...(values.password.trim() ? { password: values.password } : {}),
+    }
+
+    if (isAdd) {
+      createMutation.mutate(payload, {
+        onSuccess: () => {
+          toast.success('مدیر با موفقیت ایجاد شد')
+          handleClose()
+        },
+        onError: (error) => {
+          toast.error(getApiErrorMessage(error, 'خطا در ایجاد مدیر'))
+        },
+      })
+    }
+
+    if (isEdit && currentRow) {
+      updateMutation.mutate(
+        { id: currentRow.id, payload },
+        {
+          onSuccess: () => {
+            toast.success('مدیر با موفقیت ویرایش شد')
+            handleClose()
+          },
+          onError: (error) => {
+            toast.error(getApiErrorMessage(error, 'خطا در ویرایش مدیر'))
+          },
+        }
+      )
+    }
+  }
+
+  const isLoading =
+    ((isEdit || isView) && adminQuery.isLoading) ||
+    (canReadRoles && rolesQuery.isLoading)
+
+  return (
+    <Dialog open={opened} onOpenChange={(state) => !state && handleClose()}>
+      <DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-lg'>
+        <DialogHeader>
+          <DialogTitle>
+            {isAdd && 'افزودن مدیر'}
+            {isEdit && 'ویرایش مدیر'}
+            {isView && 'مشاهده مدیر'}
+          </DialogTitle>
+        </DialogHeader>
+
+        {isLoading ? (
+          <div className='py-10 text-center'>در حال دریافت...</div>
+        ) : adminQuery.isError ? (
+          <div className='py-10 text-center text-sm text-destructive'>
+            دریافت اطلاعات مدیر ممکن نشد.
+          </div>
+        ) : (
+          <Form {...form}>
+            <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
+              <FormField
+                control={form.control}
+                name='name'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>نام</FormLabel>
+                    <FormControl>
+                      <Input
+                        {...field}
+                        disabled={readOnly}
+                        placeholder='نام مدیر'
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='username'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>نام کاربری</FormLabel>
+                    <FormControl>
+                      <Input
+                        {...field}
+                        disabled={readOnly}
+                        placeholder='نام کاربری'
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              {!readOnly && (
+                <FormField
+                  control={form.control}
+                  name='password'
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>رمز عبور</FormLabel>
+                      <FormControl>
+                        <PasswordInput
+                          {...field}
+                          placeholder={
+                            isEdit
+                              ? 'برای تغییر رمز، مقدار جدید را وارد کنید'
+                              : 'رمز عبور'
+                          }
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              )}
+
+              <FormField
+                control={form.control}
+                name='status'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>وضعیت</FormLabel>
+                    <Select
+                      disabled={readOnly}
+                      value={field.value}
+                      onValueChange={field.onChange}
+                    >
+                      <FormControl>
+                        <SelectTrigger className='w-full'>
+                          <SelectValue placeholder='انتخاب وضعیت' />
+                        </SelectTrigger>
+                      </FormControl>
+                      <SelectContent>
+                        <SelectItem value='active'>فعال</SelectItem>
+                        <SelectItem value='inactive'>غیرفعال</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={form.control}
+                name='roles'
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>نقش‌ها</FormLabel>
+                    {!canReadRoles && roleOptions.length === 0 ? (
+                      <p className='text-sm text-muted-foreground'>
+                        برای انتخاب نقش، دسترسی مشاهده نقش‌ها لازم است.
+                      </p>
+                    ) : rolesQuery.isError ? (
+                      <p className='text-sm text-destructive'>
+                        دریافت نقش‌ها ممکن نشد.
+                      </p>
+                    ) : roleOptions.length === 0 ? (
+                      <p className='text-sm text-muted-foreground'>
+                        نقشی برای انتخاب وجود ندارد.
+                      </p>
+                    ) : (
+                      <div className='grid gap-2 rounded-md border p-3'>
+                        {roleOptions.map((role) => (
+                          <label
+                            key={role.id}
+                            className='flex items-center gap-2 text-sm'
+                          >
+                            <Checkbox
+                              checked={field.value.includes(role.id)}
+                              disabled={readOnly}
+                              onCheckedChange={(checked) => {
+                                field.onChange(
+                                  checked === true
+                                    ? [...field.value, role.id]
+                                    : field.value.filter((id) => id !== role.id)
+                                )
+                              }}
+                            />
+                            {role.name}
+                          </label>
+                        ))}
+                      </div>
+                    )}
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter>
+                <Button type='button' variant='outline' onClick={handleClose}>
+                  {readOnly ? 'بستن' : 'انصراف'}
+                </Button>
+                {!readOnly && (
+                  <Button type='submit' disabled={isPending}>
+                    {isPending ? 'در حال ذخیره...' : 'ذخیره'}
+                  </Button>
+                )}
+              </DialogFooter>
+            </form>
+          </Form>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 88 - 0
src/features/admins/components/admins-columns.tsx

@@ -0,0 +1,88 @@
+import { type ColumnDef } from '@tanstack/react-table'
+import { type Admin } from '@/types/admins.types'
+import { Badge } from '@/components/ui/badge'
+import { DataTableColumnHeader } from '@/components/data-table'
+import { LongText } from '@/components/long-text'
+import { DataTableRowActions } from './data-table-row-actions'
+
+const adminStatusLabels: Record<string, string> = {
+  active: 'فعال',
+  inactive: 'غیرفعال',
+}
+
+export const adminsColumns: ColumnDef<Admin>[] = [
+  {
+    accessorKey: 'id',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='شناسه' />
+    ),
+    cell: ({ row }) => <div className='w-15'>#{row.original.id}</div>,
+  },
+  {
+    accessorKey: 'name',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نام' />
+    ),
+    cell: ({ row }) => (
+      <LongText className='max-w-48'>{row.original.name}</LongText>
+    ),
+  },
+  {
+    accessorKey: 'username',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نام کاربری' />
+    ),
+    cell: ({ row }) => <div>{row.original.username}</div>,
+  },
+  {
+    accessorKey: 'status',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='وضعیت' />
+    ),
+    cell: ({ row }) => (
+      <Badge
+        variant={row.original.status === 'active' ? 'default' : 'secondary'}
+      >
+        {adminStatusLabels[row.original.status] ?? row.original.status}
+      </Badge>
+    ),
+  },
+  {
+    id: 'roles',
+    accessorFn: (row) => row.roles.join(', '),
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='نقش‌ها' />
+    ),
+    cell: ({ row }) => (
+      <div className='flex flex-wrap gap-1'>
+        {row.original.role_details.length > 0
+          ? row.original.role_details.map((role) => (
+              <Badge key={role.id} variant='secondary'>
+                {role.name}
+              </Badge>
+            ))
+          : row.original.roles.map((role) => (
+              <Badge key={role} variant='secondary'>
+                {role}
+              </Badge>
+            ))}
+      </div>
+    ),
+  },
+  {
+    accessorKey: 'created_at',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='تاریخ ایجاد' />
+    ),
+    cell: ({ row }) => (
+      <div className='text-sm text-muted-foreground'>
+        {new Date(row.original.created_at).toLocaleDateString('fa-IR')}
+      </div>
+    ),
+  },
+  {
+    id: 'actions',
+    cell: DataTableRowActions,
+    enableSorting: false,
+  },
+]

+ 93 - 0
src/features/admins/components/admins-delete-dialog.tsx

@@ -0,0 +1,93 @@
+import { useState } from 'react'
+import { type Admin } from '@/types/admins.types'
+import { AlertTriangle } from 'lucide-react'
+import { toast } from 'sonner'
+import { getApiErrorMessage } from '@/lib/api-error'
+import { useDeleteAdmin } from '@/hooks/admins/useDeleteAdmin'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { ConfirmDialog } from '@/components/confirm-dialog'
+
+interface Props {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentRow: Admin | null
+}
+
+export function AdminsDeleteDialog({ open, onOpenChange, currentRow }: Props) {
+  const [value, setValue] = useState('')
+  const { mutate, isPending } = useDeleteAdmin()
+
+  if (!currentRow) return null
+
+  const handleDelete = () => {
+    mutate(currentRow.id, {
+      onSuccess: () => {
+        toast.success('مدیر با موفقیت حذف شد')
+        setValue('')
+        onOpenChange(false)
+      },
+      onError: (error) => {
+        toast.error(getApiErrorMessage(error, 'خطا در حذف مدیر'))
+      },
+    })
+  }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={(state) => {
+        if (!state) setValue('')
+        onOpenChange(state)
+      }}
+      form='admins-delete-form'
+      disabled={value.trim() !== currentRow.username}
+      title={
+        <span className='text-destructive'>
+          <AlertTriangle
+            className='me-1 inline-block stroke-destructive'
+            size={18}
+          />{' '}
+          حذف مدیر
+        </span>
+      }
+      desc={
+        <form
+          id='admins-delete-form'
+          onSubmit={(event) => {
+            event.preventDefault()
+            handleDelete()
+          }}
+          className='space-y-4'
+        >
+          <p className='mb-2'>
+            آیا از حذف مدیر <span className='font-bold'>{currentRow.name}</span>{' '}
+            مطمئن هستید؟
+          </p>
+
+          <Label>
+            نام کاربری:
+            <Input
+              value={value}
+              onChange={(event) => setValue(event.target.value)}
+              placeholder='نام کاربری را برای تایید حذف وارد کنید.'
+              autoFocus
+            />
+          </Label>
+
+          <Alert variant='destructive'>
+            <AlertTitle>هشدار!</AlertTitle>
+            <AlertDescription>
+              این عملیات قابل برگشت نیست و دسترسی این مدیر قطع می‌شود.
+            </AlertDescription>
+          </Alert>
+        </form>
+      }
+      confirmText='حذف'
+      cancelBtnText='انصراف'
+      isLoading={isPending}
+      destructive
+    />
+  )
+}

+ 23 - 0
src/features/admins/components/admins-dialogs.tsx

@@ -0,0 +1,23 @@
+import { AdminsActionDialog } from './admins-action-dialog'
+import { AdminsDeleteDialog } from './admins-delete-dialog'
+import { useAdminsContext } from './admins-provider'
+
+export function AdminsDialogs() {
+  const { open, setOpen, currentRow, setCurrentRow } = useAdminsContext()
+
+  return (
+    <>
+      <AdminsActionDialog />
+      <AdminsDeleteDialog
+        open={open === 'delete'}
+        onOpenChange={(state) => {
+          if (!state) {
+            setOpen(null)
+            setCurrentRow(null)
+          }
+        }}
+        currentRow={currentRow}
+      />
+    </>
+  )
+}

+ 18 - 0
src/features/admins/components/admins-primary-buttons.tsx

@@ -0,0 +1,18 @@
+import { UserPlus } from 'lucide-react'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
+import { Button } from '@/components/ui/button'
+import { useAdminsContext } from './admins-provider'
+
+export function AdminsPrimaryButtons() {
+  const { setOpen } = useAdminsContext()
+  const { can } = useCan()
+
+  if (!can(PERMISSIONS.admins.create)) return null
+
+  return (
+    <Button className='space-x-1' onClick={() => setOpen('add')}>
+      <span>افزودن مدیر</span> <UserPlus size={18} />
+    </Button>
+  )
+}

+ 37 - 0
src/features/admins/components/admins-provider.tsx

@@ -0,0 +1,37 @@
+import React, { createContext, useContext, useState } from 'react'
+import { type Admin } from '@/types/admins.types'
+
+type AdminsDialogType = 'add' | 'edit' | 'view' | 'delete'
+
+interface AdminsContextType {
+  open: AdminsDialogType | null
+  setOpen: (value: AdminsDialogType | null) => void
+  currentRow: Admin | null
+  setCurrentRow: React.Dispatch<React.SetStateAction<Admin | null>>
+}
+
+const AdminsContext = createContext<AdminsContextType | undefined>(undefined)
+
+export function AdminsProvider({ children }: { children: React.ReactNode }) {
+  const [open, setOpen] = useState<AdminsDialogType | null>(null)
+  const [currentRow, setCurrentRow] = useState<Admin | null>(null)
+
+  return (
+    <AdminsContext.Provider
+      value={{ open, setOpen, currentRow, setCurrentRow }}
+    >
+      {children}
+    </AdminsContext.Provider>
+  )
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useAdminsContext() {
+  const context = useContext(AdminsContext)
+
+  if (!context) {
+    throw new Error('useAdminsContext must be used within AdminsProvider')
+  }
+
+  return context
+}

+ 124 - 0
src/features/admins/components/admins-table.tsx

@@ -0,0 +1,124 @@
+import { useEffect, useState } from 'react'
+import {
+  type SortingState,
+  flexRender,
+  getCoreRowModel,
+  getSortedRowModel,
+  useReactTable,
+} from '@tanstack/react-table'
+import { type Admin } from '@/types/admins.types'
+import { type Pagination } from '@/types/common.types'
+import { cn } from '@/lib/utils'
+import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import { DataTablePagination } from '@/components/data-table'
+import { adminsColumns as columns } from './admins-columns'
+
+type AdminsTableProps = {
+  data: Admin[]
+  paginationData?: Pagination
+  search: Record<string, unknown>
+  navigate: NavigateFn
+}
+
+export function AdminsTable({
+  data,
+  paginationData,
+  search,
+  navigate,
+}: AdminsTableProps) {
+  const [sorting, setSorting] = useState<SortingState>([])
+  const { pagination, onPaginationChange, ensurePageInRange } =
+    useTableUrlState({
+      search,
+      navigate,
+      pagination: { defaultPage: 1, defaultPageSize: 10 },
+      globalFilter: { enabled: false },
+    })
+
+  // eslint-disable-next-line react-hooks/incompatible-library
+  const table = useReactTable({
+    data,
+    columns,
+    state: { sorting, pagination },
+    pageCount: paginationData?.last_page ?? -1,
+    manualPagination: true,
+    onPaginationChange,
+    onSortingChange: setSorting,
+    getCoreRowModel: getCoreRowModel(),
+    getSortedRowModel: getSortedRowModel(),
+  })
+
+  useEffect(() => {
+    ensurePageInRange(table.getPageCount())
+  }, [table, ensurePageInRange])
+
+  return (
+    <div className='flex flex-1 flex-col gap-4'>
+      <div className='overflow-hidden rounded-md border'>
+        <Table>
+          <TableHeader>
+            {table.getHeaderGroups().map((headerGroup) => (
+              <TableRow key={headerGroup.id} className='group/row'>
+                {headerGroup.headers.map((header) => (
+                  <TableHead
+                    key={header.id}
+                    colSpan={header.colSpan}
+                    className='bg-background group-hover/row:bg-muted'
+                  >
+                    {header.isPlaceholder
+                      ? null
+                      : flexRender(
+                          header.column.columnDef.header,
+                          header.getContext()
+                        )}
+                  </TableHead>
+                ))}
+              </TableRow>
+            ))}
+          </TableHeader>
+          <TableBody>
+            {table.getRowModel().rows.length ? (
+              table.getRowModel().rows.map((row) => (
+                <TableRow key={row.id} className='group/row'>
+                  {row.getVisibleCells().map((cell) => (
+                    <TableCell
+                      key={cell.id}
+                      className={cn(
+                        'bg-background group-hover/row:bg-muted',
+                        cell.column.columnDef.meta?.className,
+                        cell.column.columnDef.meta?.tdClassName
+                      )}
+                    >
+                      {flexRender(
+                        cell.column.columnDef.cell,
+                        cell.getContext()
+                      )}
+                    </TableCell>
+                  ))}
+                </TableRow>
+              ))
+            ) : (
+              <TableRow>
+                <TableCell
+                  colSpan={columns.length}
+                  className='h-24 text-center'
+                >
+                  نتیجه‌ای یافت نشد.
+                </TableCell>
+              </TableRow>
+            )}
+          </TableBody>
+        </Table>
+      </div>
+      <DataTablePagination table={table} className='mt-auto' />
+    </div>
+  )
+}

+ 79 - 0
src/features/admins/components/data-table-row-actions.tsx

@@ -0,0 +1,79 @@
+import { DotsHorizontalIcon } from '@radix-ui/react-icons'
+import { type Row } from '@tanstack/react-table'
+import { type Admin } from '@/types/admins.types'
+import { Eye, Pencil, Trash2 } from 'lucide-react'
+import { useAuthStore } from '@/stores/auth-store'
+import { PERMISSIONS } from '@/constants/permissions'
+import { useCan } from '@/hooks/use-can'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { useAdminsContext } from './admins-provider'
+
+interface Props {
+  row: Row<Admin>
+}
+
+export function DataTableRowActions({ row }: Props) {
+  const { setOpen, setCurrentRow } = useAdminsContext()
+  const { can } = useCan()
+  const currentUserId = useAuthStore((state) => state.user?.id)
+  const admin = row.original
+  const isCurrentUser = currentUserId === admin.id
+  const canUpdate = can(PERMISSIONS.admins.update)
+  const canDelete = can(PERMISSIONS.admins.delete) && !isCurrentUser
+
+  const openDialog = (type: 'view' | 'edit' | 'delete') => {
+    setCurrentRow(admin)
+    setOpen(type)
+  }
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button variant='ghost' className='flex h-8 w-8 p-0'>
+          <DotsHorizontalIcon className='h-4 w-4' />
+        </Button>
+      </DropdownMenuTrigger>
+
+      <DropdownMenuContent align='end'>
+        <DropdownMenuItem onClick={() => openDialog('view')}>
+          مشاهده
+          <DropdownMenuShortcut>
+            <Eye size={16} />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+
+        {canUpdate && (
+          <DropdownMenuItem onClick={() => openDialog('edit')}>
+            ویرایش
+            <DropdownMenuShortcut>
+              <Pencil size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        )}
+
+        {canDelete && (
+          <>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem
+              onClick={() => openDialog('delete')}
+              className='text-red-600'
+            >
+              حذف
+              <DropdownMenuShortcut>
+                <Trash2 size={16} color='red' />
+              </DropdownMenuShortcut>
+            </DropdownMenuItem>
+          </>
+        )}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  )
+}

+ 63 - 0
src/features/admins/index.tsx

@@ -0,0 +1,63 @@
+import { getRouteApi } from '@tanstack/react-router'
+import { useAdmins } from '@/hooks/admins/useAdmins'
+import { ConfigDrawer } from '@/components/config-drawer'
+import { Header } from '@/components/layout/header'
+import { Main } from '@/components/layout/main'
+import { ProfileDropdown } from '@/components/profile-dropdown'
+import { Search } from '@/components/search'
+import { ThemeSwitch } from '@/components/theme-switch'
+import { AdminsDialogs } from './components/admins-dialogs'
+import { AdminsPrimaryButtons } from './components/admins-primary-buttons'
+import { AdminsProvider } from './components/admins-provider'
+import { AdminsTable } from './components/admins-table'
+
+const route = getRouteApi('/_authenticated/admins/')
+
+export function Admins() {
+  const search = route.useSearch()
+  const navigate = route.useNavigate()
+  const { data, isLoading, isError } = useAdmins({
+    page: search.page ?? 1,
+    per_page: search.pageSize ?? 10,
+  })
+
+  return (
+    <AdminsProvider>
+      <Header fixed>
+        <Search className='me-auto' />
+        <ThemeSwitch />
+        <ConfigDrawer />
+        <ProfileDropdown />
+      </Header>
+
+      <Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
+        <div className='flex flex-wrap items-end justify-between gap-2'>
+          <div>
+            <h2 className='text-2xl font-bold tracking-tight'>لیست مدیران</h2>
+            <p className='text-muted-foreground'>
+              مدیران پنل و نقش هر کدام را از اینجا مدیریت کنید.
+            </p>
+          </div>
+          <AdminsPrimaryButtons />
+        </div>
+
+        {isLoading ? (
+          <div>در حال دریافت...</div>
+        ) : isError ? (
+          <div className='text-sm text-destructive'>
+            دریافت مدیران ممکن نشد.
+          </div>
+        ) : (
+          <AdminsTable
+            data={data?.data.admins ?? []}
+            paginationData={data?.data.pagination}
+            search={search}
+            navigate={navigate}
+          />
+        )}
+      </Main>
+
+      <AdminsDialogs />
+    </AdminsProvider>
+  )
+}

+ 11 - 0
src/hooks/admins/useAdmin.ts

@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+
+export function useAdmin(adminId?: number) {
+  return useQuery({
+    queryKey: queryKeys.admins.detail(adminId ?? 'new'),
+    queryFn: () => adminsService.detail(adminId as number),
+    enabled: typeof adminId === 'number',
+  })
+}

+ 14 - 0
src/hooks/admins/useAdmins.ts

@@ -0,0 +1,14 @@
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+import type { PaginationParams } from '@/types/common.types'
+
+export function useAdmins(
+  params: PaginationParams = { page: 1, per_page: 10 }
+) {
+  return useQuery({
+    queryKey: queryKeys.admins.list(params),
+    queryFn: () => adminsService.list(params),
+    placeholderData: keepPreviousData,
+  })
+}

+ 16 - 0
src/hooks/admins/useCreateAdmin.ts

@@ -0,0 +1,16 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+
+export function useCreateAdmin() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: adminsService.create,
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({
+        queryKey: queryKeys.admins.all,
+      })
+    },
+  })
+}

+ 16 - 0
src/hooks/admins/useDeleteAdmin.ts

@@ -0,0 +1,16 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+
+export function useDeleteAdmin() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: (adminId: number) => adminsService.delete(adminId),
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({
+        queryKey: queryKeys.admins.all,
+      })
+    },
+  })
+}

+ 29 - 0
src/hooks/admins/useUpdateAdmin.ts

@@ -0,0 +1,29 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { queryKeys } from '@/core/react-query/keys'
+import { adminsService } from '@/services/admins.service'
+import type { AdminPayload } from '@/types/admins.types'
+import { useAuthStore } from '@/stores/auth-store'
+import { syncCurrentPermissions } from '@/lib/resolve-permissions'
+
+type UpdateAdminVariables = {
+  id: number
+  payload: AdminPayload
+}
+
+export function useUpdateAdmin() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: ({ id, payload }: UpdateAdminVariables) =>
+      adminsService.update(id, payload),
+    onSuccess: async (_data, variables) => {
+      await queryClient.invalidateQueries({
+        queryKey: queryKeys.admins.all,
+      })
+
+      if (useAuthStore.getState().user?.id === variables.id) {
+        await syncCurrentPermissions()
+      }
+    },
+  })
+}

+ 5 - 1
src/hooks/roles/useRoles.ts

@@ -3,10 +3,14 @@ import { queryKeys } from '@/core/react-query/keys'
 import { rolesService } from '@/services/roles.service'
 import type { PaginationParams } from '@/types/common.types'
 
-export function useRoles(params: PaginationParams = { page: 1, per_page: 10 }) {
+export function useRoles(
+  params: PaginationParams = { page: 1, per_page: 10 },
+  enabled = true
+) {
   return useQuery({
     queryKey: queryKeys.roles.list(params),
     queryFn: () => rolesService.list(params),
     placeholderData: keepPreviousData,
+    enabled,
   })
 }

+ 22 - 0
src/routeTree.gen.ts

@@ -28,6 +28,7 @@ import { Route as AuthenticatedPermissionsIndexRouteImport } from './routes/_aut
 import { Route as AuthenticatedHelpCenterIndexRouteImport } from './routes/_authenticated/help-center/index'
 import { Route as AuthenticatedEvaluationsIndexRouteImport } from './routes/_authenticated/evaluations/index'
 import { Route as AuthenticatedChatsIndexRouteImport } from './routes/_authenticated/chats/index'
+import { Route as AuthenticatedAdminsIndexRouteImport } from './routes/_authenticated/admins/index'
 import { Route as AuthenticatedSettingsNotificationsRouteImport } from './routes/_authenticated/settings/notifications'
 import { Route as AuthenticatedSettingsDisplayRouteImport } from './routes/_authenticated/settings/display'
 import { Route as AuthenticatedSettingsAppearanceRouteImport } from './routes/_authenticated/settings/appearance'
@@ -134,6 +135,12 @@ const AuthenticatedChatsIndexRoute = AuthenticatedChatsIndexRouteImport.update({
   path: '/chats/',
   getParentRoute: () => AuthenticatedRouteRoute,
 } as any)
+const AuthenticatedAdminsIndexRoute =
+  AuthenticatedAdminsIndexRouteImport.update({
+    id: '/admins/',
+    path: '/admins/',
+    getParentRoute: () => AuthenticatedRouteRoute,
+  } as any)
 const AuthenticatedSettingsNotificationsRoute =
   AuthenticatedSettingsNotificationsRouteImport.update({
     id: '/notifications',
@@ -179,6 +186,7 @@ export interface FileRoutesByFullPath {
   '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
   '/settings/display': typeof AuthenticatedSettingsDisplayRoute
   '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
+  '/admins/': typeof AuthenticatedAdminsIndexRoute
   '/chats/': typeof AuthenticatedChatsIndexRoute
   '/evaluations/': typeof AuthenticatedEvaluationsIndexRoute
   '/help-center/': typeof AuthenticatedHelpCenterIndexRoute
@@ -203,6 +211,7 @@ export interface FileRoutesByTo {
   '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
   '/settings/display': typeof AuthenticatedSettingsDisplayRoute
   '/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
+  '/admins': typeof AuthenticatedAdminsIndexRoute
   '/chats': typeof AuthenticatedChatsIndexRoute
   '/evaluations': typeof AuthenticatedEvaluationsIndexRoute
   '/help-center': typeof AuthenticatedHelpCenterIndexRoute
@@ -230,6 +239,7 @@ export interface FileRoutesById {
   '/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
   '/_authenticated/settings/display': typeof AuthenticatedSettingsDisplayRoute
   '/_authenticated/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
+  '/_authenticated/admins/': typeof AuthenticatedAdminsIndexRoute
   '/_authenticated/chats/': typeof AuthenticatedChatsIndexRoute
   '/_authenticated/evaluations/': typeof AuthenticatedEvaluationsIndexRoute
   '/_authenticated/help-center/': typeof AuthenticatedHelpCenterIndexRoute
@@ -257,6 +267,7 @@ export interface FileRouteTypes {
     | '/settings/appearance'
     | '/settings/display'
     | '/settings/notifications'
+    | '/admins/'
     | '/chats/'
     | '/evaluations/'
     | '/help-center/'
@@ -281,6 +292,7 @@ export interface FileRouteTypes {
     | '/settings/appearance'
     | '/settings/display'
     | '/settings/notifications'
+    | '/admins'
     | '/chats'
     | '/evaluations'
     | '/help-center'
@@ -307,6 +319,7 @@ export interface FileRouteTypes {
     | '/_authenticated/settings/appearance'
     | '/_authenticated/settings/display'
     | '/_authenticated/settings/notifications'
+    | '/_authenticated/admins/'
     | '/_authenticated/chats/'
     | '/_authenticated/evaluations/'
     | '/_authenticated/help-center/'
@@ -464,6 +477,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof AuthenticatedChatsIndexRouteImport
       parentRoute: typeof AuthenticatedRouteRoute
     }
+    '/_authenticated/admins/': {
+      id: '/_authenticated/admins/'
+      path: '/admins'
+      fullPath: '/admins/'
+      preLoaderRoute: typeof AuthenticatedAdminsIndexRouteImport
+      parentRoute: typeof AuthenticatedRouteRoute
+    }
     '/_authenticated/settings/notifications': {
       id: '/_authenticated/settings/notifications'
       path: '/notifications'
@@ -529,6 +549,7 @@ interface AuthenticatedRouteRouteChildren {
   AuthenticatedSettingsRouteRoute: typeof AuthenticatedSettingsRouteRouteWithChildren
   AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
   AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute
+  AuthenticatedAdminsIndexRoute: typeof AuthenticatedAdminsIndexRoute
   AuthenticatedChatsIndexRoute: typeof AuthenticatedChatsIndexRoute
   AuthenticatedEvaluationsIndexRoute: typeof AuthenticatedEvaluationsIndexRoute
   AuthenticatedHelpCenterIndexRoute: typeof AuthenticatedHelpCenterIndexRoute
@@ -544,6 +565,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
   AuthenticatedSettingsRouteRoute: AuthenticatedSettingsRouteRouteWithChildren,
   AuthenticatedIndexRoute: AuthenticatedIndexRoute,
   AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute,
+  AuthenticatedAdminsIndexRoute: AuthenticatedAdminsIndexRoute,
   AuthenticatedChatsIndexRoute: AuthenticatedChatsIndexRoute,
   AuthenticatedEvaluationsIndexRoute: AuthenticatedEvaluationsIndexRoute,
   AuthenticatedHelpCenterIndexRoute: AuthenticatedHelpCenterIndexRoute,

+ 18 - 0
src/routes/_authenticated/admins/index.tsx

@@ -0,0 +1,18 @@
+import z from 'zod'
+import { createFileRoute } from '@tanstack/react-router'
+import { requirePermission } from '@/lib/require-permission'
+import { PERMISSIONS } from '@/constants/permissions'
+import { Admins } from '@/features/admins'
+
+const adminsSearchSchema = z.object({
+  page: z.coerce.number().optional().catch(1),
+  pageSize: z.coerce.number().optional().catch(10),
+})
+
+export const Route = createFileRoute('/_authenticated/admins/')({
+  validateSearch: adminsSearchSchema,
+  beforeLoad: () => {
+    requirePermission(PERMISSIONS.admins.view)
+  },
+  component: Admins,
+})

+ 45 - 0
src/services/admins.service.ts

@@ -0,0 +1,45 @@
+import http from '@/core/api/http'
+import type {
+  AdminDetailResponse,
+  AdminPayload,
+  AdminsListResponse,
+} from '@/types/admins.types'
+import type { ApiResponse, PaginationParams } from '@/types/common.types'
+
+const BASE_URL = '/admin/admins'
+
+export const adminsService = {
+  list: async (params?: PaginationParams) => {
+    const searchParams = new URLSearchParams()
+
+    if (params?.page) searchParams.set('page', String(params.page))
+    if (params?.per_page) searchParams.set('per_page', String(params.per_page))
+
+    const query = searchParams.toString()
+    const url = query ? `${BASE_URL}?${query}` : BASE_URL
+
+    return http.get<AdminsListResponse>(url)
+  },
+
+  detail: async (adminId: number | string) => {
+    const response = await http.get<AdminDetailResponse>(
+      `${BASE_URL}/${adminId}`
+    )
+    return response.data.admin
+  },
+
+  create: async (payload: AdminPayload) => {
+    return http.post<ApiResponse<unknown>, AdminPayload>(BASE_URL, payload)
+  },
+
+  update: async (adminId: number | string, payload: AdminPayload) => {
+    return http.put<ApiResponse<unknown>, AdminPayload>(
+      `${BASE_URL}/${adminId}`,
+      payload
+    )
+  },
+
+  delete: async (adminId: number | string) => {
+    return http.delete(`${BASE_URL}/${adminId}`)
+  },
+}

+ 43 - 0
src/types/admins.types.ts

@@ -0,0 +1,43 @@
+import type { Pagination } from './common.types'
+
+export interface AdminRoleDetail {
+  id: number
+  name: string
+  status: string
+}
+
+export interface Admin {
+  id: number
+  name: string
+  username: string
+  status: string
+  roles: string[]
+  role_details: AdminRoleDetail[]
+  created_at: string
+  updated_at: string
+}
+
+export interface AdminsListResponse {
+  success: boolean
+  message: string
+  data: {
+    admins: Admin[]
+    pagination: Pagination
+  }
+}
+
+export interface AdminDetailResponse {
+  success: boolean
+  message: string
+  data: {
+    admin: Admin
+  }
+}
+
+export interface AdminPayload {
+  name: string
+  username: string
+  password?: string
+  status: 'active' | 'inactive'
+  roles: number[]
+}