Compare commits
15 Commits
94a0091d1b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0369553c39 | |||
| 3676688849 | |||
| 7d4e2865bb | |||
| 8563508674 | |||
| 74fec3d2be | |||
| b9bc80acf1 | |||
| 2599eac261 | |||
| a5ac1d6a24 | |||
| 71ee8f4575 | |||
| 8c89128dad | |||
| bf16a84ba4 | |||
| e28a0d611e | |||
| 6b2d241eb2 | |||
| c01f1a32dc | |||
| 026b605943 |
Generated
+377
-1
@@ -26,6 +26,7 @@
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-spinners": "^0.17.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"recharts": "^3.10.0",
|
||||
"swiper": "^12.0.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
@@ -1440,6 +1441,32 @@
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.38",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
|
||||
@@ -1733,6 +1760,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
@@ -2102,6 +2141,69 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2169,6 +2271,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
|
||||
@@ -2797,6 +2905,127 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2814,6 +3043,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -2927,6 +3162,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
||||
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
@@ -3170,6 +3415,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3575,6 +3826,17 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.15",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz",
|
||||
"integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -3602,6 +3864,15 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -4496,7 +4767,8 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-loading-skeleton": {
|
||||
"version": "3.5.0",
|
||||
@@ -4531,6 +4803,30 @@
|
||||
"react-dom": ">=16.8.6 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -4602,6 +4898,58 @@
|
||||
"react-dom": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz",
|
||||
"integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^11.1.8",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.2.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -4862,6 +5210,12 @@
|
||||
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-warning": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||
@@ -5093,6 +5447,28 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-spinners": "^0.17.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"recharts": "^3.10.0",
|
||||
"swiper": "^12.0.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Archive } from 'iconsax-react'
|
||||
import { type FC, useState } from 'react'
|
||||
import ModalConfrim from './ModalConfrim'
|
||||
|
||||
interface Props {
|
||||
onArchive: () => void
|
||||
isloading?: boolean
|
||||
colorIcon?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const ArchiveWithConfirm: FC<Props> = ({
|
||||
onArchive,
|
||||
isloading = false,
|
||||
colorIcon,
|
||||
title = 'آرشیو',
|
||||
}) => {
|
||||
const [isConfirm, setIsConfirm] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={() => setIsConfirm(true)}
|
||||
className="inline-flex"
|
||||
>
|
||||
<Archive className="size-5" color={colorIcon || '#8C90A3'} />
|
||||
</button>
|
||||
|
||||
<ModalConfrim
|
||||
isOpen={isConfirm}
|
||||
close={() => setIsConfirm(false)}
|
||||
onConfrim={() => {
|
||||
onArchive()
|
||||
setIsConfirm(false)
|
||||
}}
|
||||
isloading={isloading}
|
||||
title_header="آرشیو پیشفاکتور"
|
||||
label="آیا از آرشیو کردن این پیشفاکتور مطمئن هستید؟"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArchiveWithConfirm
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type FC, type MouseEvent } from 'react'
|
||||
import { useEffect, useRef, useState, type FC, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { ArrowDown2, CloseCircle, TickSquare } from 'iconsax-react'
|
||||
import { clx } from '../helpers/utils'
|
||||
import type { ItemsSelectType } from './Select'
|
||||
@@ -55,7 +55,7 @@ const MultiSelect: FC<Props> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = (e: MouseEvent) => {
|
||||
const handleClear = (e: ReactMouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
onBlur?.()
|
||||
|
||||
@@ -8,8 +8,8 @@ export const Paths = {
|
||||
create: '/product/create',
|
||||
update: '/product/update/',
|
||||
category: {
|
||||
create: '/product/category/create',
|
||||
list: '/product/category/list',
|
||||
children: '/product/category/list/',
|
||||
update: '/product/category/update/',
|
||||
},
|
||||
attribute: {
|
||||
@@ -56,6 +56,7 @@ export const Paths = {
|
||||
print: '/service/print',
|
||||
},
|
||||
home: '/home',
|
||||
profile: '/profile',
|
||||
myOrders: '/my-orders',
|
||||
perfomaInvoice: {
|
||||
list: '/invoice/list',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Paths } from '@/config/Paths'
|
||||
|
||||
/** Permission names aligned with API PermissionEnum. */
|
||||
export const Permissions = {
|
||||
DASHBOARD: 'dashboard',
|
||||
VIEW_ORDERS: 'view_orders',
|
||||
VIEW_ASSIGNED_ORDERS: 'view_assigned_orders',
|
||||
VIEW_INVOICES: 'view_invoices',
|
||||
VIEW_REQUESTS: 'view_requests',
|
||||
MANAGE_PAYMENTS: 'manage_payments',
|
||||
VIEW_USERS: 'view_users',
|
||||
VIEW_PRINT_FORM: 'view_print_form',
|
||||
MANAGE_ADMINS: 'manage_admins',
|
||||
MANAGE_ROLES: 'manage_roles',
|
||||
MANAGE_PRODUCTS: 'manage_products',
|
||||
MANAGE_TICKETS: 'manage_tickets',
|
||||
MANAGE_ANNOUNCEMENTS: 'manage_announcements',
|
||||
MANAGE_CRITICISMS: 'manage_criticisms',
|
||||
MANAGE_LEARNINGS: 'manage_learnings',
|
||||
} as const
|
||||
|
||||
export type PermissionName = (typeof Permissions)[keyof typeof Permissions]
|
||||
|
||||
export const ORDER_VIEW_PERMISSIONS: PermissionName[] = [
|
||||
Permissions.VIEW_ORDERS,
|
||||
Permissions.VIEW_ASSIGNED_ORDERS,
|
||||
]
|
||||
|
||||
type LandingRoute = {
|
||||
permissions: PermissionName | PermissionName[]
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Sidebar-ordered landing candidates when choosing a post-login / fallback path. */
|
||||
const LANDING_ROUTES: LandingRoute[] = [
|
||||
{ permissions: Permissions.DASHBOARD, path: Paths.home },
|
||||
{ permissions: ORDER_VIEW_PERMISSIONS, path: Paths.order.list },
|
||||
{ permissions: Permissions.VIEW_INVOICES, path: Paths.perfomaInvoice.list },
|
||||
{ permissions: Permissions.VIEW_REQUESTS, path: Paths.requests.list },
|
||||
{ permissions: Permissions.MANAGE_PAYMENTS, path: Paths.payments.list },
|
||||
{ permissions: Permissions.VIEW_USERS, path: Paths.users.list },
|
||||
{ permissions: Permissions.VIEW_PRINT_FORM, path: Paths.print.list },
|
||||
{ permissions: Permissions.MANAGE_ADMINS, path: Paths.admin.list },
|
||||
{ permissions: Permissions.MANAGE_PRODUCTS, path: Paths.product.list },
|
||||
{ permissions: Permissions.MANAGE_TICKETS, path: Paths.tickets.list },
|
||||
{ permissions: Permissions.MANAGE_ANNOUNCEMENTS, path: Paths.announcement.list },
|
||||
{ permissions: Permissions.MANAGE_CRITICISMS, path: Paths.criticisms.list },
|
||||
{ permissions: Permissions.MANAGE_LEARNINGS, path: Paths.learning.list },
|
||||
]
|
||||
|
||||
export const hasPermission = (
|
||||
userPermissions: string[],
|
||||
required: string | string[],
|
||||
): boolean => {
|
||||
const requiredList = Array.isArray(required) ? required : [required]
|
||||
return requiredList.some((permission) => userPermissions.includes(permission))
|
||||
}
|
||||
|
||||
/** First permitted app path, or profile if none match. */
|
||||
export const getDefaultAdminPath = (permissions: string[]): string => {
|
||||
for (const route of LANDING_ROUTES) {
|
||||
if (hasPermission(permissions, route.permissions)) {
|
||||
return route.path
|
||||
}
|
||||
}
|
||||
return Paths.profile
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { getToken, removeRefreshToken, removeToken } from "./func";
|
||||
import { useAuthStore } from "@/pages/auth/store/AuthStore";
|
||||
|
||||
type SessionAuthStore = {
|
||||
isAuthenticated: boolean;
|
||||
@@ -14,6 +15,7 @@ export const useSessionAuth = create<SessionAuthStore>((set) => ({
|
||||
removeToken();
|
||||
removeRefreshToken();
|
||||
window.isRefreshTokenExpired = false;
|
||||
useAuthStore.getState().reset();
|
||||
set({ isAuthenticated: false });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
type UseVoiceRecorderOptions = {
|
||||
onRecordingComplete?: (audioBlob: Blob) => void;
|
||||
onRecordingComplete?: (audioFile: File, audioUrl: string) => void;
|
||||
};
|
||||
|
||||
export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
@@ -18,6 +18,33 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const audioUrlRef = useRef<string | null>(null);
|
||||
const onRecordingCompleteRef = useRef(onRecordingComplete);
|
||||
|
||||
useEffect(() => {
|
||||
onRecordingCompleteRef.current = onRecordingComplete;
|
||||
}, [onRecordingComplete]);
|
||||
|
||||
const clearRecording = useCallback((options?: { revoke?: boolean }) => {
|
||||
const shouldRevoke = options?.revoke !== false;
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
|
||||
if (shouldRevoke && audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
}
|
||||
|
||||
audioUrlRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setAudioUrl(null);
|
||||
setAudioFile(null);
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
@@ -35,14 +62,15 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
const file = new File([audioBlob], "voice-message.webm", {
|
||||
const file = new File([audioBlob], `voice-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
audioUrlRef.current = url;
|
||||
setAudioUrl(url);
|
||||
setAudioFile(file);
|
||||
onRecordingComplete?.(audioBlob);
|
||||
onRecordingCompleteRef.current?.(file, url);
|
||||
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
@@ -52,7 +80,7 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
} catch (err) {
|
||||
console.error("Microphone access error:", err);
|
||||
}
|
||||
}, [onRecordingComplete]);
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
@@ -65,24 +93,9 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
|
||||
chunksRef.current = [];
|
||||
setIsRecording(false);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setAudioUrl(null);
|
||||
setAudioFile(null);
|
||||
}, [audioUrl, isRecording]);
|
||||
clearRecording({ revoke: true });
|
||||
}, [clearRecording, isRecording]);
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
@@ -136,24 +149,18 @@ export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||
}, [isPlaying, updateProgress]);
|
||||
|
||||
return {
|
||||
// state
|
||||
isRecording,
|
||||
isPlaying,
|
||||
audioUrl,
|
||||
audioFile,
|
||||
currentTime,
|
||||
duration,
|
||||
|
||||
// refs
|
||||
audioRef,
|
||||
|
||||
// actions
|
||||
startRecording,
|
||||
stopRecording,
|
||||
togglePlayPause,
|
||||
resetRecorder,
|
||||
|
||||
// helpers
|
||||
clearRecording,
|
||||
progress: duration ? currentTime / duration : 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +26,10 @@ export const fa = {
|
||||
factureCount: "پیش فاکتور تایید نشده",
|
||||
orderCount: "سفارش های در حال انجام",
|
||||
orderDoneCount: "سفارشات انجام شده",
|
||||
customerCount: "تعداد مشتریان",
|
||||
weeklyOrders: "سفارشات هفتگی",
|
||||
week: "هفته",
|
||||
orders: "سفارش",
|
||||
services: "خدمات",
|
||||
submitNewOrder: "سفارش خودرا ثبت کنید",
|
||||
submitNewOrderButton: "سفارش جدید",
|
||||
|
||||
+26
-10
@@ -1,31 +1,47 @@
|
||||
import { useState, type FC } from "react";
|
||||
import { useGetAdmins, useDeleteAdmin } from "./hooks/useAdminData";
|
||||
import { useGetAdmins, useDeleteAdmin, useGetAdminMe } from "./hooks/useAdminData";
|
||||
import Table from "@/components/Table";
|
||||
import PresignedImage from "@/components/PresignedImage";
|
||||
import moment from "moment-jalaali";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Paths } from "@/config/Paths";
|
||||
import Button from "@/components/Button";
|
||||
import { AddSquare, Eye } from "iconsax-react";
|
||||
import { AddSquare, Eye, ShieldSecurity } from "iconsax-react";
|
||||
import TrashWithConfrim from "@/components/TrashWithConfrim";
|
||||
|
||||
const AdminList: FC = () => {
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const { data } = useGetAdmins(page);
|
||||
const deleteAdminMutation = useDeleteAdmin();
|
||||
const { data: adminMe } = useGetAdminMe();
|
||||
const canManageRoles = adminMe?.data?.role?.permissions?.some(
|
||||
(p) => p.name === "manage_roles",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-lg font-light">مدیران</h1>
|
||||
<Link to={Paths.admin.create}>
|
||||
<Button className="w-fit px-6">
|
||||
<div className="flex gap-1.5">
|
||||
<AddSquare size={18} color="black" />
|
||||
<div className="text-[13px] font-light">ادمین جدید</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
{canManageRoles && (
|
||||
<Link to={Paths.role.list}>
|
||||
<Button className="w-fit px-6 bg-[#F5F7FC]">
|
||||
<div className="flex gap-1.5">
|
||||
<ShieldSecurity size={18} color="black" />
|
||||
<div className="text-[13px] font-light text-black">نقشها</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link to={Paths.admin.create}>
|
||||
<Button className="w-fit px-6">
|
||||
<div className="flex gap-1.5">
|
||||
<AddSquare size={18} color="black" />
|
||||
<div className="text-[13px] font-light">کاربر جدید</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as api from "../service/AdminService";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CreateAdminType } from "../types/Types";
|
||||
import type { CreateAdminType, UpdateAdminMeType } from "../types/Types";
|
||||
|
||||
export const useGetAdmins = (page: number = 1, limit: number = 10) => {
|
||||
return useQuery({
|
||||
@@ -59,3 +59,14 @@ export const useGetAdminMe = () => {
|
||||
queryFn: api.getAdminMe,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateAdminMe = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UpdateAdminMeType) => api.updateAdminMe(params),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["adminMe"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
RolesResponseType,
|
||||
AdminDetailResponseType,
|
||||
AdminMeResponseType,
|
||||
UpdateAdminMeType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const getAdmins = async (page: number, limit: number = 10) => {
|
||||
@@ -52,3 +53,8 @@ export const getAdminMe = async () => {
|
||||
const { data } = await axios.get<AdminMeResponseType>("/admin/admins/me");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateAdminMe = async (params: UpdateAdminMeType) => {
|
||||
const { data } = await axios.patch<AdminMeResponseType>("/admin/admins/me", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -51,8 +51,25 @@ export type RoleItemType = {
|
||||
export type RolesResponseType = BaseResponse<RoleItemType[]>;
|
||||
export type AdminDetailResponseType = BaseResponse<AdminItemType>;
|
||||
|
||||
/** Admin "me" response: admin with full role including permissions */
|
||||
export type AdminMeDataType = Omit<AdminItemType, "role"> & {
|
||||
/** Admin "me" response: admin with full role including permissions.
|
||||
* Defined as an interface (not Omit) so RowDataType's index signature
|
||||
* does not widen named fields to unknown via mapped types.
|
||||
*/
|
||||
export interface AdminMeDataType {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
avatarUrl?: string | null;
|
||||
role: RoleItemType;
|
||||
}
|
||||
export type UpdateAdminMeType = {
|
||||
phone: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type AdminMeResponseType = BaseResponse<AdminMeDataType>;
|
||||
|
||||
@@ -11,9 +11,11 @@ import { useLoginWithOtp, useOtpVerify } from '../hooks/useAuthData'
|
||||
import { extractErrorMessage, setRefreshToken } from '../../../config/func'
|
||||
import { setToken } from '../../../config/func'
|
||||
import { toast } from '@/shared/toast'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import { useSessionAuth } from '@/config/sessionAuth'
|
||||
import { appNavigate } from '@/config/navigation'
|
||||
import { getDefaultAdminPath } from '@/config/permissions'
|
||||
import type { BaseResponse } from '@/shared/types/Types'
|
||||
import type { OtpVerifyResponseType } from '../types/AuthTypes'
|
||||
|
||||
const LoginStep2: FC = () => {
|
||||
|
||||
@@ -39,11 +41,12 @@ const LoginStep2: FC = () => {
|
||||
otp: values.otp
|
||||
}
|
||||
otpVerify.mutate(params, {
|
||||
onSuccess(data) {
|
||||
onSuccess(data: BaseResponse<OtpVerifyResponseType>) {
|
||||
setToken(data?.data?.tokens?.accessToken?.token)
|
||||
setRefreshToken(data?.data?.tokens?.refreshToken?.token)
|
||||
useSessionAuth.getState().setAuthenticated(true)
|
||||
appNavigate(Paths.home, { replace: true })
|
||||
const permissions = data?.data?.admin?.permissions ?? []
|
||||
appNavigate(getDefaultAdminPath(permissions), { replace: true })
|
||||
},
|
||||
onError(error) {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { create } from "zustand";
|
||||
import { type AuthStoreType } from "../../auth/types/AuthTypes";
|
||||
|
||||
export const useAuthStore = create<AuthStoreType>((set) => ({
|
||||
const initialState = {
|
||||
phone: "",
|
||||
email: "",
|
||||
stepLogin: 1,
|
||||
devOtpCode: "",
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthStoreType>((set) => ({
|
||||
...initialState,
|
||||
setPhone(value) {
|
||||
set({ phone: value });
|
||||
},
|
||||
email: "",
|
||||
setEmail(value) {
|
||||
set({ email: value });
|
||||
},
|
||||
stepLogin: 1,
|
||||
setStepLogin(value) {
|
||||
set({ stepLogin: value });
|
||||
},
|
||||
devOtpCode: "",
|
||||
setDevOtpCode(value) {
|
||||
set({ devOtpCode: value });
|
||||
},
|
||||
reset() {
|
||||
set({ ...initialState });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -15,6 +15,7 @@ export type AuthStoreType = {
|
||||
/** TODO: remove before production — dev-only OTP from API response */
|
||||
devOtpCode: string;
|
||||
setDevOtpCode: (value: string) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export type LoginWithPasswordType = {
|
||||
@@ -32,6 +33,22 @@ export type OtpVerifyType = {
|
||||
otp: string;
|
||||
};
|
||||
|
||||
export type AdminLoginResponseType = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
export type OtpVerifyResponseType = {
|
||||
tokens: {
|
||||
accessToken: { token: string };
|
||||
refreshToken: { token: string };
|
||||
};
|
||||
admin: AdminLoginResponseType;
|
||||
};
|
||||
|
||||
export type CheckHasAccountType = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import Button from '@/components/Button'
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import { toast } from '@/shared/toast'
|
||||
import {
|
||||
CloseCircle,
|
||||
DocumentText,
|
||||
Microphone,
|
||||
Paperclip2,
|
||||
Pause,
|
||||
Play,
|
||||
} from 'iconsax-react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type FC,
|
||||
} from 'react'
|
||||
|
||||
export type ChatComposerAttachment = {
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ChatComposerSubmitPayload = {
|
||||
content: string
|
||||
attachments: ChatComposerAttachment[]
|
||||
}
|
||||
|
||||
type PendingFile = {
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string | null
|
||||
key?: string
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
}
|
||||
|
||||
type PendingVoice = {
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string
|
||||
key?: string
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onSubmit: (payload: ChatComposerSubmitPayload) => void | Promise<void>
|
||||
isSubmitting?: boolean
|
||||
submitLabel?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
replyTo?: {
|
||||
id: string
|
||||
content: string
|
||||
senderName?: string
|
||||
} | null
|
||||
onCancelReply?: () => void
|
||||
}
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
|
||||
const VoicePreviewChip: FC<{
|
||||
voice: PendingVoice
|
||||
onRemove: () => void
|
||||
}> = ({ voice, onRemove }) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!audio.duration) return
|
||||
setProgress(audio.currentTime / audio.duration)
|
||||
}
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false)
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate)
|
||||
audio.addEventListener('ended', onEnded)
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate)
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
void audio.play()
|
||||
setIsPlaying(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-16 w-[140px] items-center gap-2 rounded-lg border border-[#EBEDF5] bg-[#F5F7FC] px-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayPause}
|
||||
disabled={voice.status === 'uploading'}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-black disabled:opacity-50"
|
||||
aria-label={isPlaying ? 'توقف' : 'پخش'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} color="#fff" variant="Bold" />
|
||||
) : (
|
||||
<Play size={16} color="#fff" variant="Bold" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex h-8 flex-1 items-center justify-center gap-[2px]">
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const passed = i / 12 <= progress
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[2px] rounded-full transition-all"
|
||||
style={{
|
||||
height: `${6 + ((i * 5) % 12)}px`,
|
||||
backgroundColor: passed ? '#0047FF' : '#E5E7EB',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{voice.status === 'uploading' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/35">
|
||||
<span className="size-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voice.status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-red-500/70 px-1">
|
||||
<span className="text-center text-[10px] text-white">خطا</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="absolute -left-1.5 -top-1.5 z-10 rounded-full bg-white shadow-sm"
|
||||
aria-label="حذف صدا"
|
||||
>
|
||||
<CloseCircle size={18} color="#EF4444" variant="Bold" />
|
||||
</button>
|
||||
|
||||
<audio ref={audioRef} src={voice.previewUrl} className="hidden" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatComposer: FC<Props> = ({
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitLabel = 'ارسال پیام',
|
||||
label = 'پیام شما',
|
||||
placeholder = 'متن پیام خود را بنویسید...',
|
||||
replyTo = null,
|
||||
onCancelReply,
|
||||
}) => {
|
||||
const inputId = useId()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([])
|
||||
const [pendingVoices, setPendingVoices] = useState<PendingVoice[]>([])
|
||||
const singleUpload = useSingleUpload()
|
||||
const clearRecordingRef = useRef<(options?: { revoke?: boolean }) => void>(
|
||||
() => undefined,
|
||||
)
|
||||
|
||||
const uploadVoice = useCallback(
|
||||
async (item: PendingVoice) => {
|
||||
try {
|
||||
const result = await singleUpload.mutateAsync(item.file)
|
||||
setPendingVoices((prev) =>
|
||||
prev.map((voice) =>
|
||||
voice.id === item.id
|
||||
? { ...voice, key: result.data.key, status: 'done' }
|
||||
: voice,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
setPendingVoices((prev) =>
|
||||
prev.map((voice) =>
|
||||
voice.id === item.id
|
||||
? { ...voice, status: 'error' }
|
||||
: voice,
|
||||
),
|
||||
)
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
},
|
||||
[singleUpload],
|
||||
)
|
||||
|
||||
const handleRecordingComplete = useCallback(
|
||||
(file: File, url: string) => {
|
||||
const item: PendingVoice = {
|
||||
id: `voice-${Date.now()}-${Math.random()}`,
|
||||
file,
|
||||
previewUrl: url,
|
||||
status: 'uploading',
|
||||
}
|
||||
setPendingVoices((prev) => [...prev, item])
|
||||
clearRecordingRef.current({ revoke: false })
|
||||
void uploadVoice(item)
|
||||
},
|
||||
[uploadVoice],
|
||||
)
|
||||
|
||||
const { isRecording, startRecording, stopRecording, clearRecording } =
|
||||
useVoiceRecorder({
|
||||
onRecordingComplete: handleRecordingComplete,
|
||||
})
|
||||
|
||||
clearRecordingRef.current = clearRecording
|
||||
|
||||
const isUploading =
|
||||
pendingFiles.some((f) => f.status === 'uploading') ||
|
||||
pendingVoices.some((v) => v.status === 'uploading')
|
||||
|
||||
const isBusy = isSubmitting || singleUpload.isPending || isUploading
|
||||
|
||||
const revokePreview = (previewUrl: string | null) => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
|
||||
const resetComposer = useCallback(() => {
|
||||
setPendingFiles((prev) => {
|
||||
prev.forEach((item) => revokePreview(item.previewUrl))
|
||||
return []
|
||||
})
|
||||
setPendingVoices((prev) => {
|
||||
prev.forEach((item) => revokePreview(item.previewUrl))
|
||||
return []
|
||||
})
|
||||
setMessage('')
|
||||
clearRecording({ revoke: true })
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}, [clearRecording])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pendingFiles.forEach((item) => revokePreview(item.previewUrl))
|
||||
pendingVoices.forEach((item) => revokePreview(item.previewUrl))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const uploadFile = async (item: PendingFile) => {
|
||||
try {
|
||||
const result = await singleUpload.mutateAsync(item.file)
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((file) =>
|
||||
file.id === item.id
|
||||
? { ...file, key: result.data.key, status: 'done' }
|
||||
: file,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
setPendingFiles((prev) =>
|
||||
prev.map((file) =>
|
||||
file.id === item.id ? { ...file, status: 'error' } : file,
|
||||
),
|
||||
)
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesSelected = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const selected = Array.from(event.target.files ?? [])
|
||||
if (!selected.length) return
|
||||
|
||||
const nextItems: PendingFile[] = selected.map((file) => ({
|
||||
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()}`,
|
||||
file,
|
||||
previewUrl: isImageFile(file) ? URL.createObjectURL(file) : null,
|
||||
status: 'uploading',
|
||||
}))
|
||||
|
||||
setPendingFiles((prev) => [...prev, ...nextItems])
|
||||
event.target.value = ''
|
||||
|
||||
for (const item of nextItems) {
|
||||
await uploadFile(item)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = (id: string) => {
|
||||
setPendingFiles((prev) => {
|
||||
const target = prev.find((item) => item.id === id)
|
||||
revokePreview(target?.previewUrl ?? null)
|
||||
return prev.filter((item) => item.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveVoice = (id: string) => {
|
||||
setPendingVoices((prev) => {
|
||||
const target = prev.find((item) => item.id === id)
|
||||
revokePreview(target?.previewUrl ?? null)
|
||||
return prev.filter((item) => item.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const uploadedFiles = pendingFiles.filter(
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
const uploadedVoices = pendingVoices.filter(
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
|
||||
if (
|
||||
!message.trim() &&
|
||||
uploadedFiles.length === 0 &&
|
||||
uploadedVoices.length === 0
|
||||
) {
|
||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (isUploading) {
|
||||
toast('لطفاً تا پایان آپلود فایلها صبر کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
pendingFiles.some((item) => item.status === 'error') ||
|
||||
pendingVoices.some((item) => item.status === 'error')
|
||||
) {
|
||||
toast(
|
||||
'برخی فایلها آپلود نشدند. آنها را حذف یا دوباره انتخاب کنید',
|
||||
'error',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const attachments: ChatComposerAttachment[] = [
|
||||
...uploadedFiles.map((item) => ({
|
||||
type: 'uploads_attach',
|
||||
url: item.key!,
|
||||
})),
|
||||
...uploadedVoices.map((item) => ({
|
||||
type: 'voice',
|
||||
url: item.key!,
|
||||
})),
|
||||
]
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
content: message.trim(),
|
||||
attachments,
|
||||
})
|
||||
resetComposer()
|
||||
} catch (error) {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const hasAttachments =
|
||||
pendingFiles.length > 0 || pendingVoices.length > 0 || isRecording
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <div className="text-sm mb-2 text-black">{label}</div>}
|
||||
|
||||
{replyTo && (
|
||||
<div className="mb-3 flex items-start justify-between gap-3 rounded-xl border border-[#D6E0FF] bg-[#F5F8FF] px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-[#0047FF]">
|
||||
در پاسخ به
|
||||
{replyTo.senderName ? ` ${replyTo.senderName}` : ''}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-[#8C90A3]">
|
||||
{replyTo.content || 'پیام ضمیمهدار'}
|
||||
</p>
|
||||
</div>
|
||||
{onCancelReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelReply}
|
||||
className="shrink-0"
|
||||
aria-label="لغو پاسخ"
|
||||
>
|
||||
<CloseCircle size={18} color="#8C90A3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-border bg-white transition-colors focus-within:border-[#0047FF]">
|
||||
{hasAttachments && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[#EBEDF5] px-3 pt-3 pb-2">
|
||||
{pendingFiles.map((item) => (
|
||||
<div key={item.id} className="relative size-16">
|
||||
<div className="relative size-full overflow-hidden rounded-lg border border-[#EBEDF5] bg-[#F5F7FC]">
|
||||
{item.previewUrl ? (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt={item.file.name}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-1 px-1">
|
||||
<DocumentText
|
||||
size={20}
|
||||
color="#0047FF"
|
||||
/>
|
||||
<span className="w-full truncate text-center text-[10px] text-[#8C90A3]">
|
||||
{item.file.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'uploading' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/35">
|
||||
<span className="size-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-red-500/70 px-1">
|
||||
<span className="text-center text-[10px] text-white">
|
||||
خطا
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(item.id)}
|
||||
className="absolute -left-1.5 -top-1.5 z-10 rounded-full bg-white shadow-sm"
|
||||
aria-label="حذف فایل"
|
||||
>
|
||||
<CloseCircle
|
||||
size={18}
|
||||
color="#EF4444"
|
||||
variant="Bold"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pendingVoices.map((voice) => (
|
||||
<VoicePreviewChip
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
onRemove={() => handleRemoveVoice(voice.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex h-16 items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3">
|
||||
<span className="size-2 animate-pulse rounded-full bg-red-500" />
|
||||
<span className="text-xs text-red-600">
|
||||
در حال ضبط...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full h-32 resize-none bg-transparent p-4 pb-14 text-sm outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-3 left-3 flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFilesSelected}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isSubmitting}
|
||||
className="flex size-8 items-center justify-center rounded-lg bg-[#EEF2FF] transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
aria-label="افزودن فایل"
|
||||
>
|
||||
<Paperclip2 size={18} color="#0047FF" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
isRecording ? stopRecording : startRecording
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
className="flex size-8 items-center justify-center rounded-lg bg-[#FFF1D7] transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
aria-label={
|
||||
isRecording ? 'توقف ضبط' : 'ضبط صدا'
|
||||
}
|
||||
>
|
||||
<Microphone
|
||||
size={20}
|
||||
color={isRecording ? '#EF4444' : 'black'}
|
||||
variant={isRecording ? 'Bold' : 'Outline'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
label={submitLabel}
|
||||
onClick={handleSubmit}
|
||||
className="w-[150px]"
|
||||
isLoading={isBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatComposer
|
||||
@@ -0,0 +1,171 @@
|
||||
import { type FC } from 'react'
|
||||
import moment from 'moment-jalaali'
|
||||
import { Paperclip2, ArrowRotateLeft } from 'iconsax-react'
|
||||
import { getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
|
||||
import VoicePlayer from '@/components/VoicePlayer'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import TrashWithConfrim from '@/components/TrashWithConfrim'
|
||||
import type { ChatAttachmentType, ChatParentMessageType } from '../type/Types'
|
||||
import { getChatSenderName } from '../type/Types'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
attachments?: ChatAttachmentType[] | null
|
||||
senderName?: string
|
||||
senderLabel?: string
|
||||
createdAt?: string
|
||||
isAdmin?: boolean
|
||||
avatarUrl?: string | null
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
parent?: ChatParentMessageType | null
|
||||
parentSenderLabel?: string
|
||||
onReply?: () => void
|
||||
canDelete?: boolean
|
||||
onDelete?: () => void
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
const ChatMessage: FC<Props> = ({
|
||||
content,
|
||||
attachments,
|
||||
senderName,
|
||||
senderLabel = 'پشتیبان',
|
||||
createdAt,
|
||||
isAdmin = false,
|
||||
avatarUrl,
|
||||
firstName,
|
||||
lastName,
|
||||
parent,
|
||||
parentSenderLabel,
|
||||
onReply,
|
||||
canDelete = false,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}) => {
|
||||
const safeAttachments = attachments ?? []
|
||||
const fileAttachments = safeAttachments.filter((a) => a.type !== 'voice')
|
||||
const voiceAttachments = safeAttachments.filter((a) => a.type === 'voice')
|
||||
|
||||
const handleOpenLink = async (key: string) => {
|
||||
const url = await getPresignedUrl(key)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const parentName = parent
|
||||
? getChatSenderName(parent.admin || parent.user)
|
||||
: ''
|
||||
|
||||
const avatar = (
|
||||
<UserAvatar
|
||||
src={avatarUrl}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
className="size-9 mt-1"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isAdmin ? 'justify-end' : ''}`}>
|
||||
{!isAdmin && avatar}
|
||||
<div
|
||||
className={`bg-[#F5F7FC] rounded-3xl p-5 max-w-[min(100%,520px)] ${
|
||||
isAdmin ? 'rounded-tl-none' : 'rounded-tr-none'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
|
||||
<span className="font-medium text-black">
|
||||
{senderLabel}:
|
||||
</span>
|
||||
{senderName ? <span>{senderName}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{onReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReply}
|
||||
className="flex items-center gap-1 text-[11px] text-[#0047FF] hover:opacity-80 transition-opacity"
|
||||
aria-label="پاسخ"
|
||||
>
|
||||
<ArrowRotateLeft size={14} color="#0047FF" />
|
||||
پاسخ
|
||||
</button>
|
||||
)}
|
||||
{canDelete && onDelete && (
|
||||
<TrashWithConfrim
|
||||
onDelete={onDelete}
|
||||
isloading={isDeleting}
|
||||
colorIcon="#EF4444"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parent && (
|
||||
<div className="mb-3 rounded-xl border border-[#D6E0FF] bg-white/70 px-3 py-2">
|
||||
<div className="text-[11px] text-[#0047FF]">
|
||||
در پاسخ به
|
||||
{parentSenderLabel || parentName
|
||||
? ` ${parentSenderLabel || parentName}`
|
||||
: ''}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-[#8C90A3]">
|
||||
{parent.content || 'پیام ضمیمهدار'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
<p className="text-sm text-black leading-7 whitespace-pre-wrap">
|
||||
{content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="flex gap-3 flex-wrap mt-3">
|
||||
{fileAttachments.map((attach) => (
|
||||
<button
|
||||
key={attach.url}
|
||||
type="button"
|
||||
onClick={() => handleOpenLink(attach.url)}
|
||||
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF] hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Paperclip2 size={18} color="#0047FF" />
|
||||
<span className="text-xs">
|
||||
{
|
||||
getFileNameAndExtensionFromUrl(
|
||||
attach.url,
|
||||
).fileName
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voiceAttachments.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{voiceAttachments.map((voice) => (
|
||||
<VoicePlayer key={voice.url} url={voice.url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdAt && (
|
||||
<div
|
||||
className="mt-3 text-[11px] text-[#8C90A3] text-left"
|
||||
dir="ltr"
|
||||
>
|
||||
{moment(createdAt).format('jYYYY/jMM/jDD HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && avatar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatMessage
|
||||
@@ -0,0 +1,229 @@
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import { useMemo, useState, type FC } from 'react'
|
||||
import { toast } from '@/shared/toast'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { useGetAdminMe } from '@/pages/admin/hooks/useAdminData'
|
||||
import { getChatSenderName, type ChatMessageType } from '../type/Types'
|
||||
import {
|
||||
useAddChatMessage,
|
||||
useDeleteChatMessage,
|
||||
useGetChatMessages,
|
||||
} from '../hooks/useChatData'
|
||||
import ChatComposer, {
|
||||
type ChatComposerSubmitPayload,
|
||||
} from './ChatComposer'
|
||||
import ChatMessage from './ChatMessage'
|
||||
|
||||
export type ChatSectionProps = {
|
||||
refId: string
|
||||
title?: string
|
||||
description?: string
|
||||
/** Label for admin / support / designer side */
|
||||
senderLabel?: string
|
||||
/** Label for customer / user side */
|
||||
userLabel?: string
|
||||
/** Optional fallback when message.user has no name */
|
||||
customerName?: string
|
||||
submitLabel?: string
|
||||
showRefresh?: boolean
|
||||
}
|
||||
|
||||
type ReplyTarget = {
|
||||
id: string
|
||||
content: string
|
||||
senderName?: string
|
||||
}
|
||||
|
||||
const ChatSection: FC<ChatSectionProps> = ({
|
||||
refId,
|
||||
title = 'گفتگو',
|
||||
description,
|
||||
senderLabel = 'پشتیبان',
|
||||
userLabel = 'مشتری',
|
||||
customerName,
|
||||
submitLabel = 'ارسال پیام',
|
||||
showRefresh = true,
|
||||
}) => {
|
||||
const [replyTo, setReplyTo] = useState<ReplyTarget | null>(null)
|
||||
const addMessage = useAddChatMessage()
|
||||
const deleteMessage = useDeleteChatMessage()
|
||||
const { data: adminMe } = useGetAdminMe()
|
||||
const currentAdminId = adminMe?.data?.id
|
||||
const {
|
||||
data,
|
||||
refetch,
|
||||
isFetching,
|
||||
isPending: isLoadingMessages,
|
||||
} = useGetChatMessages(refId)
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const list = data?.data ?? []
|
||||
return [...list].reverse()
|
||||
}, [data?.data])
|
||||
|
||||
const buildReplyTarget = (item: ChatMessageType): ReplyTarget => {
|
||||
const participant = item.admin || item.user
|
||||
return {
|
||||
id: String(item.id),
|
||||
content: item.content,
|
||||
senderName: getChatSenderName(
|
||||
participant,
|
||||
item.user ? customerName : undefined,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: ChatComposerSubmitPayload) => {
|
||||
await addMessage.mutateAsync({
|
||||
refId,
|
||||
params: {
|
||||
content: payload.content,
|
||||
attachments: payload.attachments,
|
||||
...(replyTo ? { parentId: replyTo.id } : {}),
|
||||
},
|
||||
})
|
||||
toast('پیام شما با موفقیت ارسال شد')
|
||||
setReplyTo(null)
|
||||
refetch()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMessage.mutateAsync(id)
|
||||
toast('پیام حذف شد')
|
||||
if (replyTo?.id === id) setReplyTo(null)
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#EBEDF5] px-6 py-4 md:px-8">
|
||||
<div>
|
||||
<h2 className="text-sm text-black">{title}</h2>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-[#8C90A3]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{showRefresh && (
|
||||
<RefreshButton
|
||||
onClick={() => refetch()}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="space-y-4 min-h-[80px]">
|
||||
{isLoadingMessages && messages.length === 0 && (
|
||||
<p className="text-xs text-[#8C90A3]">
|
||||
در حال بارگذاری پیامها...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && messages.length === 0 && (
|
||||
<div className="rounded-xl bg-[#F5F7FC] px-4 py-6 text-center">
|
||||
<p className="text-sm text-[#8C90A3]">
|
||||
هنوز پیامی ثبت نشده است.
|
||||
</p>
|
||||
<p className="text-xs text-[#8C90A3] mt-1">
|
||||
اولین پیام را در فرم زیر ارسال کنید.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((item) => {
|
||||
if (item.user) {
|
||||
return (
|
||||
<ChatMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
senderLabel={userLabel}
|
||||
senderName={getChatSenderName(
|
||||
item.user,
|
||||
customerName,
|
||||
)}
|
||||
avatarUrl={item.user.avatarUrl}
|
||||
firstName={item.user.firstName}
|
||||
lastName={item.user.lastName}
|
||||
parent={item.parent}
|
||||
parentSenderLabel={
|
||||
item.parent?.admin
|
||||
? senderLabel
|
||||
: item.parent?.user
|
||||
? userLabel
|
||||
: undefined
|
||||
}
|
||||
onReply={() =>
|
||||
setReplyTo(buildReplyTarget(item))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.admin) {
|
||||
const isOwnMessage =
|
||||
!!currentAdminId &&
|
||||
item.admin.id === currentAdminId
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
isAdmin
|
||||
senderLabel={senderLabel}
|
||||
senderName={getChatSenderName(item.admin)}
|
||||
avatarUrl={item.admin.avatarUrl}
|
||||
firstName={item.admin.firstName}
|
||||
lastName={item.admin.lastName}
|
||||
parent={item.parent}
|
||||
parentSenderLabel={
|
||||
item.parent?.admin
|
||||
? senderLabel
|
||||
: item.parent?.user
|
||||
? userLabel
|
||||
: undefined
|
||||
}
|
||||
onReply={() =>
|
||||
setReplyTo(buildReplyTarget(item))
|
||||
}
|
||||
canDelete={isOwnMessage}
|
||||
onDelete={() =>
|
||||
handleDelete(String(item.id))
|
||||
}
|
||||
isDeleting={
|
||||
deleteMessage.isPending &&
|
||||
deleteMessage.variables ===
|
||||
String(item.id)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border-t border-[#EBEDF5] pt-6">
|
||||
<ChatComposer
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={addMessage.isPending}
|
||||
submitLabel={submitLabel}
|
||||
replyTo={replyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatSection
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import * as api from '../service/ChatService'
|
||||
import type { AddChatMessageType } from '../type/Types'
|
||||
|
||||
export const useGetChatMessages = (refId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['chat', refId],
|
||||
queryFn: () => api.getChatMessages(refId),
|
||||
enabled: !!refId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useAddChatMessage = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
refId,
|
||||
params,
|
||||
}: {
|
||||
refId: string
|
||||
params: AddChatMessageType
|
||||
}) => api.addChatMessage(refId, params),
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteChatMessage = () => {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteChatMessage(id),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export { default as ChatSection } from './components/ChatSection'
|
||||
export { default as ChatMessage } from './components/ChatMessage'
|
||||
export { default as ChatComposer } from './components/ChatComposer'
|
||||
export type { ChatSectionProps } from './components/ChatSection'
|
||||
export type {
|
||||
ChatComposerAttachment,
|
||||
ChatComposerSubmitPayload,
|
||||
} from './components/ChatComposer'
|
||||
export {
|
||||
useGetChatMessages,
|
||||
useAddChatMessage,
|
||||
} from './hooks/useChatData'
|
||||
export type {
|
||||
AddChatMessageType,
|
||||
ChatAttachmentType,
|
||||
ChatMessageType,
|
||||
ChatMessagesResponseType,
|
||||
ChatParentMessageType,
|
||||
ChatParticipantType,
|
||||
} from './type/Types'
|
||||
export { getChatSenderName } from './type/Types'
|
||||
@@ -0,0 +1,25 @@
|
||||
import axios from '@/config/axios'
|
||||
import type {
|
||||
AddChatMessageType,
|
||||
ChatMessagesResponseType,
|
||||
} from '../type/Types'
|
||||
|
||||
export const getChatMessages = async (refId: string) => {
|
||||
const { data } = await axios.get<ChatMessagesResponseType>(
|
||||
`/admin/chat/ref/${refId}`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const addChatMessage = async (
|
||||
refId: string,
|
||||
params: AddChatMessageType,
|
||||
) => {
|
||||
const { data } = await axios.post(`/admin/chat/ref/${refId}`, params)
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteChatMessage = async (id: string) => {
|
||||
const { data } = await axios.delete(`/admin/chat/${id}`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { BaseResponse } from '@/shared/types/Types'
|
||||
|
||||
export type ChatAttachmentType = {
|
||||
url: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export type AddChatMessageType = {
|
||||
content: string
|
||||
attachments: ChatAttachmentType[]
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export type ChatParticipantType = {
|
||||
id: string
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
phone?: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export type ChatParentMessageType = {
|
||||
id: string
|
||||
content: string
|
||||
admin?: ChatParticipantType | null
|
||||
user?: ChatParticipantType | null
|
||||
}
|
||||
|
||||
export type ChatMessageType = {
|
||||
admin?: ChatParticipantType | null
|
||||
user?: ChatParticipantType | null
|
||||
attachments?: ChatAttachmentType[] | null
|
||||
content: string
|
||||
createdAt: string
|
||||
id: string
|
||||
parent?: ChatParentMessageType | null
|
||||
}
|
||||
|
||||
export type ChatMessagesResponseType = BaseResponse<ChatMessageType[]>
|
||||
|
||||
/** @deprecated Use AddChatMessageType */
|
||||
export type AddTicketType = AddChatMessageType
|
||||
/** @deprecated Use ChatMessageType */
|
||||
export type TicketType = ChatMessageType
|
||||
/** @deprecated Use ChatMessagesResponseType */
|
||||
export type TicketsResponseType = ChatMessagesResponseType
|
||||
|
||||
export const getChatSenderName = (
|
||||
participant?: ChatParticipantType | null,
|
||||
fallback?: string,
|
||||
): string => {
|
||||
if (!participant && !fallback) return ''
|
||||
return (
|
||||
[participant?.firstName, participant?.lastName].filter(Boolean).join(' ') ||
|
||||
participant?.phone ||
|
||||
fallback ||
|
||||
''
|
||||
)
|
||||
}
|
||||
@@ -10,3 +10,19 @@ export const useGetDashboardCounts = () => {
|
||||
refetchInterval: DASHBOARD_COUNTS_REFETCH_MS,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetHomeStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['home-stats'],
|
||||
queryFn: () => api.getHomeStats(),
|
||||
refetchInterval: DASHBOARD_COUNTS_REFETCH_MS,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetWeeklyOrders = () => {
|
||||
return useQuery({
|
||||
queryKey: ['weekly-orders'],
|
||||
queryFn: () => api.getWeeklyOrders(),
|
||||
refetchInterval: DASHBOARD_COUNTS_REFETCH_MS,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,30 @@ export type DashboardCountsType = {
|
||||
paymentsCount: number;
|
||||
};
|
||||
|
||||
export type HomeStatsType = {
|
||||
requestsCount: number;
|
||||
unconfirmedInvoicesCount: number;
|
||||
inProgressOrdersCount: number;
|
||||
completedOrdersCount: number;
|
||||
customersCount: number;
|
||||
};
|
||||
|
||||
export type WeeklyOrdersPoint = {
|
||||
weekStart: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const getDashboardCounts = async (): Promise<BaseResponse<DashboardCountsType>> => {
|
||||
const { data } = await axios.get<BaseResponse<DashboardCountsType>>('/admin/dashboard/counts');
|
||||
const { data } = await axios.get<BaseResponse<DashboardCountsType>>('/admin/menu/counts');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getHomeStats = async (): Promise<BaseResponse<HomeStatsType>> => {
|
||||
const { data } = await axios.get<BaseResponse<HomeStatsType>>('/admin/home/stats');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getWeeklyOrders = async (): Promise<BaseResponse<WeeklyOrdersPoint[]>> => {
|
||||
const { data } = await axios.get<BaseResponse<WeeklyOrdersPoint[]>>('/admin/home/weekly-orders');
|
||||
return data;
|
||||
};
|
||||
|
||||
+6
-72
@@ -1,80 +1,14 @@
|
||||
import { type FC } from 'react'
|
||||
import Stats from './components/Stats'
|
||||
import Services from './components/Services'
|
||||
import Orders from './components/Orders'
|
||||
import NewOrderImage from '@/assets/images/new_order.png'
|
||||
import { t } from '@/locale'
|
||||
import Button from '@/components/Button'
|
||||
import { AddSquare, MessageQuestion } from 'iconsax-react'
|
||||
import { COLORS } from '@/constants/colors'
|
||||
import SupportImage from '@/assets/images/support1.png'
|
||||
import WeeklyOrdersChart from './components/WeeklyOrdersChart'
|
||||
|
||||
const Home: FC = () => {
|
||||
return (
|
||||
<div className='flex gap-6'>
|
||||
<div className='flex-1'>
|
||||
<Stats />
|
||||
<Services />
|
||||
<Orders />
|
||||
</div>
|
||||
<div className='min-w-[267px] mt-5'>
|
||||
<div className='bg-white rounded-3xl p-6'>
|
||||
<img src={NewOrderImage} className='w-[98px] mx-auto' />
|
||||
<h4 className='mt-4 text-center font-medium'>
|
||||
{t('home.submitNewOrder')}
|
||||
</h4>
|
||||
<Button
|
||||
className='mt-2.5'>
|
||||
<div className='flex gap-1'>
|
||||
<AddSquare size={20} color='#292D32' />
|
||||
<div className=''>{t('home.submitNewOrderButton')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-7 bg-white rounded-3xl p-6 relative'>
|
||||
<h4>
|
||||
{t('home.specialSolution')}
|
||||
</h4>
|
||||
<p className='mt-2 text-[#7B7E8B] text-xs'>
|
||||
{t('home.specialSolutionDescription')}
|
||||
</p>
|
||||
|
||||
<div className='mt-2 flex flex-col gap-2'>
|
||||
<div className='flex gap-2 items-center text-xs'>
|
||||
<div className='size-1.5 rounded-full bg-primary'></div>
|
||||
<div>{t('home.specialSolutionFeature1')}</div>
|
||||
</div>
|
||||
<div className='flex gap-2 items-center text-xs'>
|
||||
<div className='size-1.5 rounded-full bg-primary'></div>
|
||||
<div>{t('home.specialSolutionFeature2')}</div>
|
||||
</div>
|
||||
<div className='flex gap-2 items-center text-xs'>
|
||||
<div className='size-1.5 rounded-full bg-primary'></div>
|
||||
<div>{t('home.specialSolutionFeature3')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label={t('home.freeConsultation')}
|
||||
className='mt-12'
|
||||
/>
|
||||
|
||||
<div className='mt-2.5 bg-primary/11 py-6 px-4 rounded-[13px]'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<MessageQuestion size={20} color={COLORS.primary} />
|
||||
<div className='text-xs font-light'>{t('home.contactUs')}</div>
|
||||
</div>
|
||||
<div>{t('home.phoneNumber')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img src={SupportImage} className='w-[50px] absolute bottom-[159px] left-2' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<Stats />
|
||||
<WeeklyOrdersChart />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
export default Home
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
import { COLORS } from '@/constants/colors'
|
||||
import { ArrowLeft } from 'iconsax-react'
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type Props = {
|
||||
icon: ReactNode,
|
||||
count: number,
|
||||
icon: ReactNode
|
||||
count: number | undefined
|
||||
description: string
|
||||
to: string
|
||||
}
|
||||
|
||||
const StatCard: FC<Props> = (props) => {
|
||||
const { icon, count, description } = props
|
||||
const StatCard: FC<Props> = ({ icon, count, description, to }) => {
|
||||
return (
|
||||
<div className='flex-1 bg-white rounded-3xl p-6'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Link
|
||||
to={to}
|
||||
className='block flex-1 rounded-3xl bg-white p-6 transition-shadow hover:shadow-md'
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
{icon}
|
||||
|
||||
<div className='size-8 bg-[#FFF1D7] rounded-full flex justify-center items-center'>
|
||||
<ArrowLeft size={16} color={COLORS.primary} className='rotate-45' />
|
||||
<div className='flex size-8 items-center justify-center rounded-full bg-[#FFF1D7]'>
|
||||
<ArrowLeft
|
||||
size={16}
|
||||
color={COLORS.primary}
|
||||
className='rotate-45'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-2 text-2xl font-semibold'>
|
||||
{count}
|
||||
{count ?? 0}
|
||||
</div>
|
||||
|
||||
<div className='mt-2 text-sm'>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-2 text-sm'>{description}</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatCard
|
||||
export default StatCard
|
||||
|
||||
@@ -1,53 +1,73 @@
|
||||
import GridWrapper from '@/components/GridWrapper'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import { COLORS } from '@/constants/colors'
|
||||
import { useGetHomeStats } from '@/pages/dashboard/hooks/useDashboardData'
|
||||
import { ProformaInvoiceStatusEnum } from '@/pages/invoice/enum/InvoiceEnum'
|
||||
import { TabOrderListEnum } from '@/pages/order/enum/OrderEnum'
|
||||
import { t } from '@/locale'
|
||||
import { BoxTick, BoxTime, ReceiptText, TruckTick } from 'iconsax-react'
|
||||
import { BoxTick, BoxTime, People, ReceiptText, TruckTick } from 'iconsax-react'
|
||||
import { type FC } from 'react'
|
||||
import StatCard from './StatCard'
|
||||
|
||||
const Stats: FC = () => {
|
||||
const { data } = useGetHomeStats()
|
||||
const stats = data?.data
|
||||
|
||||
return (
|
||||
<GridWrapper
|
||||
desktop={4}
|
||||
desktop={5}
|
||||
mobile={2}
|
||||
gapDesktop={24}
|
||||
gapMobile={12}
|
||||
className='mt-5'
|
||||
>
|
||||
<StatCard
|
||||
count={2}
|
||||
count={stats?.requestsCount}
|
||||
description={t('home.requestCount')}
|
||||
to={Paths.requests.list}
|
||||
icon={<BoxTime
|
||||
size={27}
|
||||
color={COLORS.primary}
|
||||
/>}
|
||||
/>
|
||||
<StatCard
|
||||
count={2}
|
||||
count={stats?.unconfirmedInvoicesCount}
|
||||
description={t('home.factureCount')}
|
||||
to={`${Paths.perfomaInvoice.list}?tab=${ProformaInvoiceStatusEnum.PENDING}`}
|
||||
icon={<ReceiptText
|
||||
size={27}
|
||||
color={COLORS.primary}
|
||||
/>}
|
||||
/>
|
||||
<StatCard
|
||||
count={10}
|
||||
count={stats?.inProgressOrdersCount}
|
||||
description={t('home.orderCount')}
|
||||
to={`${Paths.order.list}?tab=${TabOrderListEnum.IN_PROGRESS}`}
|
||||
icon={<BoxTick
|
||||
size={27}
|
||||
color={COLORS.primary}
|
||||
/>}
|
||||
/>
|
||||
<StatCard
|
||||
count={20}
|
||||
count={stats?.completedOrdersCount}
|
||||
description={t('home.orderDoneCount')}
|
||||
to={`${Paths.order.list}?tab=${TabOrderListEnum.FINISHED}`}
|
||||
icon={<TruckTick
|
||||
size={27}
|
||||
color={COLORS.primary}
|
||||
/>}
|
||||
/>
|
||||
<StatCard
|
||||
count={stats?.customersCount}
|
||||
description={t('home.customerCount')}
|
||||
to={Paths.users.list}
|
||||
icon={<People
|
||||
size={27}
|
||||
color={COLORS.primary}
|
||||
/>}
|
||||
/>
|
||||
</GridWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default Stats
|
||||
export default Stats
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { COLORS } from '@/constants/colors'
|
||||
import { useGetWeeklyOrders } from '@/pages/dashboard/hooks/useDashboardData'
|
||||
import { t } from '@/locale'
|
||||
import moment from 'moment-jalaali'
|
||||
import { type FC, useMemo } from 'react'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
|
||||
const WeeklyOrdersChart: FC = () => {
|
||||
const { data, isLoading } = useGetWeeklyOrders()
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const points = data?.data ?? []
|
||||
return [...points].reverse().map((point) => ({
|
||||
week: moment(point.weekStart).format('jD jMMMM'),
|
||||
count: point.count,
|
||||
}))
|
||||
}, [data?.data])
|
||||
|
||||
return (
|
||||
<div className='mt-6 bg-white p-6 rounded-3xl'>
|
||||
<h4 className='text-lg font-light mb-6'>
|
||||
{t('home.weeklyOrders')}
|
||||
</h4>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='h-64 bg-gray-100 animate-pulse rounded-2xl' />
|
||||
) : (
|
||||
<div className='h-64' dir='ltr'>
|
||||
<ResponsiveContainer width='100%' height='100%'>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray='3 3' vertical={false} stroke='#f0f0f0' />
|
||||
<XAxis
|
||||
dataKey='week'
|
||||
tick={{ fontSize: 12, fill: COLORS.grayLight }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 12, fill: COLORS.grayLight }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [value, t('home.orders')]}
|
||||
labelFormatter={(label) => `${t('home.week')}: ${label}`}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
||||
direction: 'rtl',
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey='count'
|
||||
fill={COLORS.primary}
|
||||
radius={[8, 8, 0, 0]}
|
||||
maxBarSize={48}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WeeklyOrdersChart
|
||||
@@ -1,14 +1,17 @@
|
||||
import { type FC, type ReactNode } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { type FC, type ReactNode, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import moment from 'moment-jalaali';
|
||||
import { Calendar, Edit, Profile2User, ReceiptText, WalletMoney } from 'iconsax-react';
|
||||
import { Archive, Calendar, Edit, Profile2User, ReceiptText, WalletMoney } from 'iconsax-react';
|
||||
import { Paths } from '@/config/Paths';
|
||||
import BackButton from '@/components/BackButton';
|
||||
import RefreshButton from '@/components/RefreshButton';
|
||||
import { useGetInvoiceDetail } from './hooks/useInvoiceData';
|
||||
import ModalConfrim from '@/components/ModalConfrim';
|
||||
import { useArchiveInvoice, useGetInvoiceDetail } from './hooks/useInvoiceData';
|
||||
import InvoicePaymentsSection from './components/InvoicePaymentsSection';
|
||||
import { formatItemDiscountDisplay } from './utils/invoiceItem';
|
||||
import type { InvoiceItemType } from './types/Types';
|
||||
import { toast } from 'react-toastify';
|
||||
import { extractErrorMessage } from '@/config/func';
|
||||
|
||||
const formatAmount = (amount: number) =>
|
||||
Number(amount).toLocaleString('fa-IR') + ' ریال';
|
||||
@@ -110,9 +113,26 @@ const InvoiceItemTableRow: FC<{ item: InvoiceItemType }> = ({ item }) => {
|
||||
|
||||
const DetailPerfomaInvoice: FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [isArchiveConfirmOpen, setIsArchiveConfirmOpen] = useState(false);
|
||||
const { data: invoiceData, isLoading, refetch, isFetching, isError } = useGetInvoiceDetail(id);
|
||||
const { mutate: archiveInvoice, isPending: isArchiving } = useArchiveInvoice();
|
||||
const invoice = invoiceData?.data;
|
||||
|
||||
const handleArchive = () => {
|
||||
if (!id) return;
|
||||
archiveInvoice(id, {
|
||||
onSuccess: () => {
|
||||
setIsArchiveConfirmOpen(false);
|
||||
toast.success('پیشفاکتور با موفقیت آرشیو شد');
|
||||
navigate(Paths.perfomaInvoice.list);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-5 flex min-h-[200px] items-center justify-center">
|
||||
@@ -154,18 +174,44 @@ const DetailPerfomaInvoice: FC = () => {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||
<Link to={Paths.perfomaInvoice.update + invoice.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-xl bg-primary px-5 py-2.5 text-sm font-medium hover:opacity-90"
|
||||
>
|
||||
<Edit size={18} color="black" />
|
||||
ویرایش
|
||||
</button>
|
||||
</Link>
|
||||
{invoice.status !== 'archived' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsArchiveConfirmOpen(true)}
|
||||
className="flex items-center gap-2 rounded-xl border border-border px-5 py-2.5 text-sm font-medium text-[#4B5563] transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<Archive size={18} color="#4B5563" />
|
||||
آرشیو
|
||||
</button>
|
||||
<Link to={Paths.perfomaInvoice.update + invoice.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-xl bg-primary px-5 py-2.5 text-sm font-medium hover:opacity-90"
|
||||
>
|
||||
<Edit size={18} color="black" />
|
||||
ویرایش
|
||||
</button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{invoice.status === 'archived' && (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1.5 text-xs text-gray-700">
|
||||
آرشیو شده
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalConfrim
|
||||
isOpen={isArchiveConfirmOpen}
|
||||
close={() => setIsArchiveConfirmOpen(false)}
|
||||
onConfrim={handleArchive}
|
||||
isloading={isArchiving}
|
||||
title_header="آرشیو پیشفاکتور"
|
||||
label="آیا از آرشیو کردن این پیشفاکتور مطمئن هستید؟"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="rounded-3xl border border-border/70 bg-white p-6 shadow-sm">
|
||||
<div className="font-medium text-[#111827]">اطلاعات پیش فاکتور</div>
|
||||
|
||||
@@ -1,227 +1,379 @@
|
||||
import Tabs from '@/components/Tabs'
|
||||
import { useMemo, useState, type FC } from 'react'
|
||||
import { ProformaInvoiceStatusEnum } from './enum/InvoiceEnum'
|
||||
import Filters from '@/components/Filters'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import Table from '@/components/Table'
|
||||
import { Eye, Add, Edit2 } from 'iconsax-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import moment from 'moment-jalaali'
|
||||
import type { FilterValues } from '@/components/Filters'
|
||||
import { useGetInvoice } from './hooks/useInvoiceData'
|
||||
import type { InvoiceType } from './types/Types'
|
||||
import Tabs from "@/components/Tabs";
|
||||
import { useCallback, useMemo, useState, type FC } from "react";
|
||||
import {
|
||||
invoiceListTabs,
|
||||
invoiceListTabStatuses,
|
||||
ProformaInvoiceStatusEnum,
|
||||
} from "./enum/InvoiceEnum";
|
||||
import Filters from "@/components/Filters";
|
||||
import type { FilterValues } from "@/components/Filters";
|
||||
import RefreshButton from "@/components/RefreshButton";
|
||||
import Table from "@/components/Table";
|
||||
import { Eye, Add, Edit2, ArrowDown2, Filter } from "iconsax-react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { Paths } from "@/config/Paths";
|
||||
import moment from "moment-jalaali";
|
||||
import { useArchiveInvoice, useGetInvoice, useGetInvoiceTabCounts } from "./hooks/useInvoiceData";
|
||||
import type { InvoiceType } from "./types/Types";
|
||||
import ArchiveWithConfirm from "@/components/ArchiveWithConfirm";
|
||||
import { toast } from "react-toastify";
|
||||
import { extractErrorMessage } from "@/config/func";
|
||||
import { clx } from "@/helpers/utils";
|
||||
|
||||
const INVOICE_TABS = invoiceListTabs.map((tab) => tab.value);
|
||||
|
||||
const INVOICE_LIST_FILTER_FIELDS = [
|
||||
{
|
||||
name: "search",
|
||||
type: "input" as const,
|
||||
placeholder: "جستجو (شماره سفارش / مشتری)",
|
||||
},
|
||||
{
|
||||
name: "from",
|
||||
type: "date" as const,
|
||||
placeholder: "از تاریخ",
|
||||
},
|
||||
{
|
||||
name: "to",
|
||||
type: "date" as const,
|
||||
placeholder: "تا تاریخ",
|
||||
},
|
||||
];
|
||||
|
||||
const isValidInvoiceTab = (
|
||||
value: string | null,
|
||||
): value is ProformaInvoiceStatusEnum =>
|
||||
value !== null && INVOICE_TABS.includes(value as ProformaInvoiceStatusEnum);
|
||||
|
||||
const countActiveFilters = (filters: FilterValues) =>
|
||||
[filters.search, filters.from, filters.to].filter(Boolean).length;
|
||||
|
||||
const ProformaInvoice: FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<ProformaInvoiceStatusEnum>(ProformaInvoiceStatusEnum.ALL)
|
||||
const [page, setPage] = useState(1)
|
||||
const [filters, setFilters] = useState<FilterValues>({})
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabParam = searchParams.get("tab");
|
||||
const activeTab = isValidInvoiceTab(tabParam)
|
||||
? tabParam
|
||||
: ProformaInvoiceStatusEnum.PENDING;
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [isFiltersExpanded, setIsFiltersExpanded] = useState(false);
|
||||
const [filtersKey, setFiltersKey] = useState(0);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useGetInvoice(page)
|
||||
const statuses = invoiceListTabStatuses[activeTab];
|
||||
const activeFilterCount = useMemo(
|
||||
() => countActiveFilters(filters),
|
||||
[filters],
|
||||
);
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
|
||||
const meta = data?.meta
|
||||
const setActiveTab = (tab: ProformaInvoiceStatusEnum) => {
|
||||
setPage(1);
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("tab", tab);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const list = data?.data ?? []
|
||||
const handleFiltersChange = useCallback((next: FilterValues) => {
|
||||
setPage(1);
|
||||
setFilters(next);
|
||||
}, []);
|
||||
|
||||
if (activeTab === ProformaInvoiceStatusEnum.CONFIRMED) {
|
||||
return list.filter((o) => o.confirmStatus === 'confirmed')
|
||||
}
|
||||
if (activeTab === ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED) {
|
||||
return list.filter((o) => o.confirmStatus === 'partially_confirmed')
|
||||
}
|
||||
if (activeTab === ProformaInvoiceStatusEnum.NOT_CONFIRMED) {
|
||||
return list.filter((o) => o.confirmStatus === 'pending')
|
||||
}
|
||||
const clearFilters = useCallback(() => {
|
||||
setPage(1);
|
||||
setFilters({ search: null, from: null, to: null });
|
||||
setFiltersKey((key) => key + 1);
|
||||
}, []);
|
||||
|
||||
let result = list
|
||||
const { data, isLoading, refetch, isFetching } = useGetInvoice({
|
||||
page,
|
||||
statuses,
|
||||
search: filters.search?.trim() || null,
|
||||
from: filters.from || null,
|
||||
to: filters.to || null,
|
||||
});
|
||||
const { data: tabCounts } = useGetInvoiceTabCounts({
|
||||
search: filters.search?.trim() || null,
|
||||
from: filters.from || null,
|
||||
to: filters.to || null,
|
||||
});
|
||||
const { mutate: archiveInvoice, isPending: isArchiving } =
|
||||
useArchiveInvoice();
|
||||
|
||||
const search = (filters.search ?? '').trim()
|
||||
if (search) {
|
||||
const lower = search.toLowerCase()
|
||||
result = result.filter(
|
||||
(o) =>
|
||||
String(o.invoiceNumber).includes(search) ||
|
||||
o.user?.firstName?.toLowerCase().includes(lower) ||
|
||||
o.user?.lastName?.toLowerCase().includes(lower) ||
|
||||
o.user?.phone?.includes(search)
|
||||
)
|
||||
}
|
||||
const tabCountByTab: Record<ProformaInvoiceStatusEnum, number> = useMemo(
|
||||
() => ({
|
||||
[ProformaInvoiceStatusEnum.PENDING]: tabCounts?.pending ?? 0,
|
||||
[ProformaInvoiceStatusEnum.CONFIRMED]: tabCounts?.confirmed ?? 0,
|
||||
[ProformaInvoiceStatusEnum.ARCHIVED]: tabCounts?.archived ?? 0,
|
||||
}),
|
||||
[tabCounts],
|
||||
);
|
||||
const tabsWithCount = useMemo(
|
||||
() =>
|
||||
invoiceListTabs.map((tab) => {
|
||||
const total = tabCountByTab[tab.value] ?? 0;
|
||||
return {
|
||||
...tab,
|
||||
label: total > 0 ? `${tab.label} (${total.toLocaleString("fa-IR")})` : tab.label,
|
||||
};
|
||||
}),
|
||||
[tabCountByTab],
|
||||
);
|
||||
|
||||
const dateFilter = filters.date
|
||||
if (dateFilter) {
|
||||
const filterDate = moment(dateFilter, 'jYYYY/jMM/jDD').format('YYYY-MM-DD')
|
||||
result = result.filter((o) => {
|
||||
const d = o.createdAt
|
||||
return d && moment(d).format('YYYY-MM-DD') === filterDate
|
||||
})
|
||||
}
|
||||
const meta = data?.meta;
|
||||
|
||||
return result
|
||||
}, [data?.data, activeTab, filters.search, filters.date])
|
||||
const handleArchive = useCallback(
|
||||
(id: string) => {
|
||||
archiveInvoice(id, {
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
toast.success("پیشفاکتور با موفقیت آرشیو شد");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
});
|
||||
},
|
||||
[archiveInvoice, refetch],
|
||||
);
|
||||
|
||||
const getPaymentStatus = (item: InvoiceType) => {
|
||||
const total = Number(item.total) || 0
|
||||
const paid = Number(item.paidAmount) || 0
|
||||
if (total <= 0) return '-'
|
||||
if (paid >= total) return 'پرداخت شده'
|
||||
if (paid > 0) return 'پرداخت جزئی'
|
||||
return 'پرداخت نشده'
|
||||
}
|
||||
const getServiceLabel = (item: InvoiceType) => {
|
||||
if (!item.items?.length) return "-";
|
||||
if (item.items.length === 1) return item.items[0].product?.title ?? "-";
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{item.items.map((line) => (
|
||||
<span key={line.id}>
|
||||
{line.product?.title ?? "-"} ×{" "}
|
||||
{line.quantity.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getServiceLabel = (item: InvoiceType) => {
|
||||
if (!item.items?.length) return '-'
|
||||
if (item.items.length === 1) return item.items[0].product?.title ?? '-'
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{item.items.map((line) => (
|
||||
<span key={line.id}>
|
||||
{line.product?.title ?? '-'} ({line.quantity.toLocaleString('fa-IR')})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const getCustomerLabel = (item: InvoiceType) => {
|
||||
const fullName =
|
||||
item.user?.firstName || item.user?.lastName
|
||||
? [item.user.firstName, item.user.lastName].filter(Boolean).join(" ")
|
||||
: null;
|
||||
const phone = item.user?.phone;
|
||||
|
||||
if (!fullName && !phone) return "-";
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h1 className='text-lg font-light'>پیش فاکتورها</h1>
|
||||
<div className='flex items-center gap-3'>
|
||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||
<Link to={Paths.perfomaInvoice.create}>
|
||||
<button className='flex items-center gap-2 px-4 py-2 bg-primary text-black text-sm font-medium rounded-xl hover:opacity-90 transition-opacity'>
|
||||
<Add size={18} />
|
||||
پیش فاکتور جدید
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{fullName && <span>{fullName}</span>}
|
||||
{phone && <span className="text-xs text-[#8C90A3]">{phone}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className='mt-8'>
|
||||
<Tabs
|
||||
items={[
|
||||
{ label: 'همه', value: ProformaInvoiceStatusEnum.ALL },
|
||||
{ label: 'تایید نشده', value: ProformaInvoiceStatusEnum.NOT_CONFIRMED },
|
||||
{ label: 'تایید جزئی', value: ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED },
|
||||
{ label: 'تایید شده', value: ProformaInvoiceStatusEnum.CONFIRMED },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => setActiveTab(tab as ProformaInvoiceStatusEnum)}
|
||||
/>
|
||||
</div>
|
||||
const formatAmount = (value: number | null | undefined) =>
|
||||
value != null ? Number(value).toLocaleString("fa-IR") + " ریال" : "-";
|
||||
|
||||
<div className='mt-8'>
|
||||
<Filters
|
||||
fields={[
|
||||
{
|
||||
type: 'input',
|
||||
name: 'search',
|
||||
placeholder: 'جستجو (شماره سفارش / مشتری)',
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'date',
|
||||
placeholder: 'تاریخ',
|
||||
},
|
||||
]}
|
||||
onChange={setFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-8'>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
key: 'invoiceNumber',
|
||||
title: 'شماره',
|
||||
render: (item) => item.invoiceNumber,
|
||||
},
|
||||
{
|
||||
key: 'service',
|
||||
title: 'خدمت',
|
||||
className: '!whitespace-normal',
|
||||
render: (item) => getServiceLabel(item),
|
||||
},
|
||||
{
|
||||
key: 'issueDate',
|
||||
title: 'تاریخ صدور',
|
||||
render: (item) =>
|
||||
item.createdAt
|
||||
? moment(item.createdAt).format('jYYYY/jMM/jDD')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
key: 'lastConfirmDate',
|
||||
title: 'آخرین مهلت تایید',
|
||||
render: (item) =>
|
||||
item.approvalDeadline
|
||||
? moment(item.approvalDeadline).format('jYYYY/jMM/jDD')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
key: 'total',
|
||||
title: 'مجموع',
|
||||
render: (item) =>
|
||||
item.total
|
||||
? Number(item.total).toLocaleString('fa-IR') + ' ریال'
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
key: 'confirmStatus',
|
||||
title: 'وضعیت تایید',
|
||||
render: (item) => {
|
||||
const statusMap = {
|
||||
confirmed: { className: 'bg-green-100 text-green-800', text: 'تایید شده' },
|
||||
partially_confirmed: { className: 'bg-blue-100 text-blue-800', text: 'تایید جزئی' },
|
||||
pending: { className: 'bg-amber-100 text-amber-800', text: 'تایید نشده' },
|
||||
}
|
||||
const status = statusMap[item.confirmStatus] ?? statusMap.pending
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2.5 text-xs ${status.className}`}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
title: 'وضعیت پرداخت',
|
||||
render: (item) => getPaymentStatus(item),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '',
|
||||
render: (item) => (
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Link to={Paths.perfomaInvoice.detail + item.id}>
|
||||
<Eye size={20} color='#8C90A3' />
|
||||
</Link>
|
||||
<Link to={Paths.perfomaInvoice.update + item.id}>
|
||||
<Edit2 size={20} color='#0037FF' />
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={filteredData}
|
||||
isLoading={isLoading}
|
||||
noDataMessage='پیش فاکتوری یافت نشد.'
|
||||
pagination={
|
||||
meta && meta.totalPages > 1
|
||||
? {
|
||||
currentPage: meta.page,
|
||||
totalPages: meta.totalPages,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="mt-5 space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-light">پیش فاکتورها</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="flex items-center gap-3">
|
||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||
<Link to={Paths.perfomaInvoice.create}>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-primary text-black text-sm font-medium rounded-xl hover:opacity-90 transition-opacity">
|
||||
<Add size={18} />
|
||||
پیش فاکتور جدید
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
export default ProformaInvoice
|
||||
<Tabs
|
||||
items={tabsWithCount}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => setActiveTab(tab as ProformaInvoiceStatusEnum)}
|
||||
/>
|
||||
|
||||
<div className="overflow-hidden rounded-3xl bg-white">
|
||||
<div className="flex items-center justify-between gap-3 p-5 md:px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFiltersExpanded((prev) => !prev)}
|
||||
className="flex flex-1 items-center gap-2 text-right"
|
||||
>
|
||||
<Filter size={18} color="#8C90A3" />
|
||||
<span className="text-sm font-medium text-gray-700">فیلترها</span>
|
||||
{hasActiveFilters && (
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-black">
|
||||
{activeFilterCount.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
)}
|
||||
<ArrowDown2
|
||||
size={18}
|
||||
color="#8C90A3"
|
||||
className={clx(
|
||||
"mr-auto transition-transform duration-200",
|
||||
isFiltersExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="shrink-0 text-xs text-[#0037FF] hover:underline"
|
||||
>
|
||||
پاک کردن فیلترها
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFiltersExpanded && (
|
||||
<div className="space-y-5 border-t border-gray-100 px-5 pb-5 pt-4 md:px-6 md:pb-6">
|
||||
<Filters
|
||||
key={filtersKey}
|
||||
fields={INVOICE_LIST_FILTER_FIELDS}
|
||||
initialValues={filters}
|
||||
onChange={handleFiltersChange}
|
||||
className="w-full"
|
||||
fieldClassName="grid w-full grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
key: "invoiceNumber",
|
||||
title: "شماره",
|
||||
render: (item) => item.invoiceNumber,
|
||||
},
|
||||
{
|
||||
key: "customer",
|
||||
title: "مشتری",
|
||||
className: "!whitespace-normal",
|
||||
render: (item) => getCustomerLabel(item),
|
||||
},
|
||||
{
|
||||
key: "service",
|
||||
title: "آیتم ها",
|
||||
className: "!whitespace-normal",
|
||||
render: (item) => getServiceLabel(item),
|
||||
},
|
||||
{
|
||||
key: "issueDate",
|
||||
title: "تاریخ صدور",
|
||||
render: (item) =>
|
||||
item.createdAt
|
||||
? moment(item.createdAt).format("jYYYY/jMM/jDD")
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
key: "lastConfirmDate",
|
||||
title: "آخرین مهلت تایید",
|
||||
render: (item) =>
|
||||
item.approvalDeadline
|
||||
? moment(item.approvalDeadline).format("jYYYY/jMM/jDD")
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
title: "مالی",
|
||||
className: "!whitespace-normal",
|
||||
render: (item) => (
|
||||
<table className="w-full">
|
||||
<tr>
|
||||
<td className="text-xs text-[#8C90A3]">مجموع : </td>
|
||||
<td className="text-left">{formatAmount(item.total)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="text-xs text-[#8C90A3]">پرداخت : </td>
|
||||
<td className="text-left">{formatAmount(item.paidAmount)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="text-xs text-[#8C90A3]">مانده: </td>
|
||||
<td className="text-left">{formatAmount(item.balance)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
title: "وضعیت",
|
||||
render: (item) => {
|
||||
const statusMap = {
|
||||
confirmed: {
|
||||
className: "bg-green-100 text-green-800",
|
||||
text: "تایید شده",
|
||||
},
|
||||
partially_confirmed: {
|
||||
className: "bg-blue-100 text-blue-800",
|
||||
text: "تایید جزئی",
|
||||
},
|
||||
pending: {
|
||||
className: "bg-amber-100 text-amber-800",
|
||||
text: "در انتظار تایید",
|
||||
},
|
||||
archived: {
|
||||
className: "bg-gray-100 text-gray-700",
|
||||
text: "آرشیو شده",
|
||||
},
|
||||
};
|
||||
const status = statusMap[item.status] ?? statusMap.pending;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2.5 text-xs ${status.className}`}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
title: "",
|
||||
render: (item) => (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Link to={Paths.perfomaInvoice.detail + item.id}>
|
||||
<Eye size={20} color="#8C90A3" />
|
||||
</Link>
|
||||
{item.status !== "archived" && (
|
||||
<>
|
||||
<Link to={Paths.perfomaInvoice.update + item.id}>
|
||||
<Edit2 size={20} color="#0037FF" />
|
||||
</Link>
|
||||
<ArchiveWithConfirm
|
||||
onArchive={() => handleArchive(item.id)}
|
||||
isloading={isArchiving}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={data?.data}
|
||||
isLoading={isLoading}
|
||||
noDataMessage="پیش فاکتوری یافت نشد."
|
||||
pagination={
|
||||
meta && meta.totalPages > 1
|
||||
? {
|
||||
currentPage: meta.page,
|
||||
totalPages: meta.totalPages,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProformaInvoice;
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
export const enum ProformaInvoiceStatusEnum {
|
||||
ALL = 'all',
|
||||
NOT_CONFIRMED = 'not_confirmed',
|
||||
PARTIALLY_CONFIRMED = 'partially_confirmed',
|
||||
PENDING = 'pending',
|
||||
CONFIRMED = 'confirmed',
|
||||
}
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export type InvoiceConfirmStatus =
|
||||
| 'pending'
|
||||
| 'partially_confirmed'
|
||||
| 'confirmed'
|
||||
| 'archived'
|
||||
|
||||
export const invoiceListTabStatuses: Record<
|
||||
ProformaInvoiceStatusEnum,
|
||||
InvoiceConfirmStatus[]
|
||||
> = {
|
||||
[ProformaInvoiceStatusEnum.PENDING]: ['pending'],
|
||||
[ProformaInvoiceStatusEnum.CONFIRMED]: ['partially_confirmed', 'confirmed'],
|
||||
[ProformaInvoiceStatusEnum.ARCHIVED]: ['archived'],
|
||||
}
|
||||
|
||||
export const invoiceListTabs = [
|
||||
{ label: 'در انتظار تایید', value: ProformaInvoiceStatusEnum.PENDING },
|
||||
{ label: 'تایید شده', value: ProformaInvoiceStatusEnum.CONFIRMED },
|
||||
{ label: 'آرشیو شده', value: ProformaInvoiceStatusEnum.ARCHIVED },
|
||||
] as const
|
||||
|
||||
@@ -9,14 +9,32 @@ export const useCreateInvoice = () => {
|
||||
mutationFn: api.createInvoice,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetInvoice = (page = 1) => {
|
||||
export const useGetInvoice = (params: api.GetInvoicesParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["invoice", page],
|
||||
queryFn: () => api.getInvoice(page),
|
||||
queryKey: [
|
||||
"invoice",
|
||||
params.page,
|
||||
params.statuses?.join(",") ?? "",
|
||||
params.search ?? "",
|
||||
params.from ?? "",
|
||||
params.to ?? "",
|
||||
],
|
||||
queryFn: () => api.getInvoice(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetInvoiceTabCounts = (
|
||||
params?: api.GetInvoiceTabCountsParams,
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ["invoice-tab-counts", params],
|
||||
queryFn: () => api.getInvoiceTabCounts(params),
|
||||
refetchInterval: 20_000,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -49,6 +67,19 @@ export const useUpdateInvoice = () => {
|
||||
}) => api.updateInvoice(id, params),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useArchiveInvoice = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.archiveInvoice(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,19 +6,63 @@ import type {
|
||||
InvoiceType,
|
||||
UpdatePreInvoiceType,
|
||||
} from "../types/Types";
|
||||
import type { InvoiceConfirmStatus } from "../enum/InvoiceEnum";
|
||||
import type { BaseResponse } from "@/shared/types/Types";
|
||||
|
||||
export type GetInvoicesParams = {
|
||||
page?: number;
|
||||
statuses?: InvoiceConfirmStatus[];
|
||||
search?: string | null;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
};
|
||||
|
||||
export type GetInvoiceTabCountsParams = Omit<GetInvoicesParams, "page" | "statuses">;
|
||||
|
||||
export type InvoiceTabCountsResponseType = {
|
||||
pending: number;
|
||||
confirmed: number;
|
||||
archived: number;
|
||||
};
|
||||
|
||||
export const createInvoice = async (params: CreatePreInvoiceType) => {
|
||||
const { data } = await axios.post("/admin/invoice", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getInvoice = async (page = 1): Promise<GetInvoiceResponseType> => {
|
||||
export const getInvoice = async (
|
||||
params: GetInvoicesParams = {},
|
||||
): Promise<GetInvoiceResponseType> => {
|
||||
const { page = 1, statuses, search, from, to } = params;
|
||||
const { data } = await axios.get<GetInvoiceResponseType>("/admin/invoice", {
|
||||
params: { page },
|
||||
params: {
|
||||
page,
|
||||
...(statuses?.length ? { statuses: statuses.join(",") } : {}),
|
||||
...(search ? { search } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
},
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getInvoiceTabCounts = async (
|
||||
params: GetInvoiceTabCountsParams = {},
|
||||
): Promise<InvoiceTabCountsResponseType> => {
|
||||
const { search, from, to } = params;
|
||||
const { data } = await axios.get<BaseResponse<InvoiceTabCountsResponseType>>(
|
||||
"/admin/invoice/tab-counts",
|
||||
{
|
||||
params: {
|
||||
...(search ? { search } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
return data.data;
|
||||
};
|
||||
|
||||
export const getInvoiceById = async (id: string) => {
|
||||
const { data } = await axios.get<{ data: InvoiceType }>(`/admin/invoice/${id}`);
|
||||
return data;
|
||||
@@ -35,3 +79,8 @@ export const updateInvoice = async (id: string, params: UpdatePreInvoiceType) =>
|
||||
const { data } = await axios.patch(`/admin/invoice/${id}`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const archiveInvoice = async (id: string) => {
|
||||
const { data } = await axios.patch(`/admin/invoice/${id}/archive`);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -104,7 +104,7 @@ export type InvoiceType = {
|
||||
total: number;
|
||||
paidAmount: number;
|
||||
balance: number;
|
||||
confirmStatus: 'pending' | 'partially_confirmed' | 'confirmed';
|
||||
status: 'pending' | 'partially_confirmed' | 'confirmed' | 'archived';
|
||||
enableTax: boolean;
|
||||
approvalDeadline: string;
|
||||
attachments: InvoiceAttachmentType[];
|
||||
|
||||
@@ -8,7 +8,7 @@ import Button from '@/components/Button'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { useGetOrderDetails, useUpdateOrderStatus } from './hooks/useOrderData'
|
||||
import TicketSection from './components/TicketSection'
|
||||
import ChatSection from '@/pages/chat/components/ChatSection'
|
||||
import OrderDetailSidebar from './components/OrderDetailSidebar'
|
||||
import type { OrderDetailDataType } from './types/Types'
|
||||
import { OrderStatusEnum } from './enum/OrderEnum'
|
||||
@@ -21,6 +21,10 @@ const OrderDetail: FC = () => {
|
||||
const order = data?.data as OrderDetailDataType | undefined
|
||||
// const { data: invoiceItemData } = useGetInvoiceItem(order?.invoiceItem ?? null)
|
||||
const invoiceId = order?.invoiceItem?.invoice
|
||||
const customerName =
|
||||
[order?.user?.firstName, order?.user?.lastName].filter(Boolean).join(' ') ||
|
||||
order?.user?.phone ||
|
||||
undefined
|
||||
|
||||
const handleUpdateStatus = (status: OrderStatusEnum) => {
|
||||
if (!id) return
|
||||
@@ -101,7 +105,14 @@ const OrderDetail: FC = () => {
|
||||
|
||||
<div className="mt-6 flex flex-col-reverse gap-6 xl:flex-row xl:items-start">
|
||||
<div className="min-w-0 flex-1 space-y-6">
|
||||
<TicketSection />
|
||||
<ChatSection
|
||||
refId={id!}
|
||||
customerName={customerName}
|
||||
title="گفتگو با مشتری"
|
||||
description="پیامها و فایلهای مرتبط با سفارش"
|
||||
senderLabel="طراح"
|
||||
userLabel="مشتری"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="w-full shrink-0 xl:sticky xl:top-4 xl:w-[320px]">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, type FC } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useFormik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { useGetSections } from '../print/hooks/usePrintData'
|
||||
import { useGetOrderPrint, useSubmitOrderPrint } from './hooks/useOrderData'
|
||||
import { useGetOrderDetails, useGetOrderPrint, useSubmitOrderPrint } from './hooks/useOrderData'
|
||||
import type { OrderPrintFormType } from './types/Types'
|
||||
import PrintSectionCard from './components/PrintSectionCard'
|
||||
import PrintSectionNav from './components/PrintSectionNav'
|
||||
@@ -10,6 +11,7 @@ import { ORDER_PRINT_STORAGE_KEY } from './PrintPreview'
|
||||
import Button from '@/components/Button'
|
||||
import BackButton from '@/components/BackButton'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import Input from '@/components/Input'
|
||||
import { TickSquare } from 'iconsax-react'
|
||||
import { toast } from '@/shared/toast'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
@@ -19,6 +21,7 @@ const OrderPrint: FC = () => {
|
||||
const { id: orderId } = useParams()
|
||||
const { data, refetch: refetchSections, isFetching: isSectionsFetching } = useGetSections()
|
||||
const { isPending, mutate } = useSubmitOrderPrint()
|
||||
const { data: order, refetch: refetchOrder, isFetching: isOrderFetching } = useGetOrderDetails(orderId!)
|
||||
const { data: print, refetch: refetchPrint, isFetching: isPrintFetching } = useGetOrderPrint(orderId!)
|
||||
|
||||
const sections = data?.data ?? []
|
||||
@@ -27,9 +30,13 @@ const OrderPrint: FC = () => {
|
||||
|
||||
const formik = useFormik<OrderPrintFormType>({
|
||||
initialValues: {
|
||||
title: print?.data?.title || order?.data?.title || '',
|
||||
fields: print?.data?.fileds ?? []
|
||||
},
|
||||
enableReinitialize: true,
|
||||
validationSchema: Yup.object({
|
||||
title: Yup.string().required('این فیلد اجباری می باشد.')
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
if (!orderId) return
|
||||
mutate(
|
||||
@@ -87,7 +94,7 @@ const OrderPrint: FC = () => {
|
||||
|
||||
sessionStorage.setItem(
|
||||
ORDER_PRINT_STORAGE_KEY(orderId),
|
||||
JSON.stringify({ fields: formik.values.fields })
|
||||
JSON.stringify({ title: formik.values.title, fields: formik.values.fields })
|
||||
)
|
||||
|
||||
const sectionIds = Array.from(selectedSections).join(',')
|
||||
@@ -96,10 +103,11 @@ const OrderPrint: FC = () => {
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchSections()
|
||||
refetchOrder()
|
||||
refetchPrint()
|
||||
}
|
||||
|
||||
const isRefreshing = isSectionsFetching || isPrintFetching
|
||||
const isRefreshing = isSectionsFetching || isOrderFetching || isPrintFetching
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
@@ -125,6 +133,13 @@ const OrderPrint: FC = () => {
|
||||
|
||||
<div className="flex gap-6 mt-6 items-start">
|
||||
<div className="flex-1 flex flex-col gap-5 min-w-0">
|
||||
<div className="bg-white p-6 rounded-3xl">
|
||||
<Input
|
||||
{...formik.getFieldProps('title')}
|
||||
label="عنوان"
|
||||
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ''}
|
||||
/>
|
||||
</div>
|
||||
{sections.map((section) => (
|
||||
<PrintSectionCard
|
||||
key={section.id}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, type FC } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useGetSections } from '@/pages/print/hooks/usePrintData'
|
||||
import { useGetOrderPrint } from './hooks/useOrderData'
|
||||
import { useGetOrderDetails, useGetOrderPrint } from './hooks/useOrderData'
|
||||
import PrintFieldDisplay from './components/PrintFieldDisplay'
|
||||
import Button from '@/components/Button'
|
||||
import { Printer } from 'iconsax-react'
|
||||
@@ -12,6 +12,7 @@ const OrderPrintPreview: FC = () => {
|
||||
const { id: orderId } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { data: sectionsData } = useGetSections()
|
||||
const { data: orderData } = useGetOrderDetails(orderId!)
|
||||
const { data: printData } = useGetOrderPrint(orderId!)
|
||||
|
||||
const selectedSectionIds = useMemo(() => {
|
||||
@@ -19,18 +20,24 @@ const OrderPrintPreview: FC = () => {
|
||||
return param ? param.split(',').filter(Boolean) : []
|
||||
}, [searchParams])
|
||||
|
||||
const fieldValues = useMemo(() => {
|
||||
const draft = useMemo(() => {
|
||||
const stored = sessionStorage.getItem(ORDER_PRINT_STORAGE_KEY(orderId!))
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as { fields: { fieldId: string; value: string }[] }
|
||||
return parsed.fields ?? []
|
||||
return JSON.parse(stored) as {
|
||||
title?: string
|
||||
fields: { fieldId: string; value: string }[]
|
||||
}
|
||||
} catch {
|
||||
// fall through to API data
|
||||
}
|
||||
}
|
||||
return printData?.data?.fileds ?? []
|
||||
}, [orderId, printData?.data?.fileds])
|
||||
return null
|
||||
}, [orderId])
|
||||
|
||||
const printTitle =
|
||||
draft?.title || printData?.data?.title || orderData?.data?.title || 'فرم چاپ سفارش'
|
||||
const fieldValues = draft?.fields ?? printData?.data?.fileds ?? []
|
||||
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
@@ -41,7 +48,7 @@ const OrderPrintPreview: FC = () => {
|
||||
const handlePrint = () => window.print()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'چاپ فرم سفارش'
|
||||
document.title = printTitle
|
||||
|
||||
const html = document.documentElement
|
||||
const root = document.getElementById('root')
|
||||
@@ -67,7 +74,7 @@ const OrderPrintPreview: FC = () => {
|
||||
resetScroll(document.body)
|
||||
if (root) resetScroll(root)
|
||||
}
|
||||
}, [])
|
||||
}, [printTitle])
|
||||
|
||||
if (!sections.length) {
|
||||
return (
|
||||
@@ -96,7 +103,7 @@ const OrderPrintPreview: FC = () => {
|
||||
|
||||
<div className="max-w-[210mm] mx-auto p-8 print:p-0">
|
||||
<header className="text-center mb-8 pb-4 border-b-2 border-gray-800">
|
||||
<h1 className="text-2xl font-bold text-gray-900">فرم چاپ سفارش</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{printTitle}</h1>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{new Date().toLocaleDateString('fa-IR')}
|
||||
</p>
|
||||
@@ -124,7 +131,7 @@ const OrderPrintPreview: FC = () => {
|
||||
</div>
|
||||
|
||||
<footer className="mt-8 pt-4 border-t border-gray-200 text-center text-xs text-gray-400 print:mt-6">
|
||||
نگاره — فرم چاپ سفارش
|
||||
نگاره — {printTitle}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import Button from '@/components/Button'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import { Microphone, Paperclip2 } from 'iconsax-react'
|
||||
import { useState, type FC } from 'react'
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
|
||||
import { Play, Pause } from 'iconsax-react'
|
||||
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import type { AddTicketType } from '../types/Types'
|
||||
import { useAddTicket, useGetTickets } from '../hooks/useOrderData'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { toast } from '@/shared/toast'
|
||||
import { extractErrorMessage, getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
|
||||
import VoicePlayer from '@/components/VoicePlayer'
|
||||
|
||||
|
||||
|
||||
const TicketSection: FC = () => {
|
||||
|
||||
const { id } = useParams()
|
||||
const [message, setMessage] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const addTicket = useAddTicket()
|
||||
const { data, refetch, isFetching } = useGetTickets(id!)
|
||||
const singleUpload = useSingleUpload()
|
||||
const multiUpload = useMultiUpload()
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
isPlaying,
|
||||
audioUrl,
|
||||
audioRef,
|
||||
togglePlayPause,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
progress,
|
||||
audioFile,
|
||||
resetRecorder
|
||||
} = useVoiceRecorder()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const params: AddTicketType = {
|
||||
attachments: [],
|
||||
content: message
|
||||
}
|
||||
if (audioFile) {
|
||||
const voiceResult = await singleUpload.mutateAsync(audioFile)
|
||||
params.attachments.push({
|
||||
type: 'voice',
|
||||
url: voiceResult.data.key,
|
||||
})
|
||||
}
|
||||
if (files.length) {
|
||||
const filesResult = await multiUpload.mutateAsync(files)
|
||||
filesResult.data?.forEach((item) => {
|
||||
params.attachments.push({
|
||||
type: 'uploads_attach',
|
||||
url: item.key,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
addTicket.mutate({ orderId: id!, params: params }, {
|
||||
onSuccess: () => {
|
||||
toast('پیام شما با موفقیت ارسال شد')
|
||||
refetch()
|
||||
setMessage('')
|
||||
setFiles([])
|
||||
resetRecorder()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenLink = async (key: string) => {
|
||||
const url = await getPresignedUrl(key)
|
||||
window.open(
|
||||
url,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-5 py-4 md:px-6">
|
||||
<div>
|
||||
<h2 className="text-base font-medium text-gray-900">گفتگو</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-500">پیامها و فایلهای مرتبط با سفارش</p>
|
||||
</div>
|
||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||
</div>
|
||||
<div className="p-5 md:p-6">
|
||||
{
|
||||
data?.data?.map((item) => {
|
||||
if (item.user)
|
||||
return (
|
||||
<div key={item.id} className='flex gap-3 mt-6'>
|
||||
<UserAvatar
|
||||
src={item.user.avatarUrl}
|
||||
firstName={item.user.firstName}
|
||||
lastName={item.user.lastName}
|
||||
className="size-9 mt-1"
|
||||
/>
|
||||
<div className='bg-[#F5F7FC] rounded-4xl rounded-tr-none p-6 max-w-[55%]'>
|
||||
<div className='text-sm font-light text-black leading-6'>
|
||||
{item.content}
|
||||
</div>
|
||||
|
||||
{/* attachments (non-voice) */}
|
||||
<div className="flex gap-3 flex-wrap mt-3">
|
||||
{item.attachments
|
||||
?.filter((a) => a.type !== 'voice')
|
||||
.map((attach) => (
|
||||
<div
|
||||
key={attach.url}
|
||||
onClick={() => handleOpenLink(attach.url)}
|
||||
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF]"
|
||||
>
|
||||
<Paperclip2 size={20} color="#0047FF" />
|
||||
<div className="text-xs">
|
||||
{getFileNameAndExtensionFromUrl(attach.url).fileName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* voice messages */}
|
||||
{item.attachments
|
||||
?.filter((a) => a.type === 'voice')
|
||||
.map((voice) => (
|
||||
<VoicePlayer key={voice.url} url={voice.url} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
else if (item.admin)
|
||||
return (
|
||||
<div key={item.id} className='flex justify-end gap-3 mt-6'>
|
||||
<div className='bg-[#F5F7FC] rounded-4xl rounded-tl-none p-6 max-w-[55%]'>
|
||||
<div className='flex gap-1 text-sm mb-2'>
|
||||
<div className='font-bold'>طراح : </div>
|
||||
<div>{item.admin?.firstName + ' ' + item?.admin?.lastName}</div>
|
||||
</div>
|
||||
<div className='text-sm font-light text-black leading-6'>
|
||||
{item.content}
|
||||
</div>
|
||||
|
||||
{/* attachments (non-voice) */}
|
||||
<div className="flex gap-3 flex-wrap mt-3">
|
||||
{item.attachments
|
||||
?.filter((a) => a.type !== 'voice')
|
||||
.map((attach) => (
|
||||
<div
|
||||
key={attach.url}
|
||||
onClick={() => handleOpenLink(attach.url)}
|
||||
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF]"
|
||||
>
|
||||
<Paperclip2 size={20} color="#0047FF" />
|
||||
<div className="text-xs">
|
||||
{getFileNameAndExtensionFromUrl(attach.url).fileName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* voice messages */}
|
||||
{item.attachments
|
||||
?.filter((a) => a.type === 'voice')
|
||||
.map((voice) => (
|
||||
<VoicePlayer key={voice.url} url={voice.url} />
|
||||
))}
|
||||
</div>
|
||||
<UserAvatar
|
||||
src={item.admin.avatarUrl}
|
||||
firstName={item.admin.firstName}
|
||||
lastName={item.admin.lastName}
|
||||
className="size-9 mt-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="text-sm mb-2 text-black">پیام شما</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full h-40 bg-white border border-border rounded-xl p-4 text-sm resize-none outline-none"
|
||||
placeholder=""
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Microphone
|
||||
size={20}
|
||||
color={isRecording ? '#EF4444' : 'black'}
|
||||
variant={isRecording ? 'Bold' : 'Outline'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{audioUrl && (
|
||||
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3">
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="w-8 h-8 rounded-full bg-black flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} color="#fff" variant="Bold" />
|
||||
) : (
|
||||
<Play size={16} color="#fff" variant="Bold" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex items-center gap-[2px] h-8">
|
||||
{Array.from({ length: 60 }).map((_, i) => {
|
||||
const passed = i / 60 <= progress
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[2px] rounded-full transition-all"
|
||||
style={{
|
||||
height: `${Math.random() * 18 + 4}px`,
|
||||
backgroundColor: passed ? '#0047FF' : '#E5E7EB'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<audio ref={audioRef} src={audioUrl} className="hidden" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mt-6">
|
||||
<UploadBox
|
||||
label="فایل های ضمیمه"
|
||||
isMultiple={true}
|
||||
onChange={setFiles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex mt-10 justify-end">
|
||||
<Button
|
||||
label="ارسال"
|
||||
onClick={handleSubmit}
|
||||
className='w-[150px]'
|
||||
isLoading={singleUpload.isPending || multiUpload.isPending || addTicket.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketSection
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as api from "../service/OrderService";
|
||||
import type {
|
||||
AddTicketType,
|
||||
OrderPrintFormType,
|
||||
UpdateOrderType,
|
||||
} from "../types/Types";
|
||||
@@ -44,26 +43,6 @@ export const useGetOrderDetails = (id: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetTickets = (orderId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["tickets", orderId],
|
||||
queryFn: () => api.getTickets(orderId),
|
||||
enabled: !!orderId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddTicket = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
orderId,
|
||||
params,
|
||||
}: {
|
||||
orderId: string;
|
||||
params: AddTicketType;
|
||||
}) => api.addTicket(orderId, params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateOrder = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.submitOrder,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import axios from "@/config/axios";
|
||||
import type {
|
||||
AddTicketType,
|
||||
ConvertToOrderItemType,
|
||||
CreateFinalOrderType,
|
||||
OrderDetailResponseType,
|
||||
@@ -8,7 +7,6 @@ import type {
|
||||
OrderListResponseType,
|
||||
OrderPrintFormType,
|
||||
OrderPrintResponseType,
|
||||
TicketsResponseType,
|
||||
UpdateOrderType,
|
||||
} from "../types/Types";
|
||||
import type { BaseResponse } from "@/shared/types/Types";
|
||||
@@ -99,18 +97,6 @@ export const getOrderDetail = async (id: string) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getTickets = async (orderId: string) => {
|
||||
const { data } = await axios.get<TicketsResponseType>(
|
||||
`/admin/chat/ref/${orderId}`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const addTicket = async (ticketId: string, params: AddTicketType) => {
|
||||
const { data } = await axios.post(`/admin/chat/ref/${ticketId}`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const submitOrder = async (params: CreateFinalOrderType) => {
|
||||
const { data } = await axios.post(`/admin/orders`, params);
|
||||
return data;
|
||||
|
||||
@@ -165,31 +165,14 @@ export type AttachmentsType = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type AddTicketType = {
|
||||
content: string;
|
||||
attachments: AttachmentsType[];
|
||||
};
|
||||
|
||||
export type TicketType = {
|
||||
admin?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
id: string;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
attachments: AttachmentsType[];
|
||||
content: string;
|
||||
createdAt: string;
|
||||
id: number;
|
||||
user?: {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
phone?: string;
|
||||
avatarUrl?: string | null;
|
||||
} | null;
|
||||
};
|
||||
export type {
|
||||
AddChatMessageType,
|
||||
AddTicketType,
|
||||
ChatMessageType,
|
||||
ChatMessagesResponseType,
|
||||
TicketType,
|
||||
TicketsResponseType,
|
||||
} from "@/pages/chat/type/Types";
|
||||
|
||||
export interface MyOrderType extends RowDataType {
|
||||
balance: number;
|
||||
@@ -231,7 +214,6 @@ export type OrderItemType = {
|
||||
fieldId: string;
|
||||
}[];
|
||||
};
|
||||
export type TicketsResponseType = BaseResponse<TicketType[]>;
|
||||
export type OrderDetailResponseType = BaseResponse<OrderDetailDataType>;
|
||||
|
||||
export type StoreType = {
|
||||
@@ -290,11 +272,13 @@ export type OrderPrintFieldType = {
|
||||
};
|
||||
|
||||
export type OrderPrintFormType = {
|
||||
title: string;
|
||||
fields: OrderPrintFieldType[];
|
||||
};
|
||||
|
||||
export type OrderPrintDataType = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
fileds: OrderPrintFieldType[];
|
||||
|
||||
@@ -15,6 +15,8 @@ import { toast } from 'react-toastify';
|
||||
import type { ErrorType } from '@/helpers/types';
|
||||
import { extractErrorMessage } from '@/config/func';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Paths } from '@/config/Paths'
|
||||
import BackButton from '@/components/BackButton'
|
||||
|
||||
const CreateProduct: FC = () => {
|
||||
|
||||
@@ -71,8 +73,10 @@ const CreateProduct: FC = () => {
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
|
||||
<h1 className='text-lg font-light'>محصول جدید</h1>
|
||||
<div className='flex items-center gap-4'>
|
||||
<BackButton to={Paths.product.list} />
|
||||
<h1 className='text-lg font-light'>محصول جدید</h1>
|
||||
</div>
|
||||
<Button
|
||||
className='w-fit px-6'
|
||||
onClick={() => formik.handleSubmit()}
|
||||
|
||||
@@ -1,19 +1,53 @@
|
||||
import Button from '@/components/Button'
|
||||
import Filters from '@/components/Filters'
|
||||
import Filters, { type FilterValues } from '@/components/Filters'
|
||||
import Table from '@/components/Table'
|
||||
import { AddSquare, Edit, More2 } from 'iconsax-react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { useDeleteProduct, useDuplicateProduct, useGetProducts } from './hooks/useProductData'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { useDeleteProduct, useDuplicateProduct, useGetCategory, useGetProducts } from './hooks/useProductData'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import TrashWithConfrim from '@/components/TrashWithConfrim'
|
||||
import CopyWithConfirm from '@/components/CopyWithConfirm'
|
||||
import { toast } from 'react-toastify'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import type { CategoryType } from './types/Types'
|
||||
|
||||
const flattenCategoryOptions = (categories: CategoryType[]) => {
|
||||
const options: { label: string; value: string }[] = []
|
||||
|
||||
for (const category of categories) {
|
||||
options.push({ label: category.title, value: category.id })
|
||||
for (const child of category.children ?? []) {
|
||||
options.push({ label: `— ${child.title}`, value: child.id })
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const ProductList: FC = () => {
|
||||
const [page, setPage] = useState<number>(1)
|
||||
const [search, setSearch] = useState<string>('')
|
||||
const [categoryId, setCategoryId] = useState<string>('')
|
||||
|
||||
const { data: categoriesData } = useGetCategory({ limit: 1000, parentId: 'null' })
|
||||
const { data: products, refetch } = useGetProducts({
|
||||
page,
|
||||
search: search || undefined,
|
||||
categoryId: categoryId || undefined,
|
||||
})
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() => flattenCategoryOptions((categoriesData?.data ?? []) as CategoryType[]),
|
||||
[categoriesData?.data],
|
||||
)
|
||||
|
||||
const handleFiltersChange = (filters: FilterValues) => {
|
||||
setSearch(filters.search ?? '')
|
||||
setCategoryId(filters.categoryId ?? '')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const { data: products, refetch } = useGetProducts()
|
||||
const { mutate: deleteProduct, isPending: isDeleting } = useDeleteProduct()
|
||||
const { mutate: duplicateProduct, isPending: isDuplicating } = useDuplicateProduct()
|
||||
const [duplicatingId, setDuplicatingId] = useState<string | null>(null)
|
||||
@@ -75,9 +109,15 @@ const ProductList: FC = () => {
|
||||
name: 'search',
|
||||
type: 'input',
|
||||
placeholder: 'جستجوی محصول'
|
||||
},
|
||||
{
|
||||
name: 'categoryId',
|
||||
type: 'select',
|
||||
placeholder: 'دستهبندی',
|
||||
options: categoryOptions,
|
||||
}
|
||||
]}
|
||||
onChange={() => { }}
|
||||
onChange={handleFiltersChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +128,11 @@ const ProductList: FC = () => {
|
||||
key: 'title',
|
||||
title: 'عنوان محصول',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
title: 'دستهبندی',
|
||||
render: (item) => item.category?.title ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'attribute',
|
||||
title: 'ویژگی ها',
|
||||
@@ -109,6 +154,10 @@ const ProductList: FC = () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'order',
|
||||
title: 'ترتیب',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '',
|
||||
@@ -133,6 +182,11 @@ const ProductList: FC = () => {
|
||||
},
|
||||
]}
|
||||
data={products?.data}
|
||||
pagination={{
|
||||
currentPage: page,
|
||||
onPageChange: setPage,
|
||||
totalPages: products?.meta?.totalPages ?? 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,39 +3,45 @@ import { useFormik } from 'formik'
|
||||
import { useState, type FC } from 'react'
|
||||
import type { CreateCategoryType } from '../types/Types'
|
||||
import * as Yup from 'yup'
|
||||
import { useCreateCategory, useGetCategory } from '../hooks/useProductData'
|
||||
import Select from '@/components/Select'
|
||||
import { useCreateCategory } from '../hooks/useProductData'
|
||||
import Button from '@/components/Button'
|
||||
import { AddSquare } from 'iconsax-react'
|
||||
import { AddSquare, TickCircle } from 'iconsax-react'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import SwitchComponent from '@/components/Switch'
|
||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import { toast } from 'react-toastify'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import BackButton from '@/components/BackButton'
|
||||
import DefaulModal from '@/components/DefaulModal'
|
||||
|
||||
const ProductCategory: FC = () => {
|
||||
type Props = {
|
||||
parentId?: string
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { data } = useGetCategory()
|
||||
const CreateCategory: FC<Props> = ({ parentId, refetch }) => {
|
||||
const { mutate, isPending } = useCreateCategory()
|
||||
const { mutate: upload, isPending: isUploading } = useSingleUpload()
|
||||
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [file, setFile] = useState<File>()
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModal(false)
|
||||
setFile(undefined)
|
||||
formik.resetForm()
|
||||
}
|
||||
|
||||
const handleSave = (values: CreateCategoryType) => {
|
||||
const payload: CreateCategoryType = {
|
||||
...values,
|
||||
parentId: values.parentId || undefined,
|
||||
parentId: parentId || undefined,
|
||||
}
|
||||
|
||||
mutate(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success('با موفقیت ذخیره شد')
|
||||
navigate(Paths.product.category.list)
|
||||
refetch()
|
||||
handleClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error))
|
||||
@@ -69,55 +75,39 @@ const ProductCategory: FC = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<BackButton to={Paths.product.category.list} />
|
||||
<h1 className='text-lg font-light'>دسته جدید</h1>
|
||||
<div>
|
||||
<Button
|
||||
className='w-fit px-6'
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<div className='flex gap-1.5'>
|
||||
<AddSquare size={18} color='black' />
|
||||
<div className='text-[13px] font-light'>دستهبندی جدید</div>
|
||||
</div>
|
||||
<Button
|
||||
className='w-fit px-6'
|
||||
isLoading={isPending || isUploading}
|
||||
onClick={() => formik.handleSubmit()}
|
||||
>
|
||||
<div className='flex gap-1.5'>
|
||||
<AddSquare size={18} color='black' />
|
||||
<div className='text-[13px] font-light'>ساخت دسته</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='mt-8 bg-white p-6 rounded-3xl '>
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<DefaulModal
|
||||
open={showModal}
|
||||
close={handleClose}
|
||||
isHeader
|
||||
title_header='دسته جدید'
|
||||
>
|
||||
<div className='mt-5'>
|
||||
<UploadBox
|
||||
label='تصویر دسته (اختیاری)'
|
||||
onChange={(files) => setFile(files[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rowTwoInput mt-6'>
|
||||
<div className='mt-5'>
|
||||
<Input
|
||||
label='عنوان'
|
||||
{...formik.getFieldProps('title')}
|
||||
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ''}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='انتخاب والد درسته'
|
||||
placeholder='اختیاری'
|
||||
items={data?.data?.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
value: item.id + ''
|
||||
}
|
||||
}) || []}
|
||||
onChange={formik.handleChange}
|
||||
name='parentId'
|
||||
value={formik.values.parentId || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-6'>
|
||||
<div className='mt-5'>
|
||||
<Input
|
||||
label='ترتیب اولویت'
|
||||
{...formik.getFieldProps('order')}
|
||||
@@ -125,15 +115,31 @@ const ProductCategory: FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-6'>
|
||||
<div className='mt-5'>
|
||||
<SwitchComponent
|
||||
active={formik.values.isActive}
|
||||
onChange={(value) => formik.setFieldValue('isActive', value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex justify-end'>
|
||||
<Button
|
||||
className='w-fit px-6'
|
||||
isLoading={isPending || isUploading}
|
||||
onClick={() => formik.handleSubmit()}
|
||||
>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<TickCircle
|
||||
size={18}
|
||||
color='black'
|
||||
/>
|
||||
<div>ذخیره</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductCategory
|
||||
export default CreateCategory
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { type FC, useMemo } from 'react'
|
||||
import PresignedImage from '@/components/PresignedImage'
|
||||
import { useDeleteCategory, useGetCategory } from '../hooks/useProductData'
|
||||
import { useDeleteCategory, useGetCategory, useGetCategoryDetail } from '../hooks/useProductData'
|
||||
import Table from '@/components/Table'
|
||||
import type { ColumnType, RowDataType } from '@/components/types/TableTypes'
|
||||
import type { CategoryType } from '../types/Types'
|
||||
import Button from '@/components/Button'
|
||||
import { AddSquare, Edit } from 'iconsax-react'
|
||||
import { ArrowLeft2, Edit } from 'iconsax-react'
|
||||
import TrashWithConfrim from '@/components/TrashWithConfrim'
|
||||
import { toast } from 'react-toastify'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import BackButton from '@/components/BackButton'
|
||||
import CreateCategory from './Create'
|
||||
|
||||
const CategoryList: FC = () => {
|
||||
const { parentId } = useParams<{ parentId?: string }>()
|
||||
|
||||
const { data, refetch } = useGetCategory()
|
||||
const { data, refetch } = useGetCategory({
|
||||
limit: 1000,
|
||||
parentId: parentId ?? 'null',
|
||||
})
|
||||
const { data: parentDetail } = useGetCategoryDetail(parentId ?? '')
|
||||
const { mutate, isPending } = useDeleteCategory()
|
||||
const categories = (data?.data ?? []) as (CategoryType & RowDataType)[]
|
||||
|
||||
const parentCategory = parentDetail?.data
|
||||
const backTo = parentCategory?.parent?.id
|
||||
? Paths.product.category.children + parentCategory.parent.id
|
||||
: Paths.product.category.list
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
mutate(id, {
|
||||
onSuccess: () => {
|
||||
@@ -39,19 +49,34 @@ const CategoryList: FC = () => {
|
||||
{
|
||||
title: 'عنوان',
|
||||
key: 'title',
|
||||
render: (item) => {
|
||||
return (
|
||||
<Link
|
||||
to={Paths.product.category.children + item.id}
|
||||
className='inline-flex items-center gap-1.5 text-sm text-black hover:opacity-70'
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
<ArrowLeft2 size={16} color='#8C90A3' />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'والد',
|
||||
key: 'parent',
|
||||
render: (item) => item.parent?.title || '-',
|
||||
},
|
||||
{
|
||||
title: 'تعداد زیر دسته',
|
||||
title: 'زیر دستهها',
|
||||
key: 'children',
|
||||
render: (item) => <div>{item.children?.length || 0}</div>
|
||||
render: (item) => {
|
||||
if (!item.children?.length) return '0'
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{item.children.map((child) => (
|
||||
<span key={child.id}>{child.title}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'اولویت',
|
||||
title: 'ترتیب',
|
||||
key: 'order',
|
||||
render: (item) => item.order ?? '-',
|
||||
},
|
||||
@@ -75,33 +100,30 @@ const CategoryList: FC = () => {
|
||||
}
|
||||
], [isPending])
|
||||
|
||||
const title = parentCategory
|
||||
? `زیر دستههای «${parentCategory.title}»`
|
||||
: 'دسته بندی محصولات'
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<BackButton to={Paths.product.list} />
|
||||
<h1 className='text-lg font-light'>دسته بندی محصولات</h1>
|
||||
<BackButton to={parentId ? backTo : Paths.product.list} />
|
||||
<h1 className='text-lg font-light'>{title}</h1>
|
||||
</div>
|
||||
|
||||
<Link to={Paths.product.category.create}>
|
||||
<Button className='w-fit px-6'>
|
||||
<div className='flex gap-1.5'>
|
||||
<AddSquare size={18} color='black' />
|
||||
<div className='text-[13px] font-light'>دستهبندی جدید</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
<CreateCategory parentId={parentId} refetch={refetch} />
|
||||
</div>
|
||||
|
||||
<div className='mt-8'>
|
||||
<Table<CategoryType & RowDataType>
|
||||
columns={columns}
|
||||
data={categories}
|
||||
/>
|
||||
<Table<CategoryType & RowDataType>
|
||||
columns={columns}
|
||||
data={categories}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategoryList
|
||||
export default CategoryList
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type FC, type SelectHTMLAttributes } from 'react'
|
||||
import { type FC, type SelectHTMLAttributes, useMemo } from 'react'
|
||||
import { useGetCategory } from '../hooks/useProductData'
|
||||
import Select from '@/components/Select'
|
||||
import type { CategoryType } from '../types/Types'
|
||||
|
||||
type Props = {
|
||||
error_text?: string,
|
||||
@@ -9,6 +10,19 @@ type Props = {
|
||||
clearable?: boolean,
|
||||
} & SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
const flattenCategoryOptions = (categories: CategoryType[]) => {
|
||||
const options: { label: string; value: string }[] = []
|
||||
|
||||
for (const category of categories) {
|
||||
options.push({ label: category.title, value: category.id })
|
||||
for (const child of category.children ?? []) {
|
||||
options.push({ label: `— ${child.title}`, value: child.id })
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const CategoriesSelect: FC<Props> = ({
|
||||
isDisableShowLable,
|
||||
placeholder,
|
||||
@@ -16,8 +30,12 @@ const CategoriesSelect: FC<Props> = ({
|
||||
clearable = true,
|
||||
...selectProps
|
||||
}) => {
|
||||
const { data: categories } = useGetCategory({ limit: 1000, parentId: 'null' })
|
||||
|
||||
const { data: categories } = useGetCategory()
|
||||
const items = useMemo(
|
||||
() => flattenCategoryOptions((categories?.data ?? []) as CategoryType[]),
|
||||
[categories?.data],
|
||||
)
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -25,15 +43,10 @@ const CategoriesSelect: FC<Props> = ({
|
||||
placeholder={placeholder || 'انتخاب'}
|
||||
error_text={error_text}
|
||||
clearable={clearable}
|
||||
items={categories?.data?.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
value: item.id + ''
|
||||
}
|
||||
}) || []}
|
||||
items={items}
|
||||
{...selectProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategoriesSelect
|
||||
export default CategoriesSelect
|
||||
|
||||
@@ -12,10 +12,15 @@ import type {
|
||||
CreateProductType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const useGetCategory = () => {
|
||||
export const useGetCategory = (params?: api.GetCategoryParams) => {
|
||||
const page = params?.page ?? 1;
|
||||
const limit = params?.limit;
|
||||
const search = params?.search;
|
||||
const parentId = params?.parentId;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["category"],
|
||||
queryFn: api.getCategory,
|
||||
queryKey: ["category", page, limit, search, parentId],
|
||||
queryFn: () => api.getCategory({ page, limit, search, parentId }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -52,10 +57,15 @@ export const useGetCategoryDetail = (id: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetProducts = () => {
|
||||
export const useGetProducts = (params?: api.GetProductsParams) => {
|
||||
const page = params?.page ?? 1;
|
||||
const search = params?.search;
|
||||
const limit = params?.limit;
|
||||
const categoryId = params?.categoryId;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["products"],
|
||||
queryFn: api.getProducts,
|
||||
queryKey: ["products", page, search, limit, categoryId],
|
||||
queryFn: () => api.getProducts({ page, search, limit, categoryId }),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import axios from "@/config/axios";
|
||||
import { type AttributeDetailResponseType, type AttributeResponseType, type AttributeValueDetailResponseType, type AttributeValuesResponseType, type CategoriesResponse, type CategoryResponse, type CreateAttributeType, type CreateAttributeValueType, type CreateCategoryType, type CreateProductType, type ProductDetailResponeType, type ProductResponeType } from "../types/Types";
|
||||
|
||||
export const getCategory = async () => {
|
||||
const { data } = await axios.get<CategoriesResponse>("/admin/category");
|
||||
export type GetCategoryParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
|
||||
export const getCategory = async (params?: GetCategoryParams) => {
|
||||
const { data } = await axios.get<CategoriesResponse>("/admin/category", {
|
||||
params: {
|
||||
page: params?.page,
|
||||
limit: params?.limit,
|
||||
search: params?.search || undefined,
|
||||
parentId: params?.parentId || undefined,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -26,8 +40,22 @@ export const getCategoryDetail = async(id:string) => {
|
||||
return data
|
||||
}
|
||||
|
||||
export const getProducts = async () => {
|
||||
const { data } = await axios.get<ProductResponeType>("/admin/products");
|
||||
export type GetProductsParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
categoryId?: string;
|
||||
};
|
||||
|
||||
export const getProducts = async (params?: GetProductsParams) => {
|
||||
const { data } = await axios.get<ProductResponeType>("/admin/products", {
|
||||
params: {
|
||||
page: params?.page,
|
||||
limit: params?.limit,
|
||||
search: params?.search || undefined,
|
||||
categoryId: params?.categoryId || undefined,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useFormik } from 'formik';
|
||||
import { type FC, useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import Button from '@/components/Button';
|
||||
import { Edit } from 'iconsax-react';
|
||||
import Input from '@/components/Input';
|
||||
import UploadBox from '@/components/UploadBox';
|
||||
import PresignedImage from '@/components/PresignedImage';
|
||||
import { useGetAdminMe, useUpdateAdminMe } from '@/pages/admin/hooks/useAdminData';
|
||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploader';
|
||||
import { toast } from 'react-toastify';
|
||||
import { extractErrorMessage } from '@/config/func';
|
||||
import type { UpdateAdminMeType } from '@/pages/admin/types/Types';
|
||||
|
||||
const Profile: FC = () => {
|
||||
const { data: adminData, isLoading } = useGetAdminMe();
|
||||
const { mutate: update, isPending: isUpdating } = useUpdateAdminMe();
|
||||
const { mutate: upload, isPending: isUploading } = useSingleUpload();
|
||||
const [file, setFile] = useState<File>();
|
||||
|
||||
const admin = adminData?.data;
|
||||
|
||||
const handleSave = (values: UpdateAdminMeType) => {
|
||||
update(values, {
|
||||
onSuccess: () => {
|
||||
toast.success('پروفایل با موفقیت ویرایش شد');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const formik = useFormik<UpdateAdminMeType>({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
firstName: admin?.firstName ?? '',
|
||||
lastName: admin?.lastName ?? '',
|
||||
phone: admin?.phone ? `0${admin.phone}` : '',
|
||||
avatarUrl: admin?.avatarUrl ?? undefined,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
firstName: Yup.string()
|
||||
.required('نام اجباری می باشد')
|
||||
.min(2, 'نام باید حداقل 2 کاراکتر باشد'),
|
||||
lastName: Yup.string()
|
||||
.required('نام خانوادگی اجباری می باشد')
|
||||
.min(2, 'نام خانوادگی باید حداقل 2 کاراکتر باشد'),
|
||||
phone: Yup.string()
|
||||
.required('شماره موبایل اجباری می باشد')
|
||||
.matches(/^09[0-9]{9}$/, 'شماره موبایل معتبر نمی باشد'),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
if (file) {
|
||||
upload(file, {
|
||||
onSuccess: (data) => {
|
||||
handleSave({ ...values, avatarUrl: data?.data?.key });
|
||||
},
|
||||
onError: (error) => toast.error(extractErrorMessage(error)),
|
||||
});
|
||||
} else {
|
||||
handleSave(values);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='mt-5 flex h-64 items-center justify-center'>
|
||||
<div className='text-lg'>در حال بارگذاری...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h1 className='text-lg font-light'>پروفایل</h1>
|
||||
<Button
|
||||
className='w-fit px-6'
|
||||
onClick={() => formik.handleSubmit()}
|
||||
isLoading={isUpdating || isUploading}
|
||||
>
|
||||
<div className='flex gap-1.5'>
|
||||
<Edit size={18} color='black' />
|
||||
<div className='text-[13px] font-light'>ذخیره تغییرات</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 flex-1 rounded-3xl bg-white p-6'>
|
||||
{formik.values.avatarUrl && !file && (
|
||||
<div className='mb-4'>
|
||||
<PresignedImage
|
||||
src={formik.values.avatarUrl}
|
||||
className='h-20 w-20 rounded-full object-cover'
|
||||
alt='آواتار'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadBox
|
||||
label='آواتار (اختیاری)'
|
||||
onChange={(files) => setFile(files[0])}
|
||||
/>
|
||||
|
||||
<div className='rowTwoInput mt-6'>
|
||||
<Input
|
||||
label='نام'
|
||||
placeholder='نام را وارد کنید'
|
||||
{...formik.getFieldProps('firstName')}
|
||||
error_text={
|
||||
formik.touched.firstName && formik.errors.firstName
|
||||
? formik.errors.firstName
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label='نام خانوادگی'
|
||||
placeholder='نام خانوادگی را وارد کنید'
|
||||
{...formik.getFieldProps('lastName')}
|
||||
error_text={
|
||||
formik.touched.lastName && formik.errors.lastName
|
||||
? formik.errors.lastName
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-6'>
|
||||
<Input
|
||||
label='شماره موبایل'
|
||||
placeholder='شماره موبایل را وارد کنید'
|
||||
{...formik.getFieldProps('phone')}
|
||||
error_text={
|
||||
formik.touched.phone && formik.errors.phone
|
||||
? formik.errors.phone
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "@/config/axios";
|
||||
|
||||
export interface ProfileData {
|
||||
user: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
profilePic?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const useGetProfile = () => {
|
||||
return useQuery({
|
||||
queryKey: ["profile"],
|
||||
queryFn: async (): Promise<{ data: ProfileData }> => {
|
||||
const response = await axios.get("/profile");
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import moment from 'moment-jalaali'
|
||||
import { Calendar, TickSquare } from 'iconsax-react'
|
||||
import { useGetRequestDetail } from './hooks/useRequestData'
|
||||
import RequestDetailItem from './components/RequestDetailItem'
|
||||
import TicketSection from './components/TicketSection'
|
||||
import ChatSection from '@/pages/chat/components/ChatSection'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import BackButton from '@/components/BackButton'
|
||||
import Button from '@/components/Button'
|
||||
@@ -120,7 +120,14 @@ const RequestDetail: FC = () => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<TicketSection customerName={customerName} />
|
||||
<ChatSection
|
||||
refId={id!}
|
||||
customerName={customerName}
|
||||
title="گفتگو با مشتری"
|
||||
description="پیامها و فایلهای مرتبط با درخواست"
|
||||
senderLabel="پشتیبان"
|
||||
userLabel="مشتری"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { type FC } from 'react'
|
||||
import moment from 'moment-jalaali'
|
||||
import { Paperclip2 } from 'iconsax-react'
|
||||
import { getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
|
||||
import VoicePlayer from '@/components/VoicePlayer'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import type { AttachmentsType } from '@/pages/order/types/Types'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
attachments?: AttachmentsType[]
|
||||
senderName?: string
|
||||
senderLabel?: string
|
||||
createdAt?: string
|
||||
isAdmin?: boolean
|
||||
avatarUrl?: string | null
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
}
|
||||
|
||||
const TicketMessage: FC<Props> = ({
|
||||
content,
|
||||
attachments = [],
|
||||
senderName,
|
||||
senderLabel = 'پشتیبان',
|
||||
createdAt,
|
||||
isAdmin = false,
|
||||
avatarUrl,
|
||||
firstName,
|
||||
lastName,
|
||||
}) => {
|
||||
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
|
||||
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
|
||||
|
||||
const handleOpenLink = async (key: string) => {
|
||||
const url = await getPresignedUrl(key)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const avatar = (
|
||||
<UserAvatar
|
||||
src={avatarUrl}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
className="size-9 mt-1"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isAdmin ? 'justify-end' : ''}`}>
|
||||
{!isAdmin && avatar}
|
||||
<div
|
||||
className={`bg-[#F5F7FC] rounded-3xl p-5 max-w-[min(100%,520px)] ${
|
||||
isAdmin ? 'rounded-tl-none' : 'rounded-tr-none'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
|
||||
<span className="font-medium text-black">{senderLabel}:</span>
|
||||
{senderName ? <span>{senderName}</span> : null}
|
||||
</div>
|
||||
|
||||
{content && (
|
||||
<p className="text-sm text-black leading-7 whitespace-pre-wrap">{content}</p>
|
||||
)}
|
||||
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="flex gap-3 flex-wrap mt-3">
|
||||
{fileAttachments.map((attach) => (
|
||||
<button
|
||||
key={attach.url}
|
||||
type="button"
|
||||
onClick={() => handleOpenLink(attach.url)}
|
||||
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF] hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Paperclip2 size={18} color="#0047FF" />
|
||||
<span className="text-xs">
|
||||
{getFileNameAndExtensionFromUrl(attach.url).fileName}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voiceAttachments.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{voiceAttachments.map((voice) => (
|
||||
<VoicePlayer key={voice.url} url={voice.url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdAt && (
|
||||
<div className="mt-3 text-[11px] text-[#8C90A3] text-left" dir="ltr">
|
||||
{moment(createdAt).format('jYYYY/jMM/jDD HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && avatar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketMessage
|
||||
@@ -1,240 +0,0 @@
|
||||
import Button from '@/components/Button'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import { Microphone, Play, Pause } from 'iconsax-react'
|
||||
import { useState, type FC } from 'react'
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
|
||||
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import type { AddTicketType } from '@/pages/order/types/Types'
|
||||
import { useAddTicket, useGetTickets } from '@/pages/order/hooks/useOrderData'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { toast } from '@/shared/toast'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import TicketMessage from './TicketMessage'
|
||||
|
||||
type Props = {
|
||||
customerName?: string
|
||||
}
|
||||
|
||||
const TicketSection: FC<Props> = ({ customerName }) => {
|
||||
const { id } = useParams()
|
||||
const [message, setMessage] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const addTicket = useAddTicket()
|
||||
const { data, refetch, isFetching, isPending: isLoadingTickets } = useGetTickets(id!)
|
||||
const singleUpload = useSingleUpload()
|
||||
const multiUpload = useMultiUpload()
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
isPlaying,
|
||||
audioUrl,
|
||||
audioRef,
|
||||
togglePlayPause,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
progress,
|
||||
audioFile,
|
||||
resetRecorder,
|
||||
} = useVoiceRecorder()
|
||||
|
||||
const tickets = data?.data ?? []
|
||||
const isSubmitting =
|
||||
singleUpload.isPending || multiUpload.isPending || addTicket.isPending
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!message.trim() && !audioFile && files.length === 0) {
|
||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const params: AddTicketType = {
|
||||
attachments: [],
|
||||
content: message.trim(),
|
||||
}
|
||||
|
||||
if (audioFile) {
|
||||
const voiceResult = await singleUpload.mutateAsync(audioFile)
|
||||
params.attachments.push({
|
||||
type: 'voice',
|
||||
url: voiceResult.data.key,
|
||||
})
|
||||
}
|
||||
|
||||
if (files.length) {
|
||||
const filesResult = await multiUpload.mutateAsync(files)
|
||||
filesResult.data?.forEach((item) => {
|
||||
params.attachments.push({
|
||||
type: 'uploads_attach',
|
||||
url: item.key,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
addTicket.mutate(
|
||||
{ orderId: id!, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast('پیام شما با موفقیت ارسال شد')
|
||||
refetch()
|
||||
setMessage('')
|
||||
setFiles([])
|
||||
resetRecorder()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#EBEDF5] px-6 py-4 md:px-8">
|
||||
<div>
|
||||
<h2 className="text-sm text-black">گفتگو با مشتری</h2>
|
||||
<p className="mt-1 text-xs text-[#8C90A3]">
|
||||
پیامها و فایلهای مرتبط با درخواست
|
||||
</p>
|
||||
</div>
|
||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="space-y-4 min-h-[80px]">
|
||||
{isLoadingTickets && tickets.length === 0 && (
|
||||
<p className="text-xs text-[#8C90A3]">در حال بارگذاری پیامها...</p>
|
||||
)}
|
||||
|
||||
{!isLoadingTickets && tickets.length === 0 && (
|
||||
<div className="rounded-xl bg-[#F5F7FC] px-4 py-6 text-center">
|
||||
<p className="text-sm text-[#8C90A3]">هنوز پیامی ثبت نشده است.</p>
|
||||
<p className="text-xs text-[#8C90A3] mt-1">
|
||||
اولین پیام را در فرم زیر ارسال کنید.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tickets.map((item) => {
|
||||
if (item.user) {
|
||||
return (
|
||||
<TicketMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
senderLabel="مشتری"
|
||||
senderName={
|
||||
customerName ||
|
||||
[item.user.firstName, item.user.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ') ||
|
||||
item.user.phone
|
||||
}
|
||||
avatarUrl={item.user.avatarUrl}
|
||||
firstName={item.user.firstName}
|
||||
lastName={item.user.lastName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.admin) {
|
||||
return (
|
||||
<TicketMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
isAdmin
|
||||
senderLabel="پشتیبان"
|
||||
senderName={`${item.admin.firstName} ${item.admin.lastName}`}
|
||||
avatarUrl={item.admin.avatarUrl}
|
||||
firstName={item.admin.firstName}
|
||||
lastName={item.admin.lastName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-[#EBEDF5]">
|
||||
<div className="text-sm mb-2 text-black">پیام شما</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full h-32 bg-white border border-border rounded-xl p-4 text-sm resize-none outline-none focus:border-[#0047FF] transition-colors"
|
||||
placeholder="متن پیام خود را بنویسید..."
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center hover:opacity-90 transition-opacity"
|
||||
aria-label={isRecording ? 'توقف ضبط' : 'ضبط صدا'}
|
||||
>
|
||||
<Microphone
|
||||
size={20}
|
||||
color={isRecording ? '#EF4444' : 'black'}
|
||||
variant={isRecording ? 'Bold' : 'Outline'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{audioUrl && (
|
||||
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlayPause}
|
||||
className="w-8 h-8 rounded-full bg-black flex items-center justify-center shrink-0"
|
||||
aria-label={isPlaying ? 'توقف' : 'پخش'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} color="#fff" variant="Bold" />
|
||||
) : (
|
||||
<Play size={16} color="#fff" variant="Bold" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex items-center gap-[2px] h-8">
|
||||
{Array.from({ length: 60 }).map((_, i) => {
|
||||
const passed = i / 60 <= progress
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[2px] rounded-full transition-all"
|
||||
style={{
|
||||
height: `${Math.random() * 18 + 4}px`,
|
||||
backgroundColor: passed ? '#0047FF' : '#E5E7EB',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<audio ref={audioRef} src={audioUrl} className="hidden" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<UploadBox label="فایلهای ضمیمه" isMultiple onChange={setFiles} />
|
||||
</div>
|
||||
|
||||
<div className="flex mt-8 justify-end">
|
||||
<Button
|
||||
label="ارسال پیام"
|
||||
onClick={handleSubmit}
|
||||
className="w-[150px]"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketSection
|
||||
+72
-55
@@ -1,8 +1,13 @@
|
||||
import { type FC } from "react";
|
||||
import { type FC, type ReactNode } from "react";
|
||||
import { Route, Routes, useLocation } from "react-router-dom";
|
||||
import Home from "../pages/home/Home";
|
||||
import AppLayout from "../shared/AppLayout";
|
||||
import PermissionGuard from "../shared/PermissionGuard";
|
||||
import { Paths } from "@/config/Paths";
|
||||
import {
|
||||
ORDER_VIEW_PERMISSIONS,
|
||||
Permissions,
|
||||
} from "@/config/permissions";
|
||||
import ProformaInvoice from "@/pages/invoice/ProformaInvoice";
|
||||
import CreateInvoice from "@/pages/invoice/Create";
|
||||
import RequestList from "@/pages/requests/RequestList";
|
||||
@@ -23,7 +28,6 @@ import LearningList from "@/pages/learning/List";
|
||||
import LearningCreate from "@/pages/learning/Create";
|
||||
import LearningCategory from "@/pages/learning/Category";
|
||||
import CategoryList from "@/pages/product/category/List";
|
||||
import CreateCategory from "@/pages/product/category/Create";
|
||||
import UpdateCategory from "@/pages/product/category/Update";
|
||||
import UpdateProduct from "@/pages/product/Update";
|
||||
import CreateAttribute from "@/pages/product/attribute/Create";
|
||||
@@ -47,6 +51,7 @@ import UpdateUser from "@/pages/user/Update";
|
||||
import AdminList from "@/pages/admin/List";
|
||||
import CreateAdmin from "@/pages/admin/Create";
|
||||
import EditAdmin from "@/pages/admin/Update";
|
||||
import Profile from "@/pages/profile/Profile";
|
||||
import RoleList from "@/pages/role/List";
|
||||
import CreateRole from "@/pages/role/Create";
|
||||
import UpdateRole from "@/pages/role/Update";
|
||||
@@ -58,6 +63,13 @@ import TicketList from "@/pages/ticket/TicketList";
|
||||
import TicketDetail from "@/pages/ticket/Detail";
|
||||
import PaymentsList from "@/pages/payment/List";
|
||||
|
||||
const withPermission = (
|
||||
permission: string | string[],
|
||||
element: ReactNode,
|
||||
) => (
|
||||
<PermissionGuard permission={permission}>{element}</PermissionGuard>
|
||||
);
|
||||
|
||||
const MainRouter: FC = () => {
|
||||
const location = useLocation()
|
||||
const isPrintPreview = /\/order\/print\/[^/]+\/preview/.test(location.pathname)
|
||||
@@ -73,73 +85,78 @@ const MainRouter: FC = () => {
|
||||
return (
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path={Paths.home} element={<Home />} />
|
||||
<Route path={Paths.perfomaInvoice.list} element={<ProformaInvoice />} />
|
||||
<Route path={Paths.payments.list} element={<PaymentsList />} />
|
||||
<Route path={Paths.perfomaInvoice.create} element={<CreateInvoice />} />
|
||||
<Route path={Paths.perfomaInvoice.detail + ":id"} element={<DetailPerfomaInvoice />} />
|
||||
<Route path={Paths.perfomaInvoice.update + ":id"} element={<UpdateInvoice />} />
|
||||
<Route path={Paths.requests.list} element={<RequestList />} />
|
||||
<Route path={Paths.requests.detail + ":id"} element={<RequestDetail />} />
|
||||
<Route path={Paths.product.list} element={<ProductList />} />
|
||||
<Route path={Paths.product.create} element={<CreateProduct />} />
|
||||
<Route path={Paths.product.update + ":id"} element={<UpdateProduct />} />
|
||||
<Route path={Paths.product.attribute.create + ":id"} element={<CreateAttribute />} />
|
||||
<Route path={Paths.product.attribute.list + ":id"} element={<AttributeList />} />
|
||||
<Route path={Paths.product.attribute.update + ":id"} element={<UpdateAttribute />} />
|
||||
<Route path={Paths.product.attributeValue.list + ":id"} element={<AttributeValues />} />
|
||||
<Route path={Paths.product.category.list} element={<CategoryList />} />
|
||||
<Route path={Paths.product.category.create} element={<CreateCategory />} />
|
||||
<Route path={Paths.product.category.update + ":id"} element={<UpdateCategory />} />
|
||||
<Route path="/" element={withPermission(Permissions.DASHBOARD, <Home />)} />
|
||||
<Route path={Paths.home} element={withPermission(Permissions.DASHBOARD, <Home />)} />
|
||||
<Route path={Paths.profile} element={<Profile />} />
|
||||
|
||||
<Route path={Paths.formBuilder.list + ":id/:type"} element={<FormBuilderList />} />
|
||||
<Route path={Paths.formBuilder.create + ":id/:type"} element={<FormBuilderCreate />} />
|
||||
<Route path={Paths.formBuilder.update + ":id"} element={<FormBuilderUpdate />} />
|
||||
<Route path={Paths.formBuilder.values + ":id"} element={<FormBuilderValues />} />
|
||||
<Route path={Paths.perfomaInvoice.list} element={withPermission(Permissions.VIEW_INVOICES, <ProformaInvoice />)} />
|
||||
<Route path={Paths.perfomaInvoice.create} element={withPermission(Permissions.VIEW_INVOICES, <CreateInvoice />)} />
|
||||
<Route path={Paths.perfomaInvoice.detail + ":id"} element={withPermission(Permissions.VIEW_INVOICES, <DetailPerfomaInvoice />)} />
|
||||
<Route path={Paths.perfomaInvoice.update + ":id"} element={withPermission(Permissions.VIEW_INVOICES, <UpdateInvoice />)} />
|
||||
|
||||
<Route path={Paths.order.list} element={<OrdersList />} />
|
||||
<Route path={Paths.order.details + ":id"} element={<OrderDetail />} />
|
||||
<Route path={Paths.order.create} element={<NewOrder />} />
|
||||
<Route path={Paths.order.edit + ":id"} element={<EditOrder />} />
|
||||
<Route path={Paths.convertToOrder} element={<ConvertToOrders />} />
|
||||
<Route path={Paths.orderPrint + ':id/preview'} element={<OrderPrintPreview />} />
|
||||
<Route path={Paths.orderPrint + ':id'} element={<OrderPrint />} />
|
||||
<Route path={Paths.payments.list} element={withPermission(Permissions.MANAGE_PAYMENTS, <PaymentsList />)} />
|
||||
|
||||
<Route path={Paths.tickets.list} element={<TicketList />} />
|
||||
<Route path={Paths.tickets.detail + ":id"} element={<TicketDetail />} />
|
||||
<Route path={Paths.requests.list} element={withPermission(Permissions.VIEW_REQUESTS, <RequestList />)} />
|
||||
<Route path={Paths.requests.detail + ":id"} element={withPermission(Permissions.VIEW_REQUESTS, <RequestDetail />)} />
|
||||
|
||||
<Route path={Paths.print.list} element={<SectionList />} />
|
||||
<Route path={Paths.print.create} element={<CreateSection />} />
|
||||
<Route path={Paths.print.update + ":id"} element={<UpdateSection />} />
|
||||
<Route path={Paths.print.form + ":itemId/:orderId"} element={<PrintForm />} />
|
||||
<Route path={Paths.product.list} element={withPermission(Permissions.MANAGE_PRODUCTS, <ProductList />)} />
|
||||
<Route path={Paths.product.create} element={withPermission(Permissions.MANAGE_PRODUCTS, <CreateProduct />)} />
|
||||
<Route path={Paths.product.update + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <UpdateProduct />)} />
|
||||
<Route path={Paths.product.attribute.create + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <CreateAttribute />)} />
|
||||
<Route path={Paths.product.attribute.list + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <AttributeList />)} />
|
||||
<Route path={Paths.product.attribute.update + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <UpdateAttribute />)} />
|
||||
<Route path={Paths.product.attributeValue.list + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <AttributeValues />)} />
|
||||
<Route path={Paths.product.category.list} element={withPermission(Permissions.MANAGE_PRODUCTS, <CategoryList />)} />
|
||||
<Route path={Paths.product.category.children + ":parentId"} element={withPermission(Permissions.MANAGE_PRODUCTS, <CategoryList />)} />
|
||||
<Route path={Paths.product.category.update + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <UpdateCategory />)} />
|
||||
|
||||
<Route path={Paths.users.list} element={<UsersList />} />
|
||||
<Route path={Paths.users.create} element={<CreateUser />} />
|
||||
<Route path={Paths.users.update + ":id"} element={<UpdateUser />} />
|
||||
<Route path={Paths.formBuilder.list + ":id/:type"} element={withPermission(Permissions.MANAGE_PRODUCTS, <FormBuilderList />)} />
|
||||
<Route path={Paths.formBuilder.create + ":id/:type"} element={withPermission(Permissions.MANAGE_PRODUCTS, <FormBuilderCreate />)} />
|
||||
<Route path={Paths.formBuilder.update + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <FormBuilderUpdate />)} />
|
||||
<Route path={Paths.formBuilder.values + ":id"} element={withPermission(Permissions.MANAGE_PRODUCTS, <FormBuilderValues />)} />
|
||||
|
||||
<Route path={Paths.admin.list} element={<AdminList />} />
|
||||
<Route path={Paths.admin.create} element={<CreateAdmin />} />
|
||||
<Route path={Paths.admin.update + ":id"} element={<EditAdmin />} />
|
||||
<Route path={Paths.order.list} element={withPermission(ORDER_VIEW_PERMISSIONS, <OrdersList />)} />
|
||||
<Route path={Paths.order.details + ":id"} element={withPermission(ORDER_VIEW_PERMISSIONS, <OrderDetail />)} />
|
||||
<Route path={Paths.order.create} element={withPermission(ORDER_VIEW_PERMISSIONS, <NewOrder />)} />
|
||||
<Route path={Paths.order.edit + ":id"} element={withPermission(ORDER_VIEW_PERMISSIONS, <EditOrder />)} />
|
||||
<Route path={Paths.convertToOrder} element={withPermission(ORDER_VIEW_PERMISSIONS, <ConvertToOrders />)} />
|
||||
<Route path={Paths.orderPrint + ':id/preview'} element={withPermission(ORDER_VIEW_PERMISSIONS, <OrderPrintPreview />)} />
|
||||
<Route path={Paths.orderPrint + ':id'} element={withPermission(ORDER_VIEW_PERMISSIONS, <OrderPrint />)} />
|
||||
|
||||
<Route path={Paths.role.list} element={<RoleList />} />
|
||||
<Route path={Paths.role.create} element={<CreateRole />} />
|
||||
<Route path={Paths.role.update + ":id"} element={<UpdateRole />} />
|
||||
<Route path={Paths.tickets.list} element={withPermission(Permissions.MANAGE_TICKETS, <TicketList />)} />
|
||||
<Route path={Paths.tickets.detail + ":id"} element={withPermission(Permissions.MANAGE_TICKETS, <TicketDetail />)} />
|
||||
|
||||
<Route path={Paths.print.list} element={withPermission(Permissions.VIEW_PRINT_FORM, <SectionList />)} />
|
||||
<Route path={Paths.print.create} element={withPermission(Permissions.VIEW_PRINT_FORM, <CreateSection />)} />
|
||||
<Route path={Paths.print.update + ":id"} element={withPermission(Permissions.VIEW_PRINT_FORM, <UpdateSection />)} />
|
||||
<Route path={Paths.print.form + ":itemId/:orderId"} element={withPermission(Permissions.VIEW_PRINT_FORM, <PrintForm />)} />
|
||||
|
||||
<Route path={Paths.users.list} element={withPermission(Permissions.VIEW_USERS, <UsersList />)} />
|
||||
<Route path={Paths.users.create} element={withPermission(Permissions.VIEW_USERS, <CreateUser />)} />
|
||||
<Route path={Paths.users.update + ":id"} element={withPermission(Permissions.VIEW_USERS, <UpdateUser />)} />
|
||||
|
||||
<Route path={Paths.admin.list} element={withPermission(Permissions.MANAGE_ADMINS, <AdminList />)} />
|
||||
<Route path={Paths.admin.create} element={withPermission(Permissions.MANAGE_ADMINS, <CreateAdmin />)} />
|
||||
<Route path={Paths.admin.update + ":id"} element={withPermission(Permissions.MANAGE_ADMINS, <EditAdmin />)} />
|
||||
|
||||
<Route path={Paths.role.list} element={withPermission(Permissions.MANAGE_ROLES, <RoleList />)} />
|
||||
<Route path={Paths.role.create} element={withPermission(Permissions.MANAGE_ROLES, <CreateRole />)} />
|
||||
<Route path={Paths.role.update + ":id"} element={withPermission(Permissions.MANAGE_ROLES, <UpdateRole />)} />
|
||||
|
||||
<Route path={Paths.features.list} element={<FeaturesList />} />
|
||||
<Route path={Paths.features.create} element={<CreateFeature />} />
|
||||
<Route path={Paths.service.print} element={<PrintService />} />
|
||||
|
||||
<Route path={Paths.announcement.list} element={<AnnouncementList />} />
|
||||
<Route path={Paths.announcement.detail + ":id"} element={<AnnouncementUpdate />} />
|
||||
<Route path={Paths.announcement.create} element={<AnnouncementCreate />} />
|
||||
<Route path={Paths.announcement.list} element={withPermission(Permissions.MANAGE_ANNOUNCEMENTS, <AnnouncementList />)} />
|
||||
<Route path={Paths.announcement.detail + ":id"} element={withPermission(Permissions.MANAGE_ANNOUNCEMENTS, <AnnouncementUpdate />)} />
|
||||
<Route path={Paths.announcement.create} element={withPermission(Permissions.MANAGE_ANNOUNCEMENTS, <AnnouncementCreate />)} />
|
||||
|
||||
<Route path={Paths.criticisms.list} element={<CriticismsList />} />
|
||||
<Route path={Paths.criticisms.detail + ":id"} element={<CriticismsDetail />} />
|
||||
<Route path={Paths.criticisms.list} element={withPermission(Permissions.MANAGE_CRITICISMS, <CriticismsList />)} />
|
||||
<Route path={Paths.criticisms.detail + ":id"} element={withPermission(Permissions.MANAGE_CRITICISMS, <CriticismsDetail />)} />
|
||||
|
||||
<Route path={Paths.learning.list} element={<LearningList />} />
|
||||
<Route path={Paths.learning.create} element={<LearningCreate />} />
|
||||
<Route path={Paths.learning.category} element={<LearningCategory />} />
|
||||
<Route path={Paths.learning.list} element={withPermission(Permissions.MANAGE_LEARNINGS, <LearningList />)} />
|
||||
<Route path={Paths.learning.create} element={withPermission(Permissions.MANAGE_LEARNINGS, <LearningCreate />)} />
|
||||
<Route path={Paths.learning.category} element={withPermission(Permissions.MANAGE_LEARNINGS, <LearningCategory />)} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
+19
-82
@@ -1,21 +1,16 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { type FC } from 'react'
|
||||
import Input from '@/components/Input'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import { ArrowDown2, CloseCircle, HambergerMenu, Logout } from 'iconsax-react'
|
||||
import { HambergerMenu } from 'iconsax-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import Notifications from '@/pages/notification/Notification'
|
||||
import { useSharedStore } from './store/useSharedStore'
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
|
||||
import SidebarItem from './SidebarItem'
|
||||
import { t } from '@/locale'
|
||||
import { useGetAdminMe } from '@/pages/admin/hooks/useAdminData'
|
||||
import { Paths } from '@/config/Paths'
|
||||
|
||||
const Header: FC = () => {
|
||||
|
||||
const location = useLocation();
|
||||
const [popoverKey, setPopoverKey] = useState(0);
|
||||
const { setOpenSidebar, openSidebar } = useSharedStore()
|
||||
const { data } = useGetAdminMe()
|
||||
const admin = data?.data
|
||||
@@ -23,10 +18,6 @@ const Header: FC = () => {
|
||||
const displayName = [admin?.firstName, admin?.lastName].filter(Boolean).join(' ').trim()
|
||||
const roleTitle = admin?.role?.title || admin?.role?.name || ''
|
||||
|
||||
useEffect(() => {
|
||||
setPopoverKey((prevKey) => prevKey + 1);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className='fixed z-10 left-[var(--layout-frame)] right-[var(--layout-frame)] top-[var(--layout-frame)] flex h-[var(--layout-header-height)] items-center justify-between rounded-[32px] bg-white px-6 xl:left-auto xl:right-[calc(var(--layout-frame)+var(--layout-main-offset))] xl:h-[var(--layout-header-height-xl)] xl:w-[calc(100%-(var(--layout-frame)*2)-var(--layout-main-offset))]'>
|
||||
|
||||
@@ -39,82 +30,28 @@ const Header: FC = () => {
|
||||
<div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'>
|
||||
<HambergerMenu size={24} color='black' />
|
||||
</div>
|
||||
{/* <img src={LogoImage} className='h-6 xl:hidden block absolute right-0 left-0 mx-auto' /> */}
|
||||
<div className='flex xl:gap-6 gap-4 items-center'>
|
||||
{/* <Link to={Pages.services.other}>
|
||||
<Element3 color='black' className='xl:size-[18px] size-[17px]' />
|
||||
</Link>
|
||||
<Link className='xl:hidden' to={Pages.wallet}>
|
||||
<Wallet className='xl:size-[18px] size-[17px]' color='black' />
|
||||
</Link>
|
||||
<Link className='hidden xl:block' to={Pages.wallet}> */}
|
||||
<Notifications />
|
||||
{admin && (
|
||||
<Popover className="relative" key={popoverKey}>
|
||||
<PopoverButton>
|
||||
<div className='flex gap-2 items-center mt-2.5'>
|
||||
<UserAvatar
|
||||
src={admin.avatarUrl}
|
||||
firstName={admin.firstName}
|
||||
lastName={admin.lastName}
|
||||
className='size-6'
|
||||
textClassName='text-xs'
|
||||
/>
|
||||
<div className='xl:flex hidden gap-1 items-center'>
|
||||
<div className='flex flex-col items-start'>
|
||||
<div className='text-xs'>
|
||||
{displayName || String(admin.phone ?? '')}
|
||||
</div>
|
||||
{roleTitle && (
|
||||
<div className='text-[10px] text-description'>
|
||||
{roleTitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ArrowDown2 size={14} color='#8C90A3' />
|
||||
</div>
|
||||
<Link to={Paths.profile} className='flex gap-2 items-center mt-2.5'>
|
||||
<UserAvatar
|
||||
src={admin.avatarUrl}
|
||||
firstName={admin.firstName}
|
||||
lastName={admin.lastName}
|
||||
className='size-6'
|
||||
textClassName='text-xs'
|
||||
/>
|
||||
<div className='xl:flex hidden flex-col items-start'>
|
||||
<div className='text-xs'>
|
||||
{displayName || String(admin.phone ?? '')}
|
||||
</div>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel style={{ minHeight: window.innerWidth < 1140 ? window.innerHeight : undefined }} anchor="bottom" className="flex xl:ml-6 overflow-auto flex-col gap-3 bg-white boxShadow xl:mt-7 z-30 py-4 text-xs rounded-2.5 xl:w-[300px] -mt-[50px] w-full fixed xl:h-fit h-full shadow-md">
|
||||
<div className='absolute xl:hidden top-6 left-6'>
|
||||
<CloseCircle onClick={() => setPopoverKey((prevKey) => prevKey + 1)} size={20} color='black' />
|
||||
</div>
|
||||
<Link to={`${Paths.admin.update}${admin.id}`} className='flex flex-col gap-2 items-center justify-center border-b border-[#EAEDF5] pb-4'>
|
||||
<UserAvatar
|
||||
src={admin.avatarUrl}
|
||||
firstName={admin.firstName}
|
||||
lastName={admin.lastName}
|
||||
className='size-14'
|
||||
textClassName='text-lg'
|
||||
/>
|
||||
<div>
|
||||
{displayName || String(admin.phone ?? '')}
|
||||
{roleTitle && (
|
||||
<div className='text-[10px] text-description'>
|
||||
{roleTitle}
|
||||
</div>
|
||||
{roleTitle && (
|
||||
<div className='text-description'>
|
||||
{roleTitle}
|
||||
</div>
|
||||
)}
|
||||
<div className='text-description'>
|
||||
{String(admin.phone ?? '')}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
|
||||
<div className='px-6 -mt-[2px]'>
|
||||
<SidebarItem
|
||||
icon={<Logout color={'black'} size={20} />}
|
||||
title={t('sidebar.logout')}
|
||||
isActive
|
||||
link={Paths.auth.login}
|
||||
isWithoutLine
|
||||
isLogout
|
||||
activeName=''
|
||||
/>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useGetAdminMe } from '@/pages/admin/hooks/useAdminData'
|
||||
import {
|
||||
getDefaultAdminPath,
|
||||
hasPermission,
|
||||
} from '@/config/permissions'
|
||||
|
||||
type Props = {
|
||||
permission: string | string[]
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks route access when the current admin lacks the required permission(s).
|
||||
* Pass an array to allow access if the user has any of them.
|
||||
*/
|
||||
const PermissionGuard: FC<Props> = ({ permission, children }) => {
|
||||
const { data: adminMe, isLoading } = useGetAdminMe()
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
const permissions =
|
||||
adminMe?.data?.role?.permissions?.map((p) => p.name) ?? []
|
||||
|
||||
if (!hasPermission(permissions, permission)) {
|
||||
return <Navigate to={getDefaultAdminPath(permissions)} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default PermissionGuard
|
||||
+2
-14
@@ -13,7 +13,6 @@ import {
|
||||
Printer,
|
||||
Receipt21,
|
||||
MoneyRecive,
|
||||
ShieldSecurity,
|
||||
Teacher,
|
||||
User,
|
||||
} from 'iconsax-react';
|
||||
@@ -67,7 +66,7 @@ const SideBar: FC = () => {
|
||||
title={t('sidebar.mainPage')}
|
||||
isActive={isActive('/home')}
|
||||
link={Paths.home}
|
||||
activeName=''
|
||||
activeName='dashboard'
|
||||
/>
|
||||
|
||||
<SideBarItem
|
||||
@@ -107,9 +106,6 @@ const SideBar: FC = () => {
|
||||
/>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<SideBarItem
|
||||
icon={<People size={iconSizeSideBar} color="#4F5260" />}
|
||||
title={'مشتریان'}
|
||||
@@ -128,20 +124,12 @@ const SideBar: FC = () => {
|
||||
|
||||
<SideBarItem
|
||||
icon={<User size={iconSizeSideBar} color="#4F5260" />}
|
||||
title={'ادمین'}
|
||||
title={'کاربران'}
|
||||
isActive={isActive('admin')}
|
||||
link={Paths.admin.list}
|
||||
activeName='manage_admins'
|
||||
/>
|
||||
|
||||
<SideBarItem
|
||||
icon={<ShieldSecurity size={iconSizeSideBar} color="#4F5260" />}
|
||||
title={'نقشها'}
|
||||
isActive={isActive('role')}
|
||||
link={Paths.role.list}
|
||||
activeName='manage_roles'
|
||||
/>
|
||||
|
||||
<SideBarItem
|
||||
icon={<ElementEqual size={iconSizeSideBar} color="#4F5260" />}
|
||||
title={'محصولات'}
|
||||
|
||||
Reference in New Issue
Block a user