Jelajahi Sumber

feat: implement users crud

Mohammad Mahdi Salimi 1 bulan lalu
induk
melakukan
b9a7bc19fb

+ 55 - 48
package-lock.json

@@ -42,7 +42,7 @@
         "react": "^19.2.5",
         "react-day-picker": "9.14.0",
         "react-dom": "^19.2.5",
-        "react-hook-form": "^7.72.1",
+        "react-hook-form": "^7.75.0",
         "react-top-loading-bar": "^3.0.2",
         "recharts": "^3.8.1",
         "sonner": "^2.0.7",
@@ -113,6 +113,7 @@
       "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@babel/code-frame": "^7.29.0",
         "@babel/generator": "^7.29.0",
@@ -433,27 +434,6 @@
       "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
       "license": "MIT"
     },
-    "node_modules/@emnapi/core": {
-      "version": "1.9.2",
-      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
-      "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "@emnapi/wasi-threads": "1.2.1",
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@emnapi/runtime": {
-      "version": "1.9.2",
-      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
-      "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
     "node_modules/@emnapi/wasi-threads": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -1105,6 +1085,29 @@
         "node": "^20.19.0 || >=22.12.0"
       }
     },
+    "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": {
+      "version": "1.9.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+      "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.2.1",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+      "version": "1.9.2",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+      "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
     "node_modules/@oxc-parser/binding-win32-arm64-msvc": {
       "version": "0.127.0",
       "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz",
@@ -2966,27 +2969,6 @@
         "node": "^20.19.0 || >=22.12.0"
       }
     },
-    "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
-      "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "@emnapi/wasi-threads": "1.2.1",
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
-      "version": "1.10.0",
-      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
-      "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
       "version": "1.0.0-rc.17",
       "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
@@ -3410,6 +3392,7 @@
       "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.24.tgz",
       "integrity": "sha512-CQWd9ywDZU6icG65SrjJMzWcNg/ehBKFxfxYssKSuELk0eM9SzJxJ3JBI760kdLXIsXewOX9PHpJDknxC/R5Ag==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@tanstack/history": "1.161.6",
         "@tanstack/react-store": "^0.9.3",
@@ -3499,6 +3482,7 @@
       "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.16.tgz",
       "integrity": "sha512-2lkWNMzDWWxVqTf9Y54DH1ceZOrGrOGzx9CFVMq8gQSOxhr3x3+otjVei1Rlx4xmsCc4yCqccqjun5wDtE9MZA==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@tanstack/history": "1.161.6",
         "cookie-es": "^3.0.0",
@@ -3714,6 +3698,7 @@
       "integrity": "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==",
       "dev": true,
       "license": "Apache-2.0",
+      "peer": true,
       "dependencies": {
         "@babel/generator": "^7.28.0",
         "@babel/parser": "^7.28.0",
@@ -3867,6 +3852,7 @@
       "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
       "devOptional": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "undici-types": "~7.19.0"
       }
@@ -3877,6 +3863,7 @@
       "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
       "devOptional": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "csstype": "^3.2.2"
       }
@@ -3887,6 +3874,7 @@
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
       "devOptional": true,
       "license": "MIT",
+      "peer": true,
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
@@ -3942,6 +3930,7 @@
       "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@typescript-eslint/scope-manager": "8.59.0",
         "@typescript-eslint/types": "8.59.0",
@@ -4247,6 +4236,7 @@
       "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@vitest/browser": "4.1.5",
         "@vitest/mocker": "4.1.5",
@@ -4407,6 +4397,7 @@
       "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@vitest/utils": "4.1.5",
         "fflate": "^0.8.2",
@@ -4444,6 +4435,7 @@
       "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -4649,6 +4641,7 @@
         }
       ],
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "baseline-browser-mapping": "^2.10.12",
         "caniuse-lite": "^1.0.30001782",
@@ -4814,7 +4807,8 @@
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
       "devOptional": true,
-      "license": "MIT"
+      "license": "MIT",
+      "peer": true
     },
     "node_modules/d3-array": {
       "version": "3.2.4",
@@ -5152,6 +5146,7 @@
       "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.2",
@@ -6114,6 +6109,7 @@
       "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -6886,6 +6882,7 @@
       "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "bin": {
         "prettier": "bin/prettier.cjs"
       },
@@ -6999,6 +6996,7 @@
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
       "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -7030,6 +7028,7 @@
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
       "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "scheduler": "^0.27.0"
       },
@@ -7038,10 +7037,11 @@
       }
     },
     "node_modules/react-hook-form": {
-      "version": "7.74.0",
-      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
-      "integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
+      "version": "7.75.0",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz",
+      "integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=18.0.0"
       },
@@ -7065,6 +7065,7 @@
       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
       "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "@types/use-sync-external-store": "^0.0.6",
         "use-sync-external-store": "^1.4.0"
@@ -7211,7 +7212,8 @@
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
       "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
-      "license": "MIT"
+      "license": "MIT",
+      "peer": true
     },
     "node_modules/redux-thunk": {
       "version": "3.1.0",
@@ -7298,6 +7300,7 @@
       "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz",
       "integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=10"
       }
@@ -7520,6 +7523,7 @@
       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
       "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -7607,6 +7611,7 @@
       "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
       "dev": true,
       "license": "Apache-2.0",
+      "peer": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
@@ -7804,6 +7809,7 @@
       "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
       "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
         "lightningcss": "^1.32.0",
         "picomatch": "^4.0.4",
@@ -8146,6 +8152,7 @@
       "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
       "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
       "license": "MIT",
+      "peer": true,
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }

+ 1 - 1
package.json

@@ -53,7 +53,7 @@
     "react": "^19.2.5",
     "react-day-picker": "9.14.0",
     "react-dom": "^19.2.5",
-    "react-hook-form": "^7.72.1",
+    "react-hook-form": "^7.75.0",
     "react-top-loading-bar": "^3.0.2",
     "recharts": "^3.8.1",
     "sonner": "^2.0.7",

+ 36 - 0
src/constants/users/users-constants.ts

@@ -0,0 +1,36 @@
+export const genderOption = [
+  { label: 'مرد', value: 0 },
+  { label: 'زن', value: 1 },
+]
+
+export const footOptions = [
+  { label: 'راست', value: 'right' },
+  { label: 'چپ', value: 'left' },
+  { label: 'دو پا', value: 'both' },
+]
+
+export const positionOptions = [
+  { label: 'دروازه‌بان', value: 'GK' },
+  { label: 'مدافع مرکزی', value: 'CB' },
+  { label: 'مدافع راست', value: 'RB' },
+  { label: 'مدافع چپ', value: 'LB' },
+  { label: 'هافبک دفاعی', value: 'DM' },
+  { label: 'هافبک مرکزی', value: 'CM' },
+  { label: 'هافبک هجومی', value: 'AM' },
+  { label: 'وینگر راست', value: 'LW' },
+  { label: 'وینگر چپ', value: 'RW' },
+  { label: 'مهاجم نوک', value: 'CF' },
+  { label: 'مهاجم دوم', value: 'SS' },
+  { label: 'مهاجم (گل زن)', value: 'ST' },
+]
+
+export const skillLevelOptions = [
+  { label: 'مبتدی', value: 'beginner' },
+  { label: 'نیمه حرفه‌ای', value: 'semi-professional' },
+  { label: 'حرفه‌ای', value: 'professional' },
+]
+
+export const activityHistoryOptions = [
+  { label: 'دارم', value: true },
+  { label: 'ندارم', value: false },
+]

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

@@ -1,6 +1,7 @@
 export const queryKeys = {
   users: {
     all: ['users'] as const,
+    user: (userId: number) => ['user', userId] as const,
 
     lists: () => [...queryKeys.users.all, 'list'] as const,
     list: (params: unknown) => [...queryKeys.users.lists(), params] as const,
@@ -9,4 +10,11 @@ export const queryKeys = {
     detail: (id: number | string) =>
       [...queryKeys.users.details(), id] as const,
   },
+
+  location: {
+    all: ['location'] as const,
+    provinces: ['location', 'provinces'] as const,
+    cities: (provinceId: number | string) =>
+      ['location', 'cities', provinceId] as const,
+  },
 }

+ 56 - 43
src/features/users/components/data-table-row-actions.tsx

@@ -1,6 +1,6 @@
 import { DotsHorizontalIcon } from '@radix-ui/react-icons'
 import { type Row } from '@tanstack/react-table'
-import { Trash2, UserPen } from 'lucide-react'
+import { Trash2, UserPen, Eye } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import {
   DropdownMenu,
@@ -10,54 +10,67 @@ import {
   DropdownMenuShortcut,
   DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu'
-import { type User } from '../data/schema'
+
+import { type User } from '@/types/users.types'
+
 import { useUsers } from './users-provider'
 
-type DataTableRowActionsProps = {
+interface Props {
   row: Row<User>
 }
 
-export function DataTableRowActions({ row }: DataTableRowActionsProps) {
+export function DataTableRowActions({ row }: Props) {
   const { setOpen, setCurrentRow } = useUsers()
+
+  const user = row.original
+
+  const handleEdit = () => {
+    setCurrentRow(user)
+    setOpen('edit')
+  }
+
+  const handleView = () => {
+    setCurrentRow(user)
+    setOpen('view')
+  }
+
+  const handleDelete = () => {
+    setCurrentRow(user)
+    setOpen('delete')
+  }
+
   return (
-    <>
-      <DropdownMenu modal={false}>
-        <DropdownMenuTrigger asChild>
-          <Button
-            variant='ghost'
-            className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
-          >
-            <DotsHorizontalIcon className='h-4 w-4' />
-            <span className='sr-only'>Open menu</span>
-          </Button>
-        </DropdownMenuTrigger>
-        <DropdownMenuContent align='end' className='w-40'>
-          <DropdownMenuItem
-            onClick={() => {
-              setCurrentRow(row.original)
-              setOpen('edit')
-            }}
-          >
-            ویرایش
-            <DropdownMenuShortcut>
-              <UserPen size={16} />
-            </DropdownMenuShortcut>
-          </DropdownMenuItem>
-          <DropdownMenuSeparator />
-          <DropdownMenuItem
-            onClick={() => {
-              setCurrentRow(row.original)
-              setOpen('delete')
-            }}
-            className='text-red-500!'
-          >
-            حذف
-            <DropdownMenuShortcut>
-              <Trash2 size={16} color='red' />
-            </DropdownMenuShortcut>
-          </DropdownMenuItem>
-        </DropdownMenuContent>
-      </DropdownMenu>
-    </>
+    <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={handleView}>
+          مشاهده
+          <DropdownMenuShortcut>
+            <Eye size={16} />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+
+        <DropdownMenuItem onClick={handleEdit}>
+          ویرایش
+          <DropdownMenuShortcut>
+            <UserPen size={16} />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+
+        <DropdownMenuSeparator />
+
+        <DropdownMenuItem onClick={handleDelete} className='text-red-600'>
+          حذف
+          <DropdownMenuShortcut>
+            <Trash2 size={16} color='red' />
+          </DropdownMenuShortcut>
+        </DropdownMenuItem>
+      </DropdownMenuContent>
+    </DropdownMenu>
   )
 }

+ 0 - 345
src/features/users/components/users-action-dialog.test.tsx

@@ -1,345 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { render, type RenderResult } from 'vitest-browser-react'
-import { type UserEvent, userEvent } from 'vitest/browser'
-import { showSubmittedData } from '@/lib/show-submitted-data'
-import { type User } from '../data/schema'
-import { UsersActionDialog } from './users-action-dialog'
-
-const VALIDATION_MESSAGES = {
-  firstName: 'First Name is required.',
-  lastName: 'Last Name is required.',
-  username: 'Username is required.',
-  phoneNumber: 'Phone number is required.',
-  email: 'Email is required.',
-  role: 'Role is required.',
-  password: 'Password is required.',
-  passwordMismatch: "Passwords don't match.",
-  passwordLength: 'Password must be at least 8 characters long.',
-  passwordNumber: 'Password must contain at least one number.',
-  passwordLowercase: 'Password must contain at least one lowercase letter.',
-} as const
-
-const MOCK_USER: User = {
-  id: 'alex_uuid',
-  firstName: 'Alex',
-  lastName: 'Smith',
-  username: 'alex_smith',
-  email: 'alex@smith.com',
-  phoneNumber: '+19999999999',
-  status: 'active',
-  role: 'superadmin',
-  createdAt: new Date('2026-01-01'),
-  updatedAt: new Date('2026-02-02'),
-}
-
-vi.mock('@/lib/show-submitted-data', () => ({ showSubmittedData: vi.fn() }))
-
-describe('UsersActionDialog', () => {
-  beforeEach(() => vi.clearAllMocks())
-
-  describe('add user', () => {
-    it('renders title and description', async () => {
-      const { getByRole, getByText } = await render(
-        <UsersActionDialog open onOpenChange={vi.fn()} />
-      )
-
-      const title = getByRole('heading', {
-        level: 2,
-        name: /Add New User/i,
-      })
-      const description = getByText(
-        /Create new user here. Click save when you're done./i
-      )
-
-      await expect.element(title).toBeInTheDocument()
-      await expect.element(description).toBeInTheDocument()
-    })
-
-    it('shows validation messages when the form is submitted with empty fields', async () => {
-      const { getByRole, getByText } = await render(
-        <UsersActionDialog open onOpenChange={vi.fn()} />
-      )
-
-      const submitButton = getByRole('button', { name: /Save Changes/i })
-      await userEvent.click(submitButton)
-
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.firstName))
-        .toBeInTheDocument()
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.lastName))
-        .toBeInTheDocument()
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.username))
-        .toBeInTheDocument()
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.phoneNumber))
-        .toBeInTheDocument()
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.email))
-        .toBeInTheDocument()
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.role))
-        .toBeInTheDocument()
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.password))
-        .toBeInTheDocument()
-    })
-
-    it('keeps confirm password disabled until password field is touched', async () => {
-      const { getByRole } = await render(
-        <UsersActionDialog open onOpenChange={vi.fn()} />
-      )
-
-      const password = getByRole('textbox', { name: /^Password$/i })
-      const confirmPassword = getByRole('textbox', {
-        name: /Confirm Password/i,
-      })
-      await expect.element(confirmPassword).toBeDisabled()
-
-      await userEvent.type(password, 'a')
-      await expect.element(confirmPassword).toBeEnabled()
-    })
-
-    it('shows password validation messages when password is invalid', async () => {
-      const { getByRole, getByText } = await render(
-        <UsersActionDialog open onOpenChange={vi.fn()} />
-      )
-
-      const password = getByRole('textbox', { name: /^Password$/i })
-      const confirmPassword = getByRole('textbox', {
-        name: /Confirm Password/i,
-      })
-      await userEvent.type(password, 'a')
-      await userEvent.type(confirmPassword, 'b')
-      const submitButton = getByRole('button', { name: /Save Changes/i })
-
-      await userEvent.click(submitButton)
-
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.passwordMismatch))
-        .toBeInTheDocument()
-
-      await userEvent.fill(password, 'short')
-
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.passwordLength))
-        .toBeInTheDocument()
-
-      await userEvent.fill(password, 'ONLYUPPERCASE')
-
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.passwordLowercase))
-        .toBeInTheDocument()
-
-      await userEvent.fill(password, 'onlylowercase')
-
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.passwordNumber))
-        .toBeInTheDocument()
-
-      await userEvent.fill(password, 'S3cur3P@ssw0rd')
-      await userEvent.fill(confirmPassword, 'S3cur3P@ssw0rd')
-
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.passwordMismatch))
-        .not.toBeInTheDocument()
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.passwordLength))
-        .not.toBeInTheDocument()
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.passwordNumber))
-        .not.toBeInTheDocument()
-    })
-
-    it('shows the submitted data when the form is submitted successfully', async () => {
-      const onOpenChange = vi.fn()
-
-      const screen = await render(
-        <UsersActionDialog open onOpenChange={onOpenChange} />
-      )
-
-      await fillRequiredProfileFields(userEvent, screen, MOCK_USER)
-
-      await fillPasswords(userEvent, screen, 'S3cur3P@ssw0rd', 'S3cur3P@ssw0rd')
-
-      const submitButton = screen.getByRole('button', { name: /Save Changes/i })
-      await userEvent.click(submitButton)
-
-      expect(onOpenChange).toHaveBeenCalledOnce()
-      expect(onOpenChange).toHaveBeenCalledWith(false)
-
-      expect(showSubmittedData).toHaveBeenCalledOnce()
-      expect(showSubmittedData).toHaveBeenCalledWith({
-        firstName: MOCK_USER.firstName,
-        lastName: MOCK_USER.lastName,
-        username: MOCK_USER.username,
-        email: MOCK_USER.email,
-        role: MOCK_USER.role,
-        phoneNumber: MOCK_USER.phoneNumber,
-        password: 'S3cur3P@ssw0rd',
-        confirmPassword: 'S3cur3P@ssw0rd',
-        isEdit: false,
-      })
-    })
-  })
-
-  describe('edit user', () => {
-    it('renders title and description', async () => {
-      const { getByRole, getByText } = await render(
-        <UsersActionDialog open onOpenChange={vi.fn()} currentRow={MOCK_USER} />
-      )
-
-      const title = getByRole('heading', {
-        level: 2,
-        name: /Edit User/i,
-      })
-      const description = getByText(
-        /Update the user here\. Click save when you're done\./i
-      )
-
-      await expect.element(title).toBeInTheDocument()
-      await expect.element(description).toBeInTheDocument()
-    })
-
-    it('submits without password changes', async () => {
-      const onOpenChange = vi.fn()
-      const screen = await render(
-        <UsersActionDialog
-          open
-          onOpenChange={onOpenChange}
-          currentRow={MOCK_USER}
-        />
-      )
-
-      const submitButton = screen.getByRole('button', { name: /Save Changes/i })
-      await userEvent.click(submitButton)
-
-      expect(onOpenChange).toHaveBeenCalledOnce()
-      expect(onOpenChange).toHaveBeenCalledWith(false)
-
-      expect(showSubmittedData).toHaveBeenCalledOnce()
-      expect(showSubmittedData).toHaveBeenCalledWith({
-        firstName: MOCK_USER.firstName,
-        lastName: MOCK_USER.lastName,
-        username: MOCK_USER.username,
-        email: MOCK_USER.email,
-        phoneNumber: MOCK_USER.phoneNumber,
-        role: MOCK_USER.role,
-        password: '',
-        confirmPassword: '',
-        isEdit: true,
-      })
-    })
-
-    it('requires confirm password when password is changed', async () => {
-      const { getByRole, getByText } = await render(
-        <UsersActionDialog open onOpenChange={vi.fn()} currentRow={MOCK_USER} />
-      )
-
-      const password = getByRole('textbox', { name: /^Password$/i })
-      const confirmPassword = getByRole('textbox', {
-        name: /Confirm Password/i,
-      })
-
-      await userEvent.fill(password, 'S3cur3P@ssw0rd')
-      await expect.element(confirmPassword).toBeEnabled()
-
-      const submitButton = getByRole('button', { name: /Save Changes/i })
-      await userEvent.click(submitButton)
-
-      await expect
-        .element(getByText(VALIDATION_MESSAGES.passwordMismatch))
-        .toBeInTheDocument()
-    })
-
-    it('shows the submitted data when the form is submitted successfully', async () => {
-      const onOpenChange = vi.fn()
-      const screen = await render(
-        <UsersActionDialog
-          open
-          onOpenChange={onOpenChange}
-          currentRow={MOCK_USER}
-        />
-      )
-
-      const EDIT_SUCCESS_FIRST_NAME = 'John'
-      const EDIT_SUCCESS_PASSWORD = 'S3cur3P@ssw0rd'
-
-      await userEvent.fill(
-        screen.getByLabelText(/first name/i),
-        EDIT_SUCCESS_FIRST_NAME
-      )
-      await fillPasswords(
-        userEvent,
-        screen,
-        EDIT_SUCCESS_PASSWORD,
-        EDIT_SUCCESS_PASSWORD
-      )
-
-      const submitButton = screen.getByRole('button', { name: /Save Changes/i })
-      await userEvent.click(submitButton)
-
-      expect(onOpenChange).toHaveBeenCalledOnce()
-      expect(onOpenChange).toHaveBeenCalledWith(false)
-
-      expect(showSubmittedData).toHaveBeenCalledOnce()
-      expect(showSubmittedData).toHaveBeenCalledWith({
-        firstName: EDIT_SUCCESS_FIRST_NAME,
-        lastName: MOCK_USER.lastName,
-        username: MOCK_USER.username,
-        email: MOCK_USER.email,
-        phoneNumber: MOCK_USER.phoneNumber,
-        role: MOCK_USER.role,
-        password: EDIT_SUCCESS_PASSWORD,
-        confirmPassword: EDIT_SUCCESS_PASSWORD,
-        isEdit: true,
-      })
-    })
-  })
-})
-
-async function fillRequiredProfileFields(
-  user: UserEvent,
-  screen: RenderResult,
-  overrides?: {
-    firstName?: string
-    lastName?: string
-    username?: string
-    email?: string
-    roleOption?: string | RegExp
-    phoneNumber?: string
-  }
-) {
-  const entries = [
-    [/first name/i, overrides?.firstName ?? 'John'],
-    [/last name/i, overrides?.lastName ?? 'Doe'],
-    [/username/i, overrides?.username ?? 'john_doe'],
-    [/^email$/i, overrides?.email ?? 'a@b.co'],
-    [/phone number/i, overrides?.phoneNumber ?? '+19999999999'],
-  ] as const
-
-  for (const [label, value] of entries) {
-    const el = screen.getByLabelText(label)
-    await expect.element(el).toBeInTheDocument()
-    await user.fill(el, value)
-  }
-
-  const roleSelect = screen.getByRole('combobox', { name: /Role/i })
-  await user.click(roleSelect)
-  await user.click(
-    screen.getByRole('option', { name: overrides?.roleOption ?? 'Superadmin' })
-  )
-}
-
-async function fillPasswords(
-  user: UserEvent,
-  screen: RenderResult,
-  a: string,
-  b: string
-) {
-  const password = screen.getByLabelText(/^Password$/i)
-  const confirmPassword = screen.getByLabelText(/^Confirm Password$/i)
-  await user.fill(password, a)
-  await user.fill(confirmPassword, b)
-}

+ 55 - 62
src/features/users/components/users-columns.tsx

@@ -1,11 +1,15 @@
 import { type ColumnDef } from '@tanstack/react-table'
+
 import { cn } from '@/lib/utils'
+
 import { Badge } from '@/components/ui/badge'
 import { Checkbox } from '@/components/ui/checkbox'
+
 import { DataTableColumnHeader } from '@/components/data-table'
 import { LongText } from '@/components/long-text'
-import { callTypes, roles } from '../data/data'
-import { type User } from '../data/schema'
+
+import { type User } from '@/types/users.types'
+
 import { DataTableRowActions } from './data-table-row-actions'
 
 export const usersColumns: ColumnDef<User>[] = [
@@ -22,9 +26,6 @@ export const usersColumns: ColumnDef<User>[] = [
         className='translate-y-0.5'
       />
     ),
-    meta: {
-      className: cn('inset-s-0 z-10 rounded-tl-[inherit] max-md:sticky'),
-    },
     cell: ({ row }) => (
       <Checkbox
         checked={row.getIsSelected()}
@@ -35,104 +36,96 @@ export const usersColumns: ColumnDef<User>[] = [
     ),
     enableSorting: false,
     enableHiding: false,
+    meta: {
+      className: cn('inset-s-0 z-10 rounded-tl-[inherit] max-md:sticky'),
+    },
   },
+
+  {
+    accessorKey: 'id',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='شناسه' />
+    ),
+    cell: ({ row }) => <div className='w-15'>#{row.original.id}</div>,
+    enableSorting: true,
+  },
+
   {
     accessorKey: 'username',
     header: ({ column }) => (
       <DataTableColumnHeader column={column} title='نام کاربری' />
     ),
     cell: ({ row }) => (
-      <LongText className='max-w-36 ps-3'>{row.getValue('username')}</LongText>
+      <LongText className='max-w-36 ps-3'>{row.original.username}</LongText>
     ),
+    enableHiding: false,
     meta: {
       className: cn(
-        'drop-shadow-[0_1px_2px_rgb(0_0_0_/_0.1)] dark:drop-shadow-[0_1px_2px_rgb(255_255_255_/_0.1)]',
-        'inset-s-6 ps-0.5 max-md:sticky @4xl/content:table-cell @4xl/content:drop-shadow-none'
+        'drop-shadow-[0_1px_2px_rgb(0_0_0_/_0.1)]',
+        'dark:drop-shadow-[0_1px_2px_rgb(255_255_255_/_0.1)]',
+        'inset-s-6 ps-0.5 max-md:sticky',
+        '@4xl/content:table-cell',
+        '@4xl/content:drop-shadow-none'
       ),
     },
-    enableHiding: false,
   },
+
   {
-    id: 'fullName',
+    accessorKey: 'full_name',
     header: ({ column }) => (
-      <DataTableColumnHeader column={column} title='نام' />
+      <DataTableColumnHeader column={column} title='نام کامل' />
+    ),
+    cell: ({ row }) => (
+      <LongText className='max-w-40'>{row.original.full_name}</LongText>
     ),
-    cell: ({ row }) => {
-      const { first_name, last_name } = row.original
-      const fullName = `${first_name} ${last_name}`
-      return <LongText className='max-w-36'>{fullName}</LongText>
-    },
-    meta: { className: 'w-36' },
   },
+
+  {
+    accessorKey: 'phone',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='شماره موبایل' />
+    ),
+    cell: ({ row }) => <div dir='ltr'>{row.original.phone}</div>,
+  },
+
   {
     accessorKey: 'email',
     header: ({ column }) => (
       <DataTableColumnHeader column={column} title='ایمیل' />
     ),
     cell: ({ row }) => (
-      <div className='w-fit ps-2 text-nowrap'>{row.getValue('email')}</div>
+      <div className='text-nowrap'>{row.original.email ?? '-'}</div>
     ),
   },
+
   {
-    accessorKey: 'phone',
+    accessorKey: 'followers_count',
     header: ({ column }) => (
-      <DataTableColumnHeader column={column} title='شماره موبایل' />
+      <DataTableColumnHeader column={column} title='دنبال‌کننده' />
     ),
-    cell: ({ row }) => <div>{row.getValue('phone')}</div>,
-    enableSorting: false,
-  },
-  {
-    accessorKey: 'status',
-    header: ({ column }) => (
-      <DataTableColumnHeader column={column} title='وضعیت' />
+    cell: ({ row }) => (
+      <Badge variant='secondary'>{row.original.followers_count}</Badge>
     ),
-    cell: ({ row }) => {
-      const { status } = row.original
-      const badgeColor = callTypes.get(status)
-      return (
-        <div className='flex space-x-2'>
-          <Badge variant='outline' className={cn('capitalize', badgeColor)}>
-            {row.getValue('status')}
-          </Badge>
-        </div>
-      )
-    },
-    filterFn: (row, id, value) => {
-      return value.includes(row.getValue(id))
-    },
-    enableHiding: false,
-    enableSorting: false,
   },
+
   {
-    accessorKey: 'role',
+    accessorKey: 'created_at',
     header: ({ column }) => (
-      <DataTableColumnHeader column={column} title='نقش' />
+      <DataTableColumnHeader column={column} title='تاریخ عضویت' />
     ),
     cell: ({ row }) => {
-      const { role } = row.original
-      const userType = roles.find(({ value }) => value === role)
-
-      if (!userType) {
-        return null
-      }
-
       return (
-        <div className='flex items-center gap-x-2'>
-          {userType.icon && (
-            <userType.icon size={16} className='text-muted-foreground' />
-          )}
-          <span className='text-sm capitalize'>{row.getValue('role')}</span>
+        <div className='text-sm text-muted-foreground'>
+          {new Date(row.original.created_at).toLocaleDateString('fa-IR')}
         </div>
       )
     },
-    filterFn: (row, id, value) => {
-      return value.includes(row.getValue(id))
-    },
-    enableSorting: false,
-    enableHiding: false,
   },
+
   {
     id: 'actions',
     cell: DataTableRowActions,
+    enableSorting: false,
+    enableHiding: false,
   },
 ]

+ 0 - 178
src/features/users/components/users-delete-dialog.test.tsx

@@ -1,178 +0,0 @@
-import { useState } from 'react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { render } from 'vitest-browser-react'
-import { userEvent } from 'vitest/browser'
-import { showSubmittedData } from '@/lib/show-submitted-data'
-import { type User } from '../data/schema'
-import { UsersDeleteDialog } from './users-delete-dialog'
-
-vi.mock('@/lib/show-submitted-data', () => ({ showSubmittedData: vi.fn() }))
-
-const MOCK_USER: User = {
-  id: 'user-delete-test',
-  firstName: 'John',
-  lastName: 'Doe',
-  username: 'john_doe',
-  email: 'johndoe@shadcn-admin.com',
-  phoneNumber: '+959123456789',
-  status: 'active',
-  role: 'manager',
-  createdAt: new Date('2026-01-01'),
-  updatedAt: new Date('2026-02-02'),
-}
-
-describe('UsersDeleteDialog', () => {
-  beforeEach(() => vi.clearAllMocks())
-
-  it('renders the dialog with the correct title, description, input and buttons', async () => {
-    const { getByText, getByRole } = await render(
-      <UsersDeleteDialog open onOpenChange={vi.fn()} currentRow={MOCK_USER} />
-    )
-
-    const title = getByRole('heading', {
-      level: 2,
-      name: /Delete User/i,
-    })
-    const desc = getByText(
-      new RegExp(`Are you sure you want to delete ${MOCK_USER.username}?`, 'i')
-    )
-    const usernameInput = getByRole('textbox', { name: /Username/i })
-    const cancelButton = getByRole('button', { name: /Cancel/i })
-    const deleteButton = getByRole('button', { name: /Delete/i })
-
-    await expect.element(title).toBeInTheDocument()
-    await expect.element(desc).toBeInTheDocument()
-    await expect.element(usernameInput).toBeInTheDocument()
-    await expect.element(cancelButton).toBeInTheDocument()
-    await expect.element(deleteButton).toBeInTheDocument()
-    await expect.element(deleteButton).toBeDisabled()
-  })
-
-  it('keeps the delete button disabled until the username input is filled correctly', async () => {
-    const { getByRole } = await render(
-      <UsersDeleteDialog open onOpenChange={vi.fn()} currentRow={MOCK_USER} />
-    )
-
-    const usernameInput = getByRole('textbox', { name: /Username/i })
-    const deleteButton = getByRole('button', { name: /Delete/i })
-
-    await expect.element(deleteButton).toBeDisabled()
-
-    await userEvent.fill(usernameInput, 'wrong-username')
-    await expect.element(deleteButton).toBeDisabled()
-
-    await userEvent.fill(usernameInput, MOCK_USER.username)
-    await expect.element(deleteButton).toBeEnabled()
-  })
-
-  it('closes the dialog when the cancel button is clicked', async () => {
-    const onOpenChange = vi.fn()
-    const { getByRole } = await render(
-      <UsersDeleteDialog
-        open
-        onOpenChange={onOpenChange}
-        currentRow={MOCK_USER}
-      />
-    )
-
-    const cancelButton = getByRole('button', { name: /Cancel/i })
-    await userEvent.click(cancelButton)
-
-    expect(onOpenChange).toHaveBeenCalledOnce()
-    expect(onOpenChange).toHaveBeenCalledWith(false)
-  })
-
-  it('resets the username input when the dialog is closed and reopened', async () => {
-    function Harness() {
-      const [open, setOpen] = useState(true)
-      return (
-        <>
-          <button type='button' onClick={() => setOpen(true)}>
-            Reopen
-          </button>
-          {open ? (
-            <UsersDeleteDialog
-              open={open}
-              onOpenChange={setOpen}
-              currentRow={MOCK_USER}
-            />
-          ) : null}
-        </>
-      )
-    }
-
-    const { getByRole } = await render(<Harness />)
-
-    const usernameInput = getByRole('textbox', { name: /Username/i })
-    await userEvent.fill(usernameInput, MOCK_USER.username)
-    await expect.element(usernameInput).toHaveValue(MOCK_USER.username)
-
-    const closeButton = getByRole('button', { name: /Cancel/i })
-    await userEvent.click(closeButton)
-
-    const reopenButton = getByRole('button', { name: /Reopen/i })
-    await userEvent.click(reopenButton)
-    await expect.element(usernameInput).toHaveValue('')
-  })
-
-  it('shows the submitted data when deleted successfully', async () => {
-    const onOpenChange = vi.fn()
-    const { getByRole } = await render(
-      <UsersDeleteDialog
-        open
-        onOpenChange={onOpenChange}
-        currentRow={MOCK_USER}
-      />
-    )
-
-    const usernameInput = getByRole('textbox', { name: /Username/i })
-    const deleteButton = getByRole('button', { name: /Delete/i })
-
-    await expect.element(deleteButton).toBeDisabled()
-
-    await userEvent.fill(usernameInput, MOCK_USER.username)
-
-    await expect.element(deleteButton).toBeEnabled()
-
-    await userEvent.click(deleteButton)
-
-    expect(onOpenChange).toHaveBeenCalledOnce()
-    expect(onOpenChange).toHaveBeenCalledWith(false)
-
-    expect(showSubmittedData).toHaveBeenCalledOnce()
-    expect(showSubmittedData).toHaveBeenCalledWith(
-      MOCK_USER,
-      'The following user has been deleted:'
-    )
-  })
-
-  it('deletes successfully when press Enter key on the username input', async () => {
-    const onOpenChange = vi.fn()
-    const { getByRole } = await render(
-      <UsersDeleteDialog
-        open
-        onOpenChange={onOpenChange}
-        currentRow={MOCK_USER}
-      />
-    )
-
-    const usernameInput = getByRole('textbox', { name: /Username/i })
-    const deleteButton = getByRole('button', { name: /Delete/i })
-
-    await expect.element(deleteButton).toBeDisabled()
-
-    await userEvent.fill(usernameInput, MOCK_USER.username)
-    await expect.element(deleteButton).toBeEnabled()
-
-    await userEvent.keyboard('{Enter}')
-
-    expect(onOpenChange).toHaveBeenCalledOnce()
-    expect(onOpenChange).toHaveBeenCalledWith(false)
-
-    expect(showSubmittedData).toHaveBeenCalledOnce()
-    expect(showSubmittedData).toHaveBeenCalledWith(
-      MOCK_USER,
-      'The following user has been deleted:'
-    )
-  })
-})

+ 16 - 17
src/features/users/components/users-delete-dialog.tsx

@@ -1,32 +1,30 @@
-'use client'
-
 import { useState } from 'react'
 import { AlertTriangle } from 'lucide-react'
-import { showSubmittedData } from '@/lib/show-submitted-data'
 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'
-import { type User } from '../data/schema'
+import { useDeleteUser } from '@/hooks/users/useDeleteUser'
+import { type User } from '@/types/users.types'
 
-type UserDeleteDialogProps = {
+interface Props {
   open: boolean
   onOpenChange: (open: boolean) => void
-  currentRow: User
+  currentRow: User | null
 }
 
-export function UsersDeleteDialog({
-  open,
-  onOpenChange,
-  currentRow,
-}: UserDeleteDialogProps) {
+export function UsersDeleteDialog({ open, onOpenChange, currentRow }: Props) {
   const [value, setValue] = useState('')
+  const { mutate, isPending } = useDeleteUser()
 
-  const handleDelete = () => {
-    if (value.trim() !== currentRow.username) return
+  if (!currentRow) return null
 
-    onOpenChange(false)
-    showSubmittedData(currentRow, 'The following user has been deleted:')
+  const handleDelete = () => {
+    mutate(currentRow.id, {
+      onSuccess: () => {
+        onOpenChange(false)
+      },
+    })
   }
 
   return (
@@ -55,13 +53,13 @@ export function UsersDeleteDialog({
         >
           <p className='mb-2'>
             آیا مطمئن هستید که می خواهید{' '}
-            <span className='font-bold'>{currentRow.username}</span> حذف کنید ؟
+            <span className='font-bold'>{currentRow.full_name}</span> حذف کنید ؟
             <br />
             این عمل برای همیشه کاربر را با نقش حذف می کند{' '}
             {/* <span className='font-bold'>
               {currentRow.role.toUpperCase() ?? ''}
             </span>{' '} */}
-            از سیستم این قابل واگرد نیست.
+            {/* از سیستم این قابل واگرد نیست. */}
           </p>
 
           <Label>
@@ -84,6 +82,7 @@ export function UsersDeleteDialog({
       }
       confirmText='حذف'
       cancelBtnText='انصراف'
+      isLoading={isPending}
       destructive
     />
   )

+ 30 - 38
src/features/users/components/users-dialogs.tsx

@@ -1,51 +1,43 @@
-import { UsersActionDialog } from './users-action-dialog'
-import { UsersDeleteDialog } from './users-delete-dialog'
-import { UsersInviteDialog } from './users-invite-dialog'
 import { useUsers } from './users-provider'
 
+// import { UsersInviteDialog } from './users-invite-dialog'
+import { UsersDeleteDialog } from './users-delete-dialog'
+import { UsersActionDialog } from './users-action-dialog'
+// import { UsersMultiDeleteDialog } from './users-multi-delete-dialog'
+
 export function UsersDialogs() {
   const { open, setOpen, currentRow, setCurrentRow } = useUsers()
+
+  const handleClose = () => {
+    setOpen(null)
+    setCurrentRow(null)
+  }
+
   return (
     <>
-      <UsersActionDialog
-        key='user-add'
-        open={open === 'add'}
-        onOpenChange={() => setOpen('add')}
-      />
+      <UsersActionDialog />
 
-      <UsersInviteDialog
-        key='user-invite'
+      {/* <UsersInviteDialog
         open={open === 'invite'}
-        onOpenChange={() => setOpen('invite')}
-      />
+        onOpenChange={(state) => {
+          if (!state) handleClose()
+        }}
+      /> */}
 
-      {currentRow && (
-        <>
-          <UsersActionDialog
-            key={`user-edit-${currentRow.id}`}
-            open={open === 'edit'}
-            onOpenChange={() => {
-              setOpen('edit')
-              setTimeout(() => {
-                setCurrentRow(null)
-              }, 500)
-            }}
-            currentRow={currentRow}
-          />
+      <UsersDeleteDialog
+        open={open === 'delete'}
+        onOpenChange={(state) => {
+          if (!state) handleClose()
+        }}
+        currentRow={currentRow}
+      />
 
-          <UsersDeleteDialog
-            key={`user-delete-${currentRow.id}`}
-            open={open === 'delete'}
-            onOpenChange={() => {
-              setOpen('delete')
-              setTimeout(() => {
-                setCurrentRow(null)
-              }, 500)
-            }}
-            currentRow={currentRow}
-          />
-        </>
-      )}
+      {/* <UsersMultiDeleteDialog
+        open={open === 'multi-delete'}
+        onOpenChange={(state) => {
+          if (!state) handleClose()
+        }}
+      /> */}
     </>
   )
 }

+ 0 - 157
src/features/users/components/users-invite-dialog.test.tsx

@@ -1,157 +0,0 @@
-import { useState } from 'react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { render } from 'vitest-browser-react'
-import { userEvent } from 'vitest/browser'
-import { showSubmittedData } from '@/lib/show-submitted-data'
-import { UsersInviteDialog } from './users-invite-dialog'
-
-vi.mock('@/lib/show-submitted-data', () => ({ showSubmittedData: vi.fn() }))
-
-describe('UsersInviteDialog', () => {
-  beforeEach(() => vi.clearAllMocks())
-
-  it('renders the dialog title and description', async () => {
-    const { getByRole, getByText } = await render(
-      <UsersInviteDialog open onOpenChange={vi.fn()} />
-    )
-
-    const title = getByRole('heading', {
-      level: 2,
-      name: /Invite User/i,
-    })
-    const desc = getByText(/Invite new user to join your team/i)
-
-    await expect.element(title).toBeInTheDocument()
-    await expect.element(desc).toBeInTheDocument()
-  })
-
-  it('closes when the dialog close button is clicked', async () => {
-    const onOpenChange = vi.fn()
-    const { getByRole } = await render(
-      <UsersInviteDialog open onOpenChange={onOpenChange} />
-    )
-
-    const closeButton = getByRole('button', { name: /Close/i })
-    await userEvent.click(closeButton)
-
-    expect(onOpenChange).toHaveBeenCalledOnce()
-    expect(onOpenChange).toHaveBeenCalledWith(false)
-  })
-
-  it('closes when Cancel is clicked', async () => {
-    const onOpenChange = vi.fn()
-    const { getByRole } = await render(
-      <UsersInviteDialog open onOpenChange={onOpenChange} />
-    )
-
-    const cancelButton = getByRole('button', { name: /Cancel/i })
-    await userEvent.click(cancelButton)
-
-    expect(onOpenChange).toHaveBeenCalledOnce()
-    expect(onOpenChange).toHaveBeenCalledWith(false)
-  })
-
-  it('shows error messages when submitting empty form, and clears them as fields are filled', async () => {
-    const onOpenChange = vi.fn()
-    const { getByRole, getByText } = await render(
-      <UsersInviteDialog open onOpenChange={onOpenChange} />
-    )
-
-    const emailErrorMessage = getByText(/Please enter an email to invite./i)
-    const roleErrorMessage = getByText(/Role is required./i)
-
-    const submitButton = getByRole('button', { name: /Invite/i })
-    await userEvent.click(submitButton)
-
-    await expect.element(emailErrorMessage).toBeInTheDocument()
-    await expect.element(roleErrorMessage).toBeInTheDocument()
-
-    const emailInput = getByRole('textbox', { name: /Email/i })
-    await userEvent.fill(emailInput, 'test@example.com')
-
-    const roleSelect = getByRole('combobox', { name: /Role/i })
-    await userEvent.click(roleSelect)
-    await userEvent.click(getByRole('option', { name: /Superadmin/i }))
-
-    await expect.element(emailErrorMessage).not.toBeInTheDocument()
-    await expect.element(roleErrorMessage).not.toBeInTheDocument()
-  })
-
-  it('resets entered values when the dialog is closed and reopened', async () => {
-    function Harness() {
-      const [open, setOpen] = useState(true)
-      return (
-        <>
-          <button type='button' onClick={() => setOpen(true)}>
-            Reopen
-          </button>
-          <UsersInviteDialog open={open} onOpenChange={setOpen} />
-        </>
-      )
-    }
-
-    const { getByRole } = await render(<Harness />)
-
-    const EMAIL_VALUE = 'test@example.com'
-    const ROLE_VALUE = 'Superadmin'
-    const DESC_VALUE = 'This is a test description'
-
-    const emailInput = getByRole('textbox', { name: /Email/i })
-    await userEvent.fill(emailInput, EMAIL_VALUE)
-
-    const roleSelect = getByRole('combobox', { name: /Role/i })
-    await userEvent.click(roleSelect)
-    await userEvent.click(getByRole('option', { name: ROLE_VALUE }))
-
-    const descInput = getByRole('textbox', { name: /Description/i })
-    await userEvent.fill(descInput, DESC_VALUE)
-
-    await expect.element(emailInput).toHaveValue(EMAIL_VALUE)
-    await expect.element(roleSelect).toHaveTextContent(ROLE_VALUE)
-    await expect.element(descInput).toHaveValue(DESC_VALUE)
-
-    const cancelButton = getByRole('button', { name: /Cancel/i })
-    await userEvent.click(cancelButton)
-
-    const reopenButton = getByRole('button', { name: /Reopen/i })
-    await userEvent.click(reopenButton)
-
-    await expect.element(emailInput).toHaveValue('')
-    await expect.element(roleSelect).toHaveTextContent('Select a role')
-    await expect.element(descInput).toHaveValue('')
-  })
-
-  it('shows submitted data when the form is submitted successfully', async () => {
-    const onOpenChange = vi.fn()
-    const { getByRole } = await render(
-      <UsersInviteDialog open onOpenChange={onOpenChange} />
-    )
-
-    const EMAIL_VALUE = 'test@example.com'
-    const ROLE_VALUE = 'superadmin'
-    const DESC_VALUE = 'Welcome aboard!'
-
-    const emailInput = getByRole('textbox', { name: /Email/i })
-    await userEvent.fill(emailInput, EMAIL_VALUE)
-
-    const roleSelect = getByRole('combobox', { name: /Role/i })
-    await userEvent.click(roleSelect)
-    await userEvent.click(getByRole('option', { name: ROLE_VALUE }))
-
-    const descInput = getByRole('textbox', { name: /Description/i })
-    await userEvent.fill(descInput, DESC_VALUE)
-
-    const submitButton = getByRole('button', { name: /Invite/i })
-    await userEvent.click(submitButton)
-
-    expect(onOpenChange).toHaveBeenCalledOnce()
-    expect(onOpenChange).toHaveBeenCalledWith(false)
-
-    expect(showSubmittedData).toHaveBeenCalledOnce()
-    expect(showSubmittedData).toHaveBeenCalledWith({
-      email: EMAIL_VALUE,
-      role: ROLE_VALUE,
-      desc: DESC_VALUE,
-    })
-  })
-})

+ 0 - 161
src/features/users/components/users-multi-delete-dialog.test.tsx

@@ -1,161 +0,0 @@
-import { useState } from 'react'
-import { createTableMock } from '@/test-utils/tanstack-table'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { render } from 'vitest-browser-react'
-import { userEvent } from 'vitest/browser'
-import { UsersMultiDeleteDialog } from './users-multi-delete-dialog'
-
-vi.mock('@/lib/utils', async (orig) => ({
-  ...(await orig()),
-  sleep: vi.fn(() => Promise.resolve()),
-}))
-
-describe('UsersMultiDeleteDialog', () => {
-  beforeEach(() => vi.clearAllMocks())
-
-  it('renders the dialog with the correct title, description, input and buttons', async () => {
-    const { table } = createTableMock()
-
-    const { getByRole, getByText } = await render(
-      <UsersMultiDeleteDialog open onOpenChange={vi.fn()} table={table} />
-    )
-
-    const title = getByRole('heading', {
-      level: 2,
-      name: /Delete 2 users/i,
-    })
-    const desc = getByText(
-      new RegExp(`Are you sure you want to delete the selected users?`, 'i')
-    )
-    const confirmDeleteInput = getByRole('textbox', {
-      name: /Confirm by typing "DELETE"/i,
-    })
-    const deleteButton = getByRole('button', { name: /Delete/i })
-
-    await expect.element(title).toBeInTheDocument()
-    await expect.element(desc).toBeInTheDocument()
-    await expect.element(confirmDeleteInput).toBeInTheDocument()
-    await expect.element(deleteButton).toBeInTheDocument()
-    await expect.element(deleteButton).toBeDisabled()
-  })
-
-  it('keeps the delete button disabled until the confirm delete input is filled correctly', async () => {
-    const { table } = createTableMock()
-    const { getByRole } = await render(
-      <UsersMultiDeleteDialog open onOpenChange={vi.fn()} table={table} />
-    )
-
-    const confirmDeleteInput = getByRole('textbox', {
-      name: /Confirm by typing "DELETE"/i,
-    })
-    const deleteButton = getByRole('button', { name: /Delete/i })
-
-    await expect.element(deleteButton).toBeDisabled()
-
-    await userEvent.fill(confirmDeleteInput, 'wrong-input')
-    await expect.element(deleteButton).toBeDisabled()
-
-    await userEvent.fill(confirmDeleteInput, 'DELETE')
-    await expect.element(deleteButton).toBeEnabled()
-  })
-
-  it('closes the dialog when the cancel button is clicked', async () => {
-    const { table } = createTableMock()
-    const onOpenChange = vi.fn()
-    const { getByRole } = await render(
-      <UsersMultiDeleteDialog open onOpenChange={onOpenChange} table={table} />
-    )
-
-    const cancelButton = getByRole('button', { name: /Cancel/i })
-    await userEvent.click(cancelButton)
-
-    expect(onOpenChange).toHaveBeenCalledOnce()
-    expect(onOpenChange).toHaveBeenCalledWith(false)
-  })
-
-  it('resets the confirm delete input when the dialog is closed and reopened', async () => {
-    const { table } = createTableMock()
-
-    function Harness() {
-      const [open, setOpen] = useState(true)
-      return (
-        <>
-          <button type='button' onClick={() => setOpen(true)}>
-            Reopen
-          </button>
-          {open ? (
-            <UsersMultiDeleteDialog
-              open={open}
-              onOpenChange={setOpen}
-              table={table}
-            />
-          ) : null}
-        </>
-      )
-    }
-
-    const { getByRole } = await render(<Harness />)
-
-    const confirmDeleteInput = getByRole('textbox', {
-      name: /Confirm by typing "DELETE"/i,
-    })
-    await userEvent.fill(confirmDeleteInput, 'DELETE')
-    await expect.element(confirmDeleteInput).toHaveValue('DELETE')
-
-    const cancelButton = getByRole('button', { name: /Cancel/i })
-    await userEvent.click(cancelButton)
-
-    const reopenButton = getByRole('button', { name: /Reopen/i })
-    await userEvent.click(reopenButton)
-    await expect.element(confirmDeleteInput).toHaveValue('')
-  })
-
-  it('shows the submitted data when deleted successfully', async () => {
-    const { table, resetRowSelection } = createTableMock()
-    const onOpenChange = vi.fn()
-    const { getByRole } = await render(
-      <UsersMultiDeleteDialog open onOpenChange={onOpenChange} table={table} />
-    )
-
-    const confirmDeleteInput = getByRole('textbox', {
-      name: /Confirm by typing "DELETE"/i,
-    })
-    const deleteButton = getByRole('button', { name: /Delete/i })
-
-    await expect.element(deleteButton).toBeDisabled()
-
-    await userEvent.fill(confirmDeleteInput, 'DELETE')
-    await expect.element(deleteButton).toBeEnabled()
-
-    await userEvent.click(deleteButton)
-
-    expect(onOpenChange).toHaveBeenCalledOnce()
-    expect(onOpenChange).toHaveBeenCalledWith(false)
-
-    await vi.waitFor(() => expect(resetRowSelection).toHaveBeenCalledOnce())
-  })
-
-  it('deletes successfully when press Enter key on the confirm delete input', async () => {
-    const { table, resetRowSelection } = createTableMock()
-    const onOpenChange = vi.fn()
-    const { getByRole } = await render(
-      <UsersMultiDeleteDialog open onOpenChange={onOpenChange} table={table} />
-    )
-
-    const confirmDeleteInput = getByRole('textbox', {
-      name: /Confirm by typing "DELETE"/i,
-    })
-    const deleteButton = getByRole('button', { name: /Delete/i })
-
-    await expect.element(deleteButton).toBeDisabled()
-
-    await userEvent.fill(confirmDeleteInput, 'DELETE')
-    await expect.element(deleteButton).toBeEnabled()
-
-    await userEvent.keyboard('{Enter}')
-    expect(onOpenChange).toHaveBeenCalledOnce()
-    expect(onOpenChange).toHaveBeenCalledWith(false)
-
-    await vi.waitFor(() => expect(resetRowSelection).toHaveBeenCalledOnce())
-  })
-})

+ 3 - 3
src/features/users/components/users-primary-buttons.tsx

@@ -1,4 +1,4 @@
-import { MailPlus, UserPlus } from 'lucide-react'
+import { UserPlus } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import { useUsers } from './users-provider'
 
@@ -6,13 +6,13 @@ export function UsersPrimaryButtons() {
   const { setOpen } = useUsers()
   return (
     <div className='flex gap-2'>
-      <Button
+      {/* <Button
         variant='outline'
         className='space-x-1'
         onClick={() => setOpen('invite')}
       >
         <span>دعوت کاربر</span> <MailPlus size={18} />
-      </Button>
+      </Button> */}
       <Button className='space-x-1' onClick={() => setOpen('add')}>
         <span>افزودن کاربر</span> <UserPlus size={18} />
       </Button>

+ 28 - 15
src/features/users/components/users-provider.tsx

@@ -1,36 +1,49 @@
-import React, { useState } from 'react'
-import useDialogState from '@/hooks/use-dialog-state'
-import { type User } from '../data/schema'
+import React, { createContext, useContext, useState } from 'react'
 
-type UsersDialogType = 'invite' | 'add' | 'edit' | 'delete'
+import { type User } from '@/types/users.types'
 
-type UsersContextType = {
+type UsersDialogType = 'add' | 'edit' | 'view' | 'delete' | 'multi-delete'
+
+interface UsersContextType {
   open: UsersDialogType | null
   setOpen: (str: UsersDialogType | null) => void
+
   currentRow: User | null
   setCurrentRow: React.Dispatch<React.SetStateAction<User | null>>
 }
 
-const UsersContext = React.createContext<UsersContextType | null>(null)
+const UsersContext = createContext<UsersContextType | undefined>(undefined)
+
+interface Props {
+  children: React.ReactNode
+}
+
+export function UsersProvider({ children }: Props) {
+  const [open, setOpen] = useState<UsersDialogType | null>(null)
 
-export function UsersProvider({ children }: { children: React.ReactNode }) {
-  const [open, setOpen] = useDialogState<UsersDialogType>(null)
   const [currentRow, setCurrentRow] = useState<User | null>(null)
 
   return (
-    <UsersContext value={{ open, setOpen, currentRow, setCurrentRow }}>
+    <UsersContext.Provider
+      value={{
+        open,
+        setOpen,
+        currentRow,
+        setCurrentRow,
+      }}
+    >
       {children}
-    </UsersContext>
+    </UsersContext.Provider>
   )
 }
 
 // eslint-disable-next-line react-refresh/only-export-components
-export const useUsers = () => {
-  const usersContext = React.useContext(UsersContext)
+export function useUsers() {
+  const context = useContext(UsersContext)
 
-  if (!usersContext) {
-    throw new Error('useUsers has to be used within <UsersContext>')
+  if (!context) {
+    throw new Error('useUsers must be used within UsersProvider')
   }
 
-  return usersContext
+  return context
 }

+ 3 - 6
src/features/users/components/users-table.tsx

@@ -6,9 +6,6 @@ import {
   getCoreRowModel,
   getFacetedRowModel,
   getFacetedUniqueValues,
-  getFilteredRowModel,
-  getPaginationRowModel,
-  getSortedRowModel,
   useReactTable,
 } from '@tanstack/react-table'
 import { cn } from '@/lib/utils'
@@ -74,16 +71,16 @@ export function UsersTable({ data, search, navigate }: DataTableProps) {
       columnFilters,
       columnVisibility,
     },
+    manualPagination: true,
+    manualFiltering: true,
+    manualSorting: true,
     enableRowSelection: true,
     onPaginationChange,
     onColumnFiltersChange,
     onRowSelectionChange: setRowSelection,
     onSortingChange: setSorting,
     onColumnVisibilityChange: setColumnVisibility,
-    getPaginationRowModel: getPaginationRowModel(),
     getCoreRowModel: getCoreRowModel(),
-    getFilteredRowModel: getFilteredRowModel(),
-    getSortedRowModel: getSortedRowModel(),
     getFacetedRowModel: getFacetedRowModel(),
     getFacetedUniqueValues: getFacetedUniqueValues(),
   })

+ 11 - 0
src/hooks/location/useCities.ts

@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { locationService } from '@/services/location.service'
+import { queryKeys } from '@/core/react-query/keys'
+
+export function useCities(provinceId: number) {
+  return useQuery({
+    queryKey: queryKeys.location.cities(provinceId),
+    queryFn: () => locationService.cities(provinceId!),
+    enabled: !!provinceId,
+  })
+}

+ 10 - 0
src/hooks/location/useProvinces.ts

@@ -0,0 +1,10 @@
+import { useQuery } from '@tanstack/react-query'
+import { locationService } from '@/services/location.service'
+import { queryKeys } from '@/core/react-query/keys'
+
+export function useProvinces() {
+  return useQuery({
+    queryKey: queryKeys.location.provinces,
+    queryFn: locationService.provinces,
+  })
+}

+ 17 - 0
src/hooks/users/useCreateUser.tsx

@@ -0,0 +1,17 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { usersService } from '@/services/users.service'
+import { queryKeys } from '@/core/react-query/keys'
+
+export function useCreateUser() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: usersService.create,
+
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.users.lists(),
+      })
+    },
+  })
+}

+ 18 - 0
src/hooks/users/useDeleteUser.tsx

@@ -0,0 +1,18 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import { usersService } from '@/services/users.service'
+import { queryKeys } from '@/core/react-query/keys'
+
+export function useDeleteUser() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: (userId: number) => usersService.delete(userId),
+
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.users.lists(),
+      })
+    },
+  })
+}

+ 17 - 0
src/hooks/users/useUpdateUser.tsx

@@ -0,0 +1,17 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { usersService } from '@/services/users.service'
+import { queryKeys } from '@/core/react-query/keys'
+
+export function useUpdateUser() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: ({ id, payload }: any) => usersService.update(id, payload),
+
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        queryKey: queryKeys.users.lists(),
+      })
+    },
+  })
+}

+ 11 - 0
src/hooks/users/useUserDetail.tsx

@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { usersService } from '@/services/users.service'
+import { queryKeys } from '@/core/react-query/keys'
+
+export function useUserDetail(userId: number) {
+  return useQuery({
+    queryKey: queryKeys.users.user(userId),
+    queryFn: () => usersService.detail(userId!),
+    enabled: !!userId,
+  })
+}

+ 15 - 0
src/lib/numberFromSelect.ts

@@ -0,0 +1,15 @@
+import { z } from 'zod'
+
+export const numberFromSelect = z.preprocess((value) => {
+  if (value === '' || value === null || value === undefined) {
+    return undefined
+  }
+  return Number(value)
+}, z.number().optional())
+
+export const optionalNumberFromSelect = z.preprocess((value) => {
+  if (value === '' || value === null || value === undefined) {
+    return undefined
+  }
+  return Number(value)
+}, z.number().optional())

+ 20 - 0
src/services/location.service.ts

@@ -0,0 +1,20 @@
+import http from '@/core/api/http'
+import {
+  type GetCityResponse,
+  type GetProvinceResponse,
+} from '@/types/location.type'
+
+export const locationService = {
+  provinces: async () => {
+    const res = await http.get<GetProvinceResponse>('/public/provinces')
+    return res.data.provinces
+  },
+
+  cities: async (provinceId: number) => {
+    const res = await http.get<GetCityResponse>('/public/cities', {
+      params: { province_id: provinceId },
+    })
+
+    return res.data.cities
+  },
+}

+ 19 - 3
src/services/users.service.ts

@@ -2,8 +2,9 @@ import http from '@/core/api/http'
 import type {
   GetUserResponse,
   GetUserDetailResponse,
+  CreateUserPayload,
 } from '@/types/users.types'
-import type { PaginationParams } from '@/types/common.types'
+import type { ApiResponse, PaginationParams } from '@/types/common.types'
 
 const BASE_URL = '/admin/users'
 
@@ -20,7 +21,22 @@ export const usersService = {
     return http.get<GetUserResponse>(url)
   },
 
-  detail: async (userId: number | string): Promise<GetUserDetailResponse> => {
-    return http.get<GetUserDetailResponse>(`${BASE_URL}/${userId}`)
+  detail: async (userId: number | string) => {
+    const res = await http.get<GetUserDetailResponse>(`${BASE_URL}/${userId}`)
+    return res?.data?.user
+  },
+
+  create: async (payload: CreateUserPayload) => {
+    const res = await http.post<ApiResponse<void>>(BASE_URL, payload)
+    return res?.data
+  },
+
+  update: async (id: number, payload: CreateUserPayload) => {
+    const res = await http.put<ApiResponse<void>>(`${BASE_URL}/${id}`, payload)
+    return res?.data
+  },
+
+  delete: async (userId: number | string) => {
+    return http.delete(`${BASE_URL}/${userId}`)
   },
 }

+ 7 - 0
src/types/common.types.ts

@@ -2,3 +2,10 @@ export interface PaginationParams {
   page?: number
   per_page?: number
 }
+
+export type ApiResponse<T> = {
+  success: boolean
+  message: string
+  data: T | null
+  errors?: Record<string, string[]>
+}

+ 23 - 0
src/types/users.types.ts

@@ -77,3 +77,26 @@ export interface GetUserDetailResponse {
     posts: Post[]
   }
 }
+
+export interface CreateUserPayload {
+  phone: string
+  first_name: string
+  last_name: string
+  email?: string
+  username: string
+  province_id?: number
+  city_id?: number
+  gender: number
+  birth_date: string
+  weight?: number
+  height?: number
+  foot_specialization?: string
+  post_skill?: string
+  skill_level?: string
+  activity_history?: boolean
+  team_name?: string
+  favorite_iranian_team?: string
+  favorite_foreign_team?: string
+  shirt_number?: number
+  bio?: string
+}