api ulandi
This commit is contained in:
2
.env
2
.env
@@ -1 +1 @@
|
|||||||
VITE_API_URL=https://jsonplaceholder.typicode.com
|
VITE_API_URL=https://simple-travel.felixits.uz/api/v1/
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
npx lint-staged
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
npm run build
|
|
||||||
308
package-lock.json
generated
308
package-lock.json
generated
@@ -13,11 +13,13 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@mui/material": "^7.3.4",
|
"@mui/material": "^7.3.4",
|
||||||
"@pbe/react-yandex-maps": "^1.2.5",
|
"@pbe/react-yandex-maps": "^1.2.5",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
"@tanstack/react-query": "^5.77.1",
|
"@tanstack/react-query": "^5.77.1",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -34,7 +37,9 @@
|
|||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
@@ -43,12 +48,16 @@
|
|||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"react-quill-new": "^3.6.0",
|
"react-quill-new": "^3.6.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
|
"react-select": "^5.10.2",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
@@ -493,6 +502,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
@@ -508,6 +518,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -523,6 +534,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -538,6 +550,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -553,6 +566,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -568,6 +582,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -583,6 +598,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -598,6 +614,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -613,6 +630,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -628,6 +646,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -643,6 +662,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -658,6 +678,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -673,6 +694,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -688,6 +710,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -703,6 +726,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -718,6 +742,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -733,6 +758,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -748,6 +774,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -763,6 +790,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -778,6 +806,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -793,6 +822,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -808,6 +838,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
@@ -823,6 +854,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -838,6 +870,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -853,6 +886,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -1462,6 +1496,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-avatar": {
|
||||||
|
"version": "1.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
|
||||||
|
"integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-is-hydrated": "0.1.0",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-checkbox": {
|
"node_modules/@radix-ui/react-checkbox": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||||
@@ -1902,6 +1963,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-radio-group": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-roving-focus": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||||
@@ -2146,6 +2239,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-is-hydrated": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
@@ -2250,6 +2361,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -2262,6 +2374,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -2274,6 +2387,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -2286,6 +2400,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -2298,6 +2413,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -2310,6 +2426,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -2322,6 +2439,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2334,6 +2452,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2346,6 +2465,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2358,6 +2478,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2370,6 +2491,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2382,6 +2504,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2394,6 +2517,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2406,6 +2530,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2418,6 +2543,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2430,6 +2556,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2442,6 +2569,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -2454,6 +2582,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
@@ -2466,6 +2595,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -2478,6 +2608,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -2490,6 +2621,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -2502,6 +2634,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -2784,6 +2917,39 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -2828,7 +2994,15 @@
|
|||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/js-cookie": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
@@ -2840,7 +3014,7 @@
|
|||||||
"version": "22.18.6",
|
"version": "22.18.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz",
|
||||||
"integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==",
|
"integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -2861,6 +3035,7 @@
|
|||||||
"version": "19.1.13",
|
"version": "19.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
||||||
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
|
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -2869,7 +3044,7 @@
|
|||||||
"version": "19.1.9",
|
"version": "19.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
|
||||||
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
|
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
@@ -3769,6 +3944,7 @@
|
|||||||
"version": "0.25.4",
|
"version": "0.25.4",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
|
||||||
"integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
|
"integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
@@ -4188,6 +4364,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4560,6 +4737,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-cookie": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -5015,6 +5201,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/memoize-one": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -5135,6 +5327,7 @@
|
|||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5154,6 +5347,16 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/next-themes": {
|
||||||
|
"version": "0.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
|
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@@ -5332,6 +5535,7 @@
|
|||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -5664,6 +5868,27 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-select": {
|
||||||
|
"version": "5.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||||
|
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.0",
|
||||||
|
"@emotion/cache": "^11.4.0",
|
||||||
|
"@emotion/react": "^11.8.1",
|
||||||
|
"@floating-ui/dom": "^1.0.1",
|
||||||
|
"@types/react-transition-group": "^4.4.0",
|
||||||
|
"memoize-one": "^6.0.0",
|
||||||
|
"prop-types": "^15.6.0",
|
||||||
|
"react-transition-group": "^4.3.0",
|
||||||
|
"use-isomorphic-layout-effect": "^1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
@@ -5765,6 +5990,7 @@
|
|||||||
"version": "4.52.2",
|
"version": "4.52.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
||||||
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -5905,6 +6131,16 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.5.7",
|
"version": "0.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||||
@@ -6057,6 +6293,7 @@
|
|||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3"
|
"picomatch": "^4.0.3"
|
||||||
@@ -6072,6 +6309,7 @@
|
|||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
},
|
},
|
||||||
@@ -6088,6 +6326,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6169,7 +6408,7 @@
|
|||||||
"version": "5.9.2",
|
"version": "5.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6205,7 +6444,7 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"devOptional": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
@@ -6266,6 +6505,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-isomorphic-layout-effect": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sidecar": {
|
"node_modules/use-sidecar": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
@@ -6287,10 +6540,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.7",
|
"version": "7.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
||||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -6383,6 +6646,7 @@
|
|||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
},
|
},
|
||||||
@@ -6399,6 +6663,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6494,7 +6759,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
@@ -6522,6 +6787,35 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint src --fix",
|
"lint": "eslint src --fix",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -17,11 +17,13 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@mui/material": "^7.3.4",
|
"@mui/material": "^7.3.4",
|
||||||
"@pbe/react-yandex-maps": "^1.2.5",
|
"@pbe/react-yandex-maps": "^1.2.5",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
"@tanstack/react-query": "^5.77.1",
|
"@tanstack/react-query": "^5.77.1",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -38,7 +41,9 @@
|
|||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
@@ -47,12 +52,16 @@
|
|||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"react-quill-new": "^3.6.0",
|
"react-quill-new": "^3.6.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
|
"react-select": "^5.10.2",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
|
|||||||
52
src/App.tsx
52
src/App.tsx
@@ -1,5 +1,7 @@
|
|||||||
import Agencies from "@/pages/agencies/Agencies";
|
import Agencies from "@/pages/agencies/ui/Agencies";
|
||||||
import AgencyDetail from "@/pages/agencies/AgencyDetail";
|
import AgencyDetail from "@/pages/agencies/ui/AgencyDetail";
|
||||||
|
import EditAgecy from "@/pages/agencies/ui/EditAgecy";
|
||||||
|
import Login from "@/pages/auth/ui/Login";
|
||||||
import Bookings from "@/pages/bookings/ui/Bookings";
|
import Bookings from "@/pages/bookings/ui/Bookings";
|
||||||
import Employees from "@/pages/employees/ui/Employees";
|
import Employees from "@/pages/employees/ui/Employees";
|
||||||
import Faq from "@/pages/faq/ui/Faq";
|
import Faq from "@/pages/faq/ui/Faq";
|
||||||
@@ -23,31 +25,56 @@ import CreateEditTour from "@/pages/tours/ui/CreateEditTour";
|
|||||||
import TourDetail from "@/pages/tours/ui/TourDetail";
|
import TourDetail from "@/pages/tours/ui/TourDetail";
|
||||||
import Tours from "@/pages/tours/ui/Tours";
|
import Tours from "@/pages/tours/ui/Tours";
|
||||||
import ToursSetting from "@/pages/tours/ui/ToursSetting";
|
import ToursSetting from "@/pages/tours/ui/ToursSetting";
|
||||||
import CreateUser from "@/pages/users/Create";
|
import CreateUser from "@/pages/users/ui/Create";
|
||||||
import EditUser from "@/pages/users/Edit";
|
import EditUser from "@/pages/users/ui/Edit";
|
||||||
import UserList from "@/pages/users/User";
|
import UserList from "@/pages/users/ui/User";
|
||||||
import UserDetail from "@/pages/users/UserDetail";
|
import UserDetail from "@/pages/users/ui/UserDetail";
|
||||||
import MainProvider from "@/providers/main";
|
import MainProvider from "@/providers/main";
|
||||||
import "@/shared/config/i18n";
|
import "@/shared/config/i18n";
|
||||||
|
import useUserStore from "@/shared/hooks/user";
|
||||||
|
import { getAuthToken } from "@/shared/lib/authCookies";
|
||||||
import { Sidebar } from "@/widgets/sidebar/ui/Sidebar";
|
import { Sidebar } from "@/widgets/sidebar/ui/Sidebar";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
} from "react-router-dom";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const userRole = "admin";
|
const { user } = useUserStore();
|
||||||
|
const token = getAuthToken();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const hideSidebarPaths = ["/login"];
|
||||||
|
const shouldShowSidebar = !hideSidebarPaths.includes(location.pathname);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token && user) {
|
||||||
|
navigate("/user");
|
||||||
|
} else if (!token && !user) {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
}, [token, user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainProvider>
|
<MainProvider>
|
||||||
<div className="flex max-lg:flex-col bg-gray-900">
|
<div className="flex max-lg:flex-col bg-gray-900">
|
||||||
<Sidebar role={userRole} />
|
{shouldShowSidebar && <Sidebar role={user ? user.role : "admin"} />}
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Navigate to="/user" replace />} />
|
|
||||||
|
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to={"/user"} />} />
|
||||||
<Route path="/user" element={<UserList />} />
|
<Route path="/user" element={<UserList />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/users/create" element={<CreateUser />} />
|
<Route path="/users/create" element={<CreateUser />} />
|
||||||
<Route path="/users/:id/edit" element={<EditUser />} />
|
<Route path="/users/:id/edit" element={<EditUser />} />
|
||||||
<Route path="/users/:id/" element={<UserDetail />} />
|
<Route path="/users/:id/" element={<UserDetail />} />
|
||||||
<Route path="/agencies" element={<Agencies />} />
|
<Route path="/agencies" element={<Agencies />} />
|
||||||
<Route path="/agencies/:id" element={<AgencyDetail />} />
|
<Route path="/agencies/:id" element={<AgencyDetail />} />
|
||||||
|
<Route path="/agency/:id/edit" element={<EditAgecy />} />
|
||||||
<Route path="/tours/:id" element={<TourDetail />} />
|
<Route path="/tours/:id" element={<TourDetail />} />
|
||||||
<Route path="/employees" element={<Employees />} />
|
<Route path="/employees" element={<Employees />} />
|
||||||
<Route path="/finance" element={<FinancePage />} />
|
<Route path="/finance" element={<FinancePage />} />
|
||||||
@@ -70,9 +97,6 @@ const App = () => {
|
|||||||
<Route path="/site-pages/" element={<SitePage />} />
|
<Route path="/site-pages/" element={<SitePage />} />
|
||||||
<Route path="/site-help/" element={<PolicyCrud />} />
|
<Route path="/site-help/" element={<PolicyCrud />} />
|
||||||
<Route path="/site-settings/" element={<TourSettings />} />
|
<Route path="/site-settings/" element={<TourSettings />} />
|
||||||
{/* <Route path="/site-settings" element={<SiteSettings />} />
|
|
||||||
<Route path="/page-services" element={<PageServices />} />
|
|
||||||
<Route path="/page-help" element={<PageHelp />} /> */}
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</MainProvider>
|
</MainProvider>
|
||||||
|
|||||||
36
src/ProtectedRoute.tsx
Normal file
36
src/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import useUserStore from "@/shared/hooks/user";
|
||||||
|
import { removeAuthToken, removeRefAuthToken } from "@/shared/lib/authCookies";
|
||||||
|
import React from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const allowedRoles = [
|
||||||
|
"superuser",
|
||||||
|
"admin",
|
||||||
|
"moderator",
|
||||||
|
"tour_admin",
|
||||||
|
"buxgalter",
|
||||||
|
"operator",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedRoles.includes(user.role)) {
|
||||||
|
toast.error("Kirishga huquq yo‘q!", {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
removeAuthToken();
|
||||||
|
removeRefAuthToken();
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Toaster } from "@/shared/ui/sonner.tsx";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
@@ -8,6 +9,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
<Toaster />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Badge } from "@/shared/ui/badge";
|
|
||||||
import { Button } from "@/shared/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ChevronRight,
|
|
||||||
DollarSign,
|
|
||||||
Package,
|
|
||||||
Percent,
|
|
||||||
TrendingUp,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
type Tour = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
sold: number;
|
|
||||||
profit: number;
|
|
||||||
status: "faol" | "nofaol";
|
|
||||||
};
|
|
||||||
|
|
||||||
type Agency = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
owner: string;
|
|
||||||
status: "faol" | "nofaol";
|
|
||||||
profitPercent: number;
|
|
||||||
totalTours: number;
|
|
||||||
soldTours: number;
|
|
||||||
totalProfit: number;
|
|
||||||
tours: Tour[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AgencyDetailPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const router = useNavigate();
|
|
||||||
const [agency, setAgency] = useState<Agency | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Mock data - replace with actual API call
|
|
||||||
setAgency({
|
|
||||||
id: Number(params.id),
|
|
||||||
name: "Silk Road Travel",
|
|
||||||
owner: "Ali Karimov",
|
|
||||||
status: "faol",
|
|
||||||
profitPercent: 15,
|
|
||||||
totalTours: 12,
|
|
||||||
soldTours: 56,
|
|
||||||
totalProfit: 8900000,
|
|
||||||
tours: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Dubai Tour",
|
|
||||||
description: "7 kunlik hashamatli sayohat",
|
|
||||||
sold: 23,
|
|
||||||
profit: 3450000,
|
|
||||||
status: "faol",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Bali Adventure",
|
|
||||||
description: "10 kunlik ekzotik sayohat",
|
|
||||||
sold: 33,
|
|
||||||
profit: 5450000,
|
|
||||||
status: "faol",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Istanbul Express",
|
|
||||||
description: "5 kunlik madaniy sayohat",
|
|
||||||
sold: 0,
|
|
||||||
profit: 0,
|
|
||||||
status: "nofaol",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}, [params.id]);
|
|
||||||
|
|
||||||
if (!agency) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
|
||||||
<p className="text-gray-400">Yuklanmoqda...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-900 w-full">
|
|
||||||
<div className="container mx-auto px-4 py-8 max-w-full">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => router("/")}
|
|
||||||
className="rounded-full border-gray-700 bg-gray-800 hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-5 h-5 text-gray-300" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold text-white">{agency.name}</h1>
|
|
||||||
<p className="text-gray-400 mt-1">Egasi: {agency.owner}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={agency.status === "faol" ? "default" : "secondary"}
|
|
||||||
className="text-base px-4 py-2"
|
|
||||||
>
|
|
||||||
{agency.status === "faol" ? "Faol" : "No-faol"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
||||||
{/* Total Tours */}
|
|
||||||
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-gray-400">
|
|
||||||
Jami turlar
|
|
||||||
</CardTitle>
|
|
||||||
<Package className="w-5 h-5 text-blue-400" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-3xl font-bold text-white">
|
|
||||||
{agency.totalTours}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
|
||||||
Qo'shilgan turlar soni
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Sold Tours */}
|
|
||||||
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-gray-400">
|
|
||||||
Sotilgan turlar
|
|
||||||
</CardTitle>
|
|
||||||
<TrendingUp className="w-5 h-5 text-green-400" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-3xl font-bold text-white">
|
|
||||||
{agency.soldTours}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Jami sotilgan turlar</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Profit Percent */}
|
|
||||||
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-gray-400">
|
|
||||||
Ulush foizi
|
|
||||||
</CardTitle>
|
|
||||||
<Percent className="w-5 h-5 text-purple-400" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-3xl font-bold text-white">
|
|
||||||
{agency.profitPercent}%
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Har bir sotuvdan</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Total Profit */}
|
|
||||||
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-gray-400">
|
|
||||||
Umumiy daromad
|
|
||||||
</CardTitle>
|
|
||||||
<DollarSign className="w-5 h-5 text-yellow-400" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-3xl font-bold text-white">
|
|
||||||
{(agency.totalProfit / 1000).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">so'm daromad</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tours List */}
|
|
||||||
<Card className="border border-gray-700 shadow-lg bg-gray-800">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl text-white">
|
|
||||||
Qo'shilgan turlar
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
Firma tomonidan qo'shilgan barcha turlar ro'yxati
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{agency.tours.map((tour) => (
|
|
||||||
<Link key={tour.id} to={`/tours/${tour.id}`} className="block">
|
|
||||||
<div className="p-5 border border-gray-700 rounded-xl hover:bg-gray-700 transition-all cursor-pointer group bg-gray-800">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<h3 className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
|
|
||||||
{tour.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-400 mb-3">
|
|
||||||
{tour.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-6 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Package className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-gray-400">Sotilgan:</span>
|
|
||||||
<span className="font-semibold text-white">
|
|
||||||
{tour.sold} ta
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-gray-400">Daromad:</span>
|
|
||||||
<span className="font-semibold text-yellow-400">
|
|
||||||
{(tour.profit / 1000).toLocaleString()} so'm
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-blue-400 transition-colors" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
58
src/pages/agencies/lib/api.ts
Normal file
58
src/pages/agencies/lib/api.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type {
|
||||||
|
GetAllAgencyData,
|
||||||
|
GetDetailAgencyData,
|
||||||
|
} from "@/pages/agencies/lib/type";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { GET_ALL_AGENCY } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const getAllAgency = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}): Promise<AxiosResponse<GetAllAgencyData>> => {
|
||||||
|
const response = await httpClient.get(GET_ALL_AGENCY, {
|
||||||
|
params: { page, page_size },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDetailAgency = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<GetDetailAgencyData>> => {
|
||||||
|
const response = await httpClient.get(`${GET_ALL_AGENCY}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAgency = async ({ id }: { id: number }) => {
|
||||||
|
const response = await httpClient.delete(`${GET_ALL_AGENCY}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAgencyStatus = async ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
status: "pending" | "approved" | "cancelled";
|
||||||
|
custom_id?: string;
|
||||||
|
share_percentage?: number;
|
||||||
|
name?: string;
|
||||||
|
addres?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
web_site?: string;
|
||||||
|
ticket_sold_count?: number;
|
||||||
|
total_income?: number;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.patch(`${GET_ALL_AGENCY}${id}/`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { deleteAgency, getAllAgency, getDetailAgency, updateAgencyStatus };
|
||||||
58
src/pages/agencies/lib/type.ts
Normal file
58
src/pages/agencies/lib/type.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export interface GetAllAgencyData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
current_page: number;
|
||||||
|
links: {
|
||||||
|
next: string;
|
||||||
|
previous: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
results: {
|
||||||
|
activate_tour_agency: number;
|
||||||
|
all_tickets: number;
|
||||||
|
all_tour_agency: number;
|
||||||
|
total_income: number;
|
||||||
|
list: {
|
||||||
|
id: number;
|
||||||
|
custom_id: string;
|
||||||
|
name: string;
|
||||||
|
owner_user: string;
|
||||||
|
share_percentage: number;
|
||||||
|
status: "pending" | "approved" | "cancelled";
|
||||||
|
ticket_sold_count: number;
|
||||||
|
total_income: number;
|
||||||
|
tour_count: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDetailAgencyData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
status: "pending" | "approved" | "cancelled";
|
||||||
|
custom_id: string;
|
||||||
|
share_percentage: number;
|
||||||
|
name: string;
|
||||||
|
addres: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
web_site: string;
|
||||||
|
owner_user: string;
|
||||||
|
tour_count: string;
|
||||||
|
ticket_sold_count: number;
|
||||||
|
total_income: number;
|
||||||
|
tickets: [
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
departure: string;
|
||||||
|
destination: string;
|
||||||
|
sold_count: number;
|
||||||
|
total_income: number;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,110 +1,91 @@
|
|||||||
|
import { deleteAgency, getAllAgency } from "@/pages/agencies/lib/api";
|
||||||
|
import formatPrice from "@/shared/lib/formatPrice";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/shared/ui/dialog";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
Building2,
|
Building2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Eye,
|
Eye,
|
||||||
|
Loader2,
|
||||||
Package,
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
type Agency = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
owner: string;
|
|
||||||
status: "faol" | "nofaol";
|
|
||||||
profitPercent: number;
|
|
||||||
totalTours: number;
|
|
||||||
soldTours: number;
|
|
||||||
totalProfit: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TourAgenciesPage() {
|
export default function TourAgenciesPage() {
|
||||||
const [agencies, setAgencies] = useState<Agency[]>([
|
const { t } = useTranslation();
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Silk Road Travel",
|
|
||||||
owner: "Ali Karimov",
|
|
||||||
status: "faol",
|
|
||||||
profitPercent: 15,
|
|
||||||
totalTours: 12,
|
|
||||||
soldTours: 56,
|
|
||||||
totalProfit: 8900000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "UzTour Plus",
|
|
||||||
owner: "Madina Qodirova",
|
|
||||||
status: "nofaol",
|
|
||||||
profitPercent: 10,
|
|
||||||
totalTours: 5,
|
|
||||||
soldTours: 0,
|
|
||||||
totalProfit: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Orient Express",
|
|
||||||
owner: "Sardor Rahimov",
|
|
||||||
status: "faol",
|
|
||||||
profitPercent: 12,
|
|
||||||
totalTours: 8,
|
|
||||||
soldTours: 34,
|
|
||||||
totalProfit: 5600000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: "Golden Voyage",
|
|
||||||
owner: "Dilnoza Azimova",
|
|
||||||
status: "faol",
|
|
||||||
profitPercent: 18,
|
|
||||||
totalTours: 15,
|
|
||||||
soldTours: 89,
|
|
||||||
totalProfit: 12400000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: "SkyLine Tours",
|
|
||||||
owner: "Rustam Qobilov",
|
|
||||||
status: "nofaol",
|
|
||||||
profitPercent: 8,
|
|
||||||
totalTours: 4,
|
|
||||||
soldTours: 0,
|
|
||||||
totalProfit: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: "Desert Adventures",
|
|
||||||
owner: "Kamola Saidova",
|
|
||||||
status: "faol",
|
|
||||||
profitPercent: 20,
|
|
||||||
totalTours: 11,
|
|
||||||
soldTours: 42,
|
|
||||||
totalProfit: 6700000,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 4;
|
const itemsPerPage = 4;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
const handleStatusChange = (id: number, newStatus: "faol" | "nofaol") => {
|
const { data, refetch, isLoading, isError } = useQuery({
|
||||||
setAgencies((prev) =>
|
queryKey: ["all_agency", currentPage],
|
||||||
prev.map((a) => (a.id === id ? { ...a, status: newStatus } : a)),
|
queryFn: () => getAllAgency({ page: currentPage, page_size: itemsPerPage }),
|
||||||
);
|
});
|
||||||
|
|
||||||
|
const { mutate } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) => {
|
||||||
|
return deleteAgency({ id });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
mutate({ id: id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPages = Math.ceil(agencies.length / itemsPerPage);
|
if (isLoading) {
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
return (
|
||||||
const paginatedAgencies = agencies.slice(
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
|
||||||
startIndex,
|
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
|
||||||
startIndex + itemsPerPage,
|
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const activeCount = agencies.filter((a) => a.status === "faol").length;
|
if (isError) {
|
||||||
const totalTours = agencies.reduce((sum, a) => sum + a.totalTours, 0);
|
return (
|
||||||
const totalRevenue = agencies.reduce((sum, a) => sum + a.totalProfit, 0);
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||||
|
<p className="text-lg">
|
||||||
|
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("Qayta urinish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
|
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||||
@@ -116,40 +97,57 @@ export default function TourAgenciesPage() {
|
|||||||
<Building2 className="w-8 h-8 text-white" />
|
<Building2 className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
|
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
|
||||||
Tur firmalari
|
{t("Tur firmalari")}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-400 text-lg ml-14">
|
<p className="text-slate-400 text-lg ml-14">
|
||||||
Firmalarni karta ko'rinishida boshqaring va statistikani kuzating
|
{t(
|
||||||
|
"Tur firmalaringizni boshqaring va ularning faoliyatini kuzatib boring.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-12 h-full">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Jami firmalar"
|
title={t("Jami firmalar")}
|
||||||
value={agencies.length.toString()}
|
value={
|
||||||
|
data ? data.data.data.results.all_tour_agency?.toString() : "0"
|
||||||
|
}
|
||||||
icon={<Package className="w-6 h-6" />}
|
icon={<Package className="w-6 h-6" />}
|
||||||
gradient="from-blue-600 to-blue-400"
|
gradient="from-blue-600 to-blue-400"
|
||||||
shadowColor="blue"
|
shadowColor="blue"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Faol firmalar"
|
title={t("Faol firmalar")}
|
||||||
value={activeCount.toString()}
|
value={
|
||||||
|
data && data.data.data.results.activate_tour_agency === 0
|
||||||
|
? data.data.data.results.activate_tour_agency?.toString()
|
||||||
|
: "0"
|
||||||
|
}
|
||||||
icon={<TrendingUp className="w-6 h-6" />}
|
icon={<TrendingUp className="w-6 h-6" />}
|
||||||
gradient="from-green-600 to-emerald-400"
|
gradient="from-green-600 to-emerald-400"
|
||||||
shadowColor="green"
|
shadowColor="green"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Jami turlar"
|
title={t("Jami turlar")}
|
||||||
value={totalTours.toString()}
|
value={data ? data.data.data.results.all_tickets?.toString() : "0"}
|
||||||
icon={<Package className="w-6 h-6" />}
|
icon={<Package className="w-6 h-6" />}
|
||||||
gradient="from-amber-600 to-yellow-400"
|
gradient="from-amber-600 to-yellow-400"
|
||||||
shadowColor="amber"
|
shadowColor="amber"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Umumiy daromad"
|
title={t("Umumiy daromad")}
|
||||||
value={`${(totalRevenue / 1_000_000).toFixed(1)}M`}
|
value={
|
||||||
|
data
|
||||||
|
? data.data.data.results.total_income === 0
|
||||||
|
? "0"
|
||||||
|
: formatPrice(
|
||||||
|
data.data.data.results.total_income?.toString(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
: "0"
|
||||||
|
}
|
||||||
icon={<TrendingUp className="w-6 h-6" />}
|
icon={<TrendingUp className="w-6 h-6" />}
|
||||||
gradient="from-purple-600 to-pink-400"
|
gradient="from-purple-600 to-pink-400"
|
||||||
shadowColor="purple"
|
shadowColor="purple"
|
||||||
@@ -158,21 +156,21 @@ export default function TourAgenciesPage() {
|
|||||||
|
|
||||||
{/* Cards Grid */}
|
{/* Cards Grid */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-10">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-10">
|
||||||
{paginatedAgencies.map((agency) => (
|
{data?.data.data.results.list.map((agency) => (
|
||||||
<div
|
<div
|
||||||
key={agency.id}
|
key={agency.custom_id}
|
||||||
className="group relative hover:scale-105 transition-transform duration-300"
|
className="group relative hover:scale-105 transition-transform duration-300"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-gradient-to-r ${
|
className={`absolute inset-0 bg-gradient-to-r ${
|
||||||
agency.status === "faol"
|
agency.status === "pending"
|
||||||
? "from-blue-600/20 to-cyan-600/20"
|
? "from-blue-600/20 to-cyan-600/20"
|
||||||
: "from-slate-600/20 to-slate-500/20"
|
: "from-slate-600/20 to-slate-500/20"
|
||||||
} rounded-2xl blur-xl group-hover:blur-2xl transition-all opacity-0 group-hover:opacity-100`}
|
} rounded-2xl blur-xl group-hover:blur-2xl transition-all opacity-0 group-hover:opacity-100`}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`relative bg-gradient-to-br ${
|
className={`relative bg-gradient-to-br ${
|
||||||
agency.status === "faol"
|
agency.status === "pending"
|
||||||
? "from-slate-700 to-slate-800"
|
? "from-slate-700 to-slate-800"
|
||||||
: "from-slate-800 to-slate-900"
|
: "from-slate-800 to-slate-900"
|
||||||
} border border-slate-600/50 rounded-2xl p-6 shadow-2xl hover:shadow-2xl transition-all backdrop-blur-sm hover:border-slate-500/70`}
|
} border border-slate-600/50 rounded-2xl p-6 shadow-2xl hover:shadow-2xl transition-all backdrop-blur-sm hover:border-slate-500/70`}
|
||||||
@@ -185,17 +183,23 @@ export default function TourAgenciesPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<UserIcon className="text-slate-400 size-5" />
|
<UserIcon className="text-slate-400 size-5" />
|
||||||
<p className="text-slate-400">{agency.owner}</p>
|
<p className="text-slate-400">{agency.owner_user}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`px-3 py-1 rounded-full text-sm font-semibold whitespace-nowrap ${
|
className={`px-3 py-1 rounded-full text-sm font-semibold whitespace-nowrap ${
|
||||||
agency.status === "faol"
|
agency.status === "pending"
|
||||||
? "bg-green-500/20 text-green-300 border border-green-500/50"
|
? "bg-cyan-500/30"
|
||||||
: "bg-red-500/20 text-red-300 border border-red-500/50"
|
: agency.status === "approved"
|
||||||
|
? "bg-green-500/30"
|
||||||
|
: "bg-red-500/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{agency.status === "faol" ? "Faol" : "No-faol"}
|
{agency.status === "pending"
|
||||||
|
? t("Kutilmoqda")
|
||||||
|
: agency.status === "approved"
|
||||||
|
? t("Faol")
|
||||||
|
: agency.status === "cancelled" && t("Cancelled")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -203,64 +207,97 @@ export default function TourAgenciesPage() {
|
|||||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||||
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
||||||
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
||||||
Komissiya
|
{t("Komissiya")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-blue-300">
|
<p className="text-2xl font-bold text-blue-300">
|
||||||
{agency.profitPercent}%
|
{agency.share_percentage}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
||||||
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
||||||
Jami tur
|
{t("Jami tur")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-cyan-300">
|
<p className="text-2xl font-bold text-cyan-300">
|
||||||
{agency.totalTours}
|
{agency.tour_count}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
||||||
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
||||||
Sotilgan
|
{t("Sotilgan tur")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-yellow-300">
|
<p className="text-2xl font-bold text-yellow-300">
|
||||||
{agency.soldTours}
|
{agency.ticket_sold_count}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
<div className="bg-slate-600/30 rounded-lg p-3 border border-slate-500/30 hover:border-slate-500/50 transition-all">
|
||||||
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
<p className="text-slate-400 text-xs uppercase tracking-wider mb-1">
|
||||||
Daromad
|
{t("Daromad")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg font-bold text-green-300">
|
<p className="text-lg font-bold text-green-300">
|
||||||
{(agency.totalProfit / 1_000_000).toFixed(1)}M
|
{agency.total_income === 0
|
||||||
|
? 0
|
||||||
|
: formatPrice(agency.total_income, true)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-4 border-t border-slate-600/50">
|
<div className="grid grid-cols-1 gap-2 pt-4 border-t border-slate-600/50">
|
||||||
<select
|
|
||||||
value={agency.status}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleStatusChange(
|
|
||||||
agency.id,
|
|
||||||
e.target.value as "faol" | "nofaol",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="flex-1 bg-slate-700/50 border border-slate-600 rounded-lg px-3 py-2 text-slate-300 text-sm hover:bg-slate-600/50 transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="faol" className="bg-slate-800">
|
|
||||||
Faol
|
|
||||||
</option>
|
|
||||||
<option value="nofaol" className="bg-slate-800">
|
|
||||||
No-faol
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/agencies/${agency.id}`)}
|
onClick={() => navigate(`/agencies/${agency.id}`)}
|
||||||
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white rounded-lg px-4 py-2 font-medium transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-cyan-500/50"
|
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white rounded-lg px-4 py-2 font-medium transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-cyan-500/50"
|
||||||
>
|
>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
Ko'rish
|
{t("Ko'rish")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/agency/${agency.id}/edit`)}
|
||||||
|
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 text-white rounded-lg px-4 py-2 font-medium transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-cyan-500/50"
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</button>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-red-600/80 hover:bg-red-600 text-white rounded-lg px-4 py-2 font-medium transition-all flex items-center justify-center gap-2 shadow-lg hover:shadow-red-500/40"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{t("O'chirish")}
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-[425px] bg-slate-900 border border-slate-700 text-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("Haqiqatan ham o‘chirmoqchimisiz?")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
{t(
|
||||||
|
"Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga qaytarib bo'lmaydi.",
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsDialogOpen(false)}
|
||||||
|
className="border-slate-600 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDelete(agency.id)}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
{t("O'chirish")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +314,7 @@ export default function TourAgenciesPage() {
|
|||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{[...Array(totalPages)].map((_, i) => (
|
{[...Array(data?.data.data.total_pages)].map((_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setCurrentPage(i + 1)}
|
onClick={() => setCurrentPage(i + 1)}
|
||||||
@@ -292,8 +329,12 @@ export default function TourAgenciesPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === data?.data.data.total_pages}
|
||||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
onClick={() =>
|
||||||
|
setCurrentPage((p) =>
|
||||||
|
Math.min(p + 1, data ? data?.data.data.total_pages : 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
@@ -317,7 +358,7 @@ function StatCard({
|
|||||||
shadowColor: string;
|
shadowColor: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="group relative hover:scale-105 transition-transform duration-300">
|
<div className="group relative hover:scale-105 transition-transform duration-300 h-full">
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-gradient-to-r ${gradient} rounded-xl blur-lg opacity-20 group-hover:opacity-40 transition-all`}
|
className={`absolute inset-0 bg-gradient-to-r ${gradient} rounded-xl blur-lg opacity-20 group-hover:opacity-40 transition-all`}
|
||||||
/>
|
/>
|
||||||
398
src/pages/agencies/ui/AgencyDetail.tsx
Normal file
398
src/pages/agencies/ui/AgencyDetail.tsx
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { getDetailAgency, updateAgencyStatus } from "@/pages/agencies/lib/api";
|
||||||
|
import formatPhone from "@/shared/lib/formatPhone";
|
||||||
|
import formatPrice from "@/shared/lib/formatPrice";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/ui/select";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronRight,
|
||||||
|
DollarSign,
|
||||||
|
Loader2,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Percent,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function AgencyDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, isError } = useQuery({
|
||||||
|
queryKey: ["detail_agency"],
|
||||||
|
queryFn: () => getDetailAgency({ id: Number(params.id) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (newStatus: "pending" | "approved" | "cancelled") =>
|
||||||
|
updateAgencyStatus({
|
||||||
|
id: Number(params.id),
|
||||||
|
body: { status: newStatus },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_agency"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_agency"] });
|
||||||
|
toast.success(t("Status muvaffaqiyatli o'zgartirildi"));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Statusni o'zgartirishda xatolik yuz berdi"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleStatusChange = (
|
||||||
|
newStatus: "pending" | "approved" | "cancelled",
|
||||||
|
) => {
|
||||||
|
statusMutation.mutate(newStatus);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
|
||||||
|
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||||
|
<p className="text-lg">
|
||||||
|
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("Qayta urinish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return (
|
||||||
|
<Badge className="bg-yellow-500/20 text-yellow-300 border border-yellow-500/40">
|
||||||
|
{t("Kutilmoqda")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "approved":
|
||||||
|
return (
|
||||||
|
<Badge className="bg-green-500/20 text-green-300 border border-green-500/40">
|
||||||
|
{t("Faol")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "cancelled":
|
||||||
|
return (
|
||||||
|
<Badge className="bg-red-500/20 text-red-300 border border-red-500/40">
|
||||||
|
{t("Cancelled")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 w-full">
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-full">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router(-1)}
|
||||||
|
className="rounded-full border-gray-700 bg-gray-800 hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5 text-gray-300" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<h1 className="text-4xl font-bold text-white">
|
||||||
|
{data?.data.data.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-md text-muted-foreground">
|
||||||
|
{data?.data.data.custom_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
{t("Egasi")}: {data?.data.data.owner_user}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Select */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{data && getStatusBadge(data?.data.data.status)}
|
||||||
|
<Select
|
||||||
|
value={data?.data.data.status}
|
||||||
|
onValueChange={handleStatusChange}
|
||||||
|
disabled={statusMutation.isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] border-gray-700 bg-gray-800 text-white hover:bg-gray-700 focus:ring-blue-500">
|
||||||
|
<SelectValue placeholder={t("Status tanlang")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-gray-800 border-gray-700">
|
||||||
|
<SelectItem
|
||||||
|
value="pending"
|
||||||
|
className="text-yellow-300 focus:bg-gray-700 focus:text-yellow-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||||
|
{t("Kutilmoqda")}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="approved"
|
||||||
|
className="text-green-300 focus:bg-gray-700 focus:text-green-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
{t("Faol")}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="cancelled"
|
||||||
|
className="text-red-300 focus:bg-gray-700 focus:text-red-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
|
{t("Cancelled")}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-400">
|
||||||
|
{t("Jami turlar")}
|
||||||
|
</CardTitle>
|
||||||
|
<Package className="w-5 h-5 text-blue-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-white">
|
||||||
|
{data?.data.data.tour_count}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{t("Qo'shilgan turlar soni")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Sold Tours */}
|
||||||
|
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-400">
|
||||||
|
{t("Sotilgan turlar soni")}
|
||||||
|
</CardTitle>
|
||||||
|
<TrendingUp className="w-5 h-5 text-green-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-white">
|
||||||
|
{data?.data.data.ticket_sold_count}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{t("Jami sotilgan turlar")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Profit Percent */}
|
||||||
|
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-400">
|
||||||
|
{t("Ulush foizi")}
|
||||||
|
</CardTitle>
|
||||||
|
<Percent className="w-5 h-5 text-purple-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-white">
|
||||||
|
{data?.data.data.share_percentage}%
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{t("Har bir sotuvdan")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Total Profit */}
|
||||||
|
<Card className="border border-gray-700 shadow-lg hover:shadow-xl transition-shadow bg-gray-800">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-400">
|
||||||
|
{t("Umumiy daromad")}
|
||||||
|
</CardTitle>
|
||||||
|
<DollarSign className="w-5 h-5 text-yellow-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-white">
|
||||||
|
{data && data.data.data.total_income !== 0
|
||||||
|
? formatPrice(data?.data.data.total_income, true)
|
||||||
|
: 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{t("so'm daromad")}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border border-gray-700 shadow-lg bg-gray-800 mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl text-white flex items-center justify-between">
|
||||||
|
<p>{t("Umumiy ma'lumot")}</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => router(`/agency/${params.id}/edit`)}
|
||||||
|
className="bg-gradient-to-r cursor-pointer from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white shadow-lg shadow-blue-500/20"
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
{t("Agentlik haqida batafsil ma'lumot")}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Address */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-gray-400">{t("Manzil")}</p>
|
||||||
|
<p className="text-base font-medium text-white">
|
||||||
|
{data?.data.data.addres || t("Ma'lumot yo'q")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-gray-400">{t("Email")}</p>
|
||||||
|
<p className="text-base font-medium text-white break-all">
|
||||||
|
{data?.data.data.email || t("Ma'lumot yo'q")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-gray-400">{t("Telefon raqami")}</p>
|
||||||
|
<p className="text-base font-medium text-white">
|
||||||
|
{data
|
||||||
|
? formatPhone(data?.data.data.phone)
|
||||||
|
: t("Ma'lumot yo'q")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-gray-400">{t("Veb-sayt")}</p>
|
||||||
|
{data?.data.data.web_site ? (
|
||||||
|
<a
|
||||||
|
href={data.data.data.web_site}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-base font-medium text-blue-400 hover:text-blue-300 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{data.data.data.web_site}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="text-base font-medium text-white">
|
||||||
|
{t("Ma'lumot yo'q")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom ID */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-gray-400">{t("ID raqami")}</p>
|
||||||
|
<p className="text-base font-medium text-white">
|
||||||
|
{data?.data.data.custom_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share Percentage */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-gray-400">{t("Komissiya")}</p>
|
||||||
|
<p className="text-base font-medium text-white">
|
||||||
|
{data?.data.data.share_percentage}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border border-gray-700 shadow-lg bg-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl text-white">
|
||||||
|
{t("Qo'shilgan turlar")}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
{t("Firma tomonidan qo'shilgan barcha turlar ro'yxati")}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data?.data.data.tickets.map((tour) => (
|
||||||
|
<Link key={tour.id} to={`/tours/${tour.id}`} className="block">
|
||||||
|
<div className="p-5 border border-gray-700 rounded-xl hover:bg-gray-700 transition-all cursor-pointer group bg-gray-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
|
||||||
|
{tour.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400 mb-3">
|
||||||
|
{tour.destination}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{t("Sotilgan")}:
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-white">
|
||||||
|
{tour.total_income}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{t("Daromad")}:
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-yellow-400">
|
||||||
|
{tour.total_income === 0
|
||||||
|
? 0
|
||||||
|
: formatPrice(tour.total_income, true)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-blue-400 transition-colors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
src/pages/agencies/ui/EditAgecy.tsx
Normal file
273
src/pages/agencies/ui/EditAgecy.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { getDetailAgency, updateAgencyStatus } from "@/pages/agencies/lib/api";
|
||||||
|
import formatPhone from "@/shared/lib/formatPhone";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/ui/select";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
status: z.enum(["pending", "approved", "cancelled"]),
|
||||||
|
share_percentage: z
|
||||||
|
.number({ message: "Share percentage raqam bo‘lishi kerak" })
|
||||||
|
.min(0)
|
||||||
|
.max(100),
|
||||||
|
name: z.string().min(1, "Nom kiritish shart"),
|
||||||
|
addres: z.string().min(1, "Manzil kiritish shart"),
|
||||||
|
email: z.string().email("Email noto‘g‘ri"),
|
||||||
|
phone: z.string().min(3, "Telefon raqami noto‘g‘ri"),
|
||||||
|
web_site: z.string().url("URL noto‘g‘ri"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const EditAgency = () => {
|
||||||
|
const params = useParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useNavigate();
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
status: "pending",
|
||||||
|
share_percentage: 0,
|
||||||
|
name: "",
|
||||||
|
addres: "",
|
||||||
|
email: "",
|
||||||
|
phone: "+998",
|
||||||
|
web_site: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isPending } = useQuery({
|
||||||
|
queryKey: ["detail_agency", params.id],
|
||||||
|
queryFn: () => getDetailAgency({ id: Number(params.id) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate } = useMutation({
|
||||||
|
mutationFn: (body: {
|
||||||
|
status: "pending" | "approved" | "cancelled";
|
||||||
|
custom_id?: string;
|
||||||
|
share_percentage?: number;
|
||||||
|
name?: string;
|
||||||
|
addres?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
web_site?: string;
|
||||||
|
ticket_sold_count?: number;
|
||||||
|
total_income?: number;
|
||||||
|
}) =>
|
||||||
|
updateAgencyStatus({
|
||||||
|
id: Number(params.id),
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_agency"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_agency"] });
|
||||||
|
router(-1);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.data?.data) {
|
||||||
|
const agency = data.data.data;
|
||||||
|
form.setValue("status", agency.status);
|
||||||
|
form.setValue("share_percentage", agency.share_percentage);
|
||||||
|
form.setValue("name", agency.name);
|
||||||
|
form.setValue("addres", agency.addres);
|
||||||
|
form.setValue("email", agency.email);
|
||||||
|
form.setValue("phone", agency.phone);
|
||||||
|
form.setValue("web_site", agency.web_site);
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = (values: FormData) => {
|
||||||
|
mutate({
|
||||||
|
status: values.status,
|
||||||
|
share_percentage: values.share_percentage,
|
||||||
|
name: values.name,
|
||||||
|
addres: values.addres,
|
||||||
|
email: values.email,
|
||||||
|
phone: values.phone,
|
||||||
|
web_site: values.web_site,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-[80%] mx-auto mt-10 bg-gray-900">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl font-semibold">
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="share_percentage"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel> {t("Komissiya")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="addres"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Manzil")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Manzil")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Email")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Telefon raqam")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="+998 90 123-45-67"
|
||||||
|
value={formatPhone(field.value)}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="web_site"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Veb-sayt")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Status")}</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
key={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pending">{t("Kutilmoqda")}</SelectItem>
|
||||||
|
<SelectItem value="approved"> {t("Faol")}</SelectItem>
|
||||||
|
<SelectItem value="cancelled">
|
||||||
|
{t("Cancelled")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full cursor-pointer">
|
||||||
|
{isPending ? <Loader2 className="animate-spin" /> : t("Saqlash")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditAgency;
|
||||||
8
src/pages/auth/lib/form.ts
Normal file
8
src/pages/auth/lib/form.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const formSchema = z.object({
|
||||||
|
phone: z.string().min(17, { message: "To'liq telefon raqamini kiriting" }),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(4, { message: "Parol kamida 4 ta belgidan iborat bo'lishi kerak" }),
|
||||||
|
});
|
||||||
181
src/pages/auth/ui/Login.tsx
Normal file
181
src/pages/auth/ui/Login.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { formSchema } from "@/pages/auth/lib/form";
|
||||||
|
import { authLogin, getMe } from "@/shared/config/api/auth/api";
|
||||||
|
import useUserStore from "@/shared/hooks/user";
|
||||||
|
import {
|
||||||
|
getAuthToken,
|
||||||
|
setAuthRefToken,
|
||||||
|
setAuthToken,
|
||||||
|
} from "@/shared/lib/authCookies";
|
||||||
|
import formatPhone from "@/shared/lib/formatPhone";
|
||||||
|
import onlyNumber from "@/shared/lib/onlyNumber";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import LangToggle from "@/widgets/lang-toggle/ui/lang-toggle";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Eye, EyeOff, LoaderCircle } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const { setUser } = useUserStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
const { mutate, isPending } = useMutation({
|
||||||
|
mutationFn: ({ password, phone }: { password: string; phone: string }) =>
|
||||||
|
authLogin({
|
||||||
|
password,
|
||||||
|
phone,
|
||||||
|
}),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setAuthToken(res.data.access);
|
||||||
|
setAuthRefToken(res.data.refresh);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["auth_get_me"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Xatolik yuz berdi", {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["auth_get_me"],
|
||||||
|
queryFn: () => getMe(),
|
||||||
|
enabled: !!token,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setUser(data.data.data);
|
||||||
|
}
|
||||||
|
if (data && data.data.data.role !== "user") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
phone: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
mutate({
|
||||||
|
password: values.password,
|
||||||
|
phone: onlyNumber(values.phone),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900 px-4 w-full">
|
||||||
|
<div className="absolute top-4 right-4 flex gap-2">
|
||||||
|
<LangToggle />
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-xl rounded-2xl bg-gray-800 p-8 shadow-lg">
|
||||||
|
<h2 className="mb-6 text-center text-3xl font-semibold text-white">
|
||||||
|
{t("Admin Panelga Kirish")}
|
||||||
|
</h2>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-gray-200">
|
||||||
|
{t("Telefon raqam")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="+998 __ ___-__-__"
|
||||||
|
{...field}
|
||||||
|
value={field.value || "+998"}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(formatPhone(e.target.value))
|
||||||
|
}
|
||||||
|
maxLength={19}
|
||||||
|
className="h-[56px] text-lg rounded-xl bg-gray-700 border-gray-600 text-white placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-gray-200">{t("Parol")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder={t("Parolingizni kiriting")}
|
||||||
|
{...field}
|
||||||
|
className="h-[56px] text-lg rounded-xl bg-gray-700 border-gray-600 text-white placeholder-gray-400 pr-12"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full h-[56px] rounded-xl text-lg font-medium"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<LoaderCircle className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("Kirish")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-400">
|
||||||
|
© {new Date().getFullYear()} {t("Admin Panel")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import formatPrice from "@/shared/lib/formatPrice";
|
||||||
import { Eye } from "lucide-react";
|
import { Eye } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
type Booking = {
|
type Booking = {
|
||||||
@@ -20,8 +21,8 @@ const initialBookings: Booking[] = [
|
|||||||
tourName: "Ichan Qala - Xiva",
|
tourName: "Ichan Qala - Xiva",
|
||||||
agentName: "Xiva Tours",
|
agentName: "Xiva Tours",
|
||||||
destination: "Xiva",
|
destination: "Xiva",
|
||||||
totalAmount: 1200,
|
totalAmount: 1200000,
|
||||||
paidAmount: 1200,
|
paidAmount: 1200000,
|
||||||
status: "Paid",
|
status: "Paid",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -30,8 +31,8 @@ const initialBookings: Booking[] = [
|
|||||||
tourName: "Samarqandning Qadimiy Go'zalligi",
|
tourName: "Samarqandning Qadimiy Go'zalligi",
|
||||||
agentName: "Samarqand Travel",
|
agentName: "Samarqand Travel",
|
||||||
destination: "Samarqand",
|
destination: "Samarqand",
|
||||||
totalAmount: 1500,
|
totalAmount: 1500000,
|
||||||
paidAmount: 800,
|
paidAmount: 800000,
|
||||||
status: "Partial",
|
status: "Partial",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -40,8 +41,8 @@ const initialBookings: Booking[] = [
|
|||||||
tourName: "Tog'li Chimyon Sayohati",
|
tourName: "Tog'li Chimyon Sayohati",
|
||||||
agentName: "Toshkent Explorer",
|
agentName: "Toshkent Explorer",
|
||||||
destination: "Toshkent V.",
|
destination: "Toshkent V.",
|
||||||
totalAmount: 1000,
|
totalAmount: 1000000,
|
||||||
paidAmount: 0,
|
paidAmount: 400000,
|
||||||
status: "Pending",
|
status: "Pending",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -60,19 +61,14 @@ const getStatusColor = (status: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BookingsPanel = () => {
|
const BookingsPanel = () => {
|
||||||
const [bookings, setBookings] = useState<Booking[]>(initialBookings);
|
const { t } = useTranslation();
|
||||||
|
const bookings = initialBookings;
|
||||||
const handleStatusChange = (id: number, newStatus: Booking["status"]) => {
|
|
||||||
setBookings((prev) =>
|
|
||||||
prev.map((b) => (b.id === id ? { ...b, status: newStatus } : b)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 p-4 sm:p-8 font-[Inter] text-gray-100 w-full">
|
<div className="min-h-screen bg-gray-900 p-4 sm:p-8 font-[Inter] text-gray-100 w-full">
|
||||||
<div className="max-w-[90%] mx-auto">
|
<div className="max-w-[90%] mx-auto">
|
||||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-blue-400 mb-6">
|
<h1 className="text-3xl sm:text-4xl font-extrabold text-blue-400 mb-6">
|
||||||
Bronlar Paneli
|
{t("Bronlar Paneli")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="bg-gray-800 shadow-2xl rounded-xl overflow-hidden">
|
<div className="bg-gray-800 shadow-2xl rounded-xl overflow-hidden">
|
||||||
@@ -81,22 +77,22 @@ const BookingsPanel = () => {
|
|||||||
<thead className="bg-gray-700">
|
<thead className="bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||||
User
|
{t("Foydalanuvchi")}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||||
Tour (Agent)
|
{t("Tour (Agent)")}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||||
Destination
|
{t("Destination")}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||||
Total / Paid
|
{t("Total / Paid")}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||||
Status
|
{t("Status")}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||||
Ko'rish
|
{t("Ko'rish")}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -119,30 +115,22 @@ const BookingsPanel = () => {
|
|||||||
{booking.destination}
|
{booking.destination}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||||
${booking.paidAmount} / ${booking.totalAmount}
|
{formatPrice(booking.paidAmount, true)} /{" "}
|
||||||
|
{formatPrice(booking.totalAmount, true)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<select
|
<p
|
||||||
className={`px-3 py-1 text-xs font-semibold rounded-full border ${getStatusColor(
|
className={`px-3 py-1 text-xs font-semibold rounded-full border ${getStatusColor(
|
||||||
booking.status,
|
booking.status,
|
||||||
)}`}
|
)}`}
|
||||||
value={booking.status}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleStatusChange(
|
|
||||||
booking.id,
|
|
||||||
e.target.value as Booking["status"],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="Paid">Paid</option>
|
{booking.status}
|
||||||
<option value="Partial">Partial</option>
|
</p>
|
||||||
<option value="Pending">Pending</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<Link to={`/bookings/${booking.id}`}>
|
<Link to={`/bookings/${booking.id}`}>
|
||||||
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
|
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
|
||||||
<Eye className="w-4 h-4" /> Details
|
<Eye className="w-4 h-4" /> {t("Details")}
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
74
src/pages/employees/lib/api.ts
Normal file
74
src/pages/employees/lib/api.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type {
|
||||||
|
GetAllEmployeesData,
|
||||||
|
GetDetailEmployeesData,
|
||||||
|
} from "@/pages/employees/lib/type";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { GET_ALL_EMPLOYEES } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const getAllEmployees = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}): Promise<AxiosResponse<GetAllEmployeesData>> => {
|
||||||
|
const response = await httpClient.get(GET_ALL_EMPLOYEES, {
|
||||||
|
params: { page, page_size },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDetailEmployees = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<GetDetailEmployeesData>> => {
|
||||||
|
const response = await httpClient.get(`${GET_ALL_EMPLOYEES}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEmployees = async ({ id }: { id: number }) => {
|
||||||
|
const response = await httpClient.delete(`${GET_ALL_EMPLOYEES}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editEmployees = async ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string | null;
|
||||||
|
role: "buxgalter" | "operator";
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.patch(`${GET_ALL_EMPLOYEES}${id}/`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEmployees = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string | null;
|
||||||
|
role: "buxgalter" | "operator";
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.post(`${GET_ALL_EMPLOYEES}`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
createEmployees,
|
||||||
|
deleteEmployees,
|
||||||
|
editEmployees,
|
||||||
|
getAllEmployees,
|
||||||
|
getDetailEmployees,
|
||||||
|
};
|
||||||
35
src/pages/employees/lib/type.ts
Normal file
35
src/pages/employees/lib/type.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface GetAllEmployeesData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
role: "buxgalter" | "operator";
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDetailEmployeesData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string | null;
|
||||||
|
role: "buxgalter" | "operator";
|
||||||
|
};
|
||||||
|
}
|
||||||
349
src/pages/employees/ui/EditEmployees.tsx
Normal file
349
src/pages/employees/ui/EditEmployees.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import {
|
||||||
|
createEmployees,
|
||||||
|
editEmployees,
|
||||||
|
getDetailEmployees,
|
||||||
|
} from "@/pages/employees/lib/api";
|
||||||
|
import formatPhone from "@/shared/lib/formatPhone";
|
||||||
|
import onlyNumber from "@/shared/lib/onlyNumber";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import { Label } from "@/shared/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/ui/select";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Loader, X } from "lucide-react";
|
||||||
|
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const roles = ["buxgalter", "operator"] as const;
|
||||||
|
|
||||||
|
// ✅ Conditional schema - create uchun password majburiy, edit uchun yo'q
|
||||||
|
const createEmployeeSchema = z.object({
|
||||||
|
firstname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
|
||||||
|
lastname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
|
||||||
|
phone: z.string().min(9, "Telefon raqam noto'g'ri"),
|
||||||
|
role: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, "Parol kamida 6 ta belgidan iborat bo'lishi kerak"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const editEmployeeSchema = z.object({
|
||||||
|
firstname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
|
||||||
|
lastname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
|
||||||
|
phone: z.string().min(9, "Telefon raqam noto'g'ri"),
|
||||||
|
role: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateEmployeeFormValues = z.infer<typeof createEmployeeSchema>;
|
||||||
|
type EditEmployeeFormValues = z.infer<typeof editEmployeeSchema>;
|
||||||
|
type EmployeeFormValues = CreateEmployeeFormValues | EditEmployeeFormValues;
|
||||||
|
|
||||||
|
const EditEmployees = ({
|
||||||
|
modalMode,
|
||||||
|
editId,
|
||||||
|
showModal,
|
||||||
|
setEditId,
|
||||||
|
setShowModal,
|
||||||
|
}: {
|
||||||
|
modalMode: "add" | "edit";
|
||||||
|
showModal: boolean;
|
||||||
|
setEditId: Dispatch<SetStateAction<number | null>>;
|
||||||
|
editId: number | null;
|
||||||
|
setShowModal: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// ✅ Dinamik schema - modalMode'ga qarab
|
||||||
|
const form = useForm<EmployeeFormValues>({
|
||||||
|
resolver: zodResolver(
|
||||||
|
modalMode === "add" ? createEmployeeSchema : editEmployeeSchema,
|
||||||
|
),
|
||||||
|
defaultValues: {
|
||||||
|
firstname: "",
|
||||||
|
lastname: "",
|
||||||
|
phone: "+998",
|
||||||
|
role: "",
|
||||||
|
...(modalMode === "add" && { password: "" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["detail_employees", editId],
|
||||||
|
queryFn: () => getDetailEmployees({ id: editId! }),
|
||||||
|
select(data) {
|
||||||
|
return data.data.data;
|
||||||
|
},
|
||||||
|
enabled: !!editId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && editId) {
|
||||||
|
form.reset({
|
||||||
|
firstname: data.first_name,
|
||||||
|
lastname: data.last_name,
|
||||||
|
phone: data.phone,
|
||||||
|
role: data.role,
|
||||||
|
});
|
||||||
|
} else if (!editId && showModal) {
|
||||||
|
form.reset({
|
||||||
|
firstname: "",
|
||||||
|
lastname: "",
|
||||||
|
phone: "+998",
|
||||||
|
role: "",
|
||||||
|
...(modalMode === "add" && { password: "" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, editId, showModal, modalMode]);
|
||||||
|
|
||||||
|
const { mutate: create, isPending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string | null;
|
||||||
|
role: "buxgalter" | "operator";
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
}) => createEmployees({ body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_employees"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["employees"] });
|
||||||
|
setShowModal(false);
|
||||||
|
setEditId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: edit, isPending: editPengding } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string | null;
|
||||||
|
role: "buxgalter" | "operator";
|
||||||
|
};
|
||||||
|
}) => editEmployees({ id, body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_employees"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["employees"] });
|
||||||
|
setShowModal(false);
|
||||||
|
setEditId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: EmployeeFormValues) {
|
||||||
|
if (modalMode === "add" && editId === null) {
|
||||||
|
create({
|
||||||
|
body: {
|
||||||
|
email: null,
|
||||||
|
first_name: values.firstname,
|
||||||
|
last_name: values.lastname,
|
||||||
|
phone: onlyNumber(values.phone),
|
||||||
|
role: values.role as "buxgalter" | "operator",
|
||||||
|
password: (values as CreateEmployeeFormValues).password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (modalMode === "edit" && editId !== null) {
|
||||||
|
edit({
|
||||||
|
id: editId,
|
||||||
|
body: {
|
||||||
|
email: null,
|
||||||
|
first_name: values.firstname,
|
||||||
|
last_name: values.lastname,
|
||||||
|
phone: onlyNumber(values.phone),
|
||||||
|
role: values.role as "buxgalter" | "operator",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-gray-800 rounded-2xl p-8 max-w-md w-full border border-gray-700 shadow-2xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-white">
|
||||||
|
{modalMode === "add"
|
||||||
|
? t("Xodim qo'shish")
|
||||||
|
: t("Xodimni tahrirlash")}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditId(null);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="firstname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Ismi")}</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Ismi" {...field} className="h-12" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Familiyasi")}</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Familiyasi"
|
||||||
|
{...field}
|
||||||
|
className="h-12"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Telefon raqami")}</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="+998 90 123 45 67"
|
||||||
|
{...field}
|
||||||
|
value={formatPhone(field.value)}
|
||||||
|
className="h-12"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ✅ Password field - faqat "add" modeda ko'rinadi */}
|
||||||
|
{modalMode === "add" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Parol")}</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••"
|
||||||
|
{...field}
|
||||||
|
className="h-12"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Role")}</Label>
|
||||||
|
<Select
|
||||||
|
key={field.value}
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(val) => field.onChange(val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full !h-12 cursor-pointer">
|
||||||
|
<SelectValue placeholder={t("Role tanlang")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<SelectItem key={r} value={r}>
|
||||||
|
{t(r)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
{isPending || editPengding ? (
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
) : modalMode === "add" ? (
|
||||||
|
t("Qo'shish")
|
||||||
|
) : (
|
||||||
|
t("Saqlash")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditId(null);
|
||||||
|
setShowModal(false);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-gray-700 cursor-pointer hover:bg-gray-600 text-white px-6 py-3 rounded-lg font-semibold transition-all"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditEmployees;
|
||||||
@@ -1,134 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { deleteEmployees, getAllEmployees } from "@/pages/employees/lib/api";
|
||||||
|
import EditEmployees from "@/pages/employees/ui/EditEmployees";
|
||||||
import formatPhone from "@/shared/lib/formatPhone";
|
import formatPhone from "@/shared/lib/formatPhone";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Dialog,
|
||||||
FormControl,
|
DialogContent,
|
||||||
FormField,
|
DialogDescription,
|
||||||
FormItem,
|
DialogFooter,
|
||||||
FormMessage,
|
DialogHeader,
|
||||||
} from "@/shared/ui/form";
|
DialogTitle,
|
||||||
import { Input } from "@/shared/ui/input";
|
DialogTrigger,
|
||||||
import { Label } from "@/shared/ui/label";
|
} from "@/shared/ui/dialog";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Select,
|
ChevronLeft,
|
||||||
SelectContent,
|
ChevronRight,
|
||||||
SelectItem,
|
Edit,
|
||||||
SelectTrigger,
|
Loader2,
|
||||||
SelectValue,
|
Phone,
|
||||||
} from "@/shared/ui/select";
|
Plus,
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
Trash2,
|
||||||
import { Edit, Phone, Plus, Trash2, X } from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useTranslation } from "react-i18next";
|
||||||
import z from "zod";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const roles = ["Operator", "Bugalter", "Manager"] as const;
|
|
||||||
|
|
||||||
type Employee = {
|
|
||||||
id: number;
|
|
||||||
firstname: string;
|
|
||||||
lastname: string;
|
|
||||||
phone: string;
|
|
||||||
role: "Operator" | "Bugalter" | "Manager";
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialEmployees: Employee[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
firstname: "Alisher",
|
|
||||||
lastname: "Karimov",
|
|
||||||
phone: "+998901234567",
|
|
||||||
role: "Operator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
firstname: "Nigora",
|
|
||||||
lastname: "Rahimova",
|
|
||||||
phone: "+998912345678",
|
|
||||||
role: "Bugalter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
firstname: "Nigora",
|
|
||||||
lastname: "Rahimova",
|
|
||||||
phone: "+998912345678",
|
|
||||||
role: "Manager",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const employeeSchema = z.object({
|
|
||||||
firstname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
|
|
||||||
lastname: z.string().min(2, "Kamida 2 ta belgidan iborat bo'lishi kerak"),
|
|
||||||
phone: z.string().min(9, "Telefon raqam noto'g'ri"),
|
|
||||||
role: z.enum(roles),
|
|
||||||
});
|
|
||||||
|
|
||||||
type EmployeeFormValues = z.infer<typeof employeeSchema>;
|
|
||||||
|
|
||||||
const EmployeesManagement = () => {
|
const EmployeesManagement = () => {
|
||||||
const form = useForm<EmployeeFormValues>({
|
const { t } = useTranslation();
|
||||||
resolver: zodResolver(employeeSchema),
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
defaultValues: {
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
firstname: "",
|
const queryClient = useQueryClient();
|
||||||
lastname: "",
|
|
||||||
phone: "+998",
|
const { data, isLoading } = useQuery({
|
||||||
role: "Bugalter",
|
queryKey: ["employees", currentPage],
|
||||||
|
queryFn: () => getAllEmployees({ page: currentPage, page_size: 10 }),
|
||||||
|
select: (data) => data.data.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteMutate, isPending: isDeleting } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) => {
|
||||||
|
return deleteEmployees({ id });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["employees"] });
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
toast.success(t("Xodim o'chirildi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [employees, setEmployees] = useState<Employee[]>(initialEmployees);
|
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [modalMode, setModalMode] = useState("add");
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(
|
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedEmployee) {
|
|
||||||
form.setValue("firstname", selectedEmployee.firstname);
|
|
||||||
form.setValue("lastname", selectedEmployee.lastname);
|
|
||||||
form.setValue("phone", selectedEmployee.phone);
|
|
||||||
form.setValue("role", selectedEmployee.role);
|
|
||||||
}
|
|
||||||
}, [selectedEmployee, form]);
|
|
||||||
|
|
||||||
const itemsPerPage = 6;
|
|
||||||
const totalPages = Math.ceil(employees.length / itemsPerPage);
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
||||||
const currentEmployees = employees.slice(
|
|
||||||
startIndex,
|
|
||||||
startIndex + itemsPerPage,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
setModalMode("add");
|
setModalMode("add");
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
|
setEditId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (employee: Employee) => {
|
const handleEdit = (id: number) => {
|
||||||
setSelectedEmployee(employee);
|
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
setModalMode("edit");
|
setModalMode("edit");
|
||||||
};
|
setEditId(id);
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
|
||||||
if (window.confirm("Ushbu xodimni o'chirishni xohlaysizmi?")) {
|
|
||||||
setEmployees(employees.filter((emp) => emp.id !== id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (values: EmployeeFormValues) => {
|
|
||||||
if (modalMode === "add") {
|
|
||||||
const newEmp: Employee = { ...values, id: Date.now() };
|
|
||||||
setEmployees([...employees, newEmp]);
|
|
||||||
} else if (selectedEmployee) {
|
|
||||||
setEmployees(
|
|
||||||
employees.map((emp) =>
|
|
||||||
emp.id === selectedEmployee.id ? { ...emp, ...values } : emp,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setShowModal(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -137,10 +81,10 @@ const EmployeesManagement = () => {
|
|||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
|
||||||
Xodimlar
|
{t("Xodimlar")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 mt-2">
|
<p className="text-gray-400 mt-2">
|
||||||
Jami {employees.length} ta xodim
|
{t("Jami")} {data?.total_items || 0} {t("ta xodim")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -148,70 +92,124 @@ const EmployeesManagement = () => {
|
|||||||
className="flex cursor-pointer items-center gap-2 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg hover:shadow-xl transform hover:scale-105"
|
className="flex cursor-pointer items-center gap-2 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
Xodim qo'shish
|
<p className="max-lg:hidden">{t("Xodim qo'shish")}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ Loading State */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-blue-500 mb-4" />
|
||||||
|
<p className="text-gray-400 text-lg">{t("Yuklanmoqda")}...</p>
|
||||||
|
</div>
|
||||||
|
) : data?.results && data.results.length > 0 ? (
|
||||||
|
// ✅ Data mavjud bo'lsa
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
{currentEmployees.map((employee) => (
|
{data.results.map((employee) => (
|
||||||
<div
|
<div
|
||||||
key={employee.id}
|
key={employee.id}
|
||||||
className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700 hover:border-blue-500/50 transition-all shadow-lg hover:shadow-2xl transform hover:-translate-y-1"
|
className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700 hover:border-blue-500/50 transition-all shadow-lg hover:shadow-2xl transform hover:-translate-y-1"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-white">
|
<h3 className="text-xl font-bold text-white">
|
||||||
{employee.firstname} {employee.lastname}
|
{employee.first_name} {employee.last_name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-blue-400 text-sm">{employee.role}</p>
|
<p className="text-blue-400 text-sm">
|
||||||
</div>
|
{t(employee.role)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
<div className="flex items-center gap-2 text-gray-300">
|
<div className="flex items-center gap-2 text-gray-300">
|
||||||
<Phone size={16} className="text-gray-500" />
|
<Phone size={16} className="text-gray-500" />
|
||||||
<span className="text-sm">{formatPhone(employee.phone)}</span>
|
<span className="text-sm">
|
||||||
|
{formatPhone(employee.phone)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4 border-t border-gray-700">
|
<div className="flex flex-col gap-2 pt-4 border-t border-gray-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(employee)}
|
onClick={() => handleEdit(employee.id)}
|
||||||
className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-all font-medium"
|
className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-all font-medium"
|
||||||
>
|
>
|
||||||
<Edit size={16} />
|
<Edit size={16} />
|
||||||
Tahrirlash
|
{t("Tahrirlash")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(employee.id)}
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-all font-medium"
|
<DialogTrigger asChild>
|
||||||
>
|
<button className="flex-1 cursor-pointer flex items-center justify-center gap-2 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-all font-medium">
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
O'chirish
|
{t("O'chirish")}
|
||||||
</button>
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="bg-gray-800 text-gray-100 border border-gray-700">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("Foydalanuvchini o'chirish")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
{t("Siz")}{" "}
|
||||||
|
<b>
|
||||||
|
{employee.first_name} {employee.last_name}
|
||||||
|
</b>{" "}
|
||||||
|
{t("foydalanuvchini o'chirmoqchimisiz?")}
|
||||||
|
<br />
|
||||||
|
{t("Ushbu amalni qaytarib bo'lmaydi")}.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
className="bg-gray-700 hover:bg-gray-600 text-white"
|
||||||
|
onClick={() => setIsDialogOpen(false)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
onClick={() => deleteMutate({ id: employee.id })}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("O'chirish")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end items-center gap-2">
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800/50 disabled:cursor-not-allowed text-white rounded-lg transition-all border border-gray-700"
|
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||||
>
|
>
|
||||||
Oldingi
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{[...Array(totalPages)].map((_, i) => (
|
{[...Array(data?.total_pages || 0)].map((_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setCurrentPage(i + 1)}
|
onClick={() => setCurrentPage(i + 1)}
|
||||||
className={`px-4 py-2 rounded-lg transition-all border ${
|
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
||||||
currentPage === i + 1
|
currentPage === i + 1
|
||||||
? "bg-gradient-to-r from-blue-500 to-purple-600 text-white border-transparent"
|
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
||||||
: "bg-gray-800 hover:bg-gray-700 text-white border-gray-700"
|
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
@@ -219,139 +217,50 @@ const EmployeesManagement = () => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
disabled={currentPage === data?.total_pages}
|
||||||
disabled={currentPage === totalPages}
|
onClick={() =>
|
||||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800/50 disabled:cursor-not-allowed text-white rounded-lg transition-all border border-gray-700"
|
setCurrentPage((p) =>
|
||||||
|
Math.min(p + 1, data ? data?.total_pages : 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||||
>
|
>
|
||||||
Keyingi
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// ✅ Ma'lumot yo'q bo'lsa
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<div className="bg-gray-800/50 rounded-full p-8 mb-4">
|
||||||
|
<Phone className="w-16 h-16 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-300 mb-2">
|
||||||
|
{t("Xodimlar topilmadi")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-6">
|
||||||
|
{t("Birinchi xodimni qo'shing")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex items-center gap-2 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
{t("Xodim qo'shish")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
<EditEmployees
|
||||||
<div className="bg-gray-800 rounded-2xl p-8 max-w-md w-full border border-gray-700 shadow-2xl">
|
key={editId || "new"}
|
||||||
<div className="flex justify-between items-center mb-6">
|
modalMode={modalMode}
|
||||||
<h2 className="text-2xl font-bold text-white">
|
showModal={showModal}
|
||||||
{modalMode === "add" ? "Xodim qo'shish" : "Xodimni tahrirlash"}
|
setShowModal={setShowModal}
|
||||||
</h2>
|
editId={editId}
|
||||||
<button
|
setEditId={setEditId}
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<X size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="firstname"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<Label>First Name</Label>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="First Name"
|
|
||||||
{...field}
|
|
||||||
className="h-12"
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="lastname"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<Label>Last Name</Label>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Last Name"
|
|
||||||
{...field}
|
|
||||||
className="h-12"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="phone"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<Label>Phone</Label>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="+998 90 123 45 67"
|
|
||||||
{...field}
|
|
||||||
value={formatPhone(field.value)}
|
|
||||||
className="h-12"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="role"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<Label>Role</Label>
|
|
||||||
<Select
|
|
||||||
value={field.value}
|
|
||||||
onValueChange={(val) => field.onChange(val)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full !h-12 cursor-pointer">
|
|
||||||
<SelectValue placeholder="Select role" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="cursor-pointer">
|
|
||||||
{roles.map((r) => (
|
|
||||||
<SelectItem
|
|
||||||
key={r}
|
|
||||||
value={r}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
{r}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 cursor-pointer bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white px-6 py-3 rounded-lg font-semibold transition-all shadow-lg"
|
|
||||||
>
|
|
||||||
{modalMode === "add" ? "Qo'shish" : "Saqlash"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowModal(false);
|
|
||||||
form.reset();
|
|
||||||
}}
|
|
||||||
className="flex-1 bg-gray-700 cursor-pointer hover:bg-gray-600 text-white px-6 py-3 rounded-lg font-semibold transition-all"
|
|
||||||
>
|
|
||||||
Bekor qilish
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
|
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
type FaqType = {
|
type FaqType = {
|
||||||
@@ -102,6 +103,7 @@ const faqForm = z.object({
|
|||||||
const Faq = () => {
|
const Faq = () => {
|
||||||
const [faqs, setFaqs] = useState<FaqType[]>(initialFaqs);
|
const [faqs, setFaqs] = useState<FaqType[]>(initialFaqs);
|
||||||
const [activeTab, setActiveTab] = useState("umumiy");
|
const [activeTab, setActiveTab] = useState("umumiy");
|
||||||
|
const { t } = useTranslation();
|
||||||
const [openModal, setOpenModal] = useState(false);
|
const [openModal, setOpenModal] = useState(false);
|
||||||
const [editFaq, setEditFaq] = useState<FaqType | null>(null);
|
const [editFaq, setEditFaq] = useState<FaqType | null>(null);
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
@@ -147,7 +149,9 @@ const Faq = () => {
|
|||||||
<div className="p-6 space-y-6 w-full">
|
<div className="p-6 space-y-6 w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold">FAQ (Savol va javoblar)</h1>
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{t("FAQ (Savol va javoblar)")}
|
||||||
|
</h1>
|
||||||
<Button
|
<Button
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -155,7 +159,7 @@ const Faq = () => {
|
|||||||
setOpenModal(true);
|
setOpenModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusCircle className="w-4 h-4" /> Yangi qo‘shish
|
<PlusCircle className="w-4 h-4" /> {t("Yangi qo‘shish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,10 +180,10 @@ const Faq = () => {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead>Savol</TableHead>
|
<TableHead>{t("Savol")}</TableHead>
|
||||||
<TableHead>Javob</TableHead>
|
<TableHead>{t("Javob")}</TableHead>
|
||||||
<TableHead className="w-[120px] text-center">
|
<TableHead className="w-[120px] text-center">
|
||||||
Amallar
|
{t("Amallar")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -220,7 +224,7 @@ const Faq = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500 text-sm mt-4">
|
<p className="text-gray-500 text-sm mt-4">
|
||||||
Bu bo‘limda savollar yo‘q.
|
{t("Bu bo‘limda savollar yo‘q.")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -230,7 +234,7 @@ const Faq = () => {
|
|||||||
<DialogContent className="sm:max-w-[500px] bg-gray-900">
|
<DialogContent className="sm:max-w-[500px] bg-gray-900">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{editFaq ? "FAQni tahrirlash" : "Yangi FAQ qo‘shish"}
|
{editFaq ? t("FAQni tahrirlash") : t("Yangi FAQ qo‘shish")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -241,18 +245,18 @@ const Faq = () => {
|
|||||||
name="categories"
|
name="categories"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Kategoriya</Label>
|
<Label className="text-md">{t("Kategoriya")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full !h-12 border-gray-700 text-white">
|
<SelectTrigger className="w-full !h-12 border-gray-700 text-white">
|
||||||
<SelectValue placeholder="Kategoriya tanlang" />
|
<SelectValue placeholder={t("Kategoriya tanlang")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-gray-700 text-white">
|
<SelectContent className="border-gray-700 text-white">
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>Kategoriyalar</SelectLabel>
|
<SelectLabel>{t("Kategoriyalar")}</SelectLabel>
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<SelectItem key={cat.value} value={cat.value}>
|
<SelectItem key={cat.value} value={cat.value}>
|
||||||
{cat.label}
|
{cat.label}
|
||||||
@@ -271,10 +275,10 @@ const Faq = () => {
|
|||||||
name="title"
|
name="title"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Savol</Label>
|
<Label className="text-md">{t("Savol")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Savol"
|
placeholder={t("Savol")}
|
||||||
{...field}
|
{...field}
|
||||||
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
||||||
/>
|
/>
|
||||||
@@ -288,10 +292,10 @@ const Faq = () => {
|
|||||||
name="answer"
|
name="answer"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Javob</Label>
|
<Label className="text-md">{t("Javob")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Javob"
|
placeholder={t("Javob")}
|
||||||
{...field}
|
{...field}
|
||||||
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
||||||
/>
|
/>
|
||||||
@@ -309,14 +313,13 @@ const Faq = () => {
|
|||||||
}}
|
}}
|
||||||
className="bg-gray-600 px-5 py-5 hover:bg-gray-700 text-white mt-4 cursor-pointer"
|
className="bg-gray-600 px-5 py-5 hover:bg-gray-700 text-white mt-4 cursor-pointer"
|
||||||
>
|
>
|
||||||
Bekor qilish
|
{t("Bekor qilish")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
||||||
>
|
>
|
||||||
{/* {isEditMode ? "Yangilikni saqlash" : "Keyingisi"} */}
|
{t("Qo'shish")}
|
||||||
Qo'shish
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -327,14 +330,14 @@ const Faq = () => {
|
|||||||
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Haqiqatan ham o‘chirmoqchimisiz?</DialogTitle>
|
<DialogTitle>{t("Haqiqatan ham o‘chirmoqchimisiz?")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||||
Bekor qilish
|
{t("Bekor qilish")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete}>
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
O‘chirish
|
{t("O'chirish")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
|
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
type FaqCategoryType = {
|
type FaqCategoryType = {
|
||||||
@@ -50,6 +51,7 @@ const categoryFormSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const FaqCategory = () => {
|
const FaqCategory = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [categories, setCategories] =
|
const [categories, setCategories] =
|
||||||
useState<FaqCategoryType[]>(initialCategories);
|
useState<FaqCategoryType[]>(initialCategories);
|
||||||
const [openModal, setOpenModal] = useState(false);
|
const [openModal, setOpenModal] = useState(false);
|
||||||
@@ -105,7 +107,7 @@ const FaqCategory = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 w-full">
|
<div className="p-6 space-y-6 w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold">FAQ Kategoriyalar</h1>
|
<h1 className="text-2xl font-semibold">{t("FAQ Kategoriyalar")}</h1>
|
||||||
<Button
|
<Button
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -113,7 +115,7 @@ const FaqCategory = () => {
|
|||||||
setOpenModal(true);
|
setOpenModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusCircle className="w-4 h-4" /> Yangi kategoriya
|
<PlusCircle className="w-4 h-4" /> {t("Yangi kategoriya")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import formatPrice from "@/shared/lib/formatPrice";
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
type Purchase = {
|
type Purchase = {
|
||||||
@@ -102,6 +104,7 @@ const mockPurchases: Purchase[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function FinancePage() {
|
export default function FinancePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [tab, setTab] = useState<"bookings" | "agencies">("bookings");
|
const [tab, setTab] = useState<"bookings" | "agencies">("bookings");
|
||||||
const [filterStatus, setFilterStatus] = useState<
|
const [filterStatus, setFilterStatus] = useState<
|
||||||
"all" | "paid" | "pending" | "cancelled" | "refunded"
|
"all" | "paid" | "pending" | "cancelled" | "refunded"
|
||||||
@@ -117,7 +120,7 @@ export default function FinancePage() {
|
|||||||
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
||||||
>
|
>
|
||||||
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
||||||
Paid
|
{t("Paid")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "pending":
|
case "pending":
|
||||||
@@ -126,7 +129,7 @@ export default function FinancePage() {
|
|||||||
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
|
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
|
||||||
>
|
>
|
||||||
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
|
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
|
||||||
Pending
|
{t("Pending")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
@@ -135,7 +138,7 @@ export default function FinancePage() {
|
|||||||
className={`${base} bg-red-900 text-red-400 border border-red-700`}
|
className={`${base} bg-red-900 text-red-400 border border-red-700`}
|
||||||
>
|
>
|
||||||
<div className="w-2 h-2 rounded-full bg-red-400"></div>
|
<div className="w-2 h-2 rounded-full bg-red-400"></div>
|
||||||
Cancelled
|
{t("Cancelled")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "refunded":
|
case "refunded":
|
||||||
@@ -187,14 +190,16 @@ export default function FinancePage() {
|
|||||||
<div className="w-[90%] mx-auto py-6">
|
<div className="w-[90%] mx-auto py-6">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Travel Finance Dashboard</h1>
|
<h1 className="text-3xl font-bold">
|
||||||
|
{t("Sayohat moliyasi boshqaruv paneli")}
|
||||||
|
</h1>
|
||||||
<p className="text-gray-400 mt-2">
|
<p className="text-gray-400 mt-2">
|
||||||
Manage bookings, payments, and agency finances
|
{t("Bronlar, to'lovlar va agentlik moliyalari boshqaruvi")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-800 rounded-lg p-3 flex items-center">
|
<div className="bg-gray-800 rounded-lg p-3 flex items-center">
|
||||||
<Plane className="text-blue-400 mr-2" size={20} />
|
<Plane className="text-blue-400 mr-2" size={20} />
|
||||||
<span className="font-medium text-gray-100">Travel Pro</span>
|
<span className="font-medium text-gray-100">Simple Travel</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,7 +214,7 @@ export default function FinancePage() {
|
|||||||
onClick={() => setTab("bookings")}
|
onClick={() => setTab("bookings")}
|
||||||
>
|
>
|
||||||
<CreditCard size={18} />
|
<CreditCard size={18} />
|
||||||
Bookings & Payments
|
{t("Bandlovlar va to‘lovlar")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`px-6 py-3 rounded-lg flex items-center gap-2 transition-all ${
|
className={`px-6 py-3 rounded-lg flex items-center gap-2 transition-all ${
|
||||||
@@ -220,7 +225,7 @@ export default function FinancePage() {
|
|||||||
onClick={() => setTab("agencies")}
|
onClick={() => setTab("agencies")}
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
Agency Reports
|
{t("Agentlik hisobotlari")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,14 +253,14 @@ export default function FinancePage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{s === "all"
|
{s === "all"
|
||||||
? "All Bookings"
|
? t("Barcha bandlovlar")
|
||||||
: s === "paid"
|
: s === "paid"
|
||||||
? "Paid"
|
? t("To'langan")
|
||||||
: s === "pending"
|
: s === "pending"
|
||||||
? "Pending"
|
? t("Kutilmoqda")
|
||||||
: s === "cancelled"
|
: s === "cancelled"
|
||||||
? "Cancelled"
|
? t("Bekor qilindi")
|
||||||
: "Refunded"}
|
: t("Qaytarilgan")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,32 +269,36 @@ export default function FinancePage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-gray-400 font-medium">Total Revenue</p>
|
<p className="text-gray-400 font-medium">
|
||||||
|
{t("Jami daromad")}
|
||||||
|
</p>
|
||||||
<DollarSign className="text-green-400 w-6 h-6" />
|
<DollarSign className="text-green-400 w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-green-400 mt-3">
|
<p className="text-2xl font-bold text-green-400 mt-3">
|
||||||
${(totalRevenue / 1000000).toFixed(1)}M
|
{formatPrice(totalRevenue, true)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
From completed bookings
|
{t("Yakunlangan bandlovlardan")}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-gray-400 font-medium">Pending Payments</p>
|
|
||||||
<TrendingUp className="text-yellow-400 w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
|
||||||
${(pendingRevenue / 1000000).toFixed(1)}M
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
Awaiting confirmation
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-gray-400 font-medium">
|
<p className="text-gray-400 font-medium">
|
||||||
Confirmed Bookings
|
{t("Kutilayotgan to‘lovlar")}
|
||||||
|
</p>
|
||||||
|
<TrendingUp className="text-yellow-400 w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
||||||
|
{formatPrice(pendingRevenue, true)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{t("Tasdiqlash kutilmoqda")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-gray-400 font-medium">
|
||||||
|
{t("Tasdiqlangan bandlovlar")}
|
||||||
</p>
|
</p>
|
||||||
<CreditCard className="text-blue-400 w-6 h-6" />
|
<CreditCard className="text-blue-400 w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
@@ -299,11 +308,15 @@ export default function FinancePage() {
|
|||||||
.length
|
.length
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">Paid and confirmed</p>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{t("Tasdiqlangan bandlovlar")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
<div className="bg-gray-800 p-6 rounded-xl shadow flex flex-col justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-gray-400 font-medium">Pending Bookings</p>
|
<p className="text-gray-400 font-medium">
|
||||||
|
{t("Kutilayotgan bandlovlar")}
|
||||||
|
</p>
|
||||||
<Hotel className="text-purple-400 w-6 h-6" />
|
<Hotel className="text-purple-400 w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-purple-400 mt-3">
|
<p className="text-2xl font-bold text-purple-400 mt-3">
|
||||||
@@ -313,12 +326,14 @@ export default function FinancePage() {
|
|||||||
).length
|
).length
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">Awaiting payment</p>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{t("Kutilayotgan to‘lovlar")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking Cards */}
|
{/* Booking Cards */}
|
||||||
<h2 className="text-xl font-bold mb-4">Recent Bookings</h2>
|
<h2 className="text-xl font-bold mb-4">{t("Oxirgi bandlovlar")}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{filteredPurchases.map((p) => (
|
{filteredPurchases.map((p) => (
|
||||||
<div
|
<div
|
||||||
@@ -341,15 +356,17 @@ export default function FinancePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-3">
|
<div className="flex justify-between mt-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 text-sm">Travel Date</p>
|
<p className="text-gray-500 text-sm">
|
||||||
|
{t("Sayohat sanasi")}
|
||||||
|
</p>
|
||||||
<p className="text-gray-100 font-medium">
|
<p className="text-gray-100 font-medium">
|
||||||
{p.travelDate}
|
{p.travelDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-gray-500 text-sm">Amount</p>
|
<p className="text-gray-500 text-sm">{t("Miqdor")}</p>
|
||||||
<p className="text-green-400 font-bold">
|
<p className="text-green-400 font-bold">
|
||||||
${(p.amount / 1000000).toFixed(1)}M
|
{formatPrice(p.amount, true)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +375,7 @@ export default function FinancePage() {
|
|||||||
{getStatusBadge(p.paymentStatus)}
|
{getStatusBadge(p.paymentStatus)}
|
||||||
<Link to={`/bookings/${p.id}`}>
|
<Link to={`/bookings/${p.id}`}>
|
||||||
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
|
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
|
||||||
<Eye className="w-4 h-4" /> Details
|
<Eye className="w-4 h-4" /> {t("Ko'rish")}
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Download,
|
|
||||||
Eye,
|
Eye,
|
||||||
Hotel,
|
Hotel,
|
||||||
MapPin,
|
MapPin,
|
||||||
Plane,
|
Plane,
|
||||||
Share2,
|
|
||||||
Star,
|
Star,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
@@ -200,16 +198,6 @@ export default function FinanceDetailTour() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
|
||||||
<button className="bg-gray-800 px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-2">
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
Export Report
|
|
||||||
</button>
|
|
||||||
<button className="bg-blue-600 px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2">
|
|
||||||
<Share2 className="w-4 h-4" />
|
|
||||||
Share
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tour Summary Cards */}
|
{/* Tour Summary Cards */}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import formatPhone from "@/shared/lib/formatPhone";
|
||||||
|
import formatPrice from "@/shared/lib/formatPrice";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Download,
|
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
Share2,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
type UserPurchase = {
|
type UserPurchase = {
|
||||||
@@ -108,6 +109,7 @@ const mockUserPurchases: UserPurchase[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function FinanceDetailUser() {
|
export default function FinanceDetailUser() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<"bookings" | "details">(
|
const [activeTab, setActiveTab] = useState<"bookings" | "details">(
|
||||||
"bookings",
|
"bookings",
|
||||||
);
|
);
|
||||||
@@ -122,7 +124,7 @@ export default function FinanceDetailUser() {
|
|||||||
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
||||||
>
|
>
|
||||||
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
||||||
Paid
|
{t("Paid")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "pending":
|
case "pending":
|
||||||
@@ -131,7 +133,7 @@ export default function FinanceDetailUser() {
|
|||||||
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
|
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
|
||||||
>
|
>
|
||||||
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
|
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
|
||||||
Pending
|
{t("Pending")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
@@ -140,7 +142,7 @@ export default function FinanceDetailUser() {
|
|||||||
className={`${base} bg-red-900 text-red-400 border border-red-700`}
|
className={`${base} bg-red-900 text-red-400 border border-red-700`}
|
||||||
>
|
>
|
||||||
<div className="w-2 h-2 rounded-full bg-red-400"></div>
|
<div className="w-2 h-2 rounded-full bg-red-400"></div>
|
||||||
Cancelled
|
{t("Cancelled")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "refunded":
|
case "refunded":
|
||||||
@@ -189,69 +191,69 @@ export default function FinanceDetailUser() {
|
|||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">User Financial Details</h1>
|
<h1 className="text-3xl font-bold">
|
||||||
|
{t("Foydalanuvchi moliyaviy tafsilotlari")}
|
||||||
|
</h1>
|
||||||
<p className="text-gray-400 mt-1">
|
<p className="text-gray-400 mt-1">
|
||||||
Detailed financial overview for {mockUserData.userName}
|
{mockUserData.userName} {t("uchun batafsil moliyaviy sharh")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
|
||||||
<button className="bg-gray-800 px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-2">
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
Export
|
|
||||||
</button>
|
|
||||||
<button className="bg-blue-600 px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2">
|
|
||||||
<Share2 className="w-4 h-4" />
|
|
||||||
Share Report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Summary Cards */}
|
{/* User Summary Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-gray-400 font-medium">Total Spent</p>
|
<p className="text-gray-400 font-medium">{t("Total Spent")}</p>
|
||||||
<DollarSign className="text-green-400 w-6 h-6" />
|
<DollarSign className="text-green-400 w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-green-400 mt-3">
|
<p className="text-2xl font-bold text-green-400 mt-3">
|
||||||
${(totalSpent / 1000000).toFixed(1)}M
|
{formatPrice(totalSpent, true)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{t("All completed bookings")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">All completed bookings</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-gray-400 font-medium">Pending Payments</p>
|
<p className="text-gray-400 font-medium">
|
||||||
|
{t("Pending Payments")}
|
||||||
|
</p>
|
||||||
<TrendingUp className="text-yellow-400 w-6 h-6" />
|
<TrendingUp className="text-yellow-400 w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
||||||
${(pendingAmount / 1000000).toFixed(1)}M
|
{formatPrice(pendingAmount, true)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{t("Awaiting confirmation")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">Awaiting confirmation</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-gray-400 font-medium">Total Bookings</p>
|
<p className="text-gray-400 font-medium">{t("Total Bookings")}</p>
|
||||||
<CreditCard className="text-blue-400 w-6 h-6" />
|
<CreditCard className="text-blue-400 w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-blue-400 mt-3">
|
<p className="text-2xl font-bold text-blue-400 mt-3">
|
||||||
{mockUserData.totalBookings}
|
{mockUserData.totalBookings}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">All time bookings</p>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{t("All time bookings")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
{/* <div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-gray-400 font-medium">Member Level</p>
|
<p className="text-gray-400 font-medium">{t("Member Level")}</p>
|
||||||
<User className="text-purple-400 w-6 h-6" />
|
<User className="text-purple-400 w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-purple-400 mt-3">
|
<p className="text-2xl font-bold text-purple-400 mt-3">
|
||||||
{mockUserData.memberLevel}
|
{mockUserData.memberLevel}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">Loyalty status</p>
|
<p className="text-sm text-gray-500 mt-1">{t("Loyalty status")}</p>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
@@ -267,7 +269,7 @@ export default function FinanceDetailUser() {
|
|||||||
onClick={() => setActiveTab("bookings")}
|
onClick={() => setActiveTab("bookings")}
|
||||||
>
|
>
|
||||||
<CreditCard className="w-4 h-4" />
|
<CreditCard className="w-4 h-4" />
|
||||||
Booking History
|
{t("Booking History")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`px-6 py-4 font-medium flex items-center gap-2 transition-colors ${
|
className={`px-6 py-4 font-medium flex items-center gap-2 transition-colors ${
|
||||||
@@ -278,14 +280,16 @@ export default function FinanceDetailUser() {
|
|||||||
onClick={() => setActiveTab("details")}
|
onClick={() => setActiveTab("details")}
|
||||||
>
|
>
|
||||||
<User className="w-4 h-4" />
|
<User className="w-4 h-4" />
|
||||||
User Details
|
{t("User Details")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{activeTab === "bookings" && (
|
{activeTab === "bookings" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-bold mb-4">Booking History</h2>
|
<h2 className="text-xl font-bold mb-4">
|
||||||
|
{t("Booking History")}
|
||||||
|
</h2>
|
||||||
{mockUserPurchases.map((purchase) => (
|
{mockUserPurchases.map((purchase) => (
|
||||||
<div
|
<div
|
||||||
key={purchase.id}
|
key={purchase.id}
|
||||||
@@ -297,7 +301,7 @@ export default function FinanceDetailUser() {
|
|||||||
{purchase.tourName}
|
{purchase.tourName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-400 text-sm">
|
<p className="text-gray-400 text-sm">
|
||||||
Booking Ref: {purchase.bookingReference}
|
{t("Booking Ref")}: {purchase.bookingReference}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{getStatusBadge(purchase.paymentStatus)}
|
{getStatusBadge(purchase.paymentStatus)}
|
||||||
@@ -307,7 +311,9 @@ export default function FinanceDetailUser() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPin className="w-4 h-4 text-gray-400" />
|
<MapPin className="w-4 h-4 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Destination</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Destination")}
|
||||||
|
</p>
|
||||||
<p className="text-gray-100">
|
<p className="text-gray-100">
|
||||||
{purchase.destination}
|
{purchase.destination}
|
||||||
</p>
|
</p>
|
||||||
@@ -317,7 +323,9 @@ export default function FinanceDetailUser() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4 text-gray-400" />
|
<Calendar className="w-4 h-4 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Travel Dates</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Travel Dates")}
|
||||||
|
</p>
|
||||||
<p className="text-gray-100">
|
<p className="text-gray-100">
|
||||||
{purchase.travelDate} - {purchase.returnDate}
|
{purchase.travelDate} - {purchase.returnDate}
|
||||||
</p>
|
</p>
|
||||||
@@ -327,19 +335,19 @@ export default function FinanceDetailUser() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<User className="w-4 h-4 text-gray-400" />
|
<User className="w-4 h-4 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Travelers</p>
|
<p className="text-sm text-gray-400">
|
||||||
<p className="text-gray-100">
|
{t("Travelers")}
|
||||||
{purchase.travelers} person(s)
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-gray-100">{purchase.travelers}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Amount</p>
|
<p className="text-sm text-gray-400">{t("Amount")}</p>
|
||||||
<p className="text-green-400 font-bold">
|
<p className="text-green-400 font-bold">
|
||||||
${(purchase.amount / 1000000).toFixed(1)}M
|
{formatPrice(purchase.amount, true)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +355,7 @@ export default function FinanceDetailUser() {
|
|||||||
|
|
||||||
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
|
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-gray-400">
|
||||||
Booked on {purchase.purchaseDate} •{" "}
|
{t("Booked on")} {purchase.purchaseDate}{" "}
|
||||||
{getPaymentMethod(purchase.paymentMethod)}
|
{getPaymentMethod(purchase.paymentMethod)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,13 +369,15 @@ export default function FinanceDetailUser() {
|
|||||||
{/* Personal Information */}
|
{/* Personal Information */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold mb-4">
|
<h3 className="text-lg font-bold mb-4">
|
||||||
Personal Information
|
{t("Personal Information")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||||
<User className="w-5 h-5 text-blue-400" />
|
<User className="w-5 h-5 text-blue-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Full Name</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Full Name")}
|
||||||
|
</p>
|
||||||
<p className="text-gray-100">{mockUserData.userName}</p>
|
<p className="text-gray-100">{mockUserData.userName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,9 +385,11 @@ export default function FinanceDetailUser() {
|
|||||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||||
<Phone className="w-5 h-5 text-green-400" />
|
<Phone className="w-5 h-5 text-green-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Phone Number</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Phone Number")}
|
||||||
|
</p>
|
||||||
<p className="text-gray-100">
|
<p className="text-gray-100">
|
||||||
{mockUserData.userPhone}
|
{formatPhone(mockUserData.userPhone)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,7 +397,9 @@ export default function FinanceDetailUser() {
|
|||||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||||
<Mail className="w-5 h-5 text-yellow-400" />
|
<Mail className="w-5 h-5 text-yellow-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Email Address</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Email Address")}
|
||||||
|
</p>
|
||||||
<p className="text-gray-100">
|
<p className="text-gray-100">
|
||||||
{mockUserData.userEmail}
|
{mockUserData.userEmail}
|
||||||
</p>
|
</p>
|
||||||
@@ -395,7 +409,9 @@ export default function FinanceDetailUser() {
|
|||||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||||
<Calendar className="w-5 h-5 text-purple-400" />
|
<Calendar className="w-5 h-5 text-purple-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Member Since</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Member Since")}
|
||||||
|
</p>
|
||||||
<p className="text-gray-100">{mockUserData.joinDate}</p>
|
<p className="text-gray-100">{mockUserData.joinDate}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,40 +420,38 @@ export default function FinanceDetailUser() {
|
|||||||
|
|
||||||
{/* Travel Preferences */}
|
{/* Travel Preferences */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold mb-4">Travel Statistics</h3>
|
<h3 className="text-lg font-bold mb-4">
|
||||||
|
{t("Travel Statistics")}
|
||||||
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="p-4 bg-gray-700 rounded-lg">
|
<div className="p-4 bg-gray-700 rounded-lg">
|
||||||
<p className="text-sm text-gray-400 mb-2">
|
<p className="text-sm text-gray-400 mb-2">
|
||||||
Favorite Destination
|
{t("Favorite Destination")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-100 font-medium">Dubai, UAE</p>
|
<p className="text-gray-100 font-medium">Dubai, UAE</p>
|
||||||
<p className="text-sm text-gray-400 mt-1">2 bookings</p>
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
2 {t("bookings")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-gray-700 rounded-lg">
|
<div className="p-4 bg-gray-700 rounded-lg">
|
||||||
<p className="text-sm text-gray-400 mb-2">
|
<p className="text-sm text-gray-400 mb-2">
|
||||||
Preferred Agency
|
{t("Preferred Agency")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-100 font-medium">
|
<p className="text-gray-100 font-medium">
|
||||||
Silk Road Travel
|
Silk Road Travel
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
2 out of 3 bookings
|
2 {t("out of")} 3 {t("bookings")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-gray-700 rounded-lg">
|
<div className="p-4 bg-gray-700 rounded-lg">
|
||||||
<p className="text-sm text-gray-400 mb-2">
|
<p className="text-sm text-gray-400 mb-2">
|
||||||
Average Booking Value
|
{t("Average Booking Value")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-green-400 font-bold">
|
<p className="text-green-400 font-bold">
|
||||||
$
|
{formatPrice(totalSpent, true)}
|
||||||
{(
|
|
||||||
totalSpent /
|
|
||||||
mockUserData.totalBookings /
|
|
||||||
1000000
|
|
||||||
).toFixed(1)}
|
|
||||||
M
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +1,75 @@
|
|||||||
import type { NewsType } from "@/pages/news/lib/type";
|
import type {
|
||||||
|
GetAllNewsCategory,
|
||||||
|
GetDetailNewsCategory,
|
||||||
|
NewsAll,
|
||||||
|
} from "@/pages/news/lib/type";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { NEWS, NEWS_CATEGORY } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
const STORAGE_KEY = "news_data";
|
const getAllNews = async ({
|
||||||
|
page,
|
||||||
export const getAllNews = (): NewsType[] => {
|
page_size,
|
||||||
const data = localStorage.getItem(STORAGE_KEY);
|
}: {
|
||||||
return data ? JSON.parse(data) : [];
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}): Promise<AxiosResponse<NewsAll>> => {
|
||||||
|
const response = await httpClient.get(NEWS, { params: { page, page_size } });
|
||||||
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addNews = (news: Omit<NewsType, "id" | "createdAt">) => {
|
const addNews = async (body: FormData) => {
|
||||||
const all = getAllNews();
|
const response = await httpClient.post(NEWS, body, {
|
||||||
const newNews: NewsType = {
|
headers: {
|
||||||
id: "1",
|
"Content-Type": "multipart/form-data",
|
||||||
createdAt: new Date().toISOString(),
|
},
|
||||||
...news,
|
});
|
||||||
};
|
return response;
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([newNews, ...all]));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateNews = (id: string, updated: Partial<NewsType>) => {
|
// category news
|
||||||
const all = getAllNews().map((n) => (n.id === id ? { ...n, ...updated } : n));
|
const getAllNewsCategory = async (params: {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}): Promise<AxiosResponse<GetAllNewsCategory>> => {
|
||||||
|
const res = await httpClient.get(NEWS_CATEGORY, { params });
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteNews = (id: string) => {
|
const getDetailNewsCategory = async (
|
||||||
const filtered = getAllNews().filter((n) => n.id !== id);
|
id: number,
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
): Promise<AxiosResponse<GetDetailNewsCategory>> => {
|
||||||
|
const res = await httpClient.get(`${NEWS_CATEGORY}${id}/`);
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getNewsById = (id: string) => {
|
const addNewsCategory = async (body: { name: string; name_ru: string }) => {
|
||||||
return getAllNews().find((n) => n.id === id);
|
const res = await httpClient.post(NEWS_CATEGORY, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNewsCategory = async ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
body: { name: string; name_ru: string };
|
||||||
|
id: number;
|
||||||
|
}) => {
|
||||||
|
const res = await httpClient.patch(`${NEWS_CATEGORY}${id}/`, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNewsCategory = async (id: number) => {
|
||||||
|
const res = await httpClient.delete(`${NEWS_CATEGORY}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
addNews,
|
||||||
|
addNewsCategory,
|
||||||
|
deleteNewsCategory,
|
||||||
|
getAllNews,
|
||||||
|
getAllNewsCategory,
|
||||||
|
getDetailNewsCategory,
|
||||||
|
updateNewsCategory,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,54 +1,40 @@
|
|||||||
import type { NewsAll } from "./type";
|
// src/store/useNewsStore.ts
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
export const fakeNewsData: NewsAll[] = [
|
interface NewsData {
|
||||||
{
|
title: string;
|
||||||
id: 1,
|
desc: string;
|
||||||
short_title: "Yangi sayohat yo‘nalishlari ochildi",
|
title_ru: string;
|
||||||
slug: "yangi-sayohat-yonalishlari",
|
desc_ru: string;
|
||||||
image: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e",
|
category: string;
|
||||||
category: { id: 1, name: "Turlar" },
|
banner: File | undefined;
|
||||||
short_text:
|
}
|
||||||
"Bu yozda yangi xalqaro yo‘nalishlar ochilmoqda — Turkiya, Dubay, Malayziya va yana ko‘plab manzillar.",
|
|
||||||
created: "2025-10-15T08:45:00Z",
|
interface NewsStore {
|
||||||
|
stepOneData: NewsData;
|
||||||
|
setStepOneData: (data: NewsData) => void;
|
||||||
|
resetStepOneData: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNewsStore = create<NewsStore>((set) => ({
|
||||||
|
stepOneData: {
|
||||||
|
title: "",
|
||||||
|
desc: "",
|
||||||
|
category: "",
|
||||||
|
banner: undefined,
|
||||||
|
desc_ru: "",
|
||||||
|
title_ru: "",
|
||||||
},
|
},
|
||||||
{
|
setStepOneData: (data) => set({ stepOneData: data }),
|
||||||
id: 2,
|
resetStepOneData: () =>
|
||||||
short_title: "Tur firmalar uchun yangi litsenziya tizimi",
|
set({
|
||||||
slug: "litsenziya-tizimi",
|
stepOneData: {
|
||||||
image: "https://images.unsplash.com/photo-1488646953014-85cb44e25828",
|
title: "",
|
||||||
category: { id: 2, name: "Yangiliklar" },
|
desc: "",
|
||||||
short_text:
|
category: "",
|
||||||
"Turizm agentliklari uchun raqamli litsenziya olish tizimi ishga tushirildi. Endi barcha jarayon onlayn bo‘ladi.",
|
banner: undefined,
|
||||||
created: "2025-09-22T12:30:00Z",
|
desc_ru: "",
|
||||||
|
title_ru: "",
|
||||||
},
|
},
|
||||||
{
|
}),
|
||||||
id: 3,
|
}));
|
||||||
short_title: "Sayohat bozorida narxlar pasaymoqda",
|
|
||||||
slug: "narxlar-pasaymoqda",
|
|
||||||
image: "https://images.unsplash.com/photo-1473625247510-8ceb1760943f",
|
|
||||||
category: { id: 3, name: "Blog" },
|
|
||||||
short_text:
|
|
||||||
"So‘nggi haftalarda xalqaro aviabiletlar narxi 15% gacha arzonlashgani kuzatildi. Mutaxassislar bunga tahlil beradi.",
|
|
||||||
created: "2025-10-10T09:15:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
short_title: "Yangi mehmonxonalar tarmog‘i ish boshladi",
|
|
||||||
slug: "yangi-mehmonxonalar",
|
|
||||||
image: "https://images.unsplash.com/photo-1566073771259-6a8506099945",
|
|
||||||
category: { id: 4, name: "Yangiliklar" },
|
|
||||||
short_text:
|
|
||||||
"O‘zbekistonda 5 ta yangi premium mehmonxona ochildi. Bu turizm industriyasi uchun muhim qadam.",
|
|
||||||
created: "2025-10-05T15:10:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
short_title: "Sayyohlar uchun foydali maslahatlar",
|
|
||||||
slug: "sayyohlar-maslahatlar",
|
|
||||||
image: "https://images.unsplash.com/photo-1526778548025-fa2f459cd5c1",
|
|
||||||
category: { id: 5, name: "Blog" },
|
|
||||||
short_text:
|
|
||||||
"Chet elga chiqayotganlar uchun xavfsizlik, valyuta va mobil aloqa haqida 10 ta foydali maslahat.",
|
|
||||||
created: "2025-09-30T10:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -1,21 +1,50 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
|
const fileSchema = z.instanceof(File, { message: "Rasm faylini yuklang" });
|
||||||
|
|
||||||
export const newsForm = z.object({
|
export const newsForm = z.object({
|
||||||
title: z.string().min(2, {
|
title: z.string().min(2, {
|
||||||
message: "Username must be at least 2 characters.",
|
message: "Kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||||
|
}),
|
||||||
|
title_ru: z.string().min(2, {
|
||||||
|
message: "Kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||||
}),
|
}),
|
||||||
desc: z.string().min(2, {
|
desc: z.string().min(2, {
|
||||||
message: "Username must be at least 2 characters.",
|
message: "Kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||||
|
}),
|
||||||
|
desc_ru: z.string().min(2, {
|
||||||
|
message: "Kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||||
}),
|
}),
|
||||||
category: z.string().min(1, {
|
category: z.string().min(1, {
|
||||||
message: "Majburiy maydon",
|
message: "Majburiy maydon",
|
||||||
}),
|
}),
|
||||||
banner: z.string().min(1, { message: "Banner rasmi majburiy" }),
|
banner: fileSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newsPostForm = z.object({
|
export const newsPostForm = z.object({
|
||||||
desc: z.string().min(2, {
|
desc: z
|
||||||
message: "Username must be at least 2 characters.",
|
.string()
|
||||||
|
.min(2, { message: "Kamida 2 ta belgidan iborat bo'lishi kerak." }),
|
||||||
|
desc_ru: z
|
||||||
|
.string()
|
||||||
|
.min(2, { message: "Kamida 2 ta belgidan iborat bo'lishi kerak." }),
|
||||||
|
is_public: z.enum(["no", "yes"], { message: "Iltimos, tanlang" }),
|
||||||
|
|
||||||
|
sections: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
image: fileSchema,
|
||||||
|
text: z.string().min(1, { message: "Matn bo'sh bo'lmasligi kerak." }),
|
||||||
|
text_ru: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Ruscha matn bo'sh bo'lmasligi kerak." }),
|
||||||
}),
|
}),
|
||||||
banner: z.string().min(1, { message: "Banner rasmi majburiy" }),
|
)
|
||||||
|
.min(1, { message: "Kamida bitta bo‘lim qo‘shing." }),
|
||||||
|
|
||||||
|
post_tags: z
|
||||||
|
.array(z.string().min(1, { message: "Teg bo'sh bo'lmasligi kerak." }))
|
||||||
|
.min(1, { message: "Kamida bitta teg kiriting." }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type NewsPostFormType = z.infer<typeof newsPostForm>;
|
||||||
|
|||||||
@@ -20,14 +20,67 @@ export interface NewsType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NewsAll {
|
export interface NewsAll {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: {
|
||||||
id: number;
|
id: number;
|
||||||
short_title: string;
|
title: string;
|
||||||
slug: string;
|
title_ru: string;
|
||||||
image: string;
|
image: string;
|
||||||
|
text: string;
|
||||||
|
text_ru: string;
|
||||||
|
is_public: true;
|
||||||
category: {
|
category: {
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
};
|
||||||
|
tag: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
}[];
|
||||||
|
post_images: {
|
||||||
|
image: string;
|
||||||
|
text: string;
|
||||||
|
text_ru: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetAllNewsCategory {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDetailNewsCategory {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
};
|
};
|
||||||
short_text: string;
|
|
||||||
created: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,33 +3,35 @@ import StepOne from "@/pages/news/ui/StepOne";
|
|||||||
import StepTwo from "@/pages/news/ui/StepTwo";
|
import StepTwo from "@/pages/news/ui/StepTwo";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
const AddNews = () => {
|
const AddNews = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const isEditMode = useMemo(() => !!id, [id]);
|
const isEditMode = useMemo(() => !!id, [id]);
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 w-full mx-auto bg-gray-900 text-white rounded-2xl shadow-lg">
|
<div className="p-8 w-full mx-auto bg-gray-900 text-white rounded-2xl shadow-lg">
|
||||||
<h1 className="text-3xl font-bold mb-6">
|
<h1 className="text-3xl font-bold mb-6">
|
||||||
{isEditMode ? "Yangilikni tahrirlash" : "Yangi yangilik qo‘shish"}
|
{isEditMode ? t("Yangilikni tahrirlash") : t("Yangi yangilik qo‘shish")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div
|
<div
|
||||||
className={`flex-1 text-center py-2 rounded-l-lg ${step === 1 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
className={`flex-1 text-center py-2 rounded-l-lg ${step === 1 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||||
>
|
>
|
||||||
1. Yangilik sarlavhasi
|
1. {t("Yangilik sarlavhasi")}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`flex-1 text-center py-2 rounded-r-lg ${step === 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
className={`flex-1 text-center py-2 rounded-r-lg ${step === 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||||
>
|
>
|
||||||
2. Yangilik ma'lumotlari
|
2. {t("Yangilik ma'lumotlari")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{step === 1 && <StepOne isEditMode={isEditMode} setStep={setStep} />}
|
{step === 1 && <StepOne isEditMode={isEditMode} setStep={setStep} />}
|
||||||
{step === 2 && <StepTwo isEditMode={isEditMode} setStep={setStep} />}
|
{step === 2 && <StepTwo />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { fakeNewsData } from "@/pages/news/lib/data";
|
import { getAllNews } from "@/pages/news/lib/api";
|
||||||
import type { NewsAll } from "@/pages/news/lib/type";
|
|
||||||
import formatDate from "@/shared/lib/formatDate";
|
|
||||||
import { Badge } from "@/shared/ui/badge";
|
import { Badge } from "@/shared/ui/badge";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Card } from "@/shared/ui/card";
|
import { Card } from "@/shared/ui/card";
|
||||||
@@ -12,42 +10,44 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/ui/dialog";
|
} from "@/shared/ui/dialog";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Calendar, Edit, FolderOpen, PlusCircle, Trash2 } from "lucide-react";
|
import { Edit, FolderOpen, PlusCircle, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
const News = () => {
|
const News = () => {
|
||||||
const [newsList, setNewsList] = useState<NewsAll[]>(fakeNewsData);
|
const { t } = useTranslation();
|
||||||
const loading = false;
|
|
||||||
const error = null;
|
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
data: allNews,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["all_news"],
|
||||||
|
queryFn: () => getAllNews({ page: 1, page_size: 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {};
|
||||||
if (deleteId !== null) {
|
|
||||||
setNewsList((prev) => prev.filter((t) => t.id !== deleteId));
|
|
||||||
setDeleteId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center">
|
<div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
||||||
<p className="text-lg">Yuklanmoqda...</p>
|
<p className="text-lg">{t("Yuklanmoqda...")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center">
|
<div className="min-h-screen bg-gray-900 w-full text-white flex justify-center items-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-xl text-red-400">{error}</p>
|
<Button className="mt-4">{t("Qayta urinish")}</Button>
|
||||||
<Button className="mt-4">Qayta urinish</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -58,9 +58,10 @@ const News = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-center mb-8 w-[90%] mx-auto">
|
<div className="flex justify-between items-center mb-8 w-[90%] mx-auto">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-bold mb-2">Yangiliklar</h1>
|
<h1 className="text-4xl font-bold mb-2">{t("Yangiliklar")}</h1>
|
||||||
<p className="text-gray-400">
|
<p className="text-gray-400">
|
||||||
Jami {newsList.length} ta yangilik mavjud
|
{t("Jami")} {allNews?.data.data.total_items}{" "}
|
||||||
|
{t("ta yangilik mavjud")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -68,7 +69,7 @@ const News = () => {
|
|||||||
className="flex items-center gap-2 cursor-pointer bg-blue-600 hover:bg-blue-700 text-white"
|
className="flex items-center gap-2 cursor-pointer bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
>
|
>
|
||||||
<PlusCircle size={18} />
|
<PlusCircle size={18} />
|
||||||
Yangilik qo'shish
|
{t("Yangilik qo'shish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,12 +77,12 @@ const News = () => {
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"gap-6 w-[90%] mx-auto",
|
"gap-6 w-[90%] mx-auto",
|
||||||
newsList.length === 0
|
allNews?.data.data.total_items === 0
|
||||||
? "flex justify-center items-center min-h-[60vh]"
|
? "flex justify-center items-center min-h-[60vh]"
|
||||||
: "grid md:grid-cols-2 lg:grid-cols-3",
|
: "grid md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{newsList.length === 0 ? (
|
{allNews?.data.data.total_items === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="w-24 h-24 bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-24 h-24 bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
@@ -89,20 +90,20 @@ const News = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl text-gray-400 mb-2 font-semibold">
|
<p className="text-2xl text-gray-400 mb-2 font-semibold">
|
||||||
Hozircha yangilik yo'q
|
{t("Hozircha yangilik yo'q")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 mb-6">
|
<p className="text-gray-500 mb-6">
|
||||||
Birinchi yangilikni qo'shib boshlang
|
{t("Birinchi yangilikni qo'shishni boshlang")}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate("/news/add")}
|
onClick={() => navigate("/news/add")}
|
||||||
className="flex items-center gap-2 mx-auto bg-blue-600 hover:bg-blue-700 text-white"
|
className="flex items-center gap-2 mx-auto bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
>
|
>
|
||||||
<PlusCircle size={18} /> Yangilik qo'shish
|
<PlusCircle size={18} /> {t("Yangilik qo'shish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
newsList.map((item) => (
|
allNews?.data.data.results.map((item) => (
|
||||||
<Card
|
<Card
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="overflow-hidden bg-neutral-900 hover:bg-neutral-800 transition-all duration-300 border border-neutral-800 hover:border-neutral-700 group"
|
className="overflow-hidden bg-neutral-900 hover:bg-neutral-800 transition-all duration-300 border border-neutral-800 hover:border-neutral-700 group"
|
||||||
@@ -111,7 +112,7 @@ const News = () => {
|
|||||||
<div className="relative h-48 overflow-hidden">
|
<div className="relative h-48 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={item.image}
|
src={item.image}
|
||||||
alt={item.short_title}
|
alt={item.title}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.currentTarget.src =
|
e.currentTarget.src =
|
||||||
@@ -131,25 +132,26 @@ const News = () => {
|
|||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h2 className="text-xl font-bold line-clamp-2 group-hover:text-blue-400 transition-colors">
|
<h2 className="text-xl font-bold line-clamp-2 group-hover:text-blue-400 transition-colors">
|
||||||
{item.short_title}
|
{item.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Short Text */}
|
{/* Short Text */}
|
||||||
<p className="text-sm text-gray-400 line-clamp-3 leading-relaxed">
|
<p className="text-sm text-gray-400 line-clamp-3 leading-relaxed">
|
||||||
{item.short_text}
|
{item.text}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<Calendar size={14} />
|
<span>{item.is_public}</span>
|
||||||
<span>{formatDate.format(item.created, "DD.MM.YYYY")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Slug */}
|
{/* Slug */}
|
||||||
<div className="pt-2 border-t border-neutral-800">
|
<div className="pt-2 border-t border-neutral-800">
|
||||||
|
{item.tag?.map((e) => (
|
||||||
<code className="text-xs text-gray-500 bg-neutral-800 px-2 py-1 rounded">
|
<code className="text-xs text-gray-500 bg-neutral-800 px-2 py-1 rounded">
|
||||||
/{item.slug}
|
/{e.name}
|
||||||
</code>
|
</code>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -161,7 +163,7 @@ const News = () => {
|
|||||||
className="hover:bg-neutral-700 hover:text-blue-400"
|
className="hover:bg-neutral-700 hover:text-blue-400"
|
||||||
>
|
>
|
||||||
<Edit size={16} className="mr-1" />
|
<Edit size={16} className="mr-1" />
|
||||||
Tahrirlash
|
{t("Tahrirlash")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -170,7 +172,7 @@ const News = () => {
|
|||||||
className="hover:bg-red-700"
|
className="hover:bg-red-700"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} className="mr-1" />
|
<Trash2 size={16} className="mr-1" />
|
||||||
O'chirish
|
{t("O'chirish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,22 +185,23 @@ const News = () => {
|
|||||||
<DialogContent className="sm:max-w-[425px] bg-gray-900">
|
<DialogContent className="sm:max-w-[425px] bg-gray-900">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl">
|
<DialogTitle className="text-xl">
|
||||||
Yangilikni o'chirishni tasdiqlang
|
{t("Yangilikni o'chirishni tasdiqlang")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga
|
{t(
|
||||||
qaytarib bo'lmaydi.
|
"Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga qaytarib bo'lmaydi.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-4 flex">
|
<DialogFooter className="gap-4 flex">
|
||||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||||
Bekor qilish
|
{t("Bekor qilish")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete}>
|
<Button variant="destructive" onClick={confirmDelete}>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
O'chirish
|
{t("O'chirish")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge } from "@/shared/ui/badge";
|
import {
|
||||||
|
addNewsCategory,
|
||||||
|
deleteNewsCategory,
|
||||||
|
getAllNewsCategory,
|
||||||
|
getDetailNewsCategory,
|
||||||
|
updateNewsCategory,
|
||||||
|
} from "@/pages/news/lib/api";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -26,85 +32,182 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shared/ui/table";
|
} from "@/shared/ui/table";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Edit, PlusCircle, Trash2 } from "lucide-react";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit,
|
||||||
|
Loader2,
|
||||||
|
PlusCircle,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
type NewsCategoryType = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fakeCategories: NewsCategoryType[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Blog",
|
|
||||||
count: 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "News",
|
|
||||||
count: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Tours",
|
|
||||||
count: 5,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const NewsCategory = () => {
|
const NewsCategory = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
const newsForm = z.object({
|
const newsForm = z.object({
|
||||||
title: z.string().min(2, {
|
title: z.string().min(2, {
|
||||||
message: "Username must be at least 2 characters.",
|
message: "Username must be at least 2 characters.",
|
||||||
}),
|
}),
|
||||||
|
title_ru: z.string().min(2, {
|
||||||
|
message: "Username must be at least 2 characters.",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const [categories, setCategories] =
|
const { t } = useTranslation();
|
||||||
useState<NewsCategoryType[]>(fakeCategories);
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["news_category", currentPage],
|
||||||
|
queryFn: () =>
|
||||||
|
getAllNewsCategory({ page: currentPage, page_size: itemsPerPage }),
|
||||||
|
});
|
||||||
|
const [editItem, setEditItem] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: detail } = useQuery({
|
||||||
|
queryKey: ["news_category_detail", editItem],
|
||||||
|
queryFn: () => getDetailNewsCategory(editItem!),
|
||||||
|
enabled: editItem !== null,
|
||||||
|
});
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editItem, setEditItem] = useState<NewsCategoryType | null>(null);
|
|
||||||
const form = useForm<z.infer<typeof newsForm>>({
|
const form = useForm<z.infer<typeof newsForm>>({
|
||||||
resolver: zodResolver(newsForm),
|
resolver: zodResolver(newsForm),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: "",
|
title: "",
|
||||||
|
title_ru: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editItem) {
|
if (detail) {
|
||||||
form.setValue("title", editItem.name);
|
form.setValue("title", detail.data.data.name);
|
||||||
|
form.setValue("title_ru", detail.data.data.name_ru);
|
||||||
}
|
}
|
||||||
}, [editItem, form]);
|
}, [editItem, form, detail]);
|
||||||
|
|
||||||
const openDialog = () => {
|
const openDialog = () => {
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
function onSubmit() {
|
const { mutate: added } = useMutation({
|
||||||
|
mutationFn: (body: { name: string; name_ru: string }) =>
|
||||||
|
addNewsCategory(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["news_category"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["news_category_detail"] });
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: edit } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { name: string; name_ru: string };
|
||||||
|
}) => updateNewsCategory({ body, id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["news_category"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["news_category_detail"] });
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteCategory } = useMutation({
|
||||||
|
mutationFn: (id: number) => deleteNewsCategory(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["news_category"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(value: z.infer<typeof newsForm>) {
|
||||||
|
if (editItem === null) {
|
||||||
|
added({
|
||||||
|
name: value.title,
|
||||||
|
name_ru: value.title_ru,
|
||||||
|
});
|
||||||
|
} else if (editItem !== null) {
|
||||||
|
edit({
|
||||||
|
id: editItem,
|
||||||
|
body: {
|
||||||
|
name: value.title,
|
||||||
|
name_ru: value.title_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleDelete = (id: number) => {
|
||||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
deleteCategory(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
|
||||||
|
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||||
|
<p className="text-lg">
|
||||||
|
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("Qayta urinish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 w-full text-gray-100 p-8">
|
<div className="min-h-screen bg-gray-900 w-full text-gray-100 p-8">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">
|
<h1 className="text-3xl font-semibold tracking-tight">
|
||||||
News Categories
|
{t("News Categories")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
openDialog();
|
openDialog();
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setEditItem(null);
|
||||||
}}
|
}}
|
||||||
className="flex items-center bg-blue-600 hover:bg-blue-700 text-white"
|
className="flex items-center bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
>
|
>
|
||||||
<PlusCircle className="w-4 h-4 mr-2" /> Yangi qo‘shish
|
<PlusCircle className="w-4 h-4 mr-2" /> {t("Yangi qo‘shish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-gray-800 overflow-hidden bg-gray-900">
|
<div className="rounded-lg border border-gray-800 overflow-hidden bg-gray-900">
|
||||||
@@ -112,19 +215,21 @@ const NewsCategory = () => {
|
|||||||
<TableHeader className="bg-gray-800/50">
|
<TableHeader className="bg-gray-800/50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="text-gray-300 w-[60px]">#</TableHead>
|
<TableHead className="text-gray-300 w-[60px]">#</TableHead>
|
||||||
<TableHead className="text-gray-300">Kategoriya nomi</TableHead>
|
<TableHead className="text-gray-300">
|
||||||
<TableHead className="text-gray-300 text-center">
|
{t("Kategoriya nomi")}
|
||||||
Yangiliklar soni
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
{/* <TableHead className="text-gray-300 text-center">
|
||||||
|
{t("Yangiliklar soni")}
|
||||||
|
</TableHead> */}
|
||||||
<TableHead className="text-gray-300 text-right">
|
<TableHead className="text-gray-300 text-right">
|
||||||
Harakatlar
|
{t("Harakatlar")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{categories.length > 0 ? (
|
{data && data?.data.data.total_items > 0 ? (
|
||||||
categories.map((cat, index) => (
|
data?.data.data.results.map((cat, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
className="border-b border-gray-800 hover:bg-gray-800/40 transition-colors"
|
className="border-b border-gray-800 hover:bg-gray-800/40 transition-colors"
|
||||||
@@ -138,11 +243,11 @@ const NewsCategory = () => {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-center">
|
{/* <TableCell className="text-center">
|
||||||
<Badge variant="secondary" className="bg-gray-700">
|
<Badge variant="secondary" className="bg-gray-700">
|
||||||
{cat.count} ta
|
{cat.}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell> */}
|
||||||
|
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@@ -152,17 +257,17 @@ const NewsCategory = () => {
|
|||||||
className="border-gray-700 text-gray-200 hover:bg-gray-800"
|
className="border-gray-700 text-gray-200 hover:bg-gray-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
openDialog();
|
openDialog();
|
||||||
setEditItem(cat);
|
setEditItem(cat.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4 mr-1" /> Edit
|
<Edit className="w-4 h-4 mr-1" /> {t("Tahrirlash")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => handleDelete(cat.id)}
|
onClick={() => handleDelete(cat.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-1" /> O‘chirish
|
<Trash2 className="w-4 h-4 mr-1" /> {t("O'chirish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -174,18 +279,26 @@ const NewsCategory = () => {
|
|||||||
colSpan={4}
|
colSpan={4}
|
||||||
className="text-center py-8 text-gray-400"
|
className="text-center py-8 text-gray-400"
|
||||||
>
|
>
|
||||||
Hech qanday kategoriya topilmadi
|
{t("Hech qanday kategoriya topilmadi")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsDialogOpen(open);
|
||||||
|
setEditItem(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent className="bg-gray-900 border border-gray-700 text-gray-100">
|
<DialogContent className="bg-gray-900 border border-gray-700 text-gray-100">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{editItem ? "Kategoriya tahrirlash" : "Yangi kategoriya qo‘shish"}
|
{editItem
|
||||||
|
? t("Kategoriya tahrirlash")
|
||||||
|
: t("Yangi kategoriya qo‘shish")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -200,10 +313,29 @@ const NewsCategory = () => {
|
|||||||
name="title"
|
name="title"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Yangilik nomi</Label>
|
<Label className="text-md">{t("Kategoriya nomi")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Masalan: Yangi turistik joylar ochildi"
|
placeholder={t("Kategoriya nomi")}
|
||||||
|
{...field}
|
||||||
|
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label className="text-md">
|
||||||
|
{t("Kategoriya nomi")} (ru)
|
||||||
|
</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t("Kategoriya nomi")}
|
||||||
{...field}
|
{...field}
|
||||||
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
||||||
/>
|
/>
|
||||||
@@ -217,7 +349,7 @@ const NewsCategory = () => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
||||||
>
|
>
|
||||||
Saqlash
|
{t("Saqlash")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -225,6 +357,41 @@ const NewsCategory = () => {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<div className="flex justify-end gap-2 mt-10">
|
||||||
|
<button
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{[...Array(data?.data.data.total_pages)].map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrentPage(i + 1)}
|
||||||
|
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
||||||
|
currentPage === i + 1
|
||||||
|
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
||||||
|
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={currentPage === data?.data.data.total_pages}
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((p) =>
|
||||||
|
Math.min(p + 1, data ? data?.data.data.total_pages : 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getAllNewsCategory } from "@/pages/news/lib/api";
|
||||||
|
import { useNewsStore } from "@/pages/news/lib/data";
|
||||||
import { newsForm } from "@/pages/news/lib/form";
|
import { newsForm } from "@/pages/news/lib/form";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -20,52 +22,102 @@ import {
|
|||||||
} from "@/shared/ui/select";
|
} from "@/shared/ui/select";
|
||||||
import { Textarea } from "@/shared/ui/textarea";
|
import { Textarea } from "@/shared/ui/textarea";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import { type Dispatch, type SetStateAction } from "react";
|
import { type Dispatch, type SetStateAction, useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type z from "zod";
|
import type z from "zod";
|
||||||
|
|
||||||
const StepOne = ({
|
const StepOne = ({
|
||||||
setStep,
|
setStep,
|
||||||
isEditMode,
|
|
||||||
}: {
|
}: {
|
||||||
setStep: Dispatch<SetStateAction<number>>;
|
setStep: Dispatch<SetStateAction<number>>;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const categories = [
|
const { t } = useTranslation();
|
||||||
{ name: "Blog", id: "1" },
|
const { setStepOneData, stepOneData } = useNewsStore();
|
||||||
{ name: "Tours", id: "2" },
|
|
||||||
{ name: "News", id: "3" },
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||||
];
|
useInfiniteQuery({
|
||||||
|
queryKey: ["news_category"],
|
||||||
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
|
getAllNewsCategory({ page: pageParam, page_size: 5 }),
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const currentPage = lastPage.data.data.current_page;
|
||||||
|
const totalPages = lastPage.data.data.total_pages;
|
||||||
|
return currentPage < totalPages ? currentPage + 1 : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allCategories =
|
||||||
|
data?.pages.flatMap((page) => page.data.data.results) ?? [];
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof newsForm>>({
|
const form = useForm<z.infer<typeof newsForm>>({
|
||||||
resolver: zodResolver(newsForm),
|
resolver: zodResolver(newsForm),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: "",
|
title: stepOneData.title,
|
||||||
category: "",
|
category: stepOneData.category,
|
||||||
banner: "",
|
banner: stepOneData.banner,
|
||||||
desc: "",
|
desc: stepOneData.desc,
|
||||||
|
desc_ru: stepOneData.desc_ru,
|
||||||
|
title_ru: stepOneData.title_ru,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function onSubmit() {
|
// ✅ Haqiqiy scroll elementni topish va scroll eventni qo‘shish
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const viewport = document.querySelector(
|
||||||
|
"[data-radix-select-viewport]",
|
||||||
|
) as HTMLDivElement | null;
|
||||||
|
|
||||||
|
if (viewport) {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
||||||
|
if (
|
||||||
|
scrollHeight - scrollTop - clientHeight < 50 &&
|
||||||
|
hasNextPage &&
|
||||||
|
!isFetchingNextPage
|
||||||
|
) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
viewport.addEventListener("scroll", handleScroll);
|
||||||
|
clearInterval(interval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
viewport.removeEventListener("scroll", handleScroll);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof newsForm>) {
|
||||||
|
setStepOneData(values);
|
||||||
setStep(2);
|
setStep(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-6 bg-gray-900"
|
className="space-y-6 bg-gray-900"
|
||||||
>
|
>
|
||||||
|
{/* title */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="title"
|
name="title"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Yangilik nomi</Label>
|
<Label className="text-md">{t("Yangilik nomi")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Masalan: Yangi turistik joylar ochildi"
|
placeholder={t("Yangilik nomi")}
|
||||||
{...field}
|
{...field}
|
||||||
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
||||||
/>
|
/>
|
||||||
@@ -74,15 +126,35 @@ const StepOne = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label className="text-md">{t("Yangilik nomi")} (ru)</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t("Yangilik nomi") + " (ru)"}
|
||||||
|
{...field}
|
||||||
|
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* desc */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="desc"
|
name="desc"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Yangilik haqida</Label>
|
<Label className="text-md">{t("Yangilik haqida")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Yangilik haqida"
|
placeholder={t("Yangilik haqida")}
|
||||||
{...field}
|
{...field}
|
||||||
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
||||||
/>
|
/>
|
||||||
@@ -91,25 +163,54 @@ const StepOne = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="desc_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label className="text-md">{t("Yangilik haqida")} (ru)</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t("Yangilik haqida") + " (ru)"}
|
||||||
|
{...field}
|
||||||
|
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="category"
|
name="category"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Kategoriya</Label>
|
<Label className="text-md">{t("Kategoriya")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select onValueChange={field.onChange} value={field.value}>
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
<SelectTrigger className="w-full !h-12 bg-gray-800 border-gray-700 text-white">
|
<SelectTrigger className="w-full !h-12 bg-gray-800 border-gray-700 text-white">
|
||||||
<SelectValue placeholder="Kategoriya tanlang" />
|
<SelectValue placeholder={t("Kategoriya tanlang")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-gray-800 border-gray-700 text-white">
|
<SelectContent className="bg-gray-800 border-gray-700 text-white max-h-[180px] overflow-y-auto">
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>Kategoriyalar</SelectLabel>
|
<SelectLabel>{t("Kategoriyalar")}</SelectLabel>
|
||||||
{categories.map((cat) => (
|
{allCategories.map((cat) => (
|
||||||
<SelectItem key={cat.id} value={cat.id}>
|
<SelectItem key={cat.id} value={String(cat.id)}>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<div className="text-center py-2 text-gray-400 text-sm">
|
||||||
|
{t("Yuklanmoqda...")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasNextPage && allCategories.length > 0 && (
|
||||||
|
<div className="text-center py-2 text-gray-500 text-xs">
|
||||||
|
{t("Barcha kategoriyalar yuklandi")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -118,53 +219,59 @@ const StepOne = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Banner */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="banner"
|
name="banner"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Banner rasmi</Label>
|
<Label className="text-md">{t("Banner rasmi")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
id="license-files"
|
id="banner-file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
const url = URL.createObjectURL(file);
|
form.setValue("banner", file, { shouldValidate: true });
|
||||||
form.setValue("banner", url);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
htmlFor="license-files"
|
htmlFor="banner-file"
|
||||||
className="w-full border-2 border-dashed h-40 border-[#D3D3D3] flex flex-col items-center gap-2 justify-center py-4 rounded-2xl cursor-pointer"
|
className="w-full border-2 border-dashed h-40 border-gray-500 flex flex-col items-center gap-2 justify-center py-4 rounded-2xl cursor-pointer hover:bg-gray-800 transition"
|
||||||
>
|
>
|
||||||
<p className="font-semibold text-xl text-[#FFFF]">
|
<p className="font-semibold text-xl text-white">
|
||||||
Drag or select files
|
{t("Drag or select files")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#FFFF] text-sm">
|
<p className="text-gray-400 text-sm">
|
||||||
Drop files here or click to browse
|
{t("Drop files here or click to browse")}
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{form.watch("banner") && (
|
{/* ✅ Preview (URL.createObjectURL bilan) */}
|
||||||
<div className="relative size-24 rounded-md overflow-hidden border">
|
{form.watch("banner") instanceof File && (
|
||||||
|
<div className="relative w-32 h-32 rounded-md overflow-hidden border border-gray-600">
|
||||||
<img
|
<img
|
||||||
src={form.watch("banner")}
|
src={URL.createObjectURL(form.watch("banner"))}
|
||||||
alt={`Nanner`}
|
alt="Banner preview"
|
||||||
className="object-cover w-full h-full"
|
className="object-cover w-full h-full"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => form.setValue("banner", "")}
|
onClick={() =>
|
||||||
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white"
|
form.setValue("banner", null as any, {
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="absolute top-1 right-1 bg-black/70 rounded-full p-1 hover:bg-black/90"
|
||||||
>
|
>
|
||||||
<XIcon className="size-4 text-destructive" />
|
<XIcon className="size-4 text-red-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -174,12 +281,13 @@ const StepOne = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex justify-end">
|
<div className="w-full flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
||||||
>
|
>
|
||||||
{isEditMode ? "Yangilikni saqlash" : "Keyingisi"}
|
{t("Keyingisi")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { addNews } from "@/pages/news/lib/api";
|
||||||
|
import { useNewsStore } from "@/pages/news/lib/data";
|
||||||
|
import { newsPostForm, type NewsPostFormType } from "@/pages/news/lib/form";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -11,87 +15,123 @@ import { Input } from "@/shared/ui/input";
|
|||||||
import { Label } from "@/shared/ui/label";
|
import { Label } from "@/shared/ui/label";
|
||||||
import { Textarea } from "@/shared/ui/textarea";
|
import { Textarea } from "@/shared/ui/textarea";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PlusCircle, Trash2, XIcon } from "lucide-react";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import { ImagePlus, PlusCircle, Trash2 } from "lucide-react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import z from "zod";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const newsItemSchema = z.object({
|
const StepTwo = () => {
|
||||||
desc: z.string().min(2, {
|
const { t } = useTranslation();
|
||||||
message: "Yangilik matni kamida 2 belgidan iborat bo‘lishi kerak",
|
const navigate = useNavigate();
|
||||||
}),
|
const { stepOneData } = useNewsStore();
|
||||||
banner: z.string().min(1, { message: "Banner rasmi majburiy" }),
|
const queryClient = useQueryClient();
|
||||||
});
|
|
||||||
|
|
||||||
const newsListSchema = z.object({
|
const form = useForm<NewsPostFormType>({
|
||||||
items: z
|
resolver: zodResolver(newsPostForm),
|
||||||
.array(newsItemSchema)
|
|
||||||
.min(1, { message: "Kamida 1 ta yangilik kerak" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
type NewsFormType = z.infer<typeof newsListSchema>;
|
|
||||||
|
|
||||||
const StepTwo = ({
|
|
||||||
setStep,
|
|
||||||
isEditMode,
|
|
||||||
}: {
|
|
||||||
setStep: Dispatch<SetStateAction<number>>;
|
|
||||||
isEditMode: boolean;
|
|
||||||
}) => {
|
|
||||||
const form = useForm<NewsFormType>({
|
|
||||||
resolver: zodResolver(newsListSchema),
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
items: [{ desc: "", banner: "" }],
|
desc: "",
|
||||||
|
desc_ru: "",
|
||||||
|
is_public: "yes",
|
||||||
|
sections: [{ image: undefined as any, text: "", text_ru: "" }],
|
||||||
|
post_tags: [""],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: "items",
|
name: "sections",
|
||||||
});
|
});
|
||||||
const navigator = useNavigate();
|
const { watch, setValue } = form;
|
||||||
|
const postTags = watch("post_tags");
|
||||||
|
const addTag = () => setValue("post_tags", [...postTags, ""]);
|
||||||
|
const removeTag = (i: number) =>
|
||||||
|
setValue(
|
||||||
|
"post_tags",
|
||||||
|
postTags.filter((_, idx) => idx !== i),
|
||||||
|
);
|
||||||
|
|
||||||
function onSubmit() {
|
const { mutate: added } = useMutation({
|
||||||
navigator("/news");
|
mutationFn: (body: FormData) => addNews(body),
|
||||||
}
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_news"] });
|
||||||
|
navigate("/news");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleImageChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) form.setValue(`sections.${index}.image`, file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: NewsPostFormType) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append("title", stepOneData.title);
|
||||||
|
formData.append("title_ru", stepOneData.title_ru);
|
||||||
|
formData.append("image", stepOneData.banner ?? "");
|
||||||
|
formData.append("text", stepOneData.desc);
|
||||||
|
formData.append("text_ru", stepOneData.desc_ru);
|
||||||
|
formData.append("is_public", values.is_public === "no" ? "false" : "true");
|
||||||
|
formData.append("category", stepOneData.category);
|
||||||
|
|
||||||
|
values.sections.forEach((section, i) => {
|
||||||
|
formData.append(`post_images[${i}]`, section.image);
|
||||||
|
formData.append(`post_text[${i}]`, section.text);
|
||||||
|
formData.append(`post_text_ru[${i}]`, section.text_ru);
|
||||||
|
});
|
||||||
|
|
||||||
|
values.post_tags.forEach((tag, i) => {
|
||||||
|
formData.append(`post_tags[${i}]`, tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
added(formData);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8 bg-gray-900 p-6 rounded-2xl text-white"
|
className="space-y-6 bg-gray-900 p-6 rounded-2xl text-white"
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-semibold">Yangiliklar ro‘yxati</h2>
|
<Label className="text-lg">{t("Yangilik bo‘limlari")}</Label>
|
||||||
|
|
||||||
{fields.map((field, index) => (
|
{/* DESC (UZ) */}
|
||||||
<div
|
|
||||||
key={field.id}
|
|
||||||
className="relative border border-gray-700 bg-gray-800 rounded-xl p-4 space-y-4"
|
|
||||||
>
|
|
||||||
{/* O'chirish tugmasi */}
|
|
||||||
{fields.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
className="absolute top-3 right-3 text-red-400 hover:text-red-500"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DESC FIELD */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`items.${index}.desc`}
|
name="desc"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Yangilik haqida</Label>
|
<Label>{t("Qisqacha ta'rif (UZ)")}</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} placeholder={t("Qisqacha ta'rif")} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* DESC (RU) */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="desc_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Qisqacha ta'rif (RU)")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Yangilik haqida"
|
|
||||||
{...field}
|
{...field}
|
||||||
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
placeholder={t("Qisqacha ta'rif (rus tilida)")}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -99,92 +139,151 @@ const StepTwo = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* BANNER FIELD */}
|
<div className="space-y-4">
|
||||||
|
<Label>{t("Teglar")}</Label>
|
||||||
|
{postTags.map((__, i) => (
|
||||||
<FormField
|
<FormField
|
||||||
|
key={i}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`items.${index}.banner`}
|
name={`post_tags.${i}`}
|
||||||
render={() => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Banner rasmi</Label>
|
<div className="relative">
|
||||||
<FormControl>
|
{postTags.length > 1 && (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<button
|
||||||
<Input
|
type="button"
|
||||||
type="file"
|
onClick={() => removeTag(i)}
|
||||||
id={`file-${index}`}
|
className="absolute top-1 right-1 text-red-400 hover:text-red-500"
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
form.setValue(`items.${index}.banner`, url);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label
|
|
||||||
htmlFor={`file-${index}`}
|
|
||||||
className="w-full border-2 border-dashed h-40 border-gray-600 hover:border-gray-500 transition-all flex flex-col items-center gap-2 justify-center py-4 rounded-2xl cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<p className="font-semibold text-xl text-white">
|
<Trash2 className="size-4" />
|
||||||
Drag or select files
|
</button>
|
||||||
</p>
|
)}
|
||||||
<p className="text-gray-300 text-sm">
|
<FormControl>
|
||||||
Drop files here or click to browse
|
<Input {...field} placeholder={t("Masalan: sport")} />
|
||||||
</p>
|
</FormControl>
|
||||||
</label>
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addTag}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<PlusCircle className="size-5 mr-2" />
|
||||||
|
{t("Teg qo'shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{form.watch(`items.${index}.banner`) && (
|
{fields.map((field, index) => (
|
||||||
<div className="relative size-24 rounded-md overflow-hidden border border-gray-700">
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className="border border-gray-700 rounded-lg p-4 space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
{t("Bo‘lim")} #{index + 1}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
className="text-red-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<div>
|
||||||
|
<Label>{t("Rasm")}</Label>
|
||||||
|
{form.watch(`sections.${index}.image`) ? (
|
||||||
|
<div className="relative mt-2 w-48">
|
||||||
<img
|
<img
|
||||||
src={form.watch(`items.${index}.banner`)}
|
src={URL.createObjectURL(
|
||||||
alt="Banner preview"
|
form.watch(`sections.${index}.image`),
|
||||||
className="object-cover w-full h-full"
|
)}
|
||||||
|
alt="preview"
|
||||||
|
className="rounded-lg border border-gray-700 object-cover h-40 w-full"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
form.setValue(`items.${index}.banner`, "")
|
form.setValue(`sections.${index}.image`, undefined as any)
|
||||||
}
|
}
|
||||||
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white"
|
className="absolute top-1 right-1 bg-black/70 rounded-full p-1 hover:bg-black/90"
|
||||||
>
|
>
|
||||||
<XIcon className="size-4 text-destructive" />
|
<Trash2 className="size-4 text-red-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
id={`section-img-${index}`}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleImageChange(e, index)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`section-img-${index}`}
|
||||||
|
className="inline-flex items-center cursor-pointer bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg mt-2"
|
||||||
|
>
|
||||||
|
<ImagePlus className="size-5 mr-2" />
|
||||||
|
{t("Rasm tanlash")}
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Text (UZ) */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`sections.${index}.text`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Matn (UZ)")}</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} placeholder={t("Matn kiriting")} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text (RU) */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`sections.${index}.text_ru`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Matn (RU)")}</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} placeholder={t("Matn (rus tilida)")} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
append({ image: undefined as any, text: "", text_ru: "" })
|
||||||
|
}
|
||||||
|
className="bg-gray-700 hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
<PlusCircle className="size-5 mr-2" />
|
||||||
|
{t("Bo‘lim qo‘shish")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => append({ desc: "", banner: "" })}
|
|
||||||
className="flex items-center px-6 py-5 text-lg gap-2 bg-gray-600 hover:bg-gray-700 text-white cursor-pointer"
|
|
||||||
>
|
|
||||||
<PlusCircle className="size-5" />
|
|
||||||
Qo‘shish
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigatsiya tugmalari */}
|
|
||||||
<div className="w-full flex justify-between pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setStep(1)}
|
|
||||||
className="bg-gray-600 hover:bg-gray-700 text-white"
|
|
||||||
>
|
|
||||||
Orqaga
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
className="mt-6 px-8 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 cursor-pointer"
|
||||||
>
|
>
|
||||||
{isEditMode ? "Yangiliklarni saqlash" : "Saqlash"}
|
{t("Saqlash")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
425
src/pages/tours/lib/api.ts
Normal file
425
src/pages/tours/lib/api.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import type {
|
||||||
|
CreateTourRes,
|
||||||
|
GetAllTours,
|
||||||
|
GetOneTours,
|
||||||
|
Hotel_Badge,
|
||||||
|
Hotel_BadgeId,
|
||||||
|
Hotel_Tarif,
|
||||||
|
Hotel_TarifId,
|
||||||
|
Hotel_TranportId,
|
||||||
|
Hotel_Transport,
|
||||||
|
Hotel_Type,
|
||||||
|
Hotel_TypeId,
|
||||||
|
HotelAllFeatures,
|
||||||
|
HotelAllFeaturesType,
|
||||||
|
HotelFeaturesDetail,
|
||||||
|
HotelFeaturesTypeDetail,
|
||||||
|
} from "@/pages/tours/lib/type";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import {
|
||||||
|
GET_TICKET,
|
||||||
|
HOTEL,
|
||||||
|
HOTEL_BADGE,
|
||||||
|
HOTEL_FEATURES,
|
||||||
|
HOTEL_FEATURES_TYPE,
|
||||||
|
HOTEL_TARIF,
|
||||||
|
HPTEL_TYPES,
|
||||||
|
TOUR_TRANSPORT,
|
||||||
|
} from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const getAllTours = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
}: {
|
||||||
|
page_size: number;
|
||||||
|
page: number;
|
||||||
|
}): Promise<AxiosResponse<GetAllTours>> => {
|
||||||
|
const response = await httpClient.get(GET_TICKET, {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOneTours = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<GetOneTours>> => {
|
||||||
|
const response = await httpClient.get(`${GET_TICKET}${id}`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTours = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: FormData;
|
||||||
|
}): Promise<AxiosResponse<CreateTourRes>> => {
|
||||||
|
const response = await httpClient.post(`${GET_TICKET}`, body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createHotel = async ({ body }: { body: FormData }) => {
|
||||||
|
const response = await httpClient.post(`${HOTEL}`, body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTours = async ({ id }: { id: number }) => {
|
||||||
|
const response = await httpClient.delete(`${GET_TICKET}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// htoel_badge api
|
||||||
|
const hotelBadge = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
}: {
|
||||||
|
page_size: number;
|
||||||
|
page: number;
|
||||||
|
}): Promise<AxiosResponse<Hotel_Badge>> => {
|
||||||
|
const response = await httpClient.get(HOTEL_BADGE, {
|
||||||
|
params: { page, page_size },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelBadgeCreate = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: { name: string; color: string; name_ru: string };
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.post(`${HOTEL_BADGE}`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelBadgeUpdate = async ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { name: string; color: string; name_ru: string };
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.patch(`${HOTEL_BADGE}${id}/`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelBadgeDetail = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<Hotel_BadgeId>> => {
|
||||||
|
const response = await httpClient.get(`${HOTEL_BADGE}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelBadgeDelete = async ({ id }: { id: number }) => {
|
||||||
|
const response = await httpClient.delete(`${HOTEL_BADGE}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// htoel_tarif api
|
||||||
|
const hotelTarif = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
}: {
|
||||||
|
page_size: number;
|
||||||
|
page: number;
|
||||||
|
}): Promise<AxiosResponse<Hotel_Tarif>> => {
|
||||||
|
const response = await httpClient.get(HOTEL_TARIF, {
|
||||||
|
params: { page, page_size },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTarifCreate = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.post(HOTEL_TARIF, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTarfiDetail = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<Hotel_TarifId>> => {
|
||||||
|
const response = await httpClient.get(`${HOTEL_TARIF}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTarifDelete = async ({ id }: { id: number }) => {
|
||||||
|
const response = await httpClient.delete(`${HOTEL_TARIF}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hoteltarifUpdate = async ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { name: string; name_ru: string };
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.patch(`${HOTEL_TARIF}${id}/`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// htoel_tarnsport api
|
||||||
|
const hotelTransport = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
}: {
|
||||||
|
page_size: number;
|
||||||
|
page: number;
|
||||||
|
}): Promise<AxiosResponse<Hotel_Transport>> => {
|
||||||
|
const response = await httpClient.get(TOUR_TRANSPORT, {
|
||||||
|
params: { page, page_size },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTranportCreate = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
icon_name: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.post(TOUR_TRANSPORT, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTransportDelete = async ({ id }: { id: number }) => {
|
||||||
|
const response = await httpClient.delete(`${TOUR_TRANSPORT}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTransportDetail = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<Hotel_TranportId>> => {
|
||||||
|
const response = await httpClient.get(`${TOUR_TRANSPORT}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTransportUpdate = async ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { name: string; name_ru: string; icon_name: string };
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.patch(`${TOUR_TRANSPORT}${id}/`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
//hotel_type
|
||||||
|
const hotelType = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
}: {
|
||||||
|
page_size: number;
|
||||||
|
page: number;
|
||||||
|
}): Promise<AxiosResponse<Hotel_Type>> => {
|
||||||
|
const response = await httpClient.get(HPTEL_TYPES, {
|
||||||
|
params: { page, page_size },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTypeCreate = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.post(HPTEL_TYPES, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTypeDelete = async ({ id }: { id: number }) => {
|
||||||
|
const response = await httpClient.delete(`${HPTEL_TYPES}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTypeDetail = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<Hotel_TypeId>> => {
|
||||||
|
const response = await httpClient.get(`${HPTEL_TYPES}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelTypeUpdate = async ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { name: string; name_ru: string };
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.patch(`${HPTEL_TYPES}${id}/`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// hotel_features
|
||||||
|
const hotelFeature = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
}: {
|
||||||
|
page_size: number;
|
||||||
|
page: number;
|
||||||
|
}): Promise<AxiosResponse<HotelAllFeatures>> => {
|
||||||
|
const response = await httpClient.get(HOTEL_FEATURES, {
|
||||||
|
params: { page, page_size },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelFeatureType = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
feature_type,
|
||||||
|
}: {
|
||||||
|
page_size: number;
|
||||||
|
page: number;
|
||||||
|
feature_type: number;
|
||||||
|
}): Promise<AxiosResponse<HotelAllFeaturesType>> => {
|
||||||
|
const response = await httpClient.get(HOTEL_FEATURES_TYPE, {
|
||||||
|
params: { page, page_size, feature_type },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelFeatureCreate = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
hotel_feature_type_name: string;
|
||||||
|
hotel_feature_type_name_ru: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.post(`${HOTEL_FEATURES}`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelFeatureTypeCreate = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
feature_name: string;
|
||||||
|
feature_name_ru: string;
|
||||||
|
feature_type: number;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.post(`${HOTEL_FEATURES_TYPE}`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelFeatureUpdate = async ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { hotel_feature_type_name: string; hotel_feature_type_name_ru: string };
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.patch(`${HOTEL_FEATURES}${id}/`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelFeatureTypeUpdate = async ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { feature_name: string; feature_name_ru: string };
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.patch(`${HOTEL_FEATURES_TYPE}${id}/`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelFeatureDetail = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<HotelFeaturesDetail>> => {
|
||||||
|
const response = await httpClient.get(`${HOTEL_FEATURES}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelFeatureTypeDetail = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<HotelFeaturesTypeDetail>> => {
|
||||||
|
const response = await httpClient.get(`${HOTEL_FEATURES_TYPE}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelFeatureDelete = async ({ id }: { id: number }) => {
|
||||||
|
const response = await httpClient.delete(`${HOTEL_FEATURES}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hotelFeatureTypeDelete = async ({ id }: { id: number }) => {
|
||||||
|
const response = await httpClient.delete(`${HOTEL_FEATURES_TYPE}${id}/`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
createHotel,
|
||||||
|
createTours,
|
||||||
|
deleteTours,
|
||||||
|
getAllTours,
|
||||||
|
getOneTours,
|
||||||
|
hotelBadge,
|
||||||
|
hotelBadgeCreate,
|
||||||
|
hotelBadgeDelete,
|
||||||
|
hotelBadgeDetail,
|
||||||
|
hotelBadgeUpdate,
|
||||||
|
hotelFeature,
|
||||||
|
hotelFeatureCreate,
|
||||||
|
hotelFeatureDelete,
|
||||||
|
hotelFeatureDetail,
|
||||||
|
hotelFeatureType,
|
||||||
|
hotelFeatureTypeCreate,
|
||||||
|
hotelFeatureTypeDelete,
|
||||||
|
hotelFeatureTypeDetail,
|
||||||
|
hotelFeatureTypeUpdate,
|
||||||
|
hotelFeatureUpdate,
|
||||||
|
hotelTarfiDetail,
|
||||||
|
hotelTarif,
|
||||||
|
hotelTarifCreate,
|
||||||
|
hotelTarifDelete,
|
||||||
|
hoteltarifUpdate,
|
||||||
|
hotelTranportCreate,
|
||||||
|
hotelTransport,
|
||||||
|
hotelTransportDelete,
|
||||||
|
hotelTransportDetail,
|
||||||
|
hotelTransportUpdate,
|
||||||
|
hotelType,
|
||||||
|
hotelTypeCreate,
|
||||||
|
hotelTypeDelete,
|
||||||
|
hotelTypeDetail,
|
||||||
|
hotelTypeUpdate,
|
||||||
|
};
|
||||||
307
src/pages/tours/lib/column.tsx
Normal file
307
src/pages/tours/lib/column.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Badge,
|
||||||
|
HotelFeatures,
|
||||||
|
HotelFeaturesType,
|
||||||
|
Tarif,
|
||||||
|
Transport,
|
||||||
|
Type,
|
||||||
|
} from "@/pages/tours/lib/type";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { type ColumnDef } from "@tanstack/react-table";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const BadgesColumns = (
|
||||||
|
onEdit: (id: number) => void,
|
||||||
|
onDelete: (id: number) => void,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): ColumnDef<Badge>[] => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
cell: ({ row }) => <span>{row.original.id}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("Nomi"),
|
||||||
|
cell: ({ row }) => <span>{row.original.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "color",
|
||||||
|
header: t("Rang"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-5 h-5 rounded-full border border-gray-600"
|
||||||
|
style={{ backgroundColor: row.original.color }}
|
||||||
|
></span>
|
||||||
|
<span>{row.original.color}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="text-right">{t("Harakatlar")}</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{" "}
|
||||||
|
{/* o‘ngga tekislangan tugmalar */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onEdit(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDelete(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("O'chirish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TarifColumns = (
|
||||||
|
onEdit: (id: number) => void,
|
||||||
|
onDelete: (id: number) => void,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): ColumnDef<Tarif>[] => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
cell: ({ row }) => <span>{row.original.id}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("Nomi"),
|
||||||
|
cell: ({ row }) => <span>{row.original.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="text-right">{t("Harakatlar")}</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
onClick={() => onEdit(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDelete(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("O'chirish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TranportColumns = (
|
||||||
|
onEdit: (id: number) => void,
|
||||||
|
onDelete: (id: number) => void,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): ColumnDef<Transport>[] => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
cell: ({ row }) => <span>{row.original.id}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("Nomi"),
|
||||||
|
cell: ({ row }) => <span>{row.original.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="text-right">{t("Harakatlar")}</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onEdit(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDelete(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("O'chirish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TypeColumns = (
|
||||||
|
onEdit: (id: number) => void,
|
||||||
|
onDelete: (id: number) => void,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): ColumnDef<Type>[] => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
cell: ({ row }) => <span>{row.original.id}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("Nomi"),
|
||||||
|
cell: ({ row }) => <span>{row.original.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="text-right">{t("Harakatlar")}</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onEdit(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDelete(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("O'chirish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FeatureColumns = (
|
||||||
|
onEdit: (id: number) => void,
|
||||||
|
onDelete: (id: number) => void,
|
||||||
|
t: (key: string) => string,
|
||||||
|
setActiveTab: Dispatch<SetStateAction<string>>,
|
||||||
|
setFeatureId: Dispatch<SetStateAction<number | null>>,
|
||||||
|
): ColumnDef<HotelFeatures>[] => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
cell: ({ row }) => <span>{row.original.id}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("Nomi"),
|
||||||
|
cell: ({ row }) => <span>{row.original.hotel_feature_type_name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: () => <div>{t("Kategoriya nomi")} (ru)</div>,
|
||||||
|
cell: ({ row }) => <span>{row.original.hotel_feature_type_name_ru}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="text-right">{t("Harakatlar")}</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("feature_type");
|
||||||
|
setFeatureId(row.original.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Ko'rish")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onEdit(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDelete(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("O'chirish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FeatureTypeColumns = (
|
||||||
|
onEdit: (id: number) => void,
|
||||||
|
onDelete: (id: number) => void,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): ColumnDef<HotelFeaturesType>[] => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
cell: ({ row }) => <span>{row.original.id}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("Nomi"),
|
||||||
|
cell: ({ row }) => <span>{row.original.feature_name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: () => <div>{t("Kategoriya nomi")}</div>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span>{row.original.feature_type.hotel_feature_type_name}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="text-right">{t("Harakatlar")}</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onEdit(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDelete(row.original.id)}
|
||||||
|
>
|
||||||
|
{t("O'chirish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
188
src/pages/tours/lib/form.ts
Normal file
188
src/pages/tours/lib/form.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const fileSchema = z.instanceof(File, { message: "Rasm faylini yuklang" });
|
||||||
|
|
||||||
|
export const TourformSchema = z.object({
|
||||||
|
title: z.string().min(2, {
|
||||||
|
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
|
||||||
|
}),
|
||||||
|
title_ru: z.string().min(2, {
|
||||||
|
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
|
||||||
|
}),
|
||||||
|
hotel_info: z.string().min(2, {
|
||||||
|
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
|
||||||
|
}),
|
||||||
|
hotel_info_ru: z.string(),
|
||||||
|
hotel_meals_info: z.string().min(2, {
|
||||||
|
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
|
||||||
|
}),
|
||||||
|
hotel_meals_info_ru: z.string(),
|
||||||
|
price: z.number().min(1000, {
|
||||||
|
message: "Narx kamida 1000 UZS bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
passenger_count: z.number().min(1, {
|
||||||
|
message: "Kamida 1 yo'lovchi bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
min_person: z.number().min(1, {
|
||||||
|
message: "Kamida 1 yo'lovchi bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
max_person: z.number().min(1, {
|
||||||
|
message: "Kamida 1 yo'lovchi bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
departure: z.string().min(2, {
|
||||||
|
message: "Ketish joyi eng kamida 2 ta belgidan iborat bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
departure_ru: z.string().min(2, {
|
||||||
|
message: "Ketish joyi eng kamida 2 ta belgidan iborat bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
destination: z.string().min(2, {
|
||||||
|
message: "Borish joyi eng kamida 2 ta belgidan iborat bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
destination_ru: z.string().min(2, {
|
||||||
|
message: "Borish joyi eng kamida 2 ta belgidan iborat bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
location_name: z.string().min(2, {
|
||||||
|
message: "Eng kamida 2 ta belgidan iborat bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
location_name_ru: z.string().min(2, {
|
||||||
|
message: "Eng kamida 2 ta belgidan iborat bo'lishi kerak.",
|
||||||
|
}),
|
||||||
|
visa_required: z
|
||||||
|
.enum(["no", "yes"], { message: "Iltimos, visa talabliligini tanlang" })
|
||||||
|
.refine((val) => val === "no" || val === "yes", {
|
||||||
|
message: "Iltimos, visa talabliligini tanlang",
|
||||||
|
}),
|
||||||
|
departureDateTime: z.object({
|
||||||
|
date: z.date({ message: "Jo‘nash vaqti majburiy" }),
|
||||||
|
time: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Jo‘nash vaqti majburiy" })
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
// HH:MM:SS formatini tekshirish
|
||||||
|
const parts = val.split(":");
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
|
||||||
|
const [hour, minute, second] = parts.map(Number);
|
||||||
|
return (
|
||||||
|
!isNaN(hour) &&
|
||||||
|
!isNaN(minute) &&
|
||||||
|
!isNaN(second) &&
|
||||||
|
hour >= 0 &&
|
||||||
|
hour <= 23 &&
|
||||||
|
minute >= 0 &&
|
||||||
|
minute <= 59 &&
|
||||||
|
second >= 0 &&
|
||||||
|
second <= 59
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ message: "Yaroqli vaqt kiriting (masalan, 08:30:00)" },
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
travelDateTime: z.object({
|
||||||
|
date: z.date({ message: "Jo‘nash vaqti majburiy" }),
|
||||||
|
time: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Jo‘nash vaqti majburiy" })
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
// HH:MM:SS formatini tekshirish
|
||||||
|
const parts = val.split(":");
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
|
||||||
|
const [hour, minute, second] = parts.map(Number);
|
||||||
|
return (
|
||||||
|
!isNaN(hour) &&
|
||||||
|
!isNaN(minute) &&
|
||||||
|
!isNaN(second) &&
|
||||||
|
hour >= 0 &&
|
||||||
|
hour <= 23 &&
|
||||||
|
minute >= 0 &&
|
||||||
|
minute <= 59 &&
|
||||||
|
second >= 0 &&
|
||||||
|
second <= 59
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ message: "Yaroqli vaqt kiriting (masalan, 08:30:00)" },
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
languages: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
duration: z.number().min(1, { message: "Kamida 1kun bo'lishi kerak" }),
|
||||||
|
badges: z.array(z.number()).optional(),
|
||||||
|
tarif: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
tariff: z.number().min(1, { message: "Transport ID majburiy" }),
|
||||||
|
price: z
|
||||||
|
.number()
|
||||||
|
.min(1000, { message: "Narx kamida 1000 UZS bo'lishi kerak" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, { message: "Kamida bitta transport tanlang." }),
|
||||||
|
transport: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
transport: z.number().min(1, { message: "Transport ID majburiy" }),
|
||||||
|
price: z
|
||||||
|
.number()
|
||||||
|
.min(1000, { message: "Narx kamida 1000 UZS bo'lishi kerak" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, { message: "Kamida bitta transport tanlang." }),
|
||||||
|
banner: z.any().nullable(),
|
||||||
|
images: z
|
||||||
|
.array(z.union([z.instanceof(File), z.string()]))
|
||||||
|
.min(1, { message: "Kamida bitta rasm yuklang." }),
|
||||||
|
amenities: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1, { message: "Majburoy maydon" }),
|
||||||
|
name_ru: z.string().min(1, { message: "Majburoy maydon" }),
|
||||||
|
icon_name: z.string().min(1, { message: "Majburoy maydon" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, { message: "Kamida bitta qulaylik kiriting." }),
|
||||||
|
hotel_services: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
image: z.any().nullable(),
|
||||||
|
title: z.string().min(1, "Xizmat nomi majburiy"),
|
||||||
|
title_ru: z.string().min(1, { message: "Majburoy maydon" }),
|
||||||
|
description: z.string().min(1, "Tavsif majburiy"),
|
||||||
|
desc_ru: z.string().min(1, { message: "Majburoy maydon" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, { message: "Kamida bitta xizmat kiriting." }),
|
||||||
|
hotel_meals: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
image: z.any().nullable(),
|
||||||
|
title: z.string().min(1, "Xizmat nomi majburiy"),
|
||||||
|
title_ru: z.string().min(1, "Majburiy maydon"),
|
||||||
|
description: z.string().min(1, "Tavsif majburiy"),
|
||||||
|
desc_ru: z.string().min(1, "Majburiy maydon"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, { message: "Kamida bitta xizmat kiriting." }),
|
||||||
|
|
||||||
|
ticket_itinerary: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
ticket_itinerary_image: z.array(
|
||||||
|
z.object({
|
||||||
|
image: fileSchema,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
title: z.string().min(1, "Sarlavha majburiy"),
|
||||||
|
title_ru: z.string().min(1, "Sarlavha (RU) majburiy"),
|
||||||
|
duration: z.number().min(1, { message: "Kamida 1 kun bo'lishi kerak" }),
|
||||||
|
ticket_itinerary_destinations: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, { message: "Kamida bitta xizmat kiriting." }),
|
||||||
|
});
|
||||||
36
src/pages/tours/lib/store.ts
Normal file
36
src/pages/tours/lib/store.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface Amenity {
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
icon_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TicketStore {
|
||||||
|
amenities: Amenity[];
|
||||||
|
id: number | null;
|
||||||
|
setId: (id: number) => void;
|
||||||
|
setAmenities: (amenities: Amenity[]) => void;
|
||||||
|
addAmenity: (amenity: Amenity) => void;
|
||||||
|
removeAmenity: (index: number) => void;
|
||||||
|
updateAmenity: (index: number, updated: Partial<Amenity>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTicketStore = create<TicketStore>((set) => ({
|
||||||
|
amenities: [],
|
||||||
|
id: null,
|
||||||
|
setId: (id) => set({ id }),
|
||||||
|
setAmenities: (amenities) => set({ amenities }),
|
||||||
|
addAmenity: (amenity) =>
|
||||||
|
set((state) => ({ amenities: [...state.amenities, amenity] })),
|
||||||
|
removeAmenity: (index) =>
|
||||||
|
set((state) => ({
|
||||||
|
amenities: state.amenities.filter((_, i) => i !== index),
|
||||||
|
})),
|
||||||
|
updateAmenity: (index, updated) =>
|
||||||
|
set((state) => ({
|
||||||
|
amenities: state.amenities.map((a, i) =>
|
||||||
|
i === index ? { ...a, ...updated } : a,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
302
src/pages/tours/lib/type.ts
Normal file
302
src/pages/tours/lib/type.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
export interface GetAllTours {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: {
|
||||||
|
id: number;
|
||||||
|
destination: string;
|
||||||
|
duration_days: number;
|
||||||
|
hotel_name: string;
|
||||||
|
price: number;
|
||||||
|
hotel_rating: number;
|
||||||
|
hotel_aminates: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetOneTours {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: 0;
|
||||||
|
hotel_name: string;
|
||||||
|
hotel_rating: string;
|
||||||
|
hotel_amenities: string;
|
||||||
|
title: string;
|
||||||
|
price: number;
|
||||||
|
min_person: number;
|
||||||
|
max_person: number;
|
||||||
|
departure: string;
|
||||||
|
destination: string;
|
||||||
|
departure_time: string;
|
||||||
|
travel_time: string;
|
||||||
|
location_name: string;
|
||||||
|
passenger_count: number;
|
||||||
|
languages: string;
|
||||||
|
hotel_info: string;
|
||||||
|
duration_days: number;
|
||||||
|
rating: number;
|
||||||
|
hotel_meals: string;
|
||||||
|
slug: string;
|
||||||
|
visa_required: true;
|
||||||
|
image_banner: string;
|
||||||
|
badge: number[];
|
||||||
|
transports: [
|
||||||
|
{
|
||||||
|
price: number;
|
||||||
|
full_info: {
|
||||||
|
name: string;
|
||||||
|
icon_name: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ticket_images: [
|
||||||
|
{
|
||||||
|
image: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ticket_amenities: [
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
icon_name: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ticket_included_services: [
|
||||||
|
{
|
||||||
|
image: string;
|
||||||
|
title: string;
|
||||||
|
title_ru: string;
|
||||||
|
desc: string;
|
||||||
|
desc_uz: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ticket_itinerary: [
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
title_ru: string;
|
||||||
|
duration: number;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ticket_hotel_meals: [
|
||||||
|
{
|
||||||
|
image: string;
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
desc: string;
|
||||||
|
desc_ru: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTourRes {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
hotel_name: string;
|
||||||
|
hotel_rating: string;
|
||||||
|
hotel_amenities: string;
|
||||||
|
title: string;
|
||||||
|
title_ru: string;
|
||||||
|
price: number;
|
||||||
|
min_person: number;
|
||||||
|
max_person: number;
|
||||||
|
departure: string;
|
||||||
|
departure_ru: string;
|
||||||
|
destination: string;
|
||||||
|
destination_ru: string;
|
||||||
|
departure_time: string;
|
||||||
|
travel_time: string;
|
||||||
|
location_name: string;
|
||||||
|
location_name_ru: string;
|
||||||
|
passenger_count: number;
|
||||||
|
languages: string;
|
||||||
|
hotel_info: string;
|
||||||
|
hotel_info_ru: string;
|
||||||
|
duration_days: number;
|
||||||
|
rating: number;
|
||||||
|
hotel_meals: string;
|
||||||
|
hotel_meals_ru: string;
|
||||||
|
slug: string;
|
||||||
|
visa_required: true;
|
||||||
|
image_banner: string;
|
||||||
|
badge: number[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hotel_Badge {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: Badge[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hotel_BadgeId {
|
||||||
|
status: boolean;
|
||||||
|
data: Badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Badge {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hotel_Tarif {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: Tarif[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hotel_TarifId {
|
||||||
|
status: boolean;
|
||||||
|
data: Tarif;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tarif {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hotel_Transport {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: Transport[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hotel_TranportId {
|
||||||
|
status: boolean;
|
||||||
|
data: Transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transport {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
icon_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hotel_Type {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: Type[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hotel_TypeId {
|
||||||
|
status: boolean;
|
||||||
|
data: Type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Type {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
name_ru: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotelAllFeatures {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: HotelFeatures[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotelAllFeaturesType {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: HotelFeaturesType[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface HotelFeaturesDetail {
|
||||||
|
status: boolean;
|
||||||
|
data: HotelFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotelFeatures {
|
||||||
|
id: number;
|
||||||
|
hotel_feature_type_name: string;
|
||||||
|
hotel_feature_type_name_ru: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotelFeaturesType {
|
||||||
|
id: number;
|
||||||
|
feature_name: string;
|
||||||
|
feature_name_ru: string;
|
||||||
|
feature_type: {
|
||||||
|
id: number;
|
||||||
|
hotel_feature_type_name: string;
|
||||||
|
hotel_feature_type_name_ru: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotelFeaturesTypeDetail {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
feature_name: string;
|
||||||
|
feature_name_ru: string;
|
||||||
|
feature_type: {
|
||||||
|
id: number;
|
||||||
|
hotel_feature_type_name: string;
|
||||||
|
hotel_feature_type_name_ru: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
353
src/pages/tours/ui/BadgeTable.tsx
Normal file
353
src/pages/tours/ui/BadgeTable.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import {
|
||||||
|
hotelBadgeCreate,
|
||||||
|
hotelBadgeDelete,
|
||||||
|
hotelBadgeDetail,
|
||||||
|
hotelBadgeUpdate,
|
||||||
|
} from "@/pages/tours/lib/api";
|
||||||
|
import { BadgesColumns } from "@/pages/tours/lib/column";
|
||||||
|
import type { Badge } from "@/pages/tours/lib/type";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/shared/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/ui/table";
|
||||||
|
import RealPagination from "@/widgets/real-pagination/ui/RealPagination";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Loader, PlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
color: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BadgeTable = ({
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
data:
|
||||||
|
| {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: Badge[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [types, setTypes] = useState<"edit" | "create">("create");
|
||||||
|
|
||||||
|
const handleEdit = (id: number) => {
|
||||||
|
setTypes("edit");
|
||||||
|
setOpen(true);
|
||||||
|
setEditId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: badgeDetail } = useQuery({
|
||||||
|
queryKey: ["detail_badge", editId],
|
||||||
|
queryFn: () => hotelBadgeDetail({ id: editId! }),
|
||||||
|
enabled: !!editId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteMutate } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) => hotelBadgeDelete({ id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_badge"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_badge"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
deleteMutate({ id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = BadgesColumns(handleEdit, handleDelete, t);
|
||||||
|
|
||||||
|
const { mutate: create, isPending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: { name: string; color: string; name_ru: string };
|
||||||
|
}) => hotelBadgeCreate({ body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_badge"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_badge"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: update, isPending: updatePending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { name: string; color: string; name_ru: string };
|
||||||
|
}) => hotelBadgeUpdate({ body, id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_badge"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_badge"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (badgeDetail) {
|
||||||
|
form.setValue("color", badgeDetail.data.data.color);
|
||||||
|
form.setValue("name", badgeDetail.data.data.name);
|
||||||
|
form.setValue("name_ru", badgeDetail.data.data.name_ru);
|
||||||
|
}
|
||||||
|
}, [editId, badgeDetail]);
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
if (types === "create") {
|
||||||
|
create({
|
||||||
|
body: {
|
||||||
|
color: values.color,
|
||||||
|
name: values.name,
|
||||||
|
name_ru: values.name_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (types === "edit" && editId) {
|
||||||
|
update({
|
||||||
|
id: editId,
|
||||||
|
body: {
|
||||||
|
color: values.color,
|
||||||
|
name: values.name,
|
||||||
|
name_ru: values.name_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: data?.results ?? [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: data?.total_pages ?? 0,
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: page - 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setTypes("create");
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-2" />
|
||||||
|
{t("Qo‘shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-gray-700 rounded-md overflow-hidden mt-4">
|
||||||
|
<Table key={data?.current_page}>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center text-gray-400"
|
||||||
|
>
|
||||||
|
{t("Ma'lumot topilmadi")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RealPagination table={table} totalPages={data?.total_pages} />
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<p className="text-xl">
|
||||||
|
{types === "create" ? t("Saqlash") : t("Tahrirlash")}
|
||||||
|
</p>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6 p-2"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi (ru)")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="color"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Rang")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
{...field}
|
||||||
|
className="h-12 p-1 cursor-pointer rounded-md border border-gray-400"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{field.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{isPending || updatePending ? (
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
) : types === "create" ? (
|
||||||
|
t("Saqlash")
|
||||||
|
) : (
|
||||||
|
t("Tahrirlash")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BadgeTable;
|
||||||
@@ -1,15 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getOneTours } from "@/pages/tours/lib/api";
|
||||||
import StepOne from "@/pages/tours/ui/StepOne";
|
import StepOne from "@/pages/tours/ui/StepOne";
|
||||||
import StepTwo from "@/pages/tours/ui/StepTwo";
|
import StepTwo from "@/pages/tours/ui/StepTwo";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Hotel, Plane } from "lucide-react";
|
import { Hotel, Plane } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
const CreateEditTour = () => {
|
const CreateEditTour = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
const isEditMode = useMemo(() => !!id, [id]);
|
const isEditMode = useMemo(() => !!id, [id]);
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["tours_detail", id],
|
||||||
|
queryFn: () => {
|
||||||
|
return getOneTours({ id: Number(id) });
|
||||||
|
},
|
||||||
|
select(data) {
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 w-full mx-auto bg-gray-900">
|
<div className="p-8 w-full mx-auto bg-gray-900">
|
||||||
@@ -21,16 +34,18 @@ const CreateEditTour = () => {
|
|||||||
<div
|
<div
|
||||||
className={`flex-1 text-center py-2 rounded-l-lg ${step === 1 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
className={`flex-1 text-center py-2 rounded-l-lg ${step === 1 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||||
>
|
>
|
||||||
1. Tur ma'lumotlari <Plane className="w-5 h-5 inline ml-2" />
|
1. {t("Tur ma'lumotlari")} <Plane className="w-5 h-5 inline ml-2" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`flex-1 text-center py-2 rounded-r-lg ${step === 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
className={`flex-1 text-center py-2 rounded-r-lg ${step === 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border"}`}
|
||||||
>
|
>
|
||||||
2. Mehmonxona <Hotel className="w-5 h-5 inline ml-2" />
|
2. {t("Mehmonxona")} <Hotel className="w-5 h-5 inline ml-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{step === 1 && <StepOne setStep={setStep} />}
|
{step === 1 && (
|
||||||
{step === 2 && <StepTwo setStep={setStep} />}
|
<StepOne setStep={setStep} data={data} isEditMode={isEditMode} />
|
||||||
|
)}
|
||||||
|
{step === 2 && <StepTwo data={data} isEditMode={isEditMode} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
351
src/pages/tours/ui/FeaturesTable.tsx
Normal file
351
src/pages/tours/ui/FeaturesTable.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import {
|
||||||
|
hotelFeatureCreate,
|
||||||
|
hotelFeatureDelete,
|
||||||
|
hotelFeatureDetail,
|
||||||
|
hotelFeatureUpdate,
|
||||||
|
} from "@/pages/tours/lib/api";
|
||||||
|
import { FeatureColumns } from "@/pages/tours/lib/column";
|
||||||
|
import type { HotelFeatures } from "@/pages/tours/lib/type";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/shared/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/ui/table";
|
||||||
|
import RealPagination from "@/widgets/real-pagination/ui/RealPagination";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Loader, PlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FeaturesTable = ({
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
setActiveTab,
|
||||||
|
setFeatureId,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
setActiveTab: Dispatch<SetStateAction<string>>;
|
||||||
|
setFeatureId: Dispatch<SetStateAction<number | null>>;
|
||||||
|
data:
|
||||||
|
| {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: HotelFeatures[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [types, setTypes] = useState<"edit" | "create">("create");
|
||||||
|
|
||||||
|
const handleEdit = (id: number) => {
|
||||||
|
setTypes("edit");
|
||||||
|
setOpen(true);
|
||||||
|
setEditId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: badgeDetail } = useQuery({
|
||||||
|
queryKey: ["detail_feature", editId],
|
||||||
|
queryFn: () => hotelFeatureDetail({ id: editId! }),
|
||||||
|
enabled: !!editId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteMutate } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) => hotelFeatureDelete({ id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_feature"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_feature"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
deleteMutate({ id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = FeatureColumns(
|
||||||
|
handleEdit,
|
||||||
|
handleDelete,
|
||||||
|
t,
|
||||||
|
setActiveTab,
|
||||||
|
setFeatureId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: create, isPending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
hotel_feature_type_name: string;
|
||||||
|
hotel_feature_type_name_ru: string;
|
||||||
|
};
|
||||||
|
}) => hotelFeatureCreate({ body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_feature"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_feature"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: update, isPending: updatePending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
hotel_feature_type_name: string;
|
||||||
|
hotel_feature_type_name_ru: string;
|
||||||
|
};
|
||||||
|
}) => hotelFeatureUpdate({ body, id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_feature"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_feature"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
name_ru: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (badgeDetail) {
|
||||||
|
form.setValue("name", badgeDetail.data.data.hotel_feature_type_name);
|
||||||
|
form.setValue(
|
||||||
|
"name_ru",
|
||||||
|
badgeDetail.data.data.hotel_feature_type_name_ru,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [editId, badgeDetail]);
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
if (types === "create") {
|
||||||
|
create({
|
||||||
|
body: {
|
||||||
|
hotel_feature_type_name: values.name,
|
||||||
|
hotel_feature_type_name_ru: values.name_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (types === "edit" && editId) {
|
||||||
|
update({
|
||||||
|
id: editId,
|
||||||
|
body: {
|
||||||
|
hotel_feature_type_name: values.name,
|
||||||
|
hotel_feature_type_name_ru: values.name_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: data?.results ?? [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: data?.total_pages ?? 0,
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: page - 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setTypes("create");
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-2" />
|
||||||
|
{t("Qo‘shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-gray-700 rounded-md overflow-hidden mt-4">
|
||||||
|
<Table key={data?.current_page}>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center text-gray-400"
|
||||||
|
>
|
||||||
|
{t("Ma'lumot topilmadi")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RealPagination
|
||||||
|
table={table}
|
||||||
|
totalPages={data?.total_pages}
|
||||||
|
namePage="pageFeature"
|
||||||
|
namePageSize="pageSizeFeature"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<p className="text-xl">
|
||||||
|
{types === "create" ? t("Saqlash") : t("Tahrirlash")}
|
||||||
|
</p>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6 p-2"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi (ru)")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{isPending || updatePending ? (
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
) : types === "create" ? (
|
||||||
|
t("Saqlash")
|
||||||
|
) : (
|
||||||
|
t("Tahrirlash")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesTable;
|
||||||
342
src/pages/tours/ui/FeaturesTableType.tsx
Normal file
342
src/pages/tours/ui/FeaturesTableType.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import {
|
||||||
|
hotelFeatureTypeCreate,
|
||||||
|
hotelFeatureTypeDelete,
|
||||||
|
hotelFeatureTypeDetail,
|
||||||
|
hotelFeatureTypeUpdate,
|
||||||
|
} from "@/pages/tours/lib/api";
|
||||||
|
import { FeatureTypeColumns } from "@/pages/tours/lib/column";
|
||||||
|
import type { HotelFeaturesType } from "@/pages/tours/lib/type";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/shared/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/ui/table";
|
||||||
|
import RealPagination from "@/widgets/real-pagination/ui/RealPagination";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Loader, PlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FeaturesTableType = ({
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
featureId,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
featureId: number | null;
|
||||||
|
pageSize: number;
|
||||||
|
data:
|
||||||
|
| {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: HotelFeaturesType[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [types, setTypes] = useState<"edit" | "create">("create");
|
||||||
|
|
||||||
|
const handleEdit = (id: number) => {
|
||||||
|
setTypes("edit");
|
||||||
|
setOpen(true);
|
||||||
|
setEditId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: badgeDetail } = useQuery({
|
||||||
|
queryKey: ["detail_feature_type", editId],
|
||||||
|
queryFn: () => hotelFeatureTypeDetail({ id: editId! }),
|
||||||
|
enabled: !!editId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteMutate } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) => hotelFeatureTypeDelete({ id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_feature_type"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_feature_type"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
deleteMutate({ id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = FeatureTypeColumns(handleEdit, handleDelete, t);
|
||||||
|
|
||||||
|
const { mutate: create, isPending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
feature_name: string;
|
||||||
|
feature_name_ru: string;
|
||||||
|
feature_type: number;
|
||||||
|
};
|
||||||
|
}) => hotelFeatureTypeCreate({ body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_feature_type"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_feature_type"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: update, isPending: updatePending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
feature_name: string;
|
||||||
|
feature_name_ru: string;
|
||||||
|
};
|
||||||
|
}) => hotelFeatureTypeUpdate({ body, id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_feature_type"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_feature_type"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
name_ru: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (badgeDetail) {
|
||||||
|
form.setValue("name", badgeDetail.data.data.feature_name);
|
||||||
|
form.setValue("name_ru", badgeDetail.data.data.feature_name_ru);
|
||||||
|
}
|
||||||
|
}, [editId, badgeDetail]);
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
if (types === "create") {
|
||||||
|
create({
|
||||||
|
body: {
|
||||||
|
feature_name: values.name,
|
||||||
|
feature_name_ru: values.name_ru,
|
||||||
|
feature_type: featureId!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (types === "edit" && editId) {
|
||||||
|
update({
|
||||||
|
id: editId,
|
||||||
|
body: {
|
||||||
|
feature_name: values.name,
|
||||||
|
feature_name_ru: values.name_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: data?.results ?? [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: data?.total_pages ?? 0,
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: page - 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setTypes("create");
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-2" />
|
||||||
|
{t("Qo‘shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-gray-700 rounded-md overflow-hidden mt-4">
|
||||||
|
<Table key={data?.current_page}>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center text-gray-400"
|
||||||
|
>
|
||||||
|
{t("Ma'lumot topilmadi")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RealPagination
|
||||||
|
table={table}
|
||||||
|
totalPages={data?.total_pages}
|
||||||
|
namePage="pageFeature"
|
||||||
|
namePageSize="pageSizeFeature"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<p className="text-xl">
|
||||||
|
{types === "create" ? t("Saqlash") : t("Tahrirlash")}
|
||||||
|
</p>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6 p-2"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi (ru)")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{isPending || updatePending ? (
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
) : types === "create" ? (
|
||||||
|
t("Saqlash")
|
||||||
|
) : (
|
||||||
|
t("Tahrirlash")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesTableType;
|
||||||
324
src/pages/tours/ui/MealTable.tsx
Normal file
324
src/pages/tours/ui/MealTable.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import {
|
||||||
|
hotelTypeCreate,
|
||||||
|
hotelTypeDelete,
|
||||||
|
hotelTypeDetail,
|
||||||
|
hotelTypeUpdate,
|
||||||
|
} from "@/pages/tours/lib/api";
|
||||||
|
import { TypeColumns } from "@/pages/tours/lib/column";
|
||||||
|
import type { Type } from "@/pages/tours/lib/type";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/shared/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/ui/table";
|
||||||
|
import RealPagination from "@/widgets/real-pagination/ui/RealPagination";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Loader, PlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MealTable = ({
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
data:
|
||||||
|
| {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: Type[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [types, setTypes] = useState<"edit" | "create">("create");
|
||||||
|
|
||||||
|
const handleEdit = (id: number) => {
|
||||||
|
setTypes("edit");
|
||||||
|
setOpen(true);
|
||||||
|
setEditId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: typeDetail } = useQuery({
|
||||||
|
queryKey: ["detail_type", editId],
|
||||||
|
queryFn: () => hotelTypeDetail({ id: editId! }),
|
||||||
|
enabled: !!editId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteMutate } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) => hotelTypeDelete({ id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_type"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_type"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
deleteMutate({ id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = TypeColumns(handleEdit, handleDelete, t);
|
||||||
|
|
||||||
|
const { mutate: create, isPending } = useMutation({
|
||||||
|
mutationFn: ({ body }: { body: { name: string; name_ru: string } }) =>
|
||||||
|
hotelTypeCreate({ body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_type"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_type"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: update, isPending: updatePending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { name: string; name_ru: string };
|
||||||
|
}) => hotelTypeUpdate({ body, id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_type"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_type"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
name_ru: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeDetail) {
|
||||||
|
form.setValue("name", typeDetail.data.data.name);
|
||||||
|
form.setValue("name_ru", typeDetail.data.data.name_ru);
|
||||||
|
}
|
||||||
|
}, [editId, typeDetail]);
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
if (types === "create") {
|
||||||
|
create({
|
||||||
|
body: {
|
||||||
|
name: values.name,
|
||||||
|
name_ru: values.name_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (types === "edit" && editId) {
|
||||||
|
update({
|
||||||
|
id: editId,
|
||||||
|
body: {
|
||||||
|
name: values.name,
|
||||||
|
name_ru: values.name_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: data?.results ?? [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: data?.total_pages ?? 0,
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: page - 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setTypes("create");
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-2" />
|
||||||
|
{t("Qo‘shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-gray-700 rounded-md overflow-hidden mt-4">
|
||||||
|
<Table key={data?.current_page}>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center text-gray-400"
|
||||||
|
>
|
||||||
|
{t("Ma'lumot topilmadi")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RealPagination table={table} totalPages={data?.total_pages} />
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<p className="text-xl">
|
||||||
|
{types === "create" ? t("Saqlash") : t("Tahrirlash")}
|
||||||
|
</p>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6 p-2"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi (ru)")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{isPending || updatePending ? (
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
) : types === "create" ? (
|
||||||
|
t("Saqlash")
|
||||||
|
) : (
|
||||||
|
t("Tahrirlash")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealTable;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createHotel,
|
||||||
|
hotelFeature,
|
||||||
|
hotelFeatureType,
|
||||||
|
hotelType,
|
||||||
|
} from "@/pages/tours/lib/api";
|
||||||
|
import { useTicketStore } from "@/pages/tours/lib/store";
|
||||||
|
import type {
|
||||||
|
GetOneTours,
|
||||||
|
HotelFeatures,
|
||||||
|
HotelFeaturesType,
|
||||||
|
Type,
|
||||||
|
} from "@/pages/tours/lib/type";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -15,45 +30,64 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/ui/select";
|
} from "@/shared/ui/select";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { X } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
title: z.string().min(2, {
|
title: z.string().min(2, {
|
||||||
message: "Sarlavha kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
|
||||||
}),
|
}),
|
||||||
rating: z.number(),
|
rating: z.number().min(1).max(5),
|
||||||
mealPlan: z.string().min(1, { message: "Taom rejasi tanlanishi majburiy" }),
|
mealPlan: z.string().min(1, { message: "Taom rejasi tanlanishi majburiy" }),
|
||||||
hotelType: z
|
hotelType: z
|
||||||
.string()
|
.array(z.string())
|
||||||
.min(1, { message: "Mehmonxona turi tanlanishi majburiy" }),
|
.min(1, { message: "Kamida 1 ta mehmonxona turi tanlang" }),
|
||||||
hotelFeatures: z
|
hotelFeatures: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.min(1, { message: "Kamida 1 ta xususiyat tanlanishi kerak" }),
|
.min(1, { message: "Kamida 1 ta xususiyat tanlang" }),
|
||||||
|
hotelFeaturesType: z
|
||||||
|
.array(z.string())
|
||||||
|
.min(1, { message: "Kamida 1 ta tur tanlang" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const StepTwo = ({
|
const StepTwo = ({
|
||||||
setStep,
|
data,
|
||||||
|
isEditMode,
|
||||||
}: {
|
}: {
|
||||||
setStep: Dispatch<SetStateAction<number>>;
|
data: GetOneTours | undefined;
|
||||||
|
isEditMode: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const { amenities, id: ticketId } = useTicketStore();
|
||||||
const navigator = useNavigate();
|
const navigator = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: "",
|
title: "",
|
||||||
rating: 3.0,
|
rating: 3.0,
|
||||||
mealPlan: "",
|
mealPlan: "",
|
||||||
hotelType: "",
|
hotelType: [],
|
||||||
hotelFeatures: [],
|
hotelFeatures: [],
|
||||||
|
hotelFeaturesType: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function onSubmit() {
|
useEffect(() => {
|
||||||
navigator("tours");
|
if (isEditMode && data?.data) {
|
||||||
|
const tourData = data.data;
|
||||||
|
|
||||||
|
form.setValue("title", tourData.hotel_name);
|
||||||
|
form.setValue("rating", Number(tourData.hotel_rating));
|
||||||
|
form.setValue("mealPlan", tourData.hotel_meals);
|
||||||
}
|
}
|
||||||
|
}, [isEditMode, data, form]);
|
||||||
|
|
||||||
const mealPlans = [
|
const mealPlans = [
|
||||||
"Breakfast Only",
|
"Breakfast Only",
|
||||||
@@ -61,24 +95,221 @@ const StepTwo = ({
|
|||||||
"Full Board",
|
"Full Board",
|
||||||
"All Inclusive",
|
"All Inclusive",
|
||||||
];
|
];
|
||||||
const hotelTypes = ["Hotel", "Resort", "Guest House", "Apartment"];
|
const [allHotelTypes, setAllHotelTypes] = useState<Type[]>([]);
|
||||||
|
const [allHotelFeature, setAllHotelFeature] = useState<HotelFeatures[]>([]);
|
||||||
|
const [allHotelFeatureType, setAllHotelFeatureType] = useState<
|
||||||
|
HotelFeaturesType[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const [featureTypeMapping, setFeatureTypeMapping] = useState<
|
||||||
|
Record<string, string[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const selectedHotelFeatures = form.watch("hotelFeatures");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAll = async () => {
|
||||||
|
try {
|
||||||
|
let page = 1;
|
||||||
|
let results: Type[] = [];
|
||||||
|
let hasNext = true;
|
||||||
|
|
||||||
|
while (hasNext) {
|
||||||
|
const res = await hotelType({ page, page_size: 50 });
|
||||||
|
const data = res.data.data;
|
||||||
|
results = [...results, ...data.results];
|
||||||
|
hasNext = !!data.links.next;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
setAllHotelTypes(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAll = async () => {
|
||||||
|
try {
|
||||||
|
let page = 1;
|
||||||
|
let results: HotelFeatures[] = [];
|
||||||
|
let hasNext = true;
|
||||||
|
|
||||||
|
while (hasNext) {
|
||||||
|
const res = await hotelFeature({ page, page_size: 50 });
|
||||||
|
const data = res.data.data;
|
||||||
|
results = [...results, ...data.results];
|
||||||
|
hasNext = !!data.links.next;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
setAllHotelFeature(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedHotelFeatures.length === 0) {
|
||||||
|
setAllHotelFeatureType([]);
|
||||||
|
setFeatureTypeMapping({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAll = async () => {
|
||||||
|
try {
|
||||||
|
const selectedFeatureIds = selectedHotelFeatures
|
||||||
|
.map((featureId) => Number(featureId))
|
||||||
|
.filter((id) => !isNaN(id));
|
||||||
|
|
||||||
|
if (selectedFeatureIds.length === 0) return;
|
||||||
|
|
||||||
|
let allResults: HotelFeaturesType[] = [];
|
||||||
|
const newMapping: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
for (const featureId of selectedFeatureIds) {
|
||||||
|
let page = 1;
|
||||||
|
let hasNext = true;
|
||||||
|
const featureTypes: string[] = [];
|
||||||
|
|
||||||
|
while (hasNext) {
|
||||||
|
const res = await hotelFeatureType({
|
||||||
|
page,
|
||||||
|
page_size: 50,
|
||||||
|
feature_type: featureId,
|
||||||
|
});
|
||||||
|
const data = res.data.data;
|
||||||
|
allResults = [...allResults, ...data.results];
|
||||||
|
|
||||||
|
data.results.forEach((item: HotelFeaturesType) => {
|
||||||
|
featureTypes.push(String(item.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
hasNext = !!data.links.next;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
newMapping[String(featureId)] = featureTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueResults = allResults.filter(
|
||||||
|
(item, index, self) =>
|
||||||
|
index === self.findIndex((t) => t.id === item.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
setAllHotelFeatureType(uniqueResults);
|
||||||
|
setFeatureTypeMapping(newMapping);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAll();
|
||||||
|
}, [selectedHotelFeatures]);
|
||||||
|
|
||||||
|
const { mutate, isPending } = useMutation({
|
||||||
|
mutationFn: (body: FormData) => createHotel({ body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
navigator("/tours");
|
||||||
|
toast.success(t("Muvaffaqiyatli saqlandi"), {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeHotelType = (typeId: string) => {
|
||||||
|
const current = form.getValues("hotelType");
|
||||||
|
form.setValue(
|
||||||
|
"hotelType",
|
||||||
|
current.filter((val) => val !== typeId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("ticket", ticketId ? ticketId?.toString() : "");
|
||||||
|
formData.append("name", data.title);
|
||||||
|
formData.append("rating", String(data.rating));
|
||||||
|
formData.append(
|
||||||
|
"meal_plan",
|
||||||
|
data.mealPlan === "Breakfast Only"
|
||||||
|
? "breakfast"
|
||||||
|
: data.mealPlan === "All Inclusive"
|
||||||
|
? "all_inclusive"
|
||||||
|
: data.mealPlan === "Half Board"
|
||||||
|
? "half_board"
|
||||||
|
: data.mealPlan === "Full Board"
|
||||||
|
? "full_board"
|
||||||
|
: "all_inclusive",
|
||||||
|
);
|
||||||
|
|
||||||
|
data.hotelType.forEach((typeId) => {
|
||||||
|
formData.append("hotel_type", typeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
data.hotelFeaturesType.forEach((typeId) => {
|
||||||
|
formData.append("hotel_features", typeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
amenities.forEach((e, i) => {
|
||||||
|
formData.append(`hotel_amenities[${i}]name`, e.name);
|
||||||
|
formData.append(`hotel_amenities[${i}]name_ru`, e.name_ru);
|
||||||
|
formData.append(`hotel_amenities[${i}]icon_name`, e.icon_name);
|
||||||
|
});
|
||||||
|
|
||||||
|
mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeHotelFeature = (featureId: string) => {
|
||||||
|
const currentFeatures = form.getValues("hotelFeatures");
|
||||||
|
const currentFeatureTypes = form.getValues("hotelFeaturesType");
|
||||||
|
|
||||||
|
const typesToRemove = featureTypeMapping[featureId] || [];
|
||||||
|
|
||||||
|
form.setValue(
|
||||||
|
"hotelFeatures",
|
||||||
|
currentFeatures.filter((val) => val !== featureId),
|
||||||
|
);
|
||||||
|
|
||||||
|
form.setValue(
|
||||||
|
"hotelFeaturesType",
|
||||||
|
currentFeatureTypes.filter((val) => !typesToRemove.includes(val)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFeatureType = (typeId: string) => {
|
||||||
|
const currentValues = form.getValues("hotelFeaturesType");
|
||||||
|
form.setValue(
|
||||||
|
"hotelFeaturesType",
|
||||||
|
currentValues.filter((val) => val !== typeId),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1 items-start">
|
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1 items-end justify-end">
|
||||||
{/* Mehmonxona nomi */}
|
{/* Mehmonxona nomi */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="title"
|
name="title"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Mehmonxona nomi</Label>
|
<Label>{t("Mehmonxona nomi")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Toshkent - Dubay"
|
placeholder="Toshkent - Dubay"
|
||||||
{...field}
|
{...field}
|
||||||
className="h-12 !text-md"
|
className="h-12"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -86,25 +317,36 @@ const StepTwo = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mehmonxona rating */}
|
{/* Rating */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="rating"
|
name="rating"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Mehmonxona raytingi</Label>
|
<Label>{t("Mehmonxona reytingi")}</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="3.0"
|
placeholder="3.0"
|
||||||
{...field}
|
value={field.value}
|
||||||
className="h-12 !text-md"
|
className="h-12"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
if (/^\d*\.?\d*$/.test(val)) {
|
// Faqat raqam va nuqta kiritishga ruxsat berish
|
||||||
|
if (/^\d*\.?\d*$/.test(val) || val === "") {
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val && !isNaN(parseFloat(val))) {
|
||||||
|
// Agar 1 xonali bo'lsa, .0 qo'shish
|
||||||
|
const num = parseFloat(val);
|
||||||
|
if (val.indexOf(".") === -1) {
|
||||||
|
field.onChange(num.toFixed(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -114,33 +356,24 @@ const StepTwo = ({
|
|||||||
|
|
||||||
{/* Meal Plan */}
|
{/* Meal Plan */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
|
||||||
name="mealPlan"
|
|
||||||
render={() => (
|
|
||||||
<FormItem>
|
|
||||||
<Label className="text-md">Meal Plan</Label>
|
|
||||||
<FormControl>
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="mealPlan"
|
name="mealPlan"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<FormItem>
|
||||||
value={field.value}
|
<Label>{t("Taom rejasi")}</Label>
|
||||||
onValueChange={field.onChange}
|
<FormControl>
|
||||||
>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger className="!h-12 w-full">
|
<SelectTrigger className="!h-12 w-full">
|
||||||
<SelectValue placeholder="Taom rejasini tanlang" />
|
<SelectValue placeholder={t("Tanlang")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{mealPlans.map((plan) => (
|
{mealPlans.map((plan) => (
|
||||||
<SelectItem key={plan} value={plan}>
|
<SelectItem key={plan} value={plan}>
|
||||||
{plan}
|
{t(plan)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -149,136 +382,234 @@ const StepTwo = ({
|
|||||||
|
|
||||||
{/* Hotel Type */}
|
{/* Hotel Type */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
|
||||||
name="hotelType"
|
|
||||||
render={() => (
|
|
||||||
<FormItem>
|
|
||||||
<Label className="text-md">Mehmonxona turi</Label>
|
|
||||||
<FormControl>
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="hotelType"
|
name="hotelType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Mehmonxona turlari")}</Label>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{field.value.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 p-2 border rounded-md">
|
||||||
|
{field.value.map((selectedValue) => {
|
||||||
|
const selectedItem = allHotelTypes.find(
|
||||||
|
(item) => String(item.id) === selectedValue,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={selectedValue}
|
||||||
|
className="flex items-center gap-1 bg-purple-600 text-white px-3 py-1 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<span>{selectedItem?.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeHotelType(selectedValue)}
|
||||||
|
className="hover:bg-purple-700 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={field.value}
|
value=""
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
if (!field.value.includes(value)) {
|
||||||
|
field.onChange([...field.value, value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="!h-12 w-full">
|
<SelectTrigger className="!h-12 w-full">
|
||||||
<SelectValue placeholder="Mehmonxona turini tanlang" />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
field.value.length > 0
|
||||||
|
? t("Yana tanlang...")
|
||||||
|
: t("Tanlang")
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{hotelTypes.map((type) => (
|
{allHotelTypes
|
||||||
<SelectItem key={type} value={type}>
|
.filter(
|
||||||
{type}
|
(type) => !field.value.includes(String(type.id)),
|
||||||
|
)
|
||||||
|
.map((type) => (
|
||||||
|
<SelectItem key={type.id} value={String(type.id)}>
|
||||||
|
{type.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
</div>
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* <FormField
|
{/* Hotel Features */}
|
||||||
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="hotelFeatures"
|
name="hotelFeatures"
|
||||||
render={() => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">Mehmonxona qulayliklar</Label>
|
<Label>{t("Mehmonxona xususiyatlari")}</Label>
|
||||||
|
<FormControl>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
{field.value.length > 0 && (
|
||||||
{form.watch("amenities").map((item, idx) => {
|
<div className="flex flex-wrap gap-2 p-2 border rounded-md">
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
{field.value.map((selectedValue) => {
|
||||||
const Icon =
|
const selectedItem = allHotelFeature.find(
|
||||||
(LucideIcons as any)[item.icon_name] || XIcon;
|
(item) => String(item.id) === selectedValue,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Badge
|
<div
|
||||||
key={idx}
|
key={selectedValue}
|
||||||
variant="secondary"
|
className="flex items-center gap-1 bg-blue-600 text-white px-3 py-1 rounded-md text-sm"
|
||||||
className="px-3 py-1 text-sm flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
<span>
|
||||||
<span>{item.name}</span>
|
{selectedItem?.hotel_feature_type_name}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
const current = form.getValues("amenities");
|
removeHotelFeature(selectedValue)
|
||||||
form.setValue(
|
}
|
||||||
"amenities",
|
className="hover:bg-blue-700 rounded-full p-0.5"
|
||||||
current.filter((_, i: number) => i !== idx),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="ml-1 text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
>
|
||||||
<XIcon className="size-4" />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 items-center">
|
)}
|
||||||
<IconSelect
|
|
||||||
setSelectedIcon={setSelectedIcon}
|
|
||||||
selectedIcon={selectedIcon}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Select
|
||||||
id="amenity_name"
|
value=""
|
||||||
placeholder="Qulaylik nomi (masalan: Wi-Fi)"
|
onValueChange={(value) => {
|
||||||
className="h-12 !text-md flex-1"
|
if (!field.value.includes(value)) {
|
||||||
/>
|
field.onChange([...field.value, value]);
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const nameInput = document.getElementById(
|
|
||||||
"amenity_name",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
if (selectedIcon && nameInput.value) {
|
|
||||||
const current = form.getValues("amenities");
|
|
||||||
form.setValue("amenities", [
|
|
||||||
...current,
|
|
||||||
{
|
|
||||||
icon_name: selectedIcon,
|
|
||||||
name: nameInput.value,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
nameInput.value = "";
|
|
||||||
setSelectedIcon("");
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="h-12"
|
|
||||||
>
|
>
|
||||||
Qo‘shish
|
<SelectTrigger className="!h-12 w-full">
|
||||||
</Button>
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
field.value.length > 0
|
||||||
|
? t("Yana tanlang...")
|
||||||
|
: t("Tanlang")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allHotelFeature
|
||||||
|
.filter(
|
||||||
|
(type) => !field.value.includes(String(type.id)),
|
||||||
|
)
|
||||||
|
.map((type) => (
|
||||||
|
<SelectItem key={type.id} value={String(type.id)}>
|
||||||
|
{type.hotel_feature_type_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</div>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/> */}
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
{/* Hotel Feature Type */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="hotelFeaturesType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>{t("Xususiyat turlari")}</Label>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{field.value.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 p-2 border rounded-md">
|
||||||
|
{field.value.map((selectedValue) => {
|
||||||
|
const selectedItem = allHotelFeatureType.find(
|
||||||
|
(item) => String(item.id) === selectedValue,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={selectedValue}
|
||||||
|
className="flex items-center gap-1 bg-green-600 text-white px-3 py-1 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<span>{selectedItem?.feature_name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep(1)}
|
onClick={() => removeFeatureType(selectedValue)}
|
||||||
className="mt-6 px-6 py-3 bg-gray-600 text-white rounded-md"
|
className="hover:bg-green-700 rounded-full p-0.5"
|
||||||
>
|
>
|
||||||
Ortga
|
<X size={14} />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-md"
|
|
||||||
>
|
|
||||||
Saqlash
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (!field.value.includes(value)) {
|
||||||
|
field.onChange([...field.value, value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="!h-12 w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
selectedHotelFeatures.length === 0
|
||||||
|
? t("Avval xususiyat tanlang")
|
||||||
|
: field.value.length > 0
|
||||||
|
? t("Yana tanlang...")
|
||||||
|
: t("Tanlang")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allHotelFeatureType.length === 0 ? (
|
||||||
|
<div className="p-2 text-sm text-gray-500">
|
||||||
|
{t("Avval mehmonxona xususiyatini tanlang")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
allHotelFeatureType
|
||||||
|
.filter(
|
||||||
|
(type) => !field.value.includes(String(type.id)),
|
||||||
|
)
|
||||||
|
.map((type) => (
|
||||||
|
<SelectItem key={type.id} value={String(type.id)}>
|
||||||
|
{type.feature_name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isPending ? t("Yuklanmoqda...") : t("Saqlash")}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
320
src/pages/tours/ui/TarifTable.tsx
Normal file
320
src/pages/tours/ui/TarifTable.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import {
|
||||||
|
hotelTarfiDetail,
|
||||||
|
hotelTarifCreate,
|
||||||
|
hotelTarifDelete,
|
||||||
|
hoteltarifUpdate,
|
||||||
|
} from "@/pages/tours/lib/api";
|
||||||
|
import { TarifColumns } from "@/pages/tours/lib/column";
|
||||||
|
import type { Tarif } from "@/pages/tours/lib/type";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/shared/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/ui/table";
|
||||||
|
import RealPagination from "@/widgets/real-pagination/ui/RealPagination";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Loader, PlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TarifTable = ({
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
data:
|
||||||
|
| {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: Tarif[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [types, setTypes] = useState<"edit" | "create">("create");
|
||||||
|
|
||||||
|
const handleEdit = (id: number) => {
|
||||||
|
setTypes("edit");
|
||||||
|
setOpen(true);
|
||||||
|
setEditId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: tarifDetail } = useQuery({
|
||||||
|
queryKey: ["tarif_badge", editId],
|
||||||
|
queryFn: () => hotelTarfiDetail({ id: editId! }),
|
||||||
|
enabled: !!editId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteMutate } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) => hotelTarifDelete({ id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["tarif_badge"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_tarif"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
deleteMutate({ id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = TarifColumns(handleEdit, handleDelete, t);
|
||||||
|
|
||||||
|
const { mutate: create, isPending } = useMutation({
|
||||||
|
mutationFn: ({ body }: { body: { name: string; name_ru: string } }) =>
|
||||||
|
hotelTarifCreate({ body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["tarif_badge"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_tarif"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: update, isPending: updatePending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { name: string; name_ru: string };
|
||||||
|
}) => hoteltarifUpdate({ body, id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["tarif_badge"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_tarif"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
name_ru: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tarifDetail) {
|
||||||
|
form.setValue("name", tarifDetail.data.data.name);
|
||||||
|
}
|
||||||
|
}, [editId, tarifDetail]);
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
if (types === "create") {
|
||||||
|
create({
|
||||||
|
body: {
|
||||||
|
name: values.name,
|
||||||
|
name_ru: values.name_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (types === "edit" && editId) {
|
||||||
|
update({
|
||||||
|
id: editId,
|
||||||
|
body: {
|
||||||
|
name: values.name,
|
||||||
|
name_ru: values.name_ru,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: data?.results ?? [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: data?.total_pages ?? 0,
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: page - 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="default" disabled>
|
||||||
|
<PlusIcon className="mr-2" />
|
||||||
|
{t("Qo‘shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-gray-700 rounded-md overflow-hidden mt-4">
|
||||||
|
<Table key={data?.current_page}>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center text-gray-400"
|
||||||
|
>
|
||||||
|
{t("Ma'lumot topilmadi")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RealPagination
|
||||||
|
table={table}
|
||||||
|
totalPages={data?.total_pages}
|
||||||
|
namePage="pageTarif"
|
||||||
|
namePageSize="pageTarifSize"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<p className="text-xl">
|
||||||
|
{types === "create" ? t("Saqlash") : t("Tahrirlash")}
|
||||||
|
</p>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6 p-2"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi (ru)")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{isPending || updatePending ? (
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
) : types === "create" ? (
|
||||||
|
t("Saqlash")
|
||||||
|
) : (
|
||||||
|
t("Tahrirlash")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TarifTable;
|
||||||
@@ -9,21 +9,37 @@ import {
|
|||||||
import { Input } from "@/shared/ui/input";
|
import { Input } from "@/shared/ui/input";
|
||||||
import { Label } from "@/shared/ui/label";
|
import { Label } from "@/shared/ui/label";
|
||||||
import { ImagePlus, XIcon } from "lucide-react";
|
import { ImagePlus, XIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface TicketsImagesModelProps {
|
interface TicketsImagesModelProps {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
form: any; // React Hook Form control
|
||||||
form: any;
|
|
||||||
name: string;
|
name: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
imageUrl?: string | string[] | undefined;
|
||||||
|
multiple?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TicketsImagesModel({
|
export default function TicketsImagesModel({
|
||||||
form,
|
form,
|
||||||
name,
|
name,
|
||||||
label = "Rasmlar",
|
label = "Rasmlar",
|
||||||
|
multiple = true,
|
||||||
|
imageUrl,
|
||||||
}: TicketsImagesModelProps) {
|
}: TicketsImagesModelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [previews, setPreviews] = useState<string[]>([]);
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
|
const inputId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (imageUrl) {
|
||||||
|
if (Array.isArray(imageUrl)) {
|
||||||
|
setPreviews(imageUrl);
|
||||||
|
} else {
|
||||||
|
setPreviews([imageUrl]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [imageUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
@@ -32,39 +48,61 @@ export default function TicketsImagesModel({
|
|||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<Label className="text-md">{label}</Label>
|
<Label className="text-md">{label}</Label>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* File Input */}
|
||||||
<Input
|
<Input
|
||||||
id="ticket-images"
|
id={inputId}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple={multiple}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newFiles = e.target.files
|
const newFiles = e.target.files
|
||||||
? Array.from(e.target.files)
|
? Array.from(e.target.files)
|
||||||
: [];
|
: [];
|
||||||
const existingFiles = form.getValues(name) || [];
|
|
||||||
const allFiles = [...existingFiles, ...newFiles];
|
|
||||||
|
|
||||||
|
if (multiple) {
|
||||||
|
// ✅ Bir nechta rasm
|
||||||
|
const allFiles = [
|
||||||
|
...(form.getValues(name) || []),
|
||||||
|
...newFiles,
|
||||||
|
];
|
||||||
form.setValue(name, allFiles);
|
form.setValue(name, allFiles);
|
||||||
const urls = allFiles.map((file) =>
|
const urls = allFiles.map((file: File) =>
|
||||||
URL.createObjectURL(file),
|
URL.createObjectURL(file),
|
||||||
);
|
);
|
||||||
setPreviews(urls);
|
setPreviews(urls);
|
||||||
|
} else {
|
||||||
|
// ✅ Faqat bitta rasm (banner)
|
||||||
|
const singleFile = newFiles[0] || null;
|
||||||
|
form.setValue(name, singleFile);
|
||||||
|
setPreviews(
|
||||||
|
singleFile ? [URL.createObjectURL(singleFile)] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Upload Zone */}
|
{/* Upload Zone */}
|
||||||
<label
|
<label
|
||||||
htmlFor="ticket-images"
|
htmlFor={inputId}
|
||||||
className="border-2 border-dashed border-gray-300 h-40 rounded-2xl flex flex-col justify-center items-center cursor-pointer hover:bg-muted/20 transition"
|
className="border-2 border-dashed border-gray-300 h-40 rounded-2xl flex flex-col justify-center items-center cursor-pointer hover:bg-muted/20 transition"
|
||||||
>
|
>
|
||||||
<ImagePlus className="size-8 text-muted-foreground mb-2" />
|
<ImagePlus className="size-8 text-muted-foreground mb-2" />
|
||||||
<p className="font-semibold text-white">Rasmlarni tanlang</p>
|
<p className="font-semibold text-white">
|
||||||
<p className="text-sm text-white">
|
{t("Rasmlarni tanlang")}
|
||||||
Bir nechta rasm yuklashingiz mumkin
|
|
||||||
</p>
|
</p>
|
||||||
|
{multiple ? (
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
{t("Bir nechta rasm yuklashingiz mumkin")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
{t("Faqat bitta rasm yuklash mumkin")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Preview Images */}
|
{/* Preview Images */}
|
||||||
@@ -80,19 +118,29 @@ export default function TicketsImagesModel({
|
|||||||
alt={`preview-${i}`}
|
alt={`preview-${i}`}
|
||||||
className="object-cover w-full h-full"
|
className="object-cover w-full h-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newFiles = form
|
if (multiple) {
|
||||||
.getValues(name)
|
// ✅ Ko‘p rasm holati
|
||||||
.filter((_: File, idx: number) => idx !== i);
|
const currentFiles = form.getValues(name) || [];
|
||||||
|
const newFiles = currentFiles.filter(
|
||||||
|
(_: File, idx: number) => idx !== i,
|
||||||
|
);
|
||||||
|
form.setValue(name, newFiles);
|
||||||
const newPreviews = previews.filter(
|
const newPreviews = previews.filter(
|
||||||
(_: string, idx: number) => idx !== i,
|
(_: string, idx: number) => idx !== i,
|
||||||
);
|
);
|
||||||
form.setValue(name, newFiles);
|
|
||||||
setPreviews(newPreviews);
|
setPreviews(newPreviews);
|
||||||
|
} else {
|
||||||
|
// ✅ Bitta rasm holati
|
||||||
|
form.setValue(name, null);
|
||||||
|
setPreviews([]);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white"
|
className="absolute top-1 right-1 bg-white/80 rounded-full p-1 shadow hover:bg-white transition"
|
||||||
>
|
>
|
||||||
<XIcon className="size-4 text-destructive" />
|
<XIcon className="size-4 text-destructive" />
|
||||||
</button>
|
</button>
|
||||||
@@ -102,6 +150,7 @@ export default function TicketsImagesModel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import formatPrice from "@/shared/lib/formatPrice";
|
||||||
import { Badge } from "@/shared/ui/badge";
|
import { Badge } from "@/shared/ui/badge";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
Utensils,
|
Utensils,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
type TourDetail = {
|
type TourDetail = {
|
||||||
@@ -59,6 +61,7 @@ type TourDetail = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function TourDetailPage() {
|
export default function TourDetailPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useNavigate();
|
const router = useNavigate();
|
||||||
const [tour, setTour] = useState<TourDetail | null>(null);
|
const [tour, setTour] = useState<TourDetail | null>(null);
|
||||||
@@ -251,10 +254,10 @@ export default function TourDetailPage() {
|
|||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<DollarSign className="w-5 h-5 text-green-400" />
|
<DollarSign className="w-5 h-5 text-green-400" />
|
||||||
<p className="text-sm text-gray-400">Narxi</p>
|
<p className="text-sm text-gray-400">{t("Narxi")}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-white">
|
<p className="text-2xl font-bold text-white">
|
||||||
{tour.price.toLocaleString()} so'm
|
{formatPrice(tour.price, true)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -263,10 +266,10 @@ export default function TourDetailPage() {
|
|||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Clock className="w-5 h-5 text-blue-400" />
|
<Clock className="w-5 h-5 text-blue-400" />
|
||||||
<p className="text-sm text-gray-400">Davomiyligi</p>
|
<p className="text-sm text-gray-400">{t("Davomiyligi")}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-white">
|
<p className="text-2xl font-bold text-white">
|
||||||
{tour.duration_days} kun
|
{tour.duration_days} {t("kun")}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -275,10 +278,10 @@ export default function TourDetailPage() {
|
|||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Users className="w-5 h-5 text-purple-400" />
|
<Users className="w-5 h-5 text-purple-400" />
|
||||||
<p className="text-sm text-gray-400">Yo'lovchilar</p>
|
<p className="text-sm text-gray-400">{t("Yo'lovchilar")}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-white">
|
<p className="text-2xl font-bold text-white">
|
||||||
{tour.passenger_count} kishi
|
{tour.passenger_count} {t("kishi")}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -287,7 +290,7 @@ export default function TourDetailPage() {
|
|||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Calendar className="w-5 h-5 text-yellow-400" />
|
<Calendar className="w-5 h-5 text-yellow-400" />
|
||||||
<p className="text-sm text-gray-400">Jo'nash sanasi</p>
|
<p className="text-sm text-gray-400">{t("Jo'nash sanasi")}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-bold text-white">
|
<p className="text-xl font-bold text-white">
|
||||||
{new Date(tour.departure_date).toLocaleDateString("uz-UZ")}
|
{new Date(tour.departure_date).toLocaleDateString("uz-UZ")}
|
||||||
@@ -302,31 +305,31 @@ export default function TourDetailPage() {
|
|||||||
value="overview"
|
value="overview"
|
||||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||||
>
|
>
|
||||||
Umumiy
|
{t("Umumiy")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="itinerary"
|
value="itinerary"
|
||||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||||
>
|
>
|
||||||
Marshshrut
|
{t("Marshshrut")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="services"
|
value="services"
|
||||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||||
>
|
>
|
||||||
Xizmatlar
|
{t("Xizmatlar")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="hotel"
|
value="hotel"
|
||||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||||
>
|
>
|
||||||
Mehmonxona
|
{t("Mehmonxona")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="reviews"
|
value="reviews"
|
||||||
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-400"
|
||||||
>
|
>
|
||||||
Sharhlar
|
{t("Sharhlar")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -334,7 +337,7 @@ export default function TourDetailPage() {
|
|||||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl text-white">
|
<CardTitle className="text-2xl text-white">
|
||||||
Tur haqida ma'lumot
|
{t("Tur haqida ma'lumot")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
@@ -343,7 +346,9 @@ export default function TourDetailPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<MapPin className="w-5 h-5 text-green-400 mt-1" />
|
<MapPin className="w-5 h-5 text-green-400 mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Jo'nash joyi</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Jo'nash joyi")}
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-white">
|
<p className="font-semibold text-white">
|
||||||
{tour.departure}
|
{tour.departure}
|
||||||
</p>
|
</p>
|
||||||
@@ -353,7 +358,9 @@ export default function TourDetailPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<MapPin className="w-5 h-5 text-blue-400 mt-1" />
|
<MapPin className="w-5 h-5 text-blue-400 mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Yo'nalish</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Yo'nalish")}
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-white">
|
<p className="font-semibold text-white">
|
||||||
{tour.destination}
|
{tour.destination}
|
||||||
</p>
|
</p>
|
||||||
@@ -363,7 +370,7 @@ export default function TourDetailPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Globe className="w-5 h-5 text-purple-400 mt-1" />
|
<Globe className="w-5 h-5 text-purple-400 mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Tillar</p>
|
<p className="text-sm text-gray-400">{t("Tillar")}</p>
|
||||||
<p className="font-semibold text-white">
|
<p className="font-semibold text-white">
|
||||||
{tour.languages}
|
{tour.languages}
|
||||||
</p>
|
</p>
|
||||||
@@ -375,7 +382,9 @@ export default function TourDetailPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Hotel className="w-5 h-5 text-yellow-400 mt-1" />
|
<Hotel className="w-5 h-5 text-yellow-400 mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Mehmonxona</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Mehmonxona")}
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-white">
|
<p className="font-semibold text-white">
|
||||||
{tour.hotel_info}
|
{tour.hotel_info}
|
||||||
</p>
|
</p>
|
||||||
@@ -385,7 +394,9 @@ export default function TourDetailPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Utensils className="w-5 h-5 text-green-400 mt-1" />
|
<Utensils className="w-5 h-5 text-green-400 mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Ovqatlanish</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Ovqatlanish")}
|
||||||
|
</p>
|
||||||
<p className="font-semibold text-white">
|
<p className="font-semibold text-white">
|
||||||
{tour.hotel_meals}
|
{tour.hotel_meals}
|
||||||
</p>
|
</p>
|
||||||
@@ -395,7 +406,7 @@ export default function TourDetailPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<CheckCircle2 className="w-5 h-5 text-blue-400 mt-1" />
|
<CheckCircle2 className="w-5 h-5 text-blue-400 mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400">Tarif</p>
|
<p className="text-sm text-gray-400">{t("Tarif")}</p>
|
||||||
<p className="font-semibold text-white capitalize">
|
<p className="font-semibold text-white capitalize">
|
||||||
{tour.tariff[0]?.name}
|
{tour.tariff[0]?.name}
|
||||||
</p>
|
</p>
|
||||||
@@ -406,7 +417,7 @@ export default function TourDetailPage() {
|
|||||||
|
|
||||||
<div className="pt-6 border-t border-gray-700">
|
<div className="pt-6 border-t border-gray-700">
|
||||||
<h3 className="text-lg font-semibold mb-4 text-white">
|
<h3 className="text-lg font-semibold mb-4 text-white">
|
||||||
Qulayliklar
|
{t("Qulayliklar")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
{tour.ticket_amenities.map((amenity, idx) => (
|
{tour.ticket_amenities.map((amenity, idx) => (
|
||||||
@@ -430,7 +441,7 @@ export default function TourDetailPage() {
|
|||||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl text-white">
|
<CardTitle className="text-2xl text-white">
|
||||||
Sayohat marshshruti
|
{t("Sayohat marshshruti")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
@@ -444,7 +455,7 @@ export default function TourDetailPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-base border-gray-600 text-gray-300"
|
className="text-base border-gray-600 text-gray-300"
|
||||||
>
|
>
|
||||||
{day.duration}-kun
|
{day.duration}-{t("kun")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<h3 className="text-xl font-semibold text-white">
|
<h3 className="text-xl font-semibold text-white">
|
||||||
{day.title}
|
{day.title}
|
||||||
@@ -492,7 +503,7 @@ export default function TourDetailPage() {
|
|||||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl text-white">
|
<CardTitle className="text-2xl text-white">
|
||||||
Narxga kiritilgan xizmatlar
|
{t("Narxga kiritilgan xizmatlar")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -526,7 +537,7 @@ export default function TourDetailPage() {
|
|||||||
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
<Card className="border-gray-700 shadow-lg bg-gray-800">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl text-white">
|
<CardTitle className="text-2xl text-white">
|
||||||
Mehmonxona va ovqatlanish
|
{t("Mehmonxona va ovqatlanish")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
@@ -542,7 +553,7 @@ export default function TourDetailPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4 text-white">
|
<h3 className="text-lg font-semibold mb-4 text-white">
|
||||||
Ovqatlanish tafsilotlari
|
{t("Ovqatlanish tafsilotlari")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{tour.ticket_hotel_meals.map((meal, idx) => (
|
{tour.ticket_hotel_meals.map((meal, idx) => (
|
||||||
@@ -576,7 +587,7 @@ export default function TourDetailPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-2xl text-white">
|
<CardTitle className="text-2xl text-white">
|
||||||
Mijozlar sharhlari
|
{t("Mijozlar sharhlari")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -586,7 +597,7 @@ export default function TourDetailPage() {
|
|||||||
{tour.rating}
|
{tour.rating}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-400">
|
<span className="text-gray-400">
|
||||||
({tour.ticket_comments.length} sharh)
|
({tour.ticket_comments.length} {t("sharh")})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -621,9 +632,9 @@ export default function TourDetailPage() {
|
|||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-400 mb-1">Tur firmasi</p>
|
<p className="text-sm text-gray-400 mb-1">{t("Tur firmasi")}</p>
|
||||||
<p className="text-xl font-semibold text-white">
|
<p className="text-xl font-semibold text-white">
|
||||||
Firma ID: {tour.travel_agency_id}
|
{t("Firma ID")}: {tour.travel_agency_id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -631,7 +642,7 @@ export default function TourDetailPage() {
|
|||||||
onClick={() => router(`/agencies/${tour.travel_agency_id}`)}
|
onClick={() => router(`/agencies/${tour.travel_agency_id}`)}
|
||||||
className="border-gray-700 bg-gray-800 hover:bg-gray-700 text-gray-300"
|
className="border-gray-700 bg-gray-800 hover:bg-gray-700 text-gray-300"
|
||||||
>
|
>
|
||||||
Firma sahifasiga o'tish
|
{t("Firma sahifasiga o'tish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { deleteTours, getAllTours } from "@/pages/tours/lib/api";
|
||||||
|
import formatPrice from "@/shared/lib/formatPrice";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -16,65 +18,79 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shared/ui/table";
|
} from "@/shared/ui/table";
|
||||||
import { Edit, Plane, PlusCircle, Trash2 } from "lucide-react";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit,
|
||||||
|
Loader2,
|
||||||
|
Plane,
|
||||||
|
PlusCircle,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type Tour = {
|
|
||||||
id: number;
|
|
||||||
image?: string;
|
|
||||||
tickets: string;
|
|
||||||
min_price: string;
|
|
||||||
max_price: string;
|
|
||||||
top_duration: string;
|
|
||||||
top_destinations: string;
|
|
||||||
hotel_features_by_type: string;
|
|
||||||
hotel_types: string;
|
|
||||||
hotel_amenities: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Tours = () => {
|
const Tours = () => {
|
||||||
const [tours, setTours] = useState<Tour[]>([]);
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(3);
|
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
const mockData: Tour[] = Array.from({ length: 10 }, (_, i) => ({
|
queryKey: ["all_tours", page],
|
||||||
id: i + 1,
|
queryFn: () => getAllTours({ page: page, page_size: 10 }),
|
||||||
image: `/dubai-marina.jpg`,
|
});
|
||||||
tickets: `Bilet turi ${i + 1}`,
|
|
||||||
min_price: `${200 + i * 50}$`,
|
|
||||||
max_price: `${400 + i * 70}$`,
|
|
||||||
top_duration: `${3 + i} kun`,
|
|
||||||
top_destinations: `Shahar ${i + 1}`,
|
|
||||||
hotel_features_by_type: "Spa, Wi-Fi, Pool",
|
|
||||||
hotel_types: "5 yulduzli mehmonxona",
|
|
||||||
hotel_amenities: "Nonushta, Parking, Bar",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const itemsPerPage = 6;
|
const { mutate } = useMutation({
|
||||||
const start = (page - 1) * itemsPerPage;
|
mutationFn: (id: number) => deleteTours({ id }),
|
||||||
const end = start + itemsPerPage;
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_tours"] });
|
||||||
setTotalPages(Math.ceil(mockData.length / itemsPerPage));
|
|
||||||
setTours(mockData.slice(start, end));
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
const confirmDelete = () => {
|
|
||||||
if (deleteId !== null) {
|
|
||||||
setTours((prev) => prev.filter((t) => t.id !== deleteId));
|
|
||||||
setDeleteId(null);
|
setDeleteId(null);
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDelete = (id: number) => {
|
||||||
|
mutate(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
|
||||||
|
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||||
|
<p className="text-lg">
|
||||||
|
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("Qayta urinish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-foreground py-10 px-5 w-full">
|
<div className="min-h-screen bg-gray-900 text-foreground py-10 px-5 w-full">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<h1 className="text-3xl font-semibold">Turlar ro'yxati</h1>
|
<h1 className="text-3xl font-semibold">{t("Turlar ro'yxati")}</h1>
|
||||||
<Button onClick={() => navigate("/tours/create")} variant="default">
|
<Button onClick={() => navigate("/tours/create")} variant="default">
|
||||||
<PlusCircle className="w-5 h-5 mr-2" /> Yangi tur qo'shish
|
<PlusCircle className="w-5 h-5 mr-2" /> {t("Yangi tur qo'shish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,18 +99,19 @@ const Tours = () => {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/50">
|
<TableRow className="bg-muted/50">
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead className="min-w-[150px]">Manzil</TableHead>
|
<TableHead className="min-w-[150px]">{t("Manzil")}</TableHead>
|
||||||
<TableHead className="min-w-[120px]">Davomiyligi</TableHead>
|
<TableHead className="min-w-[120px]">
|
||||||
<TableHead className="min-w-[180px]">Mehmonxona</TableHead>
|
{t("Davomiyligi")}
|
||||||
<TableHead className="min-w-[200px]">Narx Oralig'i</TableHead>
|
</TableHead>
|
||||||
<TableHead className="min-w-[200px]">Imkoniyatlar</TableHead>
|
<TableHead className="min-w-[180px]">{t("Mehmonxona")}</TableHead>
|
||||||
|
<TableHead className="min-w-[200px]">{t("Narxi")}</TableHead>
|
||||||
<TableHead className="min-w-[150px] text-center">
|
<TableHead className="min-w-[150px] text-center">
|
||||||
Amallar
|
{t("Операции")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{tours.map((tour, idx) => (
|
{data?.data.data.results.map((tour, idx) => (
|
||||||
<TableRow key={tour.id}>
|
<TableRow key={tour.id}>
|
||||||
<TableCell className="font-medium text-center">
|
<TableCell className="font-medium text-center">
|
||||||
{(page - 1) * 6 + idx + 1}
|
{(page - 1) * 6 + idx + 1}
|
||||||
@@ -102,28 +119,25 @@ const Tours = () => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2 font-semibold">
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
<Plane className="w-4 h-4 text-primary" />
|
<Plane className="w-4 h-4 text-primary" />
|
||||||
{tour.top_destinations}
|
{tour.destination}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-primary font-medium">
|
<TableCell className="text-sm text-primary font-medium">
|
||||||
{tour.top_duration}
|
{tour.duration_days} kun
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{tour.hotel_types}</span>
|
<span className="font-medium">{tour.hotel_name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{tour.tickets}
|
{tour.hotel_rating} {t("yulduzli mehmonxona")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="font-bold text-base text-green-600">
|
<span className="font-bold text-base text-green-600">
|
||||||
{tour.min_price} – {tour.max_price}
|
{formatPrice(tour.price, true)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
|
||||||
{tour.hotel_amenities}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex gap-2 justify-center">
|
<div className="flex gap-2 justify-center">
|
||||||
@@ -148,7 +162,7 @@ const Tours = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate(`/tours/${tour.id}`)}
|
onClick={() => navigate(`/tours/${tour.id}`)}
|
||||||
>
|
>
|
||||||
Batafsil
|
{t("Batafsil")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -158,50 +172,67 @@ const Tours = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog - Faqat bitta */}
|
|
||||||
<Dialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
<Dialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||||
<DialogContent className="sm:max-w-[425px] bg-gray-900">
|
<DialogContent className="sm:max-w-[425px] bg-gray-900">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl">
|
<DialogTitle className="text-xl">
|
||||||
Turni o'chirishni tasdiqlang
|
{t("Turni o'chirishni tasdiqlang")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga
|
{t(
|
||||||
qaytarib bo'lmaydi.
|
"Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga qaytarib bo'lmaydi.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-4 flex">
|
<DialogFooter className="gap-4 flex">
|
||||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||||
Bekor qilish
|
{t("Bekor qilish")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => confirmDelete(deleteId!)}
|
||||||
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
O'chirish
|
{t("O'chirish")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<div className="flex justify-center mt-10 gap-3">
|
<div className="flex justify-end mt-10 gap-3">
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(p - 1, 1))}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
>
|
>
|
||||||
Oldingi
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</Button>
|
</button>
|
||||||
<span className="text-sm flex items-center">
|
{[...Array(data?.data.data.total_pages)].map((_, i) => (
|
||||||
Sahifa {page} / {totalPages}
|
<button
|
||||||
</span>
|
key={i}
|
||||||
<Button
|
onClick={() => setPage(i + 1)}
|
||||||
variant="outline"
|
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
page === i + 1
|
||||||
disabled={page === totalPages}
|
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
||||||
|
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Keyingi
|
{i + 1}
|
||||||
</Button>
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
disabled={page === data?.data.data.total_pages}
|
||||||
|
onClick={() =>
|
||||||
|
setPage((p) =>
|
||||||
|
Math.min(p + 1, data ? data.data.data.total_pages : 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,458 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
hotelBadge,
|
||||||
|
hotelFeature,
|
||||||
|
hotelFeatureType,
|
||||||
|
hotelTarif,
|
||||||
|
hotelTransport,
|
||||||
|
hotelType,
|
||||||
|
} from "@/pages/tours/lib/api";
|
||||||
|
import BadgeTable from "@/pages/tours/ui/BadgeTable";
|
||||||
|
import FeaturesTable from "@/pages/tours/ui/FeaturesTable";
|
||||||
|
import FeaturesTableType from "@/pages/tours/ui/FeaturesTableType";
|
||||||
|
import MealTable from "@/pages/tours/ui/MealTable";
|
||||||
|
import TarifTable from "@/pages/tours/ui/TarifTable";
|
||||||
|
import TransportTable from "@/pages/tours/ui/TransportTable";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/ui/card";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||||
import {
|
|
||||||
Dialog,
|
import { useQuery } from "@tanstack/react-query";
|
||||||
DialogContent,
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/ui/dialog";
|
|
||||||
import { Input } from "@/shared/ui/input";
|
|
||||||
import { Label } from "@/shared/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/ui/select";
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
|
||||||
import { Textarea } from "@/shared/ui/textarea";
|
|
||||||
import { Edit2, Plus, Search, Trash2 } from "lucide-react";
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
interface Badge {
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Tariff {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Transport {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MealPlan {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HotelType {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabId = "badges" | "tariffs" | "transports" | "mealPlans" | "hotelTypes";
|
|
||||||
|
|
||||||
type DataItem = Badge | Tariff | Transport | MealPlan | HotelType;
|
|
||||||
|
|
||||||
interface FormField {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type: "text" | "number" | "color" | "textarea" | "select";
|
|
||||||
required: boolean;
|
|
||||||
options?: string[];
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Tab {
|
|
||||||
id: TabId;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToursSetting: React.FC = () => {
|
const ToursSetting: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabId>("badges");
|
const { t } = useTranslation();
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchParams] = useSearchParams();
|
||||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
const [activeTab, setActiveTab] = useState("badge");
|
||||||
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
|
const [featureId, setFeatureId] = useState<number | null>(null);
|
||||||
const [currentItem, setCurrentItem] = useState<DataItem | null>(null);
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [badges, setBadges] = useState<Badge[]>([
|
const page = parseInt(searchParams.get("page") || "1", 10);
|
||||||
{ id: 1, name: "Bestseller", color: "#FFD700" },
|
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10);
|
||||||
{ id: 2, name: "Yangi", color: "#4CAF50" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [tariffs, setTariffs] = useState<Tariff[]>([
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
{ id: 1, name: "Standart", price: 500 },
|
queryKey: ["all_badge", page, pageSize],
|
||||||
{ id: 2, name: "Premium", price: 1000 },
|
queryFn: () => hotelBadge({ page, page_size: pageSize }),
|
||||||
]);
|
select: (res) => res.data.data,
|
||||||
|
});
|
||||||
|
|
||||||
const [transports, setTransports] = useState<Transport[]>([
|
const pageTarif = parseInt(searchParams.get("pageTarif") || "1", 10);
|
||||||
{ id: 1, name: "Avtobus", price: 200 },
|
const pageSizeTarif = parseInt(searchParams.get("pageTarifSize") || "10", 10);
|
||||||
{ id: 2, name: "Minivan", price: 500 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [mealPlans, setMealPlans] = useState<MealPlan[]>([
|
const {
|
||||||
{ id: 1, name: "BB (Bed & Breakfast)" },
|
data: tarifData,
|
||||||
{ id: 2, name: "HB (Half Board)" },
|
isLoading: tarifLoad,
|
||||||
{ id: 3, name: "FB (Full Board)" },
|
isError: tarifError,
|
||||||
]);
|
refetch: tarifRef,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["all_tarif", pageTarif, pageSizeTarif],
|
||||||
|
queryFn: () => hotelTarif({ page: pageTarif, page_size: pageSizeTarif }),
|
||||||
|
select: (res) => res.data.data,
|
||||||
|
});
|
||||||
|
|
||||||
const [hotelTypes, setHotelTypes] = useState<HotelType[]>([
|
const pageTransport = parseInt(searchParams.get("pageTransport") || "1", 10);
|
||||||
{ id: 1, name: "3 Yulduz" },
|
const pageSizeTransport = parseInt(
|
||||||
{ id: 2, name: "5 Yulduz" },
|
searchParams.get("pageTransportSize") || "10",
|
||||||
]);
|
10,
|
||||||
|
|
||||||
const [formData, setFormData] = useState<Partial<DataItem>>({});
|
|
||||||
|
|
||||||
const getCurrentData = (): DataItem[] => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case "badges":
|
|
||||||
return badges;
|
|
||||||
case "tariffs":
|
|
||||||
return tariffs;
|
|
||||||
case "transports":
|
|
||||||
return transports;
|
|
||||||
case "mealPlans":
|
|
||||||
return mealPlans;
|
|
||||||
case "hotelTypes":
|
|
||||||
return hotelTypes;
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSetterFunction = (): React.Dispatch<
|
|
||||||
React.SetStateAction<DataItem[]>
|
|
||||||
> => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case "badges":
|
|
||||||
return setBadges as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
|
||||||
case "tariffs":
|
|
||||||
return setTariffs as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
|
||||||
case "transports":
|
|
||||||
return setTransports as React.Dispatch<
|
|
||||||
React.SetStateAction<DataItem[]>
|
|
||||||
>;
|
|
||||||
case "mealPlans":
|
|
||||||
return setMealPlans as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
|
||||||
case "hotelTypes":
|
|
||||||
return setHotelTypes as React.Dispatch<
|
|
||||||
React.SetStateAction<DataItem[]>
|
|
||||||
>;
|
|
||||||
default:
|
|
||||||
return (() => {}) as React.Dispatch<React.SetStateAction<DataItem[]>>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredData = getCurrentData().filter((item) =>
|
|
||||||
Object.values(item).some((val) =>
|
|
||||||
val?.toString().toLowerCase().includes(searchTerm.toLowerCase()),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const getFormFields = (): FormField[] => {
|
const {
|
||||||
switch (activeTab) {
|
data: transportData,
|
||||||
case "badges":
|
isLoading: transportLoad,
|
||||||
return [
|
isError: transportError,
|
||||||
{ name: "name", label: "Nomi", type: "text", required: true },
|
refetch: transportRef,
|
||||||
{ name: "color", label: "Rang", type: "color", required: true },
|
} = useQuery({
|
||||||
];
|
queryKey: ["all_transport", pageTransport, pageSizeTransport],
|
||||||
case "tariffs":
|
queryFn: () =>
|
||||||
return [
|
hotelTransport({ page: pageTransport, page_size: pageSizeTransport }),
|
||||||
{ name: "name", label: "Tarif nomi", type: "text", required: true },
|
select: (res) => res.data.data,
|
||||||
{ name: "price", label: "Narx", type: "number", required: true },
|
});
|
||||||
];
|
|
||||||
case "transports":
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "name",
|
|
||||||
label: "Transport nomi",
|
|
||||||
type: "text",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{ name: "capacity", label: "Sig'im", type: "number", required: true },
|
|
||||||
];
|
|
||||||
case "mealPlans":
|
|
||||||
return [{ name: "name", label: "Nomi", type: "text", required: true }];
|
|
||||||
case "hotelTypes":
|
|
||||||
return [
|
|
||||||
{ name: "name", label: "Tur nomi", type: "text", required: true },
|
|
||||||
];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openModal = (
|
const pageType = parseInt(searchParams.get("pageType") || "1", 10);
|
||||||
mode: "add" | "edit",
|
const pageSizeType = parseInt(searchParams.get("pageTypeSize") || "10", 10);
|
||||||
item: DataItem | null = null,
|
|
||||||
): void => {
|
|
||||||
setModalMode(mode);
|
|
||||||
setCurrentItem(item);
|
|
||||||
if (mode === "edit" && item) {
|
|
||||||
setFormData(item);
|
|
||||||
} else {
|
|
||||||
setFormData({});
|
|
||||||
}
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = (): void => {
|
const {
|
||||||
setIsModalOpen(false);
|
data: typeData,
|
||||||
setFormData({});
|
isLoading: typeLoad,
|
||||||
setCurrentItem(null);
|
isError: typeError,
|
||||||
};
|
refetch: typeRef,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["all_type", pageType, pageSizeType],
|
||||||
|
queryFn: () => hotelType({ page: pageType, page_size: pageSizeType }),
|
||||||
|
select: (res) => res.data.data,
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const pageFeature = parseInt(searchParams.get("pageFeature") || "1", 10);
|
||||||
const setter = getSetterFunction();
|
const pageSizeFeature = parseInt(
|
||||||
|
searchParams.get("pageSizeFeature") || "10",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
if (modalMode === "add") {
|
const {
|
||||||
const newId = Math.max(...getCurrentData().map((i) => i.id), 0) + 1;
|
data: featureData,
|
||||||
setter([...getCurrentData(), { ...formData, id: newId } as DataItem]);
|
isLoading: featureLoad,
|
||||||
} else {
|
isError: featureError,
|
||||||
setter(
|
refetch: featureRef,
|
||||||
getCurrentData().map((item) =>
|
} = useQuery({
|
||||||
item.id === currentItem?.id ? { ...item, ...formData } : item,
|
queryKey: ["all_feature", pageFeature, pageSizeFeature],
|
||||||
),
|
queryFn: () =>
|
||||||
|
hotelFeature({ page: pageFeature, page_size: pageSizeFeature }),
|
||||||
|
select: (res) => res.data.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: featureTypeData,
|
||||||
|
isLoading: featureTypeLoad,
|
||||||
|
isError: featureTypeError,
|
||||||
|
refetch: featureTypeRef,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["all_feature_type", pageFeature, pageSizeFeature, featureId],
|
||||||
|
queryFn: () =>
|
||||||
|
hotelFeatureType({
|
||||||
|
page: pageFeature,
|
||||||
|
page_size: pageSizeFeature,
|
||||||
|
feature_type: featureId!,
|
||||||
|
}),
|
||||||
|
select: (res) => res.data.data,
|
||||||
|
enabled: !!featureId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
isLoading ||
|
||||||
|
tarifLoad ||
|
||||||
|
transportLoad ||
|
||||||
|
typeLoad ||
|
||||||
|
featureLoad ||
|
||||||
|
featureTypeLoad
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
|
||||||
|
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: number): void => {
|
if (
|
||||||
if (window.confirm("Rostdan ham o'chirmoqchimisiz?")) {
|
isError ||
|
||||||
const setter = getSetterFunction();
|
tarifError ||
|
||||||
setter(getCurrentData().filter((item) => item.id !== id));
|
transportError ||
|
||||||
|
typeError ||
|
||||||
|
featureError ||
|
||||||
|
featureTypeError
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||||
|
<p className="text-lg">
|
||||||
|
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
refetch();
|
||||||
|
tarifRef();
|
||||||
|
transportRef();
|
||||||
|
typeRef();
|
||||||
|
featureRef();
|
||||||
|
featureTypeRef();
|
||||||
|
}}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("Qayta urinish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const tabs: Tab[] = [
|
const handleTabChange = (value: string) => {
|
||||||
{ id: "badges", label: "Belgilar" },
|
setActiveTab(value);
|
||||||
{ id: "tariffs", label: "Tariflar" },
|
navigate({
|
||||||
{ id: "transports", label: "Transportlar" },
|
pathname: window.location.pathname,
|
||||||
{ id: "mealPlans", label: "Ovqatlanish" },
|
search: "",
|
||||||
{ id: "hotelTypes", label: "Otel turlari" },
|
});
|
||||||
];
|
|
||||||
|
|
||||||
const getFieldValue = (fieldName: string): string | number => {
|
|
||||||
return (formData as Record<string, string | number>)[fieldName] || "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 p-6 w-full">
|
<div className="min-h-screen bg-gray-900 p-6 w-full text-white">
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-[90%] mx-auto space-y-6">
|
||||||
<h1 className="text-3xl font-bold">Tur Sozlamalari</h1>
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => {
|
onValueChange={handleTabChange}
|
||||||
setActiveTab(v as TabId);
|
className="w-full"
|
||||||
setSearchTerm("");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-5">
|
<TabsList className="w-full">
|
||||||
{tabs.map((tab) => (
|
<TabsTrigger value="badge">{t("Belgilar (Badge)")}</TabsTrigger>
|
||||||
<TabsTrigger key={tab.id} value={tab.id}>
|
<TabsTrigger value="tarif">{t("Tariflar")}</TabsTrigger>
|
||||||
{tab.label}
|
<TabsTrigger value="transport">{t("Transport")}</TabsTrigger>
|
||||||
|
{/* <TabsTrigger value="meal">{t("Ovqatlanish")}</TabsTrigger> */}
|
||||||
|
<TabsTrigger value="hotel_type">{t("Otel turlari")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="hotel_features">
|
||||||
|
{t("Otel sharoitlari")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="badge" className="space-y-4">
|
||||||
|
<BadgeTable data={data} page={page} pageSize={pageSize} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="tarif" className="space-y-4">
|
||||||
|
<TarifTable
|
||||||
|
data={tarifData}
|
||||||
|
page={pageTarif}
|
||||||
|
pageSize={pageSizeTarif}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="transport" className="space-y-4">
|
||||||
|
<TransportTable
|
||||||
|
data={transportData}
|
||||||
|
page={pageTransport}
|
||||||
|
pageSize={pageSizeTransport}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="hotel_type" className="space-y-4">
|
||||||
|
<MealTable
|
||||||
|
data={typeData}
|
||||||
|
page={pageTransport}
|
||||||
|
pageSize={pageSizeTransport}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="hotel_features" className="space-y-4">
|
||||||
|
<FeaturesTable
|
||||||
|
data={featureData}
|
||||||
|
page={pageFeature}
|
||||||
|
pageSize={pageSizeFeature}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
setFeatureId={setFeatureId}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="feature_type" className="space-y-4">
|
||||||
|
<FeaturesTableType
|
||||||
|
data={featureTypeData}
|
||||||
|
page={pageFeature}
|
||||||
|
featureId={featureId}
|
||||||
|
pageSize={pageSizeFeature}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Card className="bg-gray-900">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center">
|
|
||||||
<div className="relative w-full sm:w-96">
|
|
||||||
<Search
|
|
||||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Qidirish..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => openModal("add")}
|
|
||||||
className="w-full sm:w-auto cursor-pointer"
|
|
||||||
>
|
|
||||||
<Plus size={20} className="mr-2" />
|
|
||||||
Yangi qo'shish
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-gray-900">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="min-w-full">
|
|
||||||
<div className="border-b">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-20">
|
|
||||||
ID
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider flex-1">
|
|
||||||
Nomi
|
|
||||||
</div>
|
|
||||||
{activeTab === "badges" && (
|
|
||||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-48">
|
|
||||||
Rang
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(activeTab === "tariffs" || activeTab === "transports") && (
|
|
||||||
<div className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-32">
|
|
||||||
Narx
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider w-32">
|
|
||||||
Amallar
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y">
|
|
||||||
{filteredData.length === 0 ? (
|
|
||||||
<div className="px-6 py-8 text-center text-muted-foreground">
|
|
||||||
Ma'lumot topilmadi
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredData.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex items-center hover:bg-accent/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="px-6 py-4 w-20">{item.id}</div>
|
|
||||||
<div className="px-6 py-4 font-medium flex-1">
|
|
||||||
{item.name}
|
|
||||||
</div>
|
|
||||||
{activeTab === "badges" && (
|
|
||||||
<div className="px-6 py-4 w-48">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded border"
|
|
||||||
style={{ backgroundColor: (item as Badge).color }}
|
|
||||||
/>
|
|
||||||
<span>{(item as Badge).color}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(activeTab === "tariffs" ||
|
|
||||||
activeTab === "transports") && (
|
|
||||||
<div className="px-6 py-4 w-32">
|
|
||||||
{(item as Tariff).price}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="px-6 py-4 w-32">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => openModal("edit", item)}
|
|
||||||
>
|
|
||||||
<Edit2 size={18} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleDelete(item.id)}
|
|
||||||
>
|
|
||||||
<Trash2 size={18} className="text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
||||||
<DialogContent className="sm:max-w-[500px] bg-gray-900">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{modalMode === "add" ? "Yangi qo'shish" : "Tahrirlash"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{getFormFields().map((field) => (
|
|
||||||
<div key={field.name} className="space-y-2">
|
|
||||||
<Label htmlFor={field.name}>
|
|
||||||
{field.label}
|
|
||||||
{field.required && (
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
{field.type === "textarea" ? (
|
|
||||||
<Textarea
|
|
||||||
id={field.name}
|
|
||||||
value={getFieldValue(field.name) as string}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
[field.name]: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
required={field.required}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
) : field.type === "select" ? (
|
|
||||||
<Select
|
|
||||||
value={getFieldValue(field.name) as string}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, [field.name]: value })
|
|
||||||
}
|
|
||||||
required={field.required}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Tanlang" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{field.options?.map((opt) => (
|
|
||||||
<SelectItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
id={field.name}
|
|
||||||
type={field.type}
|
|
||||||
value={getFieldValue(field.name)}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
[field.name]:
|
|
||||||
field.type === "number"
|
|
||||||
? Number(e.target.value)
|
|
||||||
: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
required={field.required}
|
|
||||||
min={field.min}
|
|
||||||
max={field.max}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Bekor qilish
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={handleSubmit} className="flex-1">
|
|
||||||
Saqlash
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
334
src/pages/tours/ui/TransportTable.tsx
Normal file
334
src/pages/tours/ui/TransportTable.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import {
|
||||||
|
hotelTranportCreate,
|
||||||
|
hotelTransportDelete,
|
||||||
|
hotelTransportDetail,
|
||||||
|
hotelTransportUpdate,
|
||||||
|
} from "@/pages/tours/lib/api";
|
||||||
|
import { TranportColumns } from "@/pages/tours/lib/column";
|
||||||
|
import type { Transport } from "@/pages/tours/lib/type";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/shared/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import IconSelect from "@/shared/ui/iocnSelect";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/ui/table";
|
||||||
|
import RealPagination from "@/widgets/real-pagination/ui/RealPagination";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Loader, PlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
icon_name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TransportTable = ({
|
||||||
|
data,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
data:
|
||||||
|
| {
|
||||||
|
links: { previous: string; next: string };
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: Transport[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [types, setTypes] = useState<"edit" | "create">("create");
|
||||||
|
const [selectedIcon, setSelectedIcon] = useState("Bus");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
name_ru: "",
|
||||||
|
icon_name: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setValue("icon_name", selectedIcon);
|
||||||
|
}, [selectedIcon]);
|
||||||
|
|
||||||
|
const handleEdit = (id: number) => {
|
||||||
|
setTypes("edit");
|
||||||
|
setOpen(true);
|
||||||
|
setEditId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: transportDetail } = useQuery({
|
||||||
|
queryKey: ["detail_transport", editId],
|
||||||
|
queryFn: () => hotelTransportDetail({ id: editId! }),
|
||||||
|
enabled: !!editId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (transportDetail) {
|
||||||
|
form.setValue("name", transportDetail.data.data.name);
|
||||||
|
form.setValue("name_ru", transportDetail.data.data.name_ru);
|
||||||
|
form.setValue("icon_name", transportDetail.data.data.icon_name);
|
||||||
|
setSelectedIcon(transportDetail.data.data.icon_name);
|
||||||
|
}
|
||||||
|
}, [transportDetail, editId, form]);
|
||||||
|
|
||||||
|
const { mutate: deleteMutate } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) => hotelTransportDelete({ id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["all_transport"] });
|
||||||
|
toast.success(t("O‘chirildi"), { position: "top-center" });
|
||||||
|
},
|
||||||
|
onError: () =>
|
||||||
|
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: create, isPending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: { name: string; name_ru: string; icon_name: string };
|
||||||
|
}) => hotelTranportCreate({ body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["all_transport"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
toast.success(t("Muvaffaqiyatli qo‘shildi"), { position: "top-center" });
|
||||||
|
},
|
||||||
|
onError: () =>
|
||||||
|
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: update, isPending: updatePending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { name: string; name_ru: string; icon_name: string };
|
||||||
|
}) => hotelTransportUpdate({ body, id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["all_transport"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
toast.success(t("Tahrirlandi"), { position: "top-center" });
|
||||||
|
},
|
||||||
|
onError: () =>
|
||||||
|
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
const body = {
|
||||||
|
name: values.name,
|
||||||
|
name_ru: values.name_ru,
|
||||||
|
icon_name: selectedIcon || values.icon_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (types === "create") create({ body });
|
||||||
|
if (types === "edit" && editId) update({ id: editId, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => deleteMutate({ id });
|
||||||
|
|
||||||
|
const columns = TranportColumns(handleEdit, handleDelete, t);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: data?.results ?? [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: data?.total_pages ?? 0,
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: page - 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setTypes("create");
|
||||||
|
form.reset();
|
||||||
|
setSelectedIcon("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-2" />
|
||||||
|
{t("Qo‘shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-gray-700 rounded-md overflow-hidden mt-4">
|
||||||
|
<Table key={data?.current_page}>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center text-gray-400"
|
||||||
|
>
|
||||||
|
{t("Ma'lumot topilmadi")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RealPagination
|
||||||
|
table={table}
|
||||||
|
totalPages={data?.total_pages}
|
||||||
|
namePage="pageTransport"
|
||||||
|
namePageSize="pageTransportSize"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<p className="text-xl font-semibold mb-4">
|
||||||
|
{types === "create"
|
||||||
|
? t("Yangi transport qo‘shish")
|
||||||
|
: t("Tahrirlash")}
|
||||||
|
</p>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6 p-2"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi (uz)")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi (uz)")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi (ru)")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="icon_name"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Belgi (Icon)")}</FormLabel>
|
||||||
|
<FormControl className="w-full">
|
||||||
|
<IconSelect
|
||||||
|
setSelectedIcon={setSelectedIcon}
|
||||||
|
selectedIcon={selectedIcon}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending || updatePending}>
|
||||||
|
{isPending || updatePending ? (
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
) : types === "create" ? (
|
||||||
|
t("Saqlash")
|
||||||
|
) : (
|
||||||
|
t("Tahrirlash")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransportTable;
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import formatPhone from "@/shared/lib/formatPhone";
|
|
||||||
import { Button } from "@/shared/ui/button";
|
|
||||||
import { Input } from "@/shared/ui/input";
|
|
||||||
import { Label } from "@/shared/ui/label";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { ArrowLeft, Mail, Phone, Save, User } from "lucide-react";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
export default function EditUser() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { id } = useParams();
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
username: "",
|
|
||||||
email: "",
|
|
||||||
phone: "+998",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFormData({
|
|
||||||
username: "john_doe",
|
|
||||||
email: "john@example.com",
|
|
||||||
phone: "+998901234567",
|
|
||||||
});
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const validateForm = () => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (!formData.username.trim()) {
|
|
||||||
newErrors.username = "Username majburiy";
|
|
||||||
} else if (formData.username.length < 3) {
|
|
||||||
newErrors.username =
|
|
||||||
"Username kamida 3 ta belgidan iborat bo'lishi kerak";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.email.trim() && !formData.phone.trim()) {
|
|
||||||
newErrors.contact = "Email yoki telefon raqami kiritilishi shart";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
|
||||||
newErrors.email = "Email formati noto'g'ri";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
formData.phone &&
|
|
||||||
!/^\+998\d{9}$/.test(formData.phone.replace(/\s/g, ""))
|
|
||||||
) {
|
|
||||||
newErrors.phone = "Telefon raqami formati: +998901234567";
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (validateForm()) {
|
|
||||||
navigate("/");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
|
|
||||||
<div className="w-full mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="mb-4 -ml-2 text-slate-400 cursor-pointer hover:text-slate-100 hover:bg-slate-800/50"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Orqaga
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-600 to-teal-700 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
|
||||||
<User className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-100">Tahrirlash</h1>
|
|
||||||
<p className="text-slate-400 text-sm">Ma'lumotlarni yangilang</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Card */}
|
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6 md:p-8">
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
|
||||||
{/* Username */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="username"
|
|
||||||
className="text-slate-300 font-medium text-sm"
|
|
||||||
>
|
|
||||||
Username <span className="text-red-400">*</span>
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
value={formData.username}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormData((p) => ({ ...p, username: e.target.value }));
|
|
||||||
setErrors((p) => ({ ...p, username: "" }));
|
|
||||||
}}
|
|
||||||
placeholder="john_doe"
|
|
||||||
className={clsx(
|
|
||||||
"h-11 pl-10 rounded-lg border transition-colors bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
|
||||||
errors.username
|
|
||||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
|
||||||
: "border-slate-700/50 focus:border-emerald-500 hover:border-slate-600/50",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.username && (
|
|
||||||
<p className="text-xs text-red-400">{errors.username}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="email"
|
|
||||||
className="text-slate-300 font-medium text-sm"
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormData((p) => ({ ...p, email: e.target.value }));
|
|
||||||
setErrors((p) => ({ ...p, email: "", contact: "" }));
|
|
||||||
}}
|
|
||||||
placeholder="email@example.com"
|
|
||||||
className={clsx(
|
|
||||||
"h-11 pl-10 rounded-lg border transition-colors bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
|
||||||
errors.email
|
|
||||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
|
||||||
: "border-slate-700/50 focus:border-emerald-500 hover:border-slate-600/50",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.email && (
|
|
||||||
<p className="text-xs text-red-400">{errors.email}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="phone"
|
|
||||||
className="text-slate-300 font-medium text-sm"
|
|
||||||
>
|
|
||||||
Telefon raqami
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
||||||
<Input
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
value={formatPhone(formData.phone)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFormData((p) => ({ ...p, phone: e.target.value }));
|
|
||||||
setErrors((p) => ({ ...p, phone: "", contact: "" }));
|
|
||||||
}}
|
|
||||||
placeholder="+998 90 123 45 67"
|
|
||||||
className={clsx(
|
|
||||||
"h-11 pl-10 rounded-lg border transition-colors bg-slate-800/50 text-slate-200 placeholder:text-slate-500",
|
|
||||||
errors.phone
|
|
||||||
? "border-red-500/50 focus:border-red-500 bg-red-500/10"
|
|
||||||
: "border-slate-700/50 focus:border-emerald-500 hover:border-slate-600/50",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.phone && (
|
|
||||||
<p className="text-xs text-red-400">{errors.phone}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Error */}
|
|
||||||
{errors.contact && (
|
|
||||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3">
|
|
||||||
<p className="text-xs text-amber-400">{errors.contact}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="flex-1 h-11 rounded-lg border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 hover:text-slate-100 font-medium cursor-pointer"
|
|
||||||
>
|
|
||||||
Bekor qilish
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 h-11 rounded-lg bg-gradient-to-r cursor-pointer text-md from-emerald-600 to-teal-700 hover:from-emerald-700 hover:to-teal-800 text-white font-medium shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/30 transition-all"
|
|
||||||
>
|
|
||||||
<Save className="!w-5 !h-5 mr-2" />
|
|
||||||
Yangilash
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
import { Button } from "@/shared/ui/button";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
Calendar,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Eye,
|
|
||||||
Mail,
|
|
||||||
Pencil,
|
|
||||||
Phone,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
Users,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
type User = {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UserList() {
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const usersPerPage = 6;
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [users, setUsers] = useState<User[]>([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
username: "john_doe",
|
|
||||||
email: "john@example.com",
|
|
||||||
createdAt: "2024-01-15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
username: "jane_smith",
|
|
||||||
phone: "+998907654321",
|
|
||||||
createdAt: "2024-01-20",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
username: "ali_karimov",
|
|
||||||
phone: "+998909876543",
|
|
||||||
createdAt: "2024-02-01",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
username: "sara_johnson",
|
|
||||||
email: "sara@example.com",
|
|
||||||
phone: "+998901234567",
|
|
||||||
createdAt: "2024-02-10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
username: "murod_toshev",
|
|
||||||
email: "murod@example.com",
|
|
||||||
createdAt: "2024-02-15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
username: "aziza_sobirova",
|
|
||||||
email: "aziza@example.com",
|
|
||||||
createdAt: "2024-03-01",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
username: "timur_ergashev",
|
|
||||||
phone: "+998907777777",
|
|
||||||
createdAt: "2024-03-10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
username: "odil_akbarov",
|
|
||||||
email: "odil@example.com",
|
|
||||||
createdAt: "2024-03-12",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
username: "lola_nazarova",
|
|
||||||
phone: "+998909111222",
|
|
||||||
createdAt: "2024-04-05",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
username: "bahrom_tursunov",
|
|
||||||
email: "bahrom@example.com",
|
|
||||||
createdAt: "2024-04-10",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState<User | null>(null);
|
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
|
||||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
|
||||||
setConfirmDelete(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPhone = (phone: string) => {
|
|
||||||
if (phone.startsWith("+998")) {
|
|
||||||
return phone.replace(
|
|
||||||
/(\+998)(\d{2})(\d{3})(\d{2})(\d{2})/,
|
|
||||||
"$1 $2 $3 $4 $5",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return phone;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredUsers = users.filter(
|
|
||||||
(user) =>
|
|
||||||
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
user.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
user.phone?.includes(searchQuery),
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
|
|
||||||
const startIndex = (currentPage - 1) * usersPerPage;
|
|
||||||
const paginatedUsers = filteredUsers.slice(
|
|
||||||
startIndex,
|
|
||||||
startIndex + usersPerPage,
|
|
||||||
);
|
|
||||||
|
|
||||||
const getInitials = (username: string) => username.slice(0, 2).toUpperCase();
|
|
||||||
|
|
||||||
const getAvatarGradient = (id: number) => {
|
|
||||||
const gradients = ["from-blue-600 to-cyan-500"];
|
|
||||||
return gradients[id % gradients.length];
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 py-12 px-4 w-full">
|
|
||||||
<div className="max-w-[90%] mx-auto">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div className="mb-12">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="p-3 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20">
|
|
||||||
<Users className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
|
|
||||||
Foydalanuvchilar
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-400 text-lg ml-14">
|
|
||||||
Jami {users.length} ta foydalanuvchini boshqaring
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate("/users/create")}
|
|
||||||
className="py-5 px-4 bg-gradient-to-br text-white cursor-pointer from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20"
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
<p>Foydalanuvchi Qo'shish</p>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
|
|
||||||
<StatCard
|
|
||||||
title="Jami foydalanuvchilar"
|
|
||||||
value={users.length.toString()}
|
|
||||||
icon={<Users className="w-6 h-6" />}
|
|
||||||
gradient="from-blue-600 to-blue-400"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Email bilan ro'yxatlangan"
|
|
||||||
value={users.filter((u) => u.email).length.toString()}
|
|
||||||
icon={<Mail className="w-6 h-6" />}
|
|
||||||
gradient="from-cyan-600 to-cyan-400"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Telefon bilan ro'yxatlangan"
|
|
||||||
value={users.filter((u) => u.phone).length.toString()}
|
|
||||||
icon={<Phone className="w-6 h-6" />}
|
|
||||||
gradient="from-purple-600 to-pink-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-10 relative">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/10 to-cyan-600/10 rounded-2xl blur-xl" />
|
|
||||||
<div className="relative bg-slate-800/50 border border-slate-700/50 rounded-2xl p-6 backdrop-blur-sm">
|
|
||||||
<Search className="absolute left-8 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Username, email yoki telefon raqami bo'yicha qidirish..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchQuery(e.target.value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="w-full pl-14 pr-4 py-3 bg-slate-700/30 border border-slate-600/50 text-white placeholder-slate-400 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-10">
|
|
||||||
{paginatedUsers.map((user) => (
|
|
||||||
<div className="group relative hover:scale-105 transition-transform duration-300">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/20 to-cyan-600/20 rounded-2xl blur-xl group-hover:blur-2xl transition-all opacity-0 group-hover:opacity-100" />
|
|
||||||
<div className="relative h-full flex flex-col justify-between bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl p-6 shadow-2xl hover:border-slate-600/70 transition-all backdrop-blur-sm">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
<div
|
|
||||||
className={`w-16 h-16 rounded-xl bg-gradient-to-br ${getAvatarGradient(
|
|
||||||
user.id,
|
|
||||||
)} flex items-center justify-center text-white text-xl font-bold shadow-lg`}
|
|
||||||
>
|
|
||||||
{getInitials(user.username)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-xl font-bold text-white truncate">
|
|
||||||
{user.username}
|
|
||||||
</h3>
|
|
||||||
<span className="inline-block mt-1 px-3 py-1 bg-green-500/20 text-green-300 text-xs font-semibold rounded-full border border-green-500/50">
|
|
||||||
Faol
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 pt-4 border-t border-slate-700/50">
|
|
||||||
{user.email && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Mail className="w-5 h-5 text-cyan-400" />
|
|
||||||
<span className="text-slate-300 text-sm truncate">
|
|
||||||
{user.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{user.phone && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Phone className="w-5 h-5 text-cyan-400" />
|
|
||||||
<span className="text-slate-300 text-sm">
|
|
||||||
{formatPhone(user.phone)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Calendar className="w-5 h-5 text-cyan-400" />
|
|
||||||
<span className="text-slate-300 text-sm">
|
|
||||||
{user.createdAt}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4 mt-4 border-t border-slate-700/50">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/users/${user.id}/`)}
|
|
||||||
className="flex-1 flex cursor-pointer items-center justify-center gap-2 px-4 py-2.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 font-medium rounded-lg transition-all border border-blue-500/30 hover:border-blue-500/50"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
Ko'rish
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/users/${user.id}/edit`)}
|
|
||||||
className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-green-500/20 hover:bg-green-500/30 text-green-300 font-medium rounded-lg transition-all border border-green-500/30 hover:border-green-500/50"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
Tahrirlash
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete(user)}
|
|
||||||
className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-300 font-medium rounded-lg transition-all border border-red-500/30 hover:border-red-500/50"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
O'chirish
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
|
||||||
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{[...Array(totalPages)].map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setCurrentPage(i + 1)}
|
|
||||||
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
|
||||||
currentPage === i + 1
|
|
||||||
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
|
||||||
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
|
||||||
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{confirmDelete && (
|
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
|
||||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 bg-red-500/20 rounded-xl border border-red-500/30">
|
|
||||||
<Trash2 className="w-6 h-6 text-red-400" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-white">
|
|
||||||
Foydalanuvchini o'chirish
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete(null)}
|
|
||||||
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5 text-slate-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="py-4">
|
|
||||||
<p className="text-slate-300">
|
|
||||||
Siz{" "}
|
|
||||||
<span className="font-semibold text-white">
|
|
||||||
{confirmDelete.username}
|
|
||||||
</span>{" "}
|
|
||||||
foydalanuvchini o'chirmoqchimisiz?
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
|
||||||
<p className="text-sm text-red-300 font-medium flex items-center gap-2">
|
|
||||||
<AlertTriangle className="w-4 h-4" />
|
|
||||||
<span>Ushbu amalni qaytarib bo'lmaydi.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDelete(null)}
|
|
||||||
className="flex-1 px-4 py-3 border border-slate-600 hover:bg-slate-700/50 text-slate-300 font-medium rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
Bekor qilish
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(confirmDelete.id)}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-white font-medium rounded-lg transition-all shadow-lg"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
<span>O'chirish</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
icon,
|
|
||||||
gradient,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
value: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
gradient: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="group relative hover:scale-105 transition-transform duration-300">
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 bg-gradient-to-r ${gradient} rounded-xl blur-lg opacity-20 group-hover:opacity-40 transition-all`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`relative bg-gradient-to-br ${gradient} bg-opacity-10 border border-white/10 backdrop-blur-sm rounded-xl p-6 shadow-xl hover:shadow-2xl transition-all hover:border-white/20`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start mb-3">
|
|
||||||
<p className="text-slate-300 text-sm font-medium">{title}</p>
|
|
||||||
<div
|
|
||||||
className={`bg-gradient-to-br ${gradient} p-2 rounded-lg text-white shadow-lg`}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-3xl font-bold text-white">{value}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,689 +0,0 @@
|
|||||||
import { Button } from "@/shared/ui/button";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Bus,
|
|
||||||
Calendar,
|
|
||||||
Clock,
|
|
||||||
CreditCard,
|
|
||||||
DollarSign,
|
|
||||||
Download,
|
|
||||||
Edit,
|
|
||||||
Mail,
|
|
||||||
MapPin,
|
|
||||||
Package,
|
|
||||||
Phone,
|
|
||||||
Shield,
|
|
||||||
Ticket,
|
|
||||||
User,
|
|
||||||
Users as UsersIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
type PassportImage = {
|
|
||||||
id: number;
|
|
||||||
image: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Companion = {
|
|
||||||
id: number;
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
birth_date: string;
|
|
||||||
phone_number: string;
|
|
||||||
gender: "male" | "female";
|
|
||||||
participant_pasport_image: PassportImage[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Participant = {
|
|
||||||
id: number;
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
gender: "male" | "female";
|
|
||||||
};
|
|
||||||
|
|
||||||
type TicketInfo = {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
service_name: string;
|
|
||||||
location_name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExtraService = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExtraPaidService = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BookingTicket = {
|
|
||||||
id: number;
|
|
||||||
departure: string;
|
|
||||||
destination: string;
|
|
||||||
departure_date: string;
|
|
||||||
arrival_time: string;
|
|
||||||
participant: Participant[];
|
|
||||||
ticket: TicketInfo;
|
|
||||||
tariff: string;
|
|
||||||
transport: string;
|
|
||||||
extra_service: ExtraService[];
|
|
||||||
extra_paid_service: ExtraPaidService[];
|
|
||||||
total_price: number;
|
|
||||||
order_status: "pending_payment" | "confirmed" | "cancelled";
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserData = {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
createdAt: string;
|
|
||||||
status: "active" | "inactive";
|
|
||||||
companions: Companion[];
|
|
||||||
bookings: BookingTicket[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserDetail = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { id } = useParams();
|
|
||||||
const [user, setUser] = useState<UserData | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Backend'dan ma'lumot olish
|
|
||||||
setUser({
|
|
||||||
id: Number(id),
|
|
||||||
username: "john_doe",
|
|
||||||
email: "john@example.com",
|
|
||||||
phone: "+998901234567",
|
|
||||||
createdAt: "2024-01-15",
|
|
||||||
status: "active",
|
|
||||||
companions: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
first_name: "Aziza",
|
|
||||||
last_name: "Karimova",
|
|
||||||
birth_date: "1995-05-20",
|
|
||||||
phone_number: "+998901111111",
|
|
||||||
gender: "female",
|
|
||||||
participant_pasport_image: [
|
|
||||||
{ id: 1, image: "/images/passport1.jpg" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
first_name: "Sardor",
|
|
||||||
last_name: "Toshev",
|
|
||||||
birth_date: "1990-08-15",
|
|
||||||
phone_number: "+998902222222",
|
|
||||||
gender: "male",
|
|
||||||
participant_pasport_image: [
|
|
||||||
{ id: 2, image: "/images/passport2.jpg" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
bookings: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
departure: "Toshkent",
|
|
||||||
destination: "Samarqand",
|
|
||||||
departure_date: "2024-06-20",
|
|
||||||
arrival_time: "2024-06-20T18:30:00",
|
|
||||||
participant: [
|
|
||||||
{ id: 1, first_name: "John", last_name: "Doe", gender: "male" },
|
|
||||||
],
|
|
||||||
ticket: {
|
|
||||||
id: 1,
|
|
||||||
title: "Premium Class",
|
|
||||||
service_name: "Express Service",
|
|
||||||
location_name: "Central Station",
|
|
||||||
},
|
|
||||||
tariff: "Standard",
|
|
||||||
transport: "Bus",
|
|
||||||
extra_service: [
|
|
||||||
{ id: 1, name: "Wi-Fi" },
|
|
||||||
{ id: 2, name: "Refreshments" },
|
|
||||||
],
|
|
||||||
extra_paid_service: [{ id: 1, name: "Extra Luggage", price: 50000 }],
|
|
||||||
total_price: 150000,
|
|
||||||
order_status: "confirmed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
departure: "Samarqand",
|
|
||||||
destination: "Buxoro",
|
|
||||||
departure_date: "2024-06-25",
|
|
||||||
arrival_time: "2024-06-25T15:00:00",
|
|
||||||
participant: [
|
|
||||||
{ id: 2, first_name: "Jane", last_name: "Smith", gender: "female" },
|
|
||||||
],
|
|
||||||
ticket: {
|
|
||||||
id: 2,
|
|
||||||
title: "Economy Class",
|
|
||||||
service_name: "Standard Service",
|
|
||||||
location_name: "Main Terminal",
|
|
||||||
},
|
|
||||||
tariff: "Economy",
|
|
||||||
transport: "Train",
|
|
||||||
extra_service: [{ id: 3, name: "AC" }],
|
|
||||||
extra_paid_service: [],
|
|
||||||
total_price: 120000,
|
|
||||||
order_status: "confirmed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
departure: "Samarqand",
|
|
||||||
destination: "Buxoro",
|
|
||||||
departure_date: "2024-06-25",
|
|
||||||
arrival_time: "2024-06-25T15:00:00",
|
|
||||||
participant: [
|
|
||||||
{ id: 2, first_name: "Jane", last_name: "Smith", gender: "female" },
|
|
||||||
],
|
|
||||||
ticket: {
|
|
||||||
id: 2,
|
|
||||||
title: "Economy Class",
|
|
||||||
service_name: "Standard Service",
|
|
||||||
location_name: "Main Terminal",
|
|
||||||
},
|
|
||||||
tariff: "Economy",
|
|
||||||
transport: "Train",
|
|
||||||
extra_service: [{ id: 3, name: "AC" }],
|
|
||||||
extra_paid_service: [],
|
|
||||||
total_price: 120000,
|
|
||||||
order_status: "confirmed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const formatPhone = (phone: string) => {
|
|
||||||
if (phone.startsWith("+998")) {
|
|
||||||
return phone.replace(
|
|
||||||
/(\+998)(\d{2})(\d{3})(\d{2})(\d{2})/,
|
|
||||||
"$1 $2 $3 $4 $5",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return phone;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPrice = (price: number) => {
|
|
||||||
return price.toLocaleString("uz-UZ") + " so'm";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const badges = {
|
|
||||||
confirmed:
|
|
||||||
"bg-emerald-500/20 text-emerald-400 border border-emerald-500/30",
|
|
||||||
pending_payment:
|
|
||||||
"bg-amber-500/20 text-amber-400 border border-amber-500/30",
|
|
||||||
cancelled: "bg-red-500/20 text-red-400 border border-red-500/30",
|
|
||||||
};
|
|
||||||
const labels = {
|
|
||||||
confirmed: "Tasdiqlangan",
|
|
||||||
pending_payment: "To'lov kutilmoqda",
|
|
||||||
cancelled: "Bekor qilingan",
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
class: badges[status as keyof typeof badges],
|
|
||||||
label: labels[status as keyof typeof labels],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadPDF = (bookingId: number) => {
|
|
||||||
console.log("Downloading PDF for booking:", bookingId);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto"></div>
|
|
||||||
<p className="mt-4 text-slate-400">Yuklanmoqda...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
|
|
||||||
<div className="w-full mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => navigate("/")}
|
|
||||||
className="mb-4 -ml-2 text-slate-400 hover:text-slate-100 hover:bg-slate-800/50 cursor-pointer"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Orqaga
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
|
||||||
<User className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-slate-100">
|
|
||||||
{user.username}
|
|
||||||
</h1>
|
|
||||||
<span
|
|
||||||
className={`inline-block mt-2 px-3 py-1 text-xs font-medium rounded-full border ${
|
|
||||||
user.status === "active"
|
|
||||||
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
|
||||||
: "bg-slate-700/50 text-slate-400 border-slate-600/30"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{user.status === "active" ? "Faol" : "Nofaol"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/users/${id}/edit`)}
|
|
||||||
className="bg-gradient-to-r cursor-pointer from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white shadow-lg shadow-blue-500/20"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
|
||||||
Tahrirlash
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{/* Left Column - Main Info */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
{/* Contact Information */}
|
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
|
||||||
<Mail className="w-5 h-5 text-blue-400" />
|
|
||||||
Aloqa ma'lumotlari
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{user.email && (
|
|
||||||
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0 border border-blue-500/30">
|
|
||||||
<Mail className="w-5 h-5 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 font-medium">
|
|
||||||
Email
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-200 font-medium">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{user.phone && (
|
|
||||||
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center flex-shrink-0 border border-emerald-500/30">
|
|
||||||
<Phone className="w-5 h-5 text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 font-medium">
|
|
||||||
Telefon
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{formatPhone(user.phone)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account Information */}
|
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
|
||||||
<Shield className="w-5 h-5 text-indigo-400" />
|
|
||||||
Hisob ma'lumotlari
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center flex-shrink-0 border border-purple-500/30">
|
|
||||||
<User className="w-5 h-5 text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 font-medium">
|
|
||||||
Username
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{user.username}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0 border border-orange-500/30">
|
|
||||||
<Calendar className="w-5 h-5 text-orange-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 font-medium">
|
|
||||||
Yaratilgan sana
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{user.createdAt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Booking Tickets */}
|
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
|
||||||
<Ticket className="w-5 h-5 text-emerald-400" />
|
|
||||||
Sotib olingan chiptalar
|
|
||||||
<span className="ml-auto text-sm font-normal text-slate-500">
|
|
||||||
{user.bookings.length} ta
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
|
|
||||||
{user.bookings.length > 0 ? (
|
|
||||||
user.bookings.map((booking) => {
|
|
||||||
const statusBadge = getStatusBadge(booking.order_status);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={booking.id}
|
|
||||||
className="p-5 bg-slate-800/50 rounded-xl border border-slate-700/50 hover:border-blue-500/50 transition-colors space-y-4"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MapPin className="w-5 h-5 text-blue-400" />
|
|
||||||
<span className="font-semibold text-slate-100 text-lg">
|
|
||||||
{booking.departure} → {booking.destination}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 text-xs font-medium rounded-full ${statusBadge.class}`}
|
|
||||||
>
|
|
||||||
{statusBadge.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ticket Info */}
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm bg-slate-900/50 p-3 rounded-lg border border-slate-700/30">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Chipta turi
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{booking.ticket.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Xizmat</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{booking.ticket.service_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Manzil</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{booking.ticket.location_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Transport</p>
|
|
||||||
<p className="text-slate-200 font-medium flex items-center gap-1">
|
|
||||||
<Bus className="w-3 h-3" />
|
|
||||||
{booking.transport}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dates */}
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4 text-slate-500" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Jo'nash</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{booking.departure_date}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4 text-slate-500" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Yetish</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{booking.arrival_time.split("T")[1].slice(0, 5)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Participants */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 mb-2">
|
|
||||||
Yo'lovchilar:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{booking.participant.map((p) => (
|
|
||||||
<span
|
|
||||||
key={p.id}
|
|
||||||
className="px-3 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-full border border-blue-500/30"
|
|
||||||
>
|
|
||||||
{p.first_name} {p.last_name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Services */}
|
|
||||||
{booking.extra_service.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 mb-2">
|
|
||||||
Qo'shimcha xizmatlar:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{booking.extra_service.map((service) => (
|
|
||||||
<span
|
|
||||||
key={service.id}
|
|
||||||
className="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-xs rounded-lg flex items-center gap-1 border border-emerald-500/30"
|
|
||||||
>
|
|
||||||
<Package className="w-3 h-3" />
|
|
||||||
{service.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Paid Services */}
|
|
||||||
{booking.extra_paid_service.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 mb-2">
|
|
||||||
Pullik xizmatlar:
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{booking.extra_paid_service.map((service) => (
|
|
||||||
<div
|
|
||||||
key={service.id}
|
|
||||||
className="flex items-center justify-between text-xs bg-amber-500/10 px-3 py-2 rounded-lg border border-amber-500/20"
|
|
||||||
>
|
|
||||||
<span className="text-slate-300">
|
|
||||||
{service.name}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-amber-400">
|
|
||||||
{formatPrice(service.price)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Total & Actions */}
|
|
||||||
<div className="flex items-center justify-between pt-3 border-t border-slate-700/50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DollarSign className="w-5 h-5 text-emerald-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Jami narx
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-bold text-emerald-400">
|
|
||||||
{formatPrice(booking.total_price)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleDownloadPDF(booking.id)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center gap-2 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 border-slate-700/50 hover:border-slate-600/50"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
PDF yuklab olish
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-500 text-center py-4">
|
|
||||||
Hozircha chiptalar mavjud emas
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Companions */}
|
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
|
||||||
<UsersIcon className="w-5 h-5 text-purple-400" />
|
|
||||||
Hamrohlar
|
|
||||||
<span className="ml-auto text-sm font-normal text-slate-500">
|
|
||||||
{user.companions.length} ta
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
|
|
||||||
{user.companions.length > 0 ? (
|
|
||||||
user.companions.map((companion) => (
|
|
||||||
<div
|
|
||||||
key={companion.id}
|
|
||||||
className="p-4 bg-slate-800/50 rounded-xl border border-slate-700/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="w-14 h-14 rounded-lg bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-purple-500/20">
|
|
||||||
<User className="w-7 h-7 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-100 text-lg">
|
|
||||||
{companion.first_name} {companion.last_name}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 text-xs rounded-full border ${
|
|
||||||
companion.gender === "male"
|
|
||||||
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
|
|
||||||
: "bg-pink-500/20 text-pink-400 border-pink-500/30"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{companion.gender === "male" ? "Erkak" : "Ayol"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Tug'ilgan sana
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{companion.birth_date}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">Telefon</p>
|
|
||||||
<p className="text-slate-200 font-medium">
|
|
||||||
{formatPhone(companion.phone_number)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{companion.participant_pasport_image.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 mb-2">
|
|
||||||
Passport rasmlari:
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{companion.participant_pasport_image.map(
|
|
||||||
(img) => (
|
|
||||||
<div
|
|
||||||
key={img.id}
|
|
||||||
className="w-20 h-20 rounded-lg bg-slate-700/50 flex items-center justify-center overflow-hidden border border-slate-600/50"
|
|
||||||
>
|
|
||||||
<CreditCard className="w-8 h-8 text-slate-500" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-500 text-center py-4">
|
|
||||||
Hozircha hamrohlar qo'shilmagan
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - Stats */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
|
||||||
<Clock className="w-5 h-5 text-emerald-400" />
|
|
||||||
Statistika
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between p-3 bg-emerald-500/10 rounded-lg border border-emerald-500/20">
|
|
||||||
<span className="text-sm text-slate-300">Chiptalar</span>
|
|
||||||
<span className="text-sm font-semibold text-emerald-400">
|
|
||||||
{user.bookings.length} ta
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-3 bg-purple-500/10 rounded-lg border border-purple-500/20">
|
|
||||||
<span className="text-sm text-slate-300">Hamrohlar</span>
|
|
||||||
<span className="text-sm font-semibold text-purple-400">
|
|
||||||
{user.companions.length} ta
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
|
||||||
<span className="text-sm text-slate-300">Status</span>
|
|
||||||
<span className="text-sm font-semibold text-blue-400">
|
|
||||||
{user.status === "active" ? "Faol" : "Nofaol"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-3 bg-orange-500/10 rounded-lg border border-orange-500/20">
|
|
||||||
<span className="text-sm text-slate-300">ID</span>
|
|
||||||
<span className="text-sm font-semibold text-orange-400">
|
|
||||||
#{user.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-blue-600 to-indigo-700 rounded-2xl shadow-xl shadow-blue-500/20 p-6 text-white border border-blue-500/20">
|
|
||||||
<h3 className="font-semibold mb-2">Qo'shimcha ma'lumot</h3>
|
|
||||||
<p className="text-sm text-blue-100">
|
|
||||||
Bu foydalanuvchi hozirda tizimda faol holatda. Barcha
|
|
||||||
ma'lumotlar to'liq va tasdiqlangan.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UserDetail;
|
|
||||||
64
src/pages/users/lib/api.ts
Normal file
64
src/pages/users/lib/api.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { UsersData, UsersDetaiData } from "@/pages/users/lib/type";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import {
|
||||||
|
DOWNLOAD_PDF,
|
||||||
|
GET_ALL_USERS,
|
||||||
|
UPDATE_USER,
|
||||||
|
} from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const getAllUsers = async ({
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
search,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
search: string;
|
||||||
|
}): Promise<AxiosResponse<UsersData>> => {
|
||||||
|
const response = await httpClient.get(GET_ALL_USERS, {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
search,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserDetail = async ({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<AxiosResponse<UsersDetaiData>> => {
|
||||||
|
const response = await httpClient.get(`${GET_ALL_USERS}${id}`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadPdf = async (body: {
|
||||||
|
order_id: number | null;
|
||||||
|
lang: string;
|
||||||
|
}): Promise<AxiosResponse<any>> => {
|
||||||
|
const response = await httpClient.post(DOWNLOAD_PDF, body, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = async ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
};
|
||||||
|
id: number;
|
||||||
|
}) => {
|
||||||
|
const response = await httpClient.patch(`${UPDATE_USER}${id}/`, body);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { downloadPdf, getAllUsers, getUserDetail, updateUser };
|
||||||
99
src/pages/users/lib/type.ts
Normal file
99
src/pages/users/lib/type.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
export interface UsersData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: string;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: string;
|
||||||
|
current_page: string;
|
||||||
|
results: {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
avatar: string;
|
||||||
|
validated_at: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
user_register_phone: string;
|
||||||
|
user_register_email: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsersDetaiData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
avatar: string;
|
||||||
|
validated_at: string;
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
id: 0;
|
||||||
|
departure: string;
|
||||||
|
transport: string;
|
||||||
|
destination: string;
|
||||||
|
ticket: 0;
|
||||||
|
travel_agency_id: string;
|
||||||
|
tariff: string;
|
||||||
|
departure_date: string;
|
||||||
|
arrival_time: string;
|
||||||
|
location_name: string;
|
||||||
|
participant: [
|
||||||
|
{
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
extra_service: [
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
extra_paid_service: [
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
total_price: number;
|
||||||
|
order_status:
|
||||||
|
| "pending_payment"
|
||||||
|
| "pending_confirmation"
|
||||||
|
| "cancelled"
|
||||||
|
| "confirmed"
|
||||||
|
| "completed";
|
||||||
|
},
|
||||||
|
];
|
||||||
|
participant: [
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
birth_date: string;
|
||||||
|
phone_number: string;
|
||||||
|
gender: "male" | "female";
|
||||||
|
participant_pasport_image: [
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
image: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ticket_count: string;
|
||||||
|
participant_count: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,11 +14,12 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export default function CreateUser() {
|
export default function CreateUser() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: "",
|
username: "",
|
||||||
email: "",
|
email: "",
|
||||||
@@ -62,8 +63,8 @@ export default function CreateUser() {
|
|||||||
|
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
newErrors.password = "Parol majburiy";
|
newErrors.password = "Parol majburiy";
|
||||||
} else if (formData.password.length < 6) {
|
} else if (formData.password.length < 8) {
|
||||||
newErrors.password = "Parol kamida 6 ta belgidan iborat bo'lishi kerak";
|
newErrors.password = "Parol kamida 8 ta belgidan iborat bo'lishi kerak";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
if (formData.password !== formData.confirmPassword) {
|
||||||
@@ -99,7 +100,7 @@ export default function CreateUser() {
|
|||||||
className="mb-4 -ml-2 text-slate-400 hover:text-slate-100 hover:bg-slate-800/50 cursor-pointer"
|
className="mb-4 -ml-2 text-slate-400 hover:text-slate-100 hover:bg-slate-800/50 cursor-pointer"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
Orqaga
|
{t("Orqaga")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
@@ -108,10 +109,10 @@ export default function CreateUser() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-slate-100">
|
<h1 className="text-3xl font-bold text-slate-100">
|
||||||
Yangi foydalanuvchi
|
{t("Yangi foydalanuvchi")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-400 mt-1">
|
<p className="text-slate-400 mt-1">
|
||||||
Ma'lumotlarni to'ldiring va saqlang
|
{t("Ma'lumotlarni to'ldiring va saqlang")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +128,7 @@ export default function CreateUser() {
|
|||||||
htmlFor="username"
|
htmlFor="username"
|
||||||
className="text-slate-300 font-medium"
|
className="text-slate-300 font-medium"
|
||||||
>
|
>
|
||||||
Username <span className="text-red-400">*</span>
|
{t("Ismi")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
@@ -149,8 +150,7 @@ export default function CreateUser() {
|
|||||||
</div>
|
</div>
|
||||||
{errors.username && (
|
{errors.username && (
|
||||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
{t(errors.username)}
|
||||||
{errors.username}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +158,7 @@ export default function CreateUser() {
|
|||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email" className="text-slate-300 font-medium">
|
<Label htmlFor="email" className="text-slate-300 font-medium">
|
||||||
Email
|
{t("Email")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
@@ -181,8 +181,7 @@ export default function CreateUser() {
|
|||||||
</div>
|
</div>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
{t(errors.email)}
|
||||||
{errors.email}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +189,7 @@ export default function CreateUser() {
|
|||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone" className="text-slate-300 font-medium">
|
<Label htmlFor="phone" className="text-slate-300 font-medium">
|
||||||
Telefon raqami
|
{t("Telefon raqami")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
@@ -213,8 +212,7 @@ export default function CreateUser() {
|
|||||||
</div>
|
</div>
|
||||||
{errors.phone && (
|
{errors.phone && (
|
||||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
{t(errors.phone)}
|
||||||
{errors.phone}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,7 +233,7 @@ export default function CreateUser() {
|
|||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
className="text-slate-300 font-medium"
|
className="text-slate-300 font-medium"
|
||||||
>
|
>
|
||||||
Parol <span className="text-red-400">*</span>
|
{t("Parol")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
@@ -271,8 +269,7 @@ export default function CreateUser() {
|
|||||||
</div>
|
</div>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
{t(errors.password)}
|
||||||
{errors.password}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +280,7 @@ export default function CreateUser() {
|
|||||||
htmlFor="confirmPassword"
|
htmlFor="confirmPassword"
|
||||||
className="text-slate-300 font-medium"
|
className="text-slate-300 font-medium"
|
||||||
>
|
>
|
||||||
Parolni tasdiqlang <span className="text-red-400">*</span>
|
{t("Parolni tasdiqlang")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
@@ -322,8 +319,7 @@ export default function CreateUser() {
|
|||||||
</div>
|
</div>
|
||||||
{errors.confirmPassword && (
|
{errors.confirmPassword && (
|
||||||
<p className="text-sm text-red-400 flex items-center gap-1">
|
<p className="text-sm text-red-400 flex items-center gap-1">
|
||||||
<span className="w-1 h-1 rounded-full bg-red-400"></span>
|
{t(errors.confirmPassword)}
|
||||||
{errors.confirmPassword}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -336,13 +332,13 @@ export default function CreateUser() {
|
|||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
className="flex-1 h-14 rounded-xl cursor-pointer border-2 border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 hover:text-slate-100 hover:border-slate-600/50 font-medium transition-all duration-200"
|
className="flex-1 h-14 rounded-xl cursor-pointer border-2 border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 hover:text-slate-100 hover:border-slate-600/50 font-medium transition-all duration-200"
|
||||||
>
|
>
|
||||||
Bekor qilish
|
{t("Bekor qilish")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1 h-14 rounded-xl cursor-pointer bg-gradient-to-r from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white font-medium shadow-lg shadow-blue-500/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200"
|
className="flex-1 h-14 rounded-xl cursor-pointer bg-gradient-to-r from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white font-medium shadow-lg shadow-blue-500/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200"
|
||||||
>
|
>
|
||||||
Saqlash
|
{t("Saqlash")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
263
src/pages/users/ui/Edit.tsx
Normal file
263
src/pages/users/ui/Edit.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { getUserDetail, updateUser } from "@/pages/users/lib/api";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import formatPhone from "@/shared/lib/formatPhone";
|
||||||
|
import onlyNumber from "@/shared/lib/onlyNumber";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import { ArrowLeft, Mail, Phone, Save, User } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
first_name: z
|
||||||
|
.string()
|
||||||
|
.min(3, "Ism kamida 3 ta belgidan iborat bo‘lishi kerak")
|
||||||
|
.nonempty("Ism majburiy"),
|
||||||
|
last_name: z
|
||||||
|
.string()
|
||||||
|
.min(3, "Familiya kamida 3 ta belgidan iborat bo‘lishi kerak")
|
||||||
|
.nonempty("Familiya majburiy"),
|
||||||
|
email: z.string().email("Email formati noto‘g‘ri").or(z.literal("")),
|
||||||
|
phone: z.string().min(9, "Telefon raqam to‘liq emas").or(z.literal("+998")),
|
||||||
|
})
|
||||||
|
.refine((data) => data.email || data.phone, {
|
||||||
|
message: "Email yoki telefon raqami kiritilishi shart",
|
||||||
|
path: ["contact"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function EditUser() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["user_detail", id],
|
||||||
|
queryFn: () => getUserDetail({ id: Number(id) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "+998",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
const u = data.data.data;
|
||||||
|
form.reset({
|
||||||
|
first_name: u.first_name,
|
||||||
|
last_name: u.last_name,
|
||||||
|
email: u.email || "",
|
||||||
|
phone: formatPhone(u.phone),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const { mutate, isPending } = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
};
|
||||||
|
id: number;
|
||||||
|
}) => updateUser({ body, id }),
|
||||||
|
onSuccess: () => navigate("/user"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof formSchema>) => {
|
||||||
|
if (data) {
|
||||||
|
mutate({
|
||||||
|
body: {
|
||||||
|
first_name: values.first_name,
|
||||||
|
last_name: values.last_name,
|
||||||
|
email: values.email.length === 0 ? null : values.email,
|
||||||
|
phone: values.phone.length === 0 ? null : onlyNumber(values.phone),
|
||||||
|
},
|
||||||
|
id: data.data.data.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
|
||||||
|
<div className="w-full mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="mb-4 -ml-2 text-slate-400 cursor-pointer hover:text-slate-100 hover:bg-slate-800/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
{t("Orqaga")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-600 to-teal-700 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||||
|
<User className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-100">
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
{t("Ma'lumotlarni yangilang")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6 md:p-8">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
{/* First Name */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="first_name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-slate-300">
|
||||||
|
{t("Ismi")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="John"
|
||||||
|
className="pl-10 h-11 bg-slate-800/50 text-slate-200 border-slate-700/50 focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Last Name */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="last_name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-slate-300">
|
||||||
|
{t("Familiyasi")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="Doe"
|
||||||
|
className="pl-10 h-11 bg-slate-800/50 text-slate-200 border-slate-700/50 focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-slate-300">
|
||||||
|
{t("Email")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
className="pl-10 h-11 bg-slate-800/50 text-slate-200 border-slate-700/50 focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-slate-300">
|
||||||
|
{t("Telefon raqami")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={formatPhone(field.value)}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
placeholder="+998 90 123 45 67"
|
||||||
|
className="pl-10 h-11 bg-slate-800/50 text-slate-200 border-slate-700/50 focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Submit buttons */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="flex-1 h-11 rounded-lg border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 hover:text-slate-100 font-medium"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex-1 h-11 rounded-lg bg-gradient-to-r from-emerald-600 to-teal-700 hover:from-emerald-700 hover:to-teal-800 text-white font-medium"
|
||||||
|
>
|
||||||
|
<Save className="w-5 h-5 mr-2" />
|
||||||
|
{t("Yangilash")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
350
src/pages/users/ui/User.tsx
Normal file
350
src/pages/users/ui/User.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import { getAllUsers } from "@/pages/users/lib/api";
|
||||||
|
import formatPhone from "@/shared/lib/formatPhone";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Calendar,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
Pencil,
|
||||||
|
Phone,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function UserList() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const usersPerPage = 6;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["user_all", currentPage, searchQuery],
|
||||||
|
queryFn: () =>
|
||||||
|
getAllUsers({
|
||||||
|
page: currentPage,
|
||||||
|
page_size: usersPerPage,
|
||||||
|
search: searchQuery,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAvatarGradient = (id: number) => {
|
||||||
|
const gradients = ["from-blue-600 to-cyan-500"];
|
||||||
|
return gradients[id % gradients.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||||
|
<p className="text-lg">
|
||||||
|
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("Qayta urinish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 py-12 px-4 w-full">
|
||||||
|
<div className="max-w-[90%] mx-auto">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="mb-12">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20">
|
||||||
|
<Users className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-cyan-300 to-blue-400 bg-clip-text text-transparent">
|
||||||
|
{t("Foydalanuvchilar")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-lg ml-14">
|
||||||
|
{t("Jami")} {data?.data.data.total_items}{" "}
|
||||||
|
{t("ta foydalanuvchini boshqaring")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* <Button
|
||||||
|
onClick={() => navigate("/users/create")}
|
||||||
|
className="py-5 px-4 bg-gradient-to-br text-white cursor-pointer from-blue-500 to-cyan-500 rounded-xl shadow-lg shadow-cyan-500/20"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
<p>{t("Foydalanuvchi Qo'shish")}</p>
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
|
||||||
|
<StatCard
|
||||||
|
title={t("Jami foydalanuvchilar")}
|
||||||
|
value={data ? data.data.data.total_items : "0"}
|
||||||
|
icon={<Users className="w-6 h-6" />}
|
||||||
|
gradient="from-blue-600 to-blue-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("Email bilan ro'yxatlangan")}
|
||||||
|
value={data ? data.data.data.results.user_register_email : "0"}
|
||||||
|
icon={<Mail className="w-6 h-6" />}
|
||||||
|
gradient="from-cyan-600 to-cyan-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("Telefon bilan ro'yxatlangan")}
|
||||||
|
value={data ? data.data.data.results.user_register_phone : "0"}
|
||||||
|
icon={<Phone className="w-6 h-6" />}
|
||||||
|
gradient="from-purple-600 to-pink-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-10 relative">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/10 to-cyan-600/10 rounded-2xl blur-xl" />
|
||||||
|
<div className="relative bg-slate-800/50 border border-slate-700/50 rounded-2xl p-6 backdrop-blur-sm">
|
||||||
|
<Search className="absolute left-8 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t(
|
||||||
|
"Username, email yoki telefon raqami bo'yicha qidirish",
|
||||||
|
)}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="w-full pl-14 pr-4 py-3 bg-slate-700/30 border border-slate-600/50 text-white placeholder-slate-400 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
|
||||||
|
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="max-w-[90%] mx-auto">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6 mb-10">
|
||||||
|
{data &&
|
||||||
|
data.data.data.results.users.map((e) => (
|
||||||
|
<div className="group relative hover:scale-105 transition-transform duration-300">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/20 to-cyan-600/20 rounded-2xl blur-xl group-hover:blur-2xl transition-all opacity-0 group-hover:opacity-100" />
|
||||||
|
<div className="relative h-full flex flex-col justify-between bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl p-6 shadow-2xl hover:border-slate-600/70 transition-all backdrop-blur-sm">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col items-center gap-4 mb-4">
|
||||||
|
<Avatar
|
||||||
|
className={`w-16 h-16 rounded-xl bg-gradient-to-br ${getAvatarGradient(
|
||||||
|
e.id,
|
||||||
|
)} flex items-center justify-center text-white text-xl font-bold shadow-lg`}
|
||||||
|
>
|
||||||
|
<AvatarImage src={e.avatar} />
|
||||||
|
<AvatarFallback
|
||||||
|
className={`w-16 h-16 rounded-xl bg-gradient-to-br ${getAvatarGradient(
|
||||||
|
e.id,
|
||||||
|
)} flex items-center justify-center text-white text-xl font-bold shadow-lg`}
|
||||||
|
>
|
||||||
|
{e.first_name.slice(0, 1).toUpperCase()}
|
||||||
|
{e.last_name.slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
<h3 className="text-xl font-bold text-white truncate">
|
||||||
|
{e.first_name} {e.last_name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 pt-4 border-t border-slate-700/50">
|
||||||
|
{e.email && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Mail className="w-5 h-5 text-cyan-400" />
|
||||||
|
<span className="text-slate-300 text-sm truncate">
|
||||||
|
{e.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{e.phone && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Phone className="w-5 h-5 text-cyan-400" />
|
||||||
|
<span className="text-slate-300 text-sm">
|
||||||
|
{formatPhone(e.phone)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Calendar className="w-5 h-5 text-cyan-400" />
|
||||||
|
<span className="text-slate-300 text-sm">
|
||||||
|
{e.validated_at}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 pt-4 mt-4 border-t border-slate-700/50">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/users/${e.id}/`)}
|
||||||
|
className="flex-1 flex cursor-pointer items-center justify-center gap-2 px-4 py-2.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 font-medium rounded-lg transition-all border border-blue-500/30 hover:border-blue-500/50"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
{t("Ko'rish")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/users/${e.id}/edit`)}
|
||||||
|
className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-green-500/20 hover:bg-green-500/30 text-green-300 font-medium rounded-lg transition-all border border-green-500/30 hover:border-green-500/50"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 flex items-center cursor-pointer justify-center gap-2 px-4 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-300 font-medium rounded-lg transition-all border border-red-500/30 hover:border-red-500/50">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{t("O'chirish")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{[...Array(data?.data.data.total_pages)].map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrentPage(i + 1)}
|
||||||
|
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
||||||
|
currentPage === i + 1
|
||||||
|
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
||||||
|
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={
|
||||||
|
data
|
||||||
|
? currentPage === data.data.data.total_pages
|
||||||
|
: currentPage === 1
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((p) =>
|
||||||
|
Math.min(p + 1, data ? data.data.data.total_pages : 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* {confirmDelete && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700/50 rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-red-500/20 rounded-xl border border-red-500/30">
|
||||||
|
<Trash2 className="w-6 h-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
{t("Foydalanuvchini o'chirish")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-slate-300">
|
||||||
|
{t("Siz")}{" "}
|
||||||
|
<span className="font-semibold text-white">
|
||||||
|
{confirmDelete.username}
|
||||||
|
</span>{" "}
|
||||||
|
{t("foydalanuvchini o'chirmoqchimisiz?")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||||
|
<p className="text-sm text-red-300 font-medium flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
<span>{t("Ushbu amalni qaytarib bo'lmaydi")}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
className="flex-1 px-4 py-3 border border-slate-600 hover:bg-slate-700/50 text-slate-300 font-medium rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(confirmDelete.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-white font-medium rounded-lg transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span>{t("O'chirish")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
gradient,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
gradient: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="group relative hover:scale-105 transition-transform duration-300">
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-gradient-to-r ${gradient} rounded-xl blur-lg opacity-20 group-hover:opacity-40 transition-all`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`relative bg-gradient-to-br ${gradient} bg-opacity-10 border border-white/10 backdrop-blur-sm rounded-xl p-6 shadow-xl hover:shadow-2xl transition-all hover:border-white/20`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<p className="text-slate-300 text-sm font-medium">{title}</p>
|
||||||
|
<div
|
||||||
|
className={`bg-gradient-to-br ${gradient} p-2 rounded-lg text-white shadow-lg`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-white">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
558
src/pages/users/ui/UserDetail.tsx
Normal file
558
src/pages/users/ui/UserDetail.tsx
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
import { downloadPdf, getUserDetail } from "@/pages/users/lib/api";
|
||||||
|
import i18n from "@/shared/config/i18n";
|
||||||
|
import formatPhone from "@/shared/lib/formatPhone";
|
||||||
|
import formatPrice from "@/shared/lib/formatPrice";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
|
Phone,
|
||||||
|
Shield,
|
||||||
|
Ticket,
|
||||||
|
User,
|
||||||
|
Users as UsersIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const UserDetail = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const locale = i18n.language;
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["user_detail", id],
|
||||||
|
queryFn: () => getUserDetail({ id: Number(id) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) =>
|
||||||
|
downloadPdf({ lang: locale, order_id: id }),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const blob = new Blob([res.data], { type: "application/pdf" });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `ticket-order-${id}.pdf`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Xatolik yuz berdi");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const badges = {
|
||||||
|
confirmed:
|
||||||
|
"bg-emerald-500/20 text-emerald-400 border border-emerald-500/30",
|
||||||
|
pending_payment:
|
||||||
|
"bg-amber-500/20 text-amber-400 border border-amber-500/30",
|
||||||
|
cancelled: "bg-red-500/20 text-red-400 border border-red-500/30",
|
||||||
|
};
|
||||||
|
const labels = {
|
||||||
|
confirmed: "Tasdiqlangan",
|
||||||
|
pending_payment: "To'lov kutilmoqda",
|
||||||
|
cancelled: "Bekor qilingan",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
class: badges[status as keyof typeof badges],
|
||||||
|
label: labels[status as keyof typeof labels],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadPDF = (bookingId: number) => {
|
||||||
|
mutate({
|
||||||
|
id: bookingId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-white gap-4 w-full">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
|
||||||
|
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4 md:p-8">
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div className="w-full mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
className="mb-4 -ml-2 text-slate-400 hover:text-slate-100 hover:bg-slate-800/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
{t("Orqaga")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||||
|
<User className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-100">
|
||||||
|
{data?.data.data.first_name} {data?.data.data.last_name}
|
||||||
|
</h1>
|
||||||
|
{/* <span
|
||||||
|
className={`inline-block mt-2 px-3 py-1 text-xs font-medium rounded-full border ${
|
||||||
|
data?.data.data.status === "active"
|
||||||
|
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
||||||
|
: "bg-slate-700/50 text-slate-400 border-slate-600/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.status === "active" ? t("Faol") : t("Nofaol")}
|
||||||
|
</span> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/users/${id}/edit`)}
|
||||||
|
className="bg-gradient-to-r cursor-pointer from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 text-white shadow-lg shadow-blue-500/20"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
{t("Tahrirlash")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-blue-400" />
|
||||||
|
{t("Aloqa ma'lumotlari")}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data?.data.data.email && (
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0 border border-blue-500/30">
|
||||||
|
<Mail className="w-5 h-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-medium">
|
||||||
|
{t("Email")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{data?.data.data.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.data.data.phone && (
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center flex-shrink-0 border border-emerald-500/30">
|
||||||
|
<Phone className="w-5 h-5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-medium">
|
||||||
|
{t("Telefon raqami")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{formatPhone(data?.data.data.phone)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-indigo-400" />
|
||||||
|
{t("Hisob ma'lumotlari")}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center flex-shrink-0 border border-purple-500/30">
|
||||||
|
<User className="w-5 h-5 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-medium">
|
||||||
|
{t("Ismi")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{data?.data.data.first_name}{" "}
|
||||||
|
{data?.data.data.last_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-slate-800/50 rounded-xl border border-slate-700/50">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center flex-shrink-0 border border-orange-500/30">
|
||||||
|
<Calendar className="w-5 h-5 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 font-medium">
|
||||||
|
{t("Yaratilgan sana")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{data?.data.data.validated_at}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||||
|
<Ticket className="w-5 h-5 text-emerald-400" />
|
||||||
|
{t("Sotib olingan chiptalar")}
|
||||||
|
<span className="ml-auto text-sm font-normal text-slate-500">
|
||||||
|
{data?.data.data.orders.length} ta
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
|
||||||
|
{data?.data.data.orders.length > 0 ? (
|
||||||
|
data?.data.data.orders.map((booking) => {
|
||||||
|
const statusBadge = getStatusBadge(
|
||||||
|
booking.order_status,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={booking.id}
|
||||||
|
className="p-5 bg-slate-800/50 rounded-xl border border-slate-700/50 hover:border-blue-500/50 transition-colors space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-blue-400" />
|
||||||
|
<span className="font-semibold text-slate-100 text-lg">
|
||||||
|
{booking.departure} → {booking.destination}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-full ${statusBadge.class}`}
|
||||||
|
>
|
||||||
|
{statusBadge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm bg-slate-900/50 p-3 rounded-lg border border-slate-700/30">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Chipta turi")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{booking.tariff}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{booking.extra_service.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Xizmat")}
|
||||||
|
</p>
|
||||||
|
<>
|
||||||
|
{booking.extra_service.map((e) => (
|
||||||
|
<p
|
||||||
|
className="text-slate-200 font-medium"
|
||||||
|
key={e.id}
|
||||||
|
>
|
||||||
|
{e.name}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Manzil")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{booking.location_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Transport")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium flex items-center gap-1">
|
||||||
|
{booking.transport}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-slate-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Jo'nash")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{booking.departure_date}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-slate-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Yetish")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{booking.arrival_time}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-2">
|
||||||
|
{t("Yo'lovchilar")}:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{booking.participant.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.first_name}
|
||||||
|
className="px-3 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-full border border-blue-500/30"
|
||||||
|
>
|
||||||
|
{p.first_name} {p.last_name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booking.extra_service.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-2">
|
||||||
|
{t("Qo'shimcha xizmatlar")}:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{booking.extra_service.map((service) => (
|
||||||
|
<span
|
||||||
|
key={service.id}
|
||||||
|
className="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-xs rounded-lg flex items-center gap-1 border border-emerald-500/30"
|
||||||
|
>
|
||||||
|
<Package className="w-3 h-3" />
|
||||||
|
{service.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{booking.extra_paid_service.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-2">
|
||||||
|
{t("Pullik xizmatlar")}:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{booking.extra_paid_service.map((service) => (
|
||||||
|
<div
|
||||||
|
key={service.id}
|
||||||
|
className="flex items-center justify-between text-xs bg-amber-500/10 px-3 py-2 rounded-lg border border-amber-500/20"
|
||||||
|
>
|
||||||
|
<span className="text-slate-300">
|
||||||
|
{service.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-amber-400">
|
||||||
|
{formatPrice(service.price, true)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="w-5 h-5 text-emerald-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Jami narx")}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-emerald-400">
|
||||||
|
{formatPrice(booking.total_price, true)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownloadPDF(booking.id)}
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-2 bg-slate-800/50 hover:bg-slate-700/50 text-slate-300 border-slate-700/50 hover:border-slate-600/50"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
{t("PDF yuklab olish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500 text-center py-4">
|
||||||
|
{t("Hozircha chiptalar mavjud emas")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||||
|
<UsersIcon className="w-5 h-5 text-purple-400" />
|
||||||
|
{t("Hamrohlar")}
|
||||||
|
<span className="ml-auto text-sm font-normal text-slate-500">
|
||||||
|
{data.data.data.participant.length} ta
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
|
||||||
|
{data.data.data.participant.length > 0 ? (
|
||||||
|
data.data.data.participant.map((companion) => (
|
||||||
|
<div
|
||||||
|
key={companion.id}
|
||||||
|
className="p-4 bg-slate-800/50 rounded-xl border border-slate-700/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-lg bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-purple-500/20">
|
||||||
|
<User className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-100 text-lg">
|
||||||
|
{companion.first_name} {companion.last_name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full border ${
|
||||||
|
companion.gender === "male"
|
||||||
|
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
|
||||||
|
: "bg-pink-500/20 text-pink-400 border-pink-500/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{companion.gender === "male"
|
||||||
|
? t("Erkak")
|
||||||
|
: t("Ayol")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Tug'ilgan sana")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{companion.birth_date}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Telefon raqami")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{formatPhone(companion.phone_number)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{companion.participant_pasport_image.length >
|
||||||
|
0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-2">
|
||||||
|
{t("Passport rasmlari")}:
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{companion.participant_pasport_image.map(
|
||||||
|
(img) => (
|
||||||
|
<div
|
||||||
|
key={img.id}
|
||||||
|
className="w-20 h-20 rounded-lg bg-slate-700/50 flex items-center justify-center overflow-hidden border border-slate-600/50"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="passport image"
|
||||||
|
src={img.image}
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500 text-center py-4">
|
||||||
|
{t("Hozircha hamrohlar qo'shilmagan")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5 text-emerald-400" />
|
||||||
|
{t("Statistika")}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-emerald-500/10 rounded-lg border border-emerald-500/20">
|
||||||
|
<span className="text-sm text-slate-300">
|
||||||
|
{t("Chiptalar")}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-emerald-400">
|
||||||
|
{data.data.data.orders.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-purple-500/10 rounded-lg border border-purple-500/20">
|
||||||
|
<span className="text-sm text-slate-300">
|
||||||
|
{t("Hamrohlar")}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-purple-400">
|
||||||
|
{data.data.data.participant_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* <div className="flex items-center justify-between p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
||||||
|
<span className="text-sm text-slate-300">
|
||||||
|
{t("Status")}
|
||||||
|
</span>
|
||||||
|
</div> */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-orange-500/10 rounded-lg border border-orange-500/20">
|
||||||
|
<span className="text-sm text-slate-300">{t("ID")}</span>
|
||||||
|
<span className="text-sm font-semibold text-orange-400">
|
||||||
|
#{data.data.data.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-blue-600 to-indigo-700 rounded-2xl shadow-xl shadow-blue-500/20 p-6 text-white border border-blue-500/20">
|
||||||
|
<h3 className="font-semibold mb-2">
|
||||||
|
{t("Qo'shimcha ma'lumot")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-100">
|
||||||
|
{t(
|
||||||
|
"Bu foydalanuvchi hozirda tizimda faol holatda. Barcha ma'lumotlar to'liq va tasdiqlangan.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserDetail;
|
||||||
@@ -1,6 +1,41 @@
|
|||||||
const BASE_URL =
|
const BASE_URL =
|
||||||
import.meta.env.VITE_API_URL || 'https://jsonplaceholder.typicode.com';
|
import.meta.env.VITE_API_URL || "https://simple-travel.felixits.uz/api/v1/";
|
||||||
|
|
||||||
const ENDP_POSTS = '/posts/';
|
const AUTH_LOGIN = "auth/token/phone/";
|
||||||
|
const GET_ME = "auth/me/";
|
||||||
|
const GET_ALL_USERS = "dashboard/users/";
|
||||||
|
const DOWNLOAD_PDF = "get-order-pdf/";
|
||||||
|
const UPDATE_USER = "/dashboard/users/";
|
||||||
|
const GET_ALL_AGENCY = "dashboard/tour-agency/";
|
||||||
|
const GET_ALL_EMPLOYEES = "dashboard/employees/";
|
||||||
|
const GET_TICKET = "dashboard/dashboard-tickets/";
|
||||||
|
const HOTEL_BADGE = "dashboard/dashboard-tickets-settings-badge/";
|
||||||
|
const HOTEL_FEATURES = "dashboard/dashboard-ticket-hotel-feature-type/";
|
||||||
|
const HOTEL_FEATURES_TYPE = "dashboard/dashboard-ticket-hotel-feature/";
|
||||||
|
const HOTEL_TARIF = "dashboard/dashboard-tickets-settings-tariff/";
|
||||||
|
const TOUR_TRANSPORT = "dashboard/dashboard-tickets-settings-transport/";
|
||||||
|
const HPTEL_TYPES = "dashboard/dashboard-tickets-settings-hotel-type/";
|
||||||
|
const NEWS = "dashboard/dashboard-post/";
|
||||||
|
const NEWS_CATEGORY = "dashboard/dashboard-category/";
|
||||||
|
const HOTEL = "dashboard/dashboard-hotel/";
|
||||||
|
|
||||||
export { BASE_URL, ENDP_POSTS };
|
export {
|
||||||
|
AUTH_LOGIN,
|
||||||
|
BASE_URL,
|
||||||
|
DOWNLOAD_PDF,
|
||||||
|
GET_ALL_AGENCY,
|
||||||
|
GET_ALL_EMPLOYEES,
|
||||||
|
GET_ALL_USERS,
|
||||||
|
GET_ME,
|
||||||
|
GET_TICKET,
|
||||||
|
HOTEL,
|
||||||
|
HOTEL_BADGE,
|
||||||
|
HOTEL_FEATURES,
|
||||||
|
HOTEL_FEATURES_TYPE,
|
||||||
|
HOTEL_TARIF,
|
||||||
|
HPTEL_TYPES,
|
||||||
|
NEWS,
|
||||||
|
NEWS_CATEGORY,
|
||||||
|
TOUR_TRANSPORT,
|
||||||
|
UPDATE_USER,
|
||||||
|
};
|
||||||
|
|||||||
22
src/shared/config/api/auth/api.ts
Normal file
22
src/shared/config/api/auth/api.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { LoginData, MeData } from "@/shared/config/api/auth/auth.model";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { AUTH_LOGIN, GET_ME } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const authLogin = async ({
|
||||||
|
phone,
|
||||||
|
password,
|
||||||
|
}: {
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<AxiosResponse<LoginData>> => {
|
||||||
|
const response = await httpClient.post(AUTH_LOGIN, { phone, password });
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMe = async (): Promise<AxiosResponse<MeData>> => {
|
||||||
|
const response = await httpClient.get(GET_ME);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { authLogin, getMe };
|
||||||
32
src/shared/config/api/auth/auth.model.ts
Normal file
32
src/shared/config/api/auth/auth.model.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export interface LoginData {
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
last_login: string;
|
||||||
|
is_superuser: boolean;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
is_staff: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
date_joined: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string;
|
||||||
|
validated_at: string;
|
||||||
|
role:
|
||||||
|
| "superuser"
|
||||||
|
| "admin"
|
||||||
|
| "moderator"
|
||||||
|
| "tour_admin"
|
||||||
|
| "buxgalter"
|
||||||
|
| "operator"
|
||||||
|
| "user";
|
||||||
|
travel_agency: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,28 @@
|
|||||||
import i18n from "@/shared/config/i18n";
|
import i18n from "@/shared/config/i18n";
|
||||||
import axios from "axios";
|
import {
|
||||||
|
getAuthToken,
|
||||||
|
getRefAuthToken,
|
||||||
|
removeAuthToken,
|
||||||
|
removeRefAuthToken,
|
||||||
|
setAuthToken,
|
||||||
|
} from "@/shared/lib/authCookies";
|
||||||
|
import axios, { AxiosError } from "axios";
|
||||||
import { BASE_URL } from "./URLs";
|
import { BASE_URL } from "./URLs";
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: {
|
||||||
|
resolve: (token: string) => void;
|
||||||
|
reject: (error: any) => void;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const processQueue = (error: any, token: string | null = null) => {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) prom.reject(error);
|
||||||
|
else if (token) prom.resolve(token);
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
const httpClient = axios.create({
|
const httpClient = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@@ -9,13 +30,16 @@ const httpClient = axios.create({
|
|||||||
|
|
||||||
httpClient.interceptors.request.use(
|
httpClient.interceptors.request.use(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
// Language configs
|
// Faqat GET so'rovlarida Accept-Language headerini qo'shish
|
||||||
|
if (config.method?.toLowerCase() === "get") {
|
||||||
const language = i18n.language;
|
const language = i18n.language;
|
||||||
config.headers["Accept-Language"] = language;
|
config.headers["Accept-Language"] = language;
|
||||||
// const accessToken = localStorage.getItem('accessToken');
|
}
|
||||||
// if (accessToken) {
|
|
||||||
// config.headers['Authorization'] = `Bearer ${accessToken}`;
|
const accessToken = getAuthToken();
|
||||||
// }
|
if (accessToken) {
|
||||||
|
config.headers["Authorization"] = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
@@ -24,7 +48,62 @@ httpClient.interceptors.request.use(
|
|||||||
|
|
||||||
httpClient.interceptors.response.use(
|
httpClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as any;
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
originalRequest.headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
return httpClient(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => Promise.reject(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
const refreshToken = getRefAuthToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
removeAuthToken();
|
||||||
|
removeRefAuthToken();
|
||||||
|
window.location.href = "/login";
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${BASE_URL}auth/token/refresh/`, {
|
||||||
|
refresh: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newAccessToken = response.data.access;
|
||||||
|
setAuthToken(newAccessToken);
|
||||||
|
|
||||||
|
httpClient.defaults.headers["Authorization"] =
|
||||||
|
`Bearer ${newAccessToken}`;
|
||||||
|
processQueue(null, newAccessToken);
|
||||||
|
|
||||||
|
originalRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;
|
||||||
|
return httpClient(originalRequest);
|
||||||
|
} catch (refreshError: any) {
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
removeAuthToken();
|
||||||
|
removeRefAuthToken();
|
||||||
|
const status = refreshError.response?.status;
|
||||||
|
|
||||||
|
if ([401].includes(status)) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.error("API error:", error);
|
console.error("API error:", error);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1 @@
|
|||||||
import httpClient from '@/shared/config/api/httpClient';
|
|
||||||
import type { TestApiType } from '@/shared/config/api/test/test.model';
|
|
||||||
import type { ReqWithPagination } from '@/shared/config/api/types';
|
|
||||||
import { ENDP_POSTS } from '@/shared/config/api/URLs';
|
|
||||||
import type { AxiosResponse } from 'axios';
|
|
||||||
|
|
||||||
const getPosts = async (
|
|
||||||
pagination?: ReqWithPagination,
|
|
||||||
): Promise<AxiosResponse<TestApiType>> => {
|
|
||||||
const response = await httpClient.get(ENDP_POSTS, { params: pagination });
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getPosts };
|
|
||||||
|
|||||||
@@ -1,13 +1,332 @@
|
|||||||
{
|
{
|
||||||
"welcome": "Добро пожаловать на наш сайт",
|
"Admin Panelga Kirish": "Вход в Админ Панель",
|
||||||
"language": "Язык",
|
"Telefon raqam": "Номер телефона",
|
||||||
|
"Parol": "Пароль",
|
||||||
|
"Parolingizni kiriting": "Введите пароль",
|
||||||
|
"Kirish": "Войти",
|
||||||
|
"Admin Panel": "Админ Панель",
|
||||||
"Foydalanuvchilar": "Пользователи",
|
"Foydalanuvchilar": "Пользователи",
|
||||||
"Tur firmalar": "Турфирмы",
|
"Tur firmalar": "Турфирмы",
|
||||||
"Xodimlar": "Сотрудники",
|
"Xodimlar": "Сотрудники",
|
||||||
"Byudjet": "Бюджет",
|
"Byudjet": "Бюджет",
|
||||||
"Turlar": "Туры",
|
"Turlar": "Туры",
|
||||||
|
"Tur sozlamalari": "Настройки туров",
|
||||||
"Bronlar": "Бронирования",
|
"Bronlar": "Бронирования",
|
||||||
"Yangiliklar": "Новости",
|
"Yangiliklar": "Новости",
|
||||||
"Yordam Arizalar": "Заявки на помощь",
|
"Kategoriya": "Категория",
|
||||||
"Tur sozlamalari": "Настройки тура"
|
"FAQ": "FAQ",
|
||||||
|
"Savollar ro‘yxati": "Список вопросов",
|
||||||
|
"Savollar kategoriyasi": "Категории вопросов",
|
||||||
|
"Arizalar": "Заявки",
|
||||||
|
"Agentlik arizalari": "Заявки агентств",
|
||||||
|
"Yordam arizalari": "Заявки на помощь",
|
||||||
|
"Sayt sozlamalari": "Настройки сайта",
|
||||||
|
"Sayt SEOsi": "SEO сайта",
|
||||||
|
"Offerta": "Оферта",
|
||||||
|
"Yordam pagelari": "Страницы помощи",
|
||||||
|
"Jami": "Всего",
|
||||||
|
"ta foydalanuvchini boshqaring": "управляйте пользователями",
|
||||||
|
"Foydalanuvchi Qo'shish": "Добавить пользователя",
|
||||||
|
"Jami foydalanuvchilar": "Всего пользователей",
|
||||||
|
"Email bilan ro'yxatlangan": "Зарегистрированы по email",
|
||||||
|
"Telefon bilan ro'yxatlangan": "Зарегистрированы по телефону",
|
||||||
|
"Username, email yoki telefon raqami bo'yicha qidirish": "Поиск по имени пользователя, email или номеру телефона...",
|
||||||
|
"Faol": "Активен",
|
||||||
|
"Ko'rish": "Просмотр",
|
||||||
|
"Tahrirlash": "Редактировать",
|
||||||
|
"O'chirish": "Удалить",
|
||||||
|
"Foydalanuvchini o'chirish": "Удалить пользователя",
|
||||||
|
"Siz": "Вы",
|
||||||
|
"foydalanuvchini o'chirmoqchimisiz?": "хотите удалить пользователя?",
|
||||||
|
"Ushbu amalni qaytarib bo'lmaydi": "Это действие невозможно отменить.",
|
||||||
|
"Bekor qilish": "Отмена",
|
||||||
|
"Orqaga": "Назад",
|
||||||
|
"Yangi foydalanuvchi": "Новый пользователь",
|
||||||
|
"Ma'lumotlarni to'ldiring va saqlang": "Заполните данные и сохраните",
|
||||||
|
"Ismi": "Имя",
|
||||||
|
"Email": "Email",
|
||||||
|
"Telefon raqami": "Номер телефона",
|
||||||
|
"Parolni tasdiqlang": "Подтвердите пароль",
|
||||||
|
"Saqlash": "Сохранить",
|
||||||
|
"Username majburiy": "Имя пользователя обязательно",
|
||||||
|
"Username kamida 3 ta belgidan iborat bo'lishi kerak": "Имя пользователя должно содержать не менее 3 символов",
|
||||||
|
"Email yoki telefon raqamlardan kamida bittasi kiritilishi kerak": "Должен быть введен как минимум один из email или номера телефона",
|
||||||
|
"Email formati noto'g'ri": "Неверный формат email",
|
||||||
|
"Telefon raqami formati: +998901234567": "Формат номера телефона: +998901234567",
|
||||||
|
"Parol majburiy": "Пароль обязателен",
|
||||||
|
"Parol kamida 8 ta belgidan iborat bo'lishi kerak": "Пароль должен содержать не менее 8 символов",
|
||||||
|
"Parollar mos kelmaydi": "Пароли не совпадают",
|
||||||
|
"Ma'lumotlarni yangilang": "Обновите данные",
|
||||||
|
"Yangilash": "Обновить",
|
||||||
|
"Nofaol": "Неактивен",
|
||||||
|
"Aloqa ma'lumotlari": "Контактная информация",
|
||||||
|
"Hisob ma'lumotlari": "Данные учетной записи",
|
||||||
|
"Yaratilgan sana": "Дата создания",
|
||||||
|
"Sotib olingan chiptalar": "Купленные билеты",
|
||||||
|
"Chipta turi": "Тип билета",
|
||||||
|
"Xizmat": "Сервис",
|
||||||
|
"Manzil": "Адрес",
|
||||||
|
"Transport": "Транспорт",
|
||||||
|
"Jo'nash": "Отправление",
|
||||||
|
"Yetish": "Прибытие",
|
||||||
|
"Yo'lovchilar": "Пассажиры",
|
||||||
|
"Qo'shimcha xizmatlar": "Дополнительные услуги",
|
||||||
|
"Pullik xizmatlar": "Платные услуги",
|
||||||
|
"Jami narx": "Общая цена",
|
||||||
|
"PDF yuklab olish": "Скачать PDF",
|
||||||
|
"Hozircha chiptalar mavjud emas": "Пока что билетов нет",
|
||||||
|
"Hamrohlar": "Партнёры",
|
||||||
|
"Erkak": "Мужчина",
|
||||||
|
"Ayol": "Женщина",
|
||||||
|
"Tug'ilgan sana": "Дата рождения",
|
||||||
|
"Passport rasmlari": "Изображения паспорта",
|
||||||
|
"Hozircha hamrohlar qo'shilmagan": "Пока что партнёры не добавлены",
|
||||||
|
"Statistika": "Статистика",
|
||||||
|
"Chiptalar": "Билеты",
|
||||||
|
"Status": "Статус",
|
||||||
|
"ID": "ID",
|
||||||
|
"Qo'shimcha ma'lumot": "Дополнительная информация",
|
||||||
|
"Bu foydalanuvchi hozirda tizimda faol holatda. Barcha ma'lumotlar to'liq va tasdiqlangan.": "Этот пользователь в настоящее время активен в системе. Все данные полные и подтвержденные.",
|
||||||
|
"Tur firmalari": "Турфирмы",
|
||||||
|
"Tur firmalaringizni boshqaring va ularning faoliyatini kuzatib boring.": "Управляйте своими турфирмами и отслеживайте их деятельность.",
|
||||||
|
"Jami firmalar": "Всего фирм",
|
||||||
|
"Faol firmalar": "Активные фирмы",
|
||||||
|
"Jami turlar": "Всего туров",
|
||||||
|
"Umumiy daromad": "Общий доход",
|
||||||
|
"Komissiya": "Комиссия",
|
||||||
|
"Jami tur": "Всего туров",
|
||||||
|
"Sotilgan tur": "Проданные туры",
|
||||||
|
"Daromad": "Доход",
|
||||||
|
"Egasi": "Владелец",
|
||||||
|
"Qo'shilgan turlar soni": "Количество добавленных туров",
|
||||||
|
"Sotilgan turlar soni": "Количество проданных туров",
|
||||||
|
"Jami sotilgan turlar": "Всего проданных туров",
|
||||||
|
"Ulush foizi": "Процент доли",
|
||||||
|
"Har bir sotuvdan": "С каждого проданного тура",
|
||||||
|
"so'm daromad": "сум дохода",
|
||||||
|
"Qo'shilgan turlar": "Добавленные туры",
|
||||||
|
"Firma tomonidan qo'shilgan barcha turlar ro'yxati": "Список всех туров, добавленных фирмой",
|
||||||
|
"Sotilgan": "Продано",
|
||||||
|
"ta xodim": "сотрудников",
|
||||||
|
"Xodim qo'shish": "Добавить сотрудника",
|
||||||
|
"Operator": "Оператор",
|
||||||
|
"Bugalter": "Бухгалтер",
|
||||||
|
"Manager": "Менеджер",
|
||||||
|
"Xodimni tahrirlash": "Редактировать сотрудника",
|
||||||
|
"Familiyasi": "Фамилия",
|
||||||
|
"Role": "Роль",
|
||||||
|
"Qo'shish": "Добавить",
|
||||||
|
"Role tanlang": "Выберите роль",
|
||||||
|
"Sayohat moliyasi boshqaruv paneli": "Панель управления финансовыми аспектами путешествий",
|
||||||
|
"Bronlar, to'lovlar va agentlik moliyalari boshqaruvi": "Управление бронированиями, платежами и финансами агентств",
|
||||||
|
"Bandlovlar va to‘lovlar": "Бронирования и платежи",
|
||||||
|
"Agentlik hisobotlari": "Отчеты агентств",
|
||||||
|
"Barcha bandlovlar": "Все бронирования",
|
||||||
|
"To'langan": "Оплачено",
|
||||||
|
"Kutilmoqda": "Ожидается",
|
||||||
|
"Bekor qilindi": "Отменено",
|
||||||
|
"Qaytarilgan": "Возвращено",
|
||||||
|
"Jami daromad": "Общий доход",
|
||||||
|
"Yakunlangan bandlovlardan": "От завершенных бронирований",
|
||||||
|
"Kutilayotgan to‘lovlar": "Ожидаемые платежи",
|
||||||
|
"Tasdiqlash kutilmoqda": "Ожидает подтверждения",
|
||||||
|
"Tasdiqlangan bandlovlar": "Подтвержденные бронирования",
|
||||||
|
"Kutilayotgan bandlovlar": "Ожидаемые бронирования",
|
||||||
|
"Oxirgi bandlovlar": "Последние бронирования",
|
||||||
|
"Sayohat sanasi": "Дата путешествия",
|
||||||
|
"Miqdor": "Сумма",
|
||||||
|
"Paid": "Оплачено",
|
||||||
|
"Pending": "В ожидании",
|
||||||
|
"Cancelled": "Отменено",
|
||||||
|
"Foydalanuvchi moliyaviy tafsilotlari": "Финансовые детали пользователя",
|
||||||
|
"uchun batafsil moliyaviy sharh": "для подробного финансового обзора",
|
||||||
|
"Total Spent": "Всего потрачено",
|
||||||
|
"Total Bookings": "Всего бронирований",
|
||||||
|
"All completed bookings": "Все завершенные бронирования",
|
||||||
|
"Pending Payments": "Ожидаемые платежи",
|
||||||
|
"Awaiting confirmation": "Ожидает подтверждения",
|
||||||
|
"All time bookings": "Бронирования за все время",
|
||||||
|
"Member Level": "Уровень участника",
|
||||||
|
"Loyalty status": "Статус лояльности",
|
||||||
|
"Booking History": "История бронирований",
|
||||||
|
"User Details": "Детали пользователя",
|
||||||
|
"Booking Ref": "Номер бронирования",
|
||||||
|
"Destination": "Направление",
|
||||||
|
"Travel Dates": "Даты путешествия",
|
||||||
|
"Travelers": "Путешественники",
|
||||||
|
"Amount": "Сумма",
|
||||||
|
"Booked on": "Забронировано",
|
||||||
|
"Personal Information": "Личная информация",
|
||||||
|
"Full Name": "Полное имя",
|
||||||
|
"Phone Number": "Номер телефона",
|
||||||
|
"Email Address": "Адрес электронной почты",
|
||||||
|
"Member Since": "Участник с",
|
||||||
|
"Travel Statistics": "Статистика путешествий",
|
||||||
|
"Favorite Destination": "Любимое направление",
|
||||||
|
"bookings": "бронирований",
|
||||||
|
"Preferred Agency": "Предпочитаемое агентство",
|
||||||
|
"out of": "из",
|
||||||
|
"Average Booking Value": "Средняя стоимость бронирования",
|
||||||
|
"Turlar ro'yxati": "Список туров",
|
||||||
|
"Yangi tur qo'shish": "Добавить новый тур",
|
||||||
|
"Davomiyligi": "Продолжительность",
|
||||||
|
"Narx Oralig'i": "Ценовой диапазон",
|
||||||
|
"Mehmonxona": "Отель",
|
||||||
|
"Imkoniyatlar": "Удобства",
|
||||||
|
"Amallar": "Операции",
|
||||||
|
"kun": "дней",
|
||||||
|
"yulduzli mehmonxona": "звездочный отель",
|
||||||
|
"Bilet turi": "Тип билета",
|
||||||
|
"Batafsil": "Подробнее",
|
||||||
|
"Turni o'chirishni tasdiqlang": "Подтвердите удаление тура",
|
||||||
|
"Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga qaytarib bo'lmaydi.": "Вы действительно хотите удалить этот тур? Это действие невозможно отменить.",
|
||||||
|
"Tur ma'lumotlari": "Информация о туре",
|
||||||
|
"Sarlavha": "Заголовок",
|
||||||
|
"Narx": "Цена",
|
||||||
|
"Ketish joyi": "Место отправления",
|
||||||
|
"Borish joyi": "Место прибытия",
|
||||||
|
"Manzil nomi": "Название места",
|
||||||
|
"Yo‘lovchilar soni": "Количество пассажиров",
|
||||||
|
"Ketish sanasi": "Дата отправления",
|
||||||
|
"Sana tanlang": "Выберите дату",
|
||||||
|
"Ketish vaqti": "Время отправления",
|
||||||
|
"Qaytish sanasi": "Дата возвращения",
|
||||||
|
"Qaytish vaqti": "Время возвращения",
|
||||||
|
"Tillar": "Языки",
|
||||||
|
"Har bir tilni vergul (,) bilan ajrating": "Разделяйте каждый язык запятой (,)",
|
||||||
|
"Tur davomiyligi": "Продолжительность тура",
|
||||||
|
"Belgilar (Badge)": "Значки (Badge)",
|
||||||
|
"Belgilarni tanlang": "Выберите значки",
|
||||||
|
"Tariflar": "Тарифы",
|
||||||
|
"Tarfilarni tanlang": "Выберите тарифы",
|
||||||
|
"Transportlar": "Транспорт",
|
||||||
|
"Transportlarni tanlang": "Выберите транспорт",
|
||||||
|
"Banner rasmi": "Изображение баннера",
|
||||||
|
"Drag or select files": "Выберите файлы",
|
||||||
|
"Drop files here or click to browse": "Перетащите файлы сюда или нажмите, чтобы просмотреть",
|
||||||
|
"Qo‘shimcha rasmlar": "Дополнительные изображения",
|
||||||
|
"Rasmlarni tanlang": "Выберите изображения",
|
||||||
|
"Bir nechta rasm yuklashingiz mumkin": "Вы можете загрузить несколько изображений",
|
||||||
|
"Qulayliklar": "Удобства",
|
||||||
|
"Ikonka tanlang": "Выберите иконку",
|
||||||
|
"Yuklanmoqda...": "Загрузка...",
|
||||||
|
"Qulaylik nomi (masalan: Wi-Fi)": "Название удобства (например: Wi-Fi)",
|
||||||
|
"Qo‘shish": "Добавить",
|
||||||
|
"Mehmonxona haqida": "О отеле",
|
||||||
|
"Mehmonxona xizmatlari": "Услуги отеля",
|
||||||
|
"Yangi xizmat qo‘shish": "Добавить новую услугу",
|
||||||
|
"Xizmat nomi": "Название услуги",
|
||||||
|
"Xizmat tavsifi": "Описание услуги",
|
||||||
|
"Mehmonxona taomlari haqida": "О еде в отеле",
|
||||||
|
"Mehmonxona taomlari": "Еда в отеле",
|
||||||
|
"Mehmonxona taomlari ro'yxati": "Список еды в отеле",
|
||||||
|
"Mehmonxona nomi": "Название отеля",
|
||||||
|
"Mehmonxona raytingi": "Рейтинг отеля",
|
||||||
|
"Meal Plan": "План питания",
|
||||||
|
"Taom rejasini tanlang": "Выберите план питания",
|
||||||
|
"Mehmonxona turi": "Тип отеля",
|
||||||
|
"Mehmonxona turini tanlang": "Выберите тип отеля",
|
||||||
|
"Sarlavha kamida 2 ta belgidan iborat bo‘lishi kerak": "Заголовок должен содержать не менее 2 символов",
|
||||||
|
"Narx kamida 1000 UZS bo‘lishi kerak.": "Цена должна быть не менее 1000 UZS.",
|
||||||
|
"Kamida 1 yo‘lovchi bo‘lishi kerak.": "Должен быть как минимум 1 пассажир.",
|
||||||
|
"Ketish joyi eng kamida 2 ta belgidan iborat bo‘lishi kerak.": "Место отправления должно содержать не менее 2 символов.",
|
||||||
|
"Borish joyi eng kamida 2 ta belgidan iborat bo‘lishi kerak.": "Место прибытия должно содержать не менее 2 символов.",
|
||||||
|
"Eng kamida 2 ta belgidan iborat bo‘lishi kerak.": "Должно содержать не менее 2 символов.",
|
||||||
|
"Majburiy maydon": "Обязательное поле",
|
||||||
|
"Kamida 1kun bo'lishi kerak": "Должно быть не менее 1 дня",
|
||||||
|
"Kamida bitta belgi tanlang.": "Выберите как минимум один значок.",
|
||||||
|
"Banner rasmi majburiy": "Изображение баннера обязательно.",
|
||||||
|
"Kamida bitta rasm yuklang.": "Загрузите как минимум одно изображение.",
|
||||||
|
"Qulaylik nomi majburiy": "Название удобства обязательно",
|
||||||
|
"Icon nomi majburiy": "Название иконки обязательно",
|
||||||
|
"Kamida bitta qulaylik kiriting.": "Введите как минимум одно удобство.",
|
||||||
|
"Kamida bitta xizmat kiriting.": "Введите как минимум одну услугу.",
|
||||||
|
"Taom rejasi tanlanishi majburiy": "Выбор плана питания обязателен",
|
||||||
|
"Mehmonxona turi tanlanishi majburiy": "Выбор типа отеля обязателен",
|
||||||
|
"Tur Sozlamalari": "Настройки туров",
|
||||||
|
"Ovqatlanish": "Питание",
|
||||||
|
"Otel turlari": "Типы отелей",
|
||||||
|
"Qidirish...": "Поиск...",
|
||||||
|
"Yangi qo'shish": "Добавить новый",
|
||||||
|
"Nomi": "Название",
|
||||||
|
"Rang": "Цвет",
|
||||||
|
"Ma'lumot topilmadi": "Данные не найдены",
|
||||||
|
"Tarif nomi": "Название тарифа",
|
||||||
|
"Transport nomi": "Название транспорта",
|
||||||
|
"Tur nomi": "Название тура",
|
||||||
|
"Narxi": "Цена",
|
||||||
|
"kishi": "человека",
|
||||||
|
"Jo'nash sanasi": "Дата отправления",
|
||||||
|
"Umumiy": "Общий",
|
||||||
|
"Marshshrut": "Маршрут",
|
||||||
|
"Xizmatlar": "Услуги",
|
||||||
|
"Sharhlar": "Отзывы",
|
||||||
|
"Tur haqida ma'lumot": "Информация о виде",
|
||||||
|
"Jo'nash joyi": "Место отправления",
|
||||||
|
"Yo'nalish": "Направление",
|
||||||
|
"Tarif": "Тариф",
|
||||||
|
"Sayohat marshshruti": "Маршрут путешествия",
|
||||||
|
"Narxga kiritilgan xizmatlar": "Услуги включены в цену",
|
||||||
|
"Mehmonxona va ovqatlanish": "Гостиницы и питание",
|
||||||
|
"Ovqatlanish tafsilotlari": "Детали питания",
|
||||||
|
"Mijozlar sharhlari": "Отзывы клиентов",
|
||||||
|
"sharh": "комментарий",
|
||||||
|
"Tur firmasi": "Туристическая фирма",
|
||||||
|
"Firma ID": "Фирменный ID",
|
||||||
|
"Firma sahifasiga o'tish": "Перейти на страницу компании",
|
||||||
|
"Bronlar Paneli": "Панель бронирования",
|
||||||
|
"Foydalanuvchi": "Пользователь",
|
||||||
|
"Tour (Agent)": "Тип (Агент)",
|
||||||
|
"Total / Paid": "Итого / Оплачено",
|
||||||
|
"Details": "Подробности",
|
||||||
|
"ta yangilik mavjud": "есть новость об этом",
|
||||||
|
"Yangilik qo'shish": "Добавить обновление",
|
||||||
|
"Hozircha yangilik yo'q": "Пока нет новостей",
|
||||||
|
"Birinchi yangilikni qo'shishni boshlang": "Начните добавлять первую новость",
|
||||||
|
"Yangilikni o'chirishni tasdiqlang": "Подтвердите удаление новости",
|
||||||
|
"Yangilikni tahrirlash": "Редактировать новости",
|
||||||
|
"Yangi yangilik qo‘shish": "Добавить новость",
|
||||||
|
"Yangilik sarlavhasi": "Заголовок новости",
|
||||||
|
"Yangilik ma'lumotlari": "Новостная информация",
|
||||||
|
"Yangilik nomi": "Название новости",
|
||||||
|
"Yangilik haqida": "О новостях",
|
||||||
|
"Kategoriya tanlang": "Выберите категорию",
|
||||||
|
"Kategoriyalar": "Категории",
|
||||||
|
"Keyingisi": "Следующий",
|
||||||
|
"Yangiliklar ro‘yxati": "Список новостей",
|
||||||
|
"Kamida 2 ta belgidan iborat bo‘lishi kerak.": "Должно содержать не менее 2 символов.",
|
||||||
|
"News Categories": "Категории новостей",
|
||||||
|
"Yangi qo‘shish": "Добавить новое",
|
||||||
|
"Kategoriya nomi": "Название категории",
|
||||||
|
"Yangiliklar soni": "Количество новостей",
|
||||||
|
"Harakatlar": "Действия",
|
||||||
|
"Hech qanday kategoriya topilmadi": "Нет категорий",
|
||||||
|
"Kategoriya tahrirlash": "Редактировать категорию",
|
||||||
|
"Yangi kategoriya qo‘shish": "Добавить новую категорию",
|
||||||
|
"FAQ (Savol va javoblar)": "FAQ (Вопросы и ответы)",
|
||||||
|
"Savol": "Вопрос",
|
||||||
|
"Javob": "Ответ",
|
||||||
|
"Bu bo‘limda savollar yo‘q.": "В этом разделе нет вопросов.",
|
||||||
|
"FAQni tahrirlash": "Редактировать FAQ",
|
||||||
|
"Yangi FAQ qo‘shish": "Добавить новый FAQ",
|
||||||
|
"Haqiqatan ham o‘chirmoqchimisiz?": "Вы уверены, что хотите удалить?",
|
||||||
|
"FAQ Kategoriyalar": "FAQ Категории",
|
||||||
|
"Siz muvaffaqiyatli akkountga kirdingiz": "Вы успешно вошли в аккаунт",
|
||||||
|
"Xatolik yuz berdi": "Произошла ошибка",
|
||||||
|
"Ruxsat darajangiz ushbu amalni bajarishga yetarli emas.": "Уровень вашего доступа недостаточен для выполнения этого действия.",
|
||||||
|
"Kirishga huquq yo‘q!": "Нет права входить!",
|
||||||
|
"Ma'lumotlar yuklanmoqda...": "Загрузка данных...",
|
||||||
|
"Ma'lumotlarni yuklashda xatolik yuz berdi.": "При загрузке данных произошла ошибка.",
|
||||||
|
"Qayta urinish": "Повторить попытку",
|
||||||
|
"Umumiy ma'lumot": "Общие сведения",
|
||||||
|
"Agentlik haqida batafsil ma'lumot": "Подробнее об агентстве",
|
||||||
|
"Ma'lumot yo'q": "Нет данных",
|
||||||
|
"Veb-sayt": "Веб-сайт",
|
||||||
|
"ID raqami": "ID номер",
|
||||||
|
"Nomi (ru)": "Название (ru)",
|
||||||
|
"Otel sharoitlari": "гостиничные условия",
|
||||||
|
"Breakfast Only": "Только завтрак",
|
||||||
|
"Half Board": "Полупансион (завтрак и обед или ужин)",
|
||||||
|
"Full Board": "Полный пансион (завтрак, обед и ужин)",
|
||||||
|
"All Inclusive": "Всё включено (питание, напитки и услуги полностью)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,332 @@
|
|||||||
{
|
{
|
||||||
"welcome": "Uzbek. Bizning saytga xush kelibsiz",
|
"Admin Panelga Kirish": "Admin Panelga Kirish",
|
||||||
"language": "Til",
|
"Telefon raqam": "Telefon raqam",
|
||||||
|
"Parol": "Parol",
|
||||||
|
"Parolingizni kiriting": "Parolingizni kiriting",
|
||||||
|
"Kirish": "Kirish",
|
||||||
|
"Admin Panel": "Admin Panel",
|
||||||
"Foydalanuvchilar": "Foydalanuvchilar",
|
"Foydalanuvchilar": "Foydalanuvchilar",
|
||||||
"Tur firmalar": "Tur firmalar",
|
"Tur firmalar": "Tur firmalar",
|
||||||
"Xodimlar": "Xodimlar",
|
"Xodimlar": "Xodimlar",
|
||||||
"Byudjet": "Byudjet",
|
"Byudjet": "Byudjet",
|
||||||
"Turlar": "Turlar",
|
"Turlar": "Turlar",
|
||||||
|
"Tur sozlamalari": "Tur sozlamalari",
|
||||||
"Bronlar": "Bronlar",
|
"Bronlar": "Bronlar",
|
||||||
"Yangiliklar": "Yangiliklar",
|
"Yangiliklar": "Yangiliklar",
|
||||||
"Yordam Arizalar": "Yordam Arizalar",
|
"Kategoriya": "Kategoriya",
|
||||||
"Tur sozlamalari": "Tur sozlamalari"
|
"FAQ": "FAQ",
|
||||||
|
"Savollar ro‘yxati": "Savollar ro‘yxati",
|
||||||
|
"Savollar kategoriyasi": "Savollar kategoriyasi",
|
||||||
|
"Arizalar": "Arizalar",
|
||||||
|
"Agentlik arizalari": "Agentlik arizalari",
|
||||||
|
"Yordam arizalari": "Yordam arizalari",
|
||||||
|
"Sayt sozlamalari": "Sayt sozlamalari",
|
||||||
|
"Sayt SEOsi": "Sayt SEOsi",
|
||||||
|
"Offerta": "Offerta",
|
||||||
|
"Yordam pagelari": "Yordam pagelari",
|
||||||
|
"Jami": "Jami",
|
||||||
|
"ta foydalanuvchini boshqaring": "ta foydalanuvchini boshqaring",
|
||||||
|
"Foydalanuvchi Qo'shish": "Foydalanuvchi Qo'shish",
|
||||||
|
"Jami foydalanuvchilar": "Jami foydalanuvchilar",
|
||||||
|
"Email bilan ro'yxatlangan": "Email bilan ro'yxatlangan",
|
||||||
|
"Telefon bilan ro'yxatlangan": "Telefon bilan ro'yxatlangan",
|
||||||
|
"Username, email yoki telefon raqami bo'yicha qidirish": "Username, email yoki telefon raqami bo'yicha qidirish...",
|
||||||
|
"Faol": "Faol",
|
||||||
|
"Ko'rish": "Ko'rish",
|
||||||
|
"Tahrirlash": "Tahrirlash",
|
||||||
|
"O'chirish": "O'chirish",
|
||||||
|
"FAQ Kategoriyalar": "FAQ Kategoriyalar",
|
||||||
|
"Foydalanuvchini o'chirish": "Foydalanuvchini o'chirish",
|
||||||
|
"Siz": "Siz",
|
||||||
|
"foydalanuvchini o'chirmoqchimisiz?": "foydalanuvchini o'chirmoqchimisiz?",
|
||||||
|
"Ushbu amalni qaytarib bo'lmaydi": "Ushbu amalni qaytarib bo'lmaydi.",
|
||||||
|
"Bekor qilish": "Bekor qilish",
|
||||||
|
"Orqaga": "Orqaga",
|
||||||
|
"Yangi foydalanuvchi": "Yangi foydalanuvchi",
|
||||||
|
"Ma'lumotlarni to'ldiring va saqlang": "Ma'lumotlarni to'ldiring va saqlang",
|
||||||
|
"Ismi": "Ismi",
|
||||||
|
"Email": "Email",
|
||||||
|
"Telefon raqami": "Telefon raqami",
|
||||||
|
"Parolni tasdiqlang": "Parolni tasdiqlang",
|
||||||
|
"Saqlash": "Saqlash",
|
||||||
|
"Username majburiy": "Username majburiy",
|
||||||
|
"Username kamida 3 ta belgidan iborat bo'lishi kerak": "Username kamida 3 ta belgidan iborat bo'lishi kerak",
|
||||||
|
"Email yoki telefon raqamlardan kamida bittasi kiritilishi kerak": "Email yoki telefon raqamlardan kamida bittasi kiritilishi kerak",
|
||||||
|
"Email formati noto'g'ri": "Email formati noto'g'ri",
|
||||||
|
"Telefon raqami formati: +998901234567": "Telefon raqami formati: +998901234567",
|
||||||
|
"Parol majburiy": "Parol majburiy",
|
||||||
|
"Parol kamida 8 ta belgidan iborat bo'lishi kerak": "Parol kamida 8 ta belgidan iborat bo'lishi kerak",
|
||||||
|
"Parollar mos kelmaydi": "Parollar mos kelmaydi",
|
||||||
|
"Ma'lumotlarni yangilang": "Ma'lumotlarni yangilang",
|
||||||
|
"Yangilash": "Yangilash",
|
||||||
|
"Nofaol": "Nofaol",
|
||||||
|
"Aloqa ma'lumotlari": "Aloqa ma'lumotlari",
|
||||||
|
"Hisob ma'lumotlari": "Hisob ma'lumotlari",
|
||||||
|
"Yaratilgan sana": "Yaratilgan sana",
|
||||||
|
"Sotib olingan chiptalar": "Sotib olingan chiptalar",
|
||||||
|
"Chipta turi": "Chipta turi",
|
||||||
|
"Xizmat": "Xizmat",
|
||||||
|
"Manzil": "Manzil",
|
||||||
|
"Transport": "Transport",
|
||||||
|
"Jo'nash": "Jo'nash",
|
||||||
|
"Yetish": "Yetish",
|
||||||
|
"Yo'lovchilar": "Yo'lovchilar",
|
||||||
|
"Qo'shimcha xizmatlar": "Qo'shimcha xizmatlar",
|
||||||
|
"Pullik xizmatlar": "Pullik xizmatlar",
|
||||||
|
"Jami narx": "Jami narx",
|
||||||
|
"PDF yuklab olish": "PDF yuklab olish",
|
||||||
|
"Hozircha chiptalar mavjud emas": "Hozircha chiptalar mavjud emas",
|
||||||
|
"Hamrohlar": "Hamrohlar",
|
||||||
|
"Erkak": "Erkak",
|
||||||
|
"Ayol": "Ayol",
|
||||||
|
"Tug'ilgan sana": "Tug'ilgan sana",
|
||||||
|
"Passport rasmlari": "Passport rasmlari",
|
||||||
|
"Hozircha hamrohlar qo'shilmagan": "Hozircha hamrohlar qo'shilmagan",
|
||||||
|
"Statistika": "Statistika",
|
||||||
|
"Chiptalar": "Chiptalar",
|
||||||
|
"Status": "Status",
|
||||||
|
"ID": "ID",
|
||||||
|
"Qo'shimcha ma'lumot": "Qo'shimcha ma'lumot",
|
||||||
|
"Bu foydalanuvchi hozirda tizimda faol holatda. Barcha ma'lumotlar to'liq va tasdiqlangan.": "Bu foydalanuvchi hozirda tizimda faol holatda. Barcha ma'lumotlar to'liq va tasdiqlangan.",
|
||||||
|
"Tur firmalari": "Tur firmalari",
|
||||||
|
"Tur firmalaringizni boshqaring va ularning faoliyatini kuzatib boring.": "Tur firmalaringizni boshqaring va ularning faoliyatini kuzatib boring.",
|
||||||
|
"Jami firmalar": "Jami firmalar",
|
||||||
|
"Faol firmalar": "Faol firmalar",
|
||||||
|
"Jami turlar": "Jami turlar",
|
||||||
|
"Umumiy daromad": "Umumiy daromad",
|
||||||
|
"Komissiya": "Komissiya",
|
||||||
|
"Jami tur": "Jami tur",
|
||||||
|
"Sotilgan tur": "Sotilgan tur",
|
||||||
|
"Daromad": "Daromad",
|
||||||
|
"Egasi": "Egasi",
|
||||||
|
"Qo'shilgan turlar soni": "Qo'shilgan turlar soni",
|
||||||
|
"Sotilgan turlar soni": "Sotilgan turlar soni",
|
||||||
|
"Jami sotilgan turlar": "Jami sotilgan turlar",
|
||||||
|
"Ulush foizi": "Ulush foizi",
|
||||||
|
"Har bir sotuvdan": "Har bir sotuvdan",
|
||||||
|
"so'm daromad": "so'm daromad",
|
||||||
|
"Qo'shilgan turlar": "Qo'shilgan turlar",
|
||||||
|
"Firma tomonidan qo'shilgan barcha turlar ro'yxati": "Firma tomonidan qo'shilgan barcha turlar ro'yxati",
|
||||||
|
"Sotilgan": "Sotilgan",
|
||||||
|
"ta xodim": "ta xodim",
|
||||||
|
"Xodim qo'shish": "Xodim qo'shish",
|
||||||
|
"Operator": "Operator",
|
||||||
|
"Bugalter": "Bugalter",
|
||||||
|
"Manager": "Manager",
|
||||||
|
"Xodimni tahrirlash": "Xodimni tahrirlash",
|
||||||
|
"Familiyasi": "Familiyasi",
|
||||||
|
"Role": "Role",
|
||||||
|
"Qo'shish": "Qo'shish",
|
||||||
|
"Role tanlang": "Role tanlang",
|
||||||
|
"Sayohat moliyasi boshqaruv paneli": "Sayohat moliyasi boshqaruv paneli",
|
||||||
|
"Bronlar, to'lovlar va agentlik moliyalari boshqaruvi": "Bronlar, to'lovlar va agentlik moliyalari boshqaruvi",
|
||||||
|
"Bandlovlar va to‘lovlar": "Bandlovlar va to‘lovlar",
|
||||||
|
"Agentlik hisobotlari": "Agentlik hisobotlari",
|
||||||
|
"Barcha bandlovlar": "Barcha bandlovlar",
|
||||||
|
"To'langan": "To'langan",
|
||||||
|
"Bekor qilindi": "Bekor qilindi",
|
||||||
|
"Kutilmoqda": "Kutilmoqda",
|
||||||
|
"Qaytarilgan": "Qaytarilgan",
|
||||||
|
"Jami daromad": "Jami daromad",
|
||||||
|
"Yakunlangan bandlovlardan": "Yakunlangan bandlovlardan",
|
||||||
|
"Kutilayotgan to‘lovlar": "Kutilayotgan to‘lovlar",
|
||||||
|
"Tasdiqlash kutilmoqda": "Tasdiqlash kutilmoqda",
|
||||||
|
"Tasdiqlangan bandlovlar": "Tasdiqlangan bandlovlar",
|
||||||
|
"Kutilayotgan bandlovlar": "Kutilayotgan bandlovlar",
|
||||||
|
"Oxirgi bandlovlar": "Oxirgi bandlovlar",
|
||||||
|
"Sayohat sanasi": "Sayohat sanasi",
|
||||||
|
"Miqdor": "Miqdor",
|
||||||
|
"Paid": "To'langan",
|
||||||
|
"Pending": "Kutilmoqda",
|
||||||
|
"Cancelled": "Bekor qilindi",
|
||||||
|
"Foydalanuvchi moliyaviy tafsilotlari": "Foydalanuvchi moliyaviy tafsilotlari",
|
||||||
|
"uchun batafsil moliyaviy sharh": "uchun batafsil moliyaviy sharh",
|
||||||
|
"Total Spent": "Jami sarflangan summa",
|
||||||
|
"Total Bookings": "Jami bandlovlar",
|
||||||
|
"All completed bookings": "Barcha yakunlangan bandlovlar",
|
||||||
|
"Pending Payments": "Kutilayotgan to'lovlar",
|
||||||
|
"Awaiting confirmation": "Tasdiqlash kutilmoqda",
|
||||||
|
"All time bookings": "Barcha vaqtlar bandlovlari",
|
||||||
|
"Member Level": "A'zo darajasi",
|
||||||
|
"Loyalty status": "Sodiqlik holati",
|
||||||
|
"Booking History": "Bandlovlar tarixi",
|
||||||
|
"User Details": "Foydalanuvchi tafsilotlari",
|
||||||
|
"Booking Ref": "Bandlov",
|
||||||
|
"Destination": "Manzil",
|
||||||
|
"Travel Dates": "Sayohat sanalari",
|
||||||
|
"Travelers": "Sayohatchilar",
|
||||||
|
"Amount": "Miqdor",
|
||||||
|
"Booked on": "Band qilingan sana",
|
||||||
|
"Personal Information": "Shaxsiy ma'lumotlar",
|
||||||
|
"Full Name": "To'liq ism",
|
||||||
|
"Phone Number": "Telefon raqami",
|
||||||
|
"Email Address": "Email manzili",
|
||||||
|
"Member Since": "A'zo bo'lgan sana",
|
||||||
|
"Travel Statistics": "Sayohat statistikasi",
|
||||||
|
"Favorite Destination": "Sevimli manzil",
|
||||||
|
"bookings": "bandlovlar",
|
||||||
|
"Preferred Agency": "Afzal ko'rilgan agentlik",
|
||||||
|
"out of": "ichidan",
|
||||||
|
"Average Booking Value": "O'rtacha bandlov qiymati",
|
||||||
|
"Turlar ro'yxati": "Turlar ro'yxati",
|
||||||
|
"Yangi tur qo'shish": "Yangi tur qo'shish",
|
||||||
|
"Davomiyligi": "Davomiyligi",
|
||||||
|
"Narx Oralig'i": "Narx Oralig'i",
|
||||||
|
"Mehmonxona": "Mehmonxona",
|
||||||
|
"Imkoniyatlar": "Imkoniyatlar",
|
||||||
|
"Amallar": "Amallar",
|
||||||
|
"kun": "kun",
|
||||||
|
"Otel sharoitlari": "Otel sharoitlari",
|
||||||
|
"yulduzli mehmonxona": "yulduzli mehmonxona",
|
||||||
|
"Bilet turi": "Bilet turi",
|
||||||
|
"Batafsil": "Batafsil",
|
||||||
|
"Turni o'chirishni tasdiqlang": "Turni o'chirishni tasdiqlang",
|
||||||
|
"Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga qaytarib bo'lmaydi.": "Haqiqatan ham bu turni o'chirib tashlamoqchimisiz? Bu amalni ortga qaytarib bo'lmaydi.",
|
||||||
|
"Tur ma'lumotlari": "Tur ma'lumotlari",
|
||||||
|
"Sarlavha": "Sarlavha",
|
||||||
|
"Narx": "Narx",
|
||||||
|
"Ketish joyi": "Ketish joyi",
|
||||||
|
"Borish joyi": "Borish joyi",
|
||||||
|
"Manzil nomi": "Manzil nomi",
|
||||||
|
"Yo‘lovchilar soni": "Yo‘lovchilar soni",
|
||||||
|
"Ketish sanasi": "Ketish sanasi",
|
||||||
|
"Sana tanlang": "Sana tanlang",
|
||||||
|
"Ketish vaqti": "Ketish vaqti",
|
||||||
|
"Qaytish sanasi": "Qaytish sanasi",
|
||||||
|
"Qaytish vaqti": "Qaytish vaqti",
|
||||||
|
"Tillar": "Tillar",
|
||||||
|
"Har bir tilni vergul (,) bilan ajrating": "Har bir tilni vergul (,) bilan ajrating",
|
||||||
|
"Tur davomiyligi": "Tur davomiyligi",
|
||||||
|
"Belgilar (Badge)": "Belgilar (Badge)",
|
||||||
|
"Belgilarni tanlang": "Belgilarni tanlang",
|
||||||
|
"Tariflar": "Tariflar",
|
||||||
|
"Tarfilarni tanlang": "Tarfilarni tanlang",
|
||||||
|
"Transportlar": "Transportlar",
|
||||||
|
"Transportlarni tanlang": "Transportlarni tanlang",
|
||||||
|
"Banner rasmi": "Banner rasmi",
|
||||||
|
"Drag or select files": "Faylni tanlang",
|
||||||
|
"Drop files here or click to browse": "Fayllarni shu yerga tashlang yoki ko'rib chiqish uchun bosing",
|
||||||
|
"Qo‘shimcha rasmlar": "Qo‘shimcha rasmlar",
|
||||||
|
"Rasmlarni tanlang": "Rasmlarni tanlang",
|
||||||
|
"Bir nechta rasm yuklashingiz mumkin": "Bir nechta rasm yuklashingiz mumkin",
|
||||||
|
"Qulayliklar": "Qulayliklar",
|
||||||
|
"Ikonka tanlang": "Ikonka tanlang",
|
||||||
|
"Yuklanmoqda...": "Yuklanmoqda...",
|
||||||
|
"Qulaylik nomi (masalan: Wi-Fi)": "Qulaylik nomi (masalan: Wi-Fi)",
|
||||||
|
"Qo‘shish": "Qo‘shish",
|
||||||
|
"Mehmonxona haqida": "Mehmonxona haqida",
|
||||||
|
"Mehmonxona xizmatlari": "Mehmonxona xizmatlari",
|
||||||
|
"Yangi xizmat qo‘shish": "Yangi xizmat qo‘shish",
|
||||||
|
"Xizmat nomi": "Xizmat nomi",
|
||||||
|
"Xizmat tavsifi": "Xizmat tavsifi",
|
||||||
|
"Mehmonxona taomlari haqida": "Mehmonxona taomlari haqida",
|
||||||
|
"Mehmonxona taomlari": "Mehmonxona taomlari",
|
||||||
|
"Mehmonxona taomlari ro'yxati": "Mehmonxona taomlari ro'yxati",
|
||||||
|
"Mehmonxona nomi": "Mehmonxona nomi",
|
||||||
|
"Mehmonxona raytingi": "Mehmonxona raytingi",
|
||||||
|
"Meal Plan": "Taom rejasi",
|
||||||
|
"Taom rejasini tanlang": "Taom rejasini tanlang",
|
||||||
|
"Mehmonxona turi": "Mehmonxona turi",
|
||||||
|
"Mehmonxona turini tanlang": "Mehmonxona turini tanlang",
|
||||||
|
"Narx kamida 1000 UZS bo‘lishi kerak.": "Narx kamida 1000 UZS bo‘lishi kerak.",
|
||||||
|
"Kamida 1 yo‘lovchi bo‘lishi kerak.": "Kamida 1 yo‘lovchi bo‘lishi kerak.",
|
||||||
|
"Sarlavha kamida 2 ta belgidan iborat bo‘lishi kerak": "Sarlavha kamida 2 ta belgidan iborat bo‘lishi kerak",
|
||||||
|
"Ketish joyi eng kamida 2 ta belgidan iborat bo‘lishi kerak.": "Ketish joyi eng kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||||
|
"Borish joyi eng kamida 2 ta belgidan iborat bo‘lishi kerak.": "Borish joyi eng kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||||
|
"Eng kamida 2 ta belgidan iborat bo‘lishi kerak.": "Eng kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||||
|
"Majburiy maydon": "Majburiy maydon",
|
||||||
|
"Kamida 1kun bo'lishi kerak": "Kamida 1kun bo'lishi kerak",
|
||||||
|
"Kamida bitta belgi tanlang.": "Kamida bitta belgi tanlang.",
|
||||||
|
"Banner rasmi majburiy": "Banner rasmi majburiy.",
|
||||||
|
"Kamida bitta rasm yuklang.": "Kamida bitta rasm yuklang.",
|
||||||
|
"Qulaylik nomi majburiy": "Qulaylik nomi majburiy.",
|
||||||
|
"Icon nomi majburiy": "Icon nomi majburiy.",
|
||||||
|
"Kamida bitta qulaylik kiriting.": "Kamida bitta qulaylik kiriting.",
|
||||||
|
"Kamida bitta xizmat kiriting.": "Kamida bitta xizmat kiriting.",
|
||||||
|
"Taom rejasi tanlanishi majburiy": "Taom rejasi tanlanishi majburiy.",
|
||||||
|
"Mehmonxona turi tanlanishi majburiy": "Mehmonxona turi tanlanishi majburiy.",
|
||||||
|
"Tur Sozlamalari": "Tur Sozlamalari",
|
||||||
|
"Ovqatlanish": "Ovqatlanish",
|
||||||
|
"Otel turlari": "Otel turlari",
|
||||||
|
"Qidirish...": "Qidirish...",
|
||||||
|
"Yangi qo'shish": "Yangi qo'shish",
|
||||||
|
"Nomi": "Nomi",
|
||||||
|
"Rang": "Rang",
|
||||||
|
"Ma'lumot topilmadi": "Ma'lumot topilmadi",
|
||||||
|
"Tarif nomi": "Tarif nomi",
|
||||||
|
"Transport nomi": "Transport nomi",
|
||||||
|
"Tur nomi": "Tur nomi",
|
||||||
|
"Narxi": "Narxi",
|
||||||
|
"kishi": "kishi",
|
||||||
|
"Jo'nash sanasi": "Jo'nash sanasi",
|
||||||
|
"Umumiy": "Umumiy",
|
||||||
|
"Marshshrut": "Marshshrut",
|
||||||
|
"Xizmatlar": "Xizmatlar",
|
||||||
|
"Sharhlar": "Sharhlar",
|
||||||
|
"Tur haqida ma'lumot": "Tur haqida ma'lumot",
|
||||||
|
"Jo'nash joyi": "Jo'nash joyi",
|
||||||
|
"Yo'nalish": "Yo'nalish",
|
||||||
|
"Tarif": "Tarif",
|
||||||
|
"Sayohat marshshruti": "Sayohat marshshruti",
|
||||||
|
"Narxga kiritilgan xizmatlar": "Narxga kiritilgan xizmatlar",
|
||||||
|
"Mehmonxona va ovqatlanish": "Mehmonxona va ovqatlanish",
|
||||||
|
"Ovqatlanish tafsilotlari": "Ovqatlanish tafsilotlari",
|
||||||
|
"Mijozlar sharhlari": "Mijozlar sharhlari",
|
||||||
|
"sharh": "sharh",
|
||||||
|
"Tur firmasi": "Tur firmasi",
|
||||||
|
"Firma ID": "Firma ID",
|
||||||
|
"Firma sahifasiga o'tish": "Firma sahifasiga o'tish",
|
||||||
|
"Bronlar Paneli": "Bronlar Paneli",
|
||||||
|
"Foydalanuvchi": "Foydalanuvchi",
|
||||||
|
"Tour (Agent)": "Tur (Agent)",
|
||||||
|
"Total / Paid": "Total / Paid",
|
||||||
|
"Details": "Batafsil",
|
||||||
|
"ta yangilik mavjud": "ta yangilik mavjud",
|
||||||
|
"Yangilik qo'shish": "Yangilik qo'shish",
|
||||||
|
"Hozircha yangilik yo'q": "'Hozircha yangilik yo'q",
|
||||||
|
"Birinchi yangilikni qo'shishni boshlang": "Birinchi yangilikni qo'shishni boshlang",
|
||||||
|
"Yangilikni o'chirishni tasdiqlang": "Yangilikni o'chirishni tasdiqlang",
|
||||||
|
"Yangilikni tahrirlash": "Yangilikni tahrirlash",
|
||||||
|
"Yangi yangilik qo‘shish": "Yangi yangilik qo‘shish",
|
||||||
|
"Yangilik sarlavhasi": "Yangilik sarlavhasi",
|
||||||
|
"Yangilik ma'lumotlari": "Yangilik ma'lumotlari",
|
||||||
|
"Yangilik nomi": "Yangilik nomi",
|
||||||
|
"Yangilik haqida": "Yangilik haqida",
|
||||||
|
"Kategoriya tanlang": "Kategoriya tanlang",
|
||||||
|
"Kategoriyalar": "Kategoriyalar",
|
||||||
|
"Keyingisi": "Keyingisi",
|
||||||
|
"Yangiliklar ro‘yxati": "Yangiliklar ro‘yxati",
|
||||||
|
"Kamida 2 ta belgidan iborat bo‘lishi kerak.": "Kamida 2 ta belgidan iborat bo‘lishi kerak.",
|
||||||
|
"News Categories": "Yangiliklar turkumlari",
|
||||||
|
"Yangi qo‘shish": "Yangi qo‘shish",
|
||||||
|
"Kategoriya nomi": "Kategoriya nomi",
|
||||||
|
"Yangiliklar soni": "Yangiliklar soni",
|
||||||
|
"Harakatlar": "Harakatlar",
|
||||||
|
"Hech qanday kategoriya topilmadi": "Hech qanday kategoriya topilmadi",
|
||||||
|
"Kategoriya tahrirlash": "Kategoriya tahrirlash",
|
||||||
|
"Yangi kategoriya qo‘shish": "Yangi kategoriya qo‘shish",
|
||||||
|
"FAQ (Savol va javoblar)": "FAQ (Savol va javoblar)",
|
||||||
|
"Savol": "Savol",
|
||||||
|
"Javob": "Javob",
|
||||||
|
"Bu bo‘limda savollar yo‘q.": "Bu bo‘limda savollar yo‘q.",
|
||||||
|
"FAQni tahrirlash": "FAQni tahrirlash",
|
||||||
|
"Yangi FAQ qo‘shish": "Yangi FAQ qo‘shish",
|
||||||
|
"Haqiqatan ham o‘chirmoqchimisiz?": "Haqiqatan ham o‘chirmoqchimisiz?",
|
||||||
|
"Siz muvaffaqiyatli akkountga kirdingiz": "Siz muvaffaqiyatli akkountga kirdingiz",
|
||||||
|
"Ma'lumotlar yuklanmoqda...": "Ma'lumotlar yuklanmoqda...",
|
||||||
|
"Xatolik yuz berdi": "Xatolik yuz berdi",
|
||||||
|
"Ruxsat darajangiz ushbu amalni bajarishga yetarli emas.": "Ruxsat darajangiz ushbu amalni bajarishga yetarli emas.",
|
||||||
|
"Kirishga huquq yo‘q!": "Kirishga huquq yo‘q!",
|
||||||
|
"Ma'lumotlarni yuklashda xatolik yuz berdi.": "Ma'lumotlarni yuklashda xatolik yuz berdi.",
|
||||||
|
"Qayta urinish": "Qayta urinish",
|
||||||
|
"Umumiy ma'lumot": "Umumiy ma'lumot",
|
||||||
|
"Agentlik haqida batafsil ma'lumot": "Agentlik haqida batafsil ma'lumot",
|
||||||
|
"Ma'lumot yo'q": "Ma'lumot yo'q",
|
||||||
|
"Veb-sayt": "Veb-sayt",
|
||||||
|
"ID raqami": "ID raqami",
|
||||||
|
"Nomi (ru)": "Nomi (ru)",
|
||||||
|
"Breakfast Only": "Faqat nonushta",
|
||||||
|
"Half Board": "Yarim pansion (nonushta va tushlik yoki kechki ovqat)",
|
||||||
|
"Full Board": "To‘liq pansion (nonushta, tushlik va kechki ovqat)",
|
||||||
|
"All Inclusive": "To‘liq pansion (nonushta, tushlik va kechki ovqat)"
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/shared/hooks/user.ts
Normal file
42
src/shared/hooks/user.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: number;
|
||||||
|
last_login: string;
|
||||||
|
is_superuser: boolean;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
is_staff: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
date_joined: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string;
|
||||||
|
validated_at: string;
|
||||||
|
role:
|
||||||
|
| "superuser"
|
||||||
|
| "admin"
|
||||||
|
| "moderator"
|
||||||
|
| "tour_admin"
|
||||||
|
| "buxgalter"
|
||||||
|
| "operator"
|
||||||
|
| "user";
|
||||||
|
travel_agency: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
interface UserStore {
|
||||||
|
user: User;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
clearUser: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useUserStore = create<UserStore>((set) => ({
|
||||||
|
user: null,
|
||||||
|
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
|
||||||
|
clearUser: () => set({ user: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useUserStore;
|
||||||
57
src/shared/lib/authCookies.ts
Normal file
57
src/shared/lib/authCookies.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "auth_token"; // cookie nomi
|
||||||
|
const REF_TOKEN_KEY = "ref_auth_token"; // cookie nomi
|
||||||
|
const EXPIRE_DAYS = 1; // token necha kun saqlansin
|
||||||
|
const REF_EXPIRE_DAYS = 30; // token necha kun saqlansin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenni cookie'ga saqlaydi
|
||||||
|
* @param token - foydalanuvchining JWT tokeni
|
||||||
|
*/
|
||||||
|
export const setAuthToken = (token: string) => {
|
||||||
|
Cookies.set(TOKEN_KEY, token, {
|
||||||
|
expires: EXPIRE_DAYS,
|
||||||
|
secure: true, // faqat https da ishlaydi
|
||||||
|
sameSite: "strict", // CSRF xavfsizligi
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAuthRefToken = (token: string) => {
|
||||||
|
Cookies.set(REF_TOKEN_KEY, token, {
|
||||||
|
expires: REF_EXPIRE_DAYS,
|
||||||
|
secure: true, // faqat https da ishlaydi
|
||||||
|
sameSite: "strict", // CSRF xavfsizligi
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie'dan tokenni oladi
|
||||||
|
* @returns string | undefined
|
||||||
|
*/
|
||||||
|
export const getAuthToken = (): string | undefined => {
|
||||||
|
return Cookies.get(TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRefAuthToken = (): string | undefined => {
|
||||||
|
return Cookies.get(REF_TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenni cookie'dan o‘chiradi
|
||||||
|
*/
|
||||||
|
export const removeAuthToken = () => {
|
||||||
|
Cookies.remove(TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeRefAuthToken = () => {
|
||||||
|
Cookies.remove(REF_TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foydalanuvchi tizimga kirgan yoki yo‘qligini tekshiradi
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
export const isAuthenticated = (): boolean => {
|
||||||
|
return !!Cookies.get(TOKEN_KEY);
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ const formatPrice = (amount: number | string, withLabel = false): string => {
|
|||||||
? locale === LanguageRoutes.RU
|
? locale === LanguageRoutes.RU
|
||||||
? " сум"
|
? " сум"
|
||||||
: locale === LanguageRoutes.UZ
|
: locale === LanguageRoutes.UZ
|
||||||
? " сўм"
|
? " so‘m"
|
||||||
: " so‘m"
|
: " so‘m"
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
|||||||
6
src/shared/lib/onlyNumber.ts
Normal file
6
src/shared/lib/onlyNumber.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const onlyNumber = (digits: string | number) => {
|
||||||
|
const phone = digits.toString();
|
||||||
|
return phone.replace(/\D/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default onlyNumber;
|
||||||
51
src/shared/ui/avatar.tsx
Normal file
51
src/shared/ui/avatar.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
FormProvider,
|
FormProvider,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
|
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import { Label } from "@/shared/ui/label";
|
import { Label } from "@/shared/ui/label";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Form = FormProvider;
|
const Form = FormProvider;
|
||||||
|
|
||||||
@@ -138,6 +139,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|||||||
|
|
||||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
const { error, formMessageId } = useFormField();
|
const { error, formMessageId } = useFormField();
|
||||||
|
const { t } = useTranslation();
|
||||||
const body = error ? String(error?.message ?? "") : props.children;
|
const body = error ? String(error?.message ?? "") : props.children;
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
@@ -151,18 +153,18 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
|||||||
className={cn("text-destructive text-sm", className)}
|
className={cn("text-destructive text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{body}
|
{t(error?.message ? error.message : "")}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
|
||||||
Form,
|
Form,
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
FormMessage,
|
|
||||||
FormField,
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useFormField,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import React, {
|
|||||||
type ComponentType,
|
type ComponentType,
|
||||||
type LazyExoticComponent,
|
type LazyExoticComponent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// 🔹 Lazy icon faqat tanlangan icon uchun
|
// 🔹 Lazy icon faqat tanlangan icon uchun
|
||||||
const LazyIcon: React.FC<{ name: string }> = ({ name }) => {
|
const LazyIcon: React.FC<{ name: string }> = ({ name }) => {
|
||||||
@@ -49,6 +50,7 @@ const IconSelect: React.FC<IconSelectProps> = ({
|
|||||||
setSelectedIcon,
|
setSelectedIcon,
|
||||||
}) => {
|
}) => {
|
||||||
const [icons, setIcons] = useState<string[]>([]);
|
const [icons, setIcons] = useState<string[]>([]);
|
||||||
|
const { t } = useTranslation();
|
||||||
const [visibleIcons, setVisibleIcons] = useState<string[]>([]);
|
const [visibleIcons, setVisibleIcons] = useState<string[]>([]);
|
||||||
const [chunkSize] = useState(100);
|
const [chunkSize] = useState(100);
|
||||||
const [index, setIndex] = useState(1);
|
const [index, setIndex] = useState(1);
|
||||||
@@ -116,14 +118,14 @@ const IconSelect: React.FC<IconSelectProps> = ({
|
|||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="!h-12 w-[220px] text-md">
|
<SelectTrigger className="!h-12 w-[220px] text-md">
|
||||||
<SelectValue placeholder="Ikonka tanlang">
|
<SelectValue placeholder={t("Ikonka tanlang")}>
|
||||||
{selectedIcon ? (
|
{selectedIcon ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<LazyIcon name={selectedIcon} />
|
<LazyIcon name={selectedIcon} />
|
||||||
{selectedIcon}
|
{selectedIcon}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
"Ikonka tanlang"
|
t("Ikonka tanlang")
|
||||||
)}
|
)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -151,7 +153,9 @@ const IconSelect: React.FC<IconSelectProps> = ({
|
|||||||
{!searchTerm && isOpen && (
|
{!searchTerm && isOpen && (
|
||||||
<div ref={loaderRef} className="h-6 flex justify-center items-center">
|
<div ref={loaderRef} className="h-6 flex justify-center items-center">
|
||||||
{visibleIcons.length < icons.length && (
|
{visibleIcons.length < icons.length && (
|
||||||
<span className="text-xs text-gray-400">Yuklanmoqda...</span>
|
<span className="text-xs text-gray-400">
|
||||||
|
{t("Yuklanmoqda...")}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
43
src/shared/ui/radio-group.tsx
Normal file
43
src/shared/ui/radio-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
38
src/shared/ui/sonner.tsx
Normal file
38
src/shared/ui/sonner.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
OctagonXIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
info: <InfoIcon className="size-4" />,
|
||||||
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
|
error: <OctagonXIcon className="size-4" />,
|
||||||
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
import { LanguageRoutes } from "@/shared/config/i18n/type";
|
import { LanguageRoutes } from "@/shared/config/i18n/type";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -7,12 +8,16 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import { languages } from "@/widgets/lang-toggle/lib/data";
|
import { languages } from "@/widgets/lang-toggle/lib/data";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { GlobeIcon } from "lucide-react";
|
import { GlobeIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const LangToggle = () => {
|
const LangToggle = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const changeLanguage = (lng: LanguageRoutes) => {
|
const changeLanguage = (lng: LanguageRoutes) => {
|
||||||
|
httpClient.defaults.headers.common["Accept-Language"] = lng;
|
||||||
|
queryClient.refetchQueries();
|
||||||
i18n.changeLanguage(lng);
|
i18n.changeLanguage(lng);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
109
src/widgets/real-pagination/ui/RealPagination.tsx
Normal file
109
src/widgets/real-pagination/ui/RealPagination.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Table } from "@tanstack/react-table";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
table: Table<T>;
|
||||||
|
totalPages?: number;
|
||||||
|
namePage?: string;
|
||||||
|
namePageSize?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RealPagination = <T,>({
|
||||||
|
table,
|
||||||
|
totalPages = 1,
|
||||||
|
namePage,
|
||||||
|
namePageSize,
|
||||||
|
}: Props<T>) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||||
|
|
||||||
|
const updateUrl = useCallback(
|
||||||
|
(page: number, pageSize: number) => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set(namePage ? namePage : "page", page.toString());
|
||||||
|
newParams.set(
|
||||||
|
namePageSize ? namePageSize : "pageSize",
|
||||||
|
pageSize.toString(),
|
||||||
|
);
|
||||||
|
setSearchParams(newParams);
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlPage = parseInt(searchParams.get("page") || "1", 10);
|
||||||
|
const urlPageSize = parseInt(searchParams.get("pageSize") || "10", 10);
|
||||||
|
|
||||||
|
if (urlPage - 1 !== table.getState().pagination.pageIndex) {
|
||||||
|
table.setPageIndex(urlPage - 1);
|
||||||
|
}
|
||||||
|
if (urlPageSize !== table.getState().pagination.pageSize) {
|
||||||
|
table.setPageSize(urlPageSize);
|
||||||
|
}
|
||||||
|
}, [searchParams, table]);
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
table.setPageIndex(currentPage - 2);
|
||||||
|
updateUrl(currentPage - 1, table.getState().pagination.pageSize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
table.setPageIndex(currentPage);
|
||||||
|
updateUrl(currentPage + 1, table.getState().pagination.pageSize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageClick = (page: number) => {
|
||||||
|
table.setPageIndex(page - 1);
|
||||||
|
updateUrl(page, table.getState().pagination.pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
{/* Previous Button */}
|
||||||
|
<button
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={handlePrev}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page Numbers */}
|
||||||
|
{[...Array(totalPages)].map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => handlePageClick(i + 1)}
|
||||||
|
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
||||||
|
currentPage === i + 1
|
||||||
|
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
||||||
|
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Next Button */}
|
||||||
|
<button
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onClick={handleNext}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealPagination;
|
||||||
@@ -13,7 +13,6 @@ import LangToggle from "@/widgets/lang-toggle/ui/lang-toggle";
|
|||||||
import {
|
import {
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Building2,
|
Building2,
|
||||||
CalendarCheck2,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
@@ -29,44 +28,83 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type Role = "admin" | "manager" | "user";
|
type Role =
|
||||||
|
| "superuser"
|
||||||
|
| "admin"
|
||||||
|
| "moderator"
|
||||||
|
| "tour_admin"
|
||||||
|
| "buxgalter"
|
||||||
|
| "operator"
|
||||||
|
| "user";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
role: Role;
|
role: Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** --- MENYU TUZILMASI --- **/
|
|
||||||
const MENU_ITEMS = [
|
const MENU_ITEMS = [
|
||||||
{ label: "Foydalanuvchilar", icon: Users, path: "/user", roles: ["admin"] },
|
{
|
||||||
|
label: "Foydalanuvchilar",
|
||||||
|
icon: Users,
|
||||||
|
path: "/user",
|
||||||
|
roles: ["moderator", "admin", "superuser", "moderator"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Tur firmalar",
|
label: "Tur firmalar",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
path: "/agencies",
|
path: "/agencies",
|
||||||
roles: ["admin", "manager"],
|
roles: ["moderator", "admin", "superuser", "moderator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Xodimlar",
|
||||||
|
icon: Briefcase,
|
||||||
|
path: "/employees",
|
||||||
|
roles: ["moderator", "admin", "superuser"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Bronlar",
|
||||||
|
icon: Wallet,
|
||||||
|
path: "/finance",
|
||||||
|
roles: ["moderator", "admin", "superuser", "buxgalter"],
|
||||||
},
|
},
|
||||||
{ label: "Xodimlar", icon: Briefcase, path: "/employees", roles: ["admin"] },
|
|
||||||
{ label: "Byudjet", icon: Wallet, path: "/finance", roles: ["admin"] },
|
|
||||||
{
|
{
|
||||||
label: "Turlar",
|
label: "Turlar",
|
||||||
icon: Plane,
|
icon: Plane,
|
||||||
path: "/tours",
|
path: "/tours",
|
||||||
roles: ["admin", "manager"],
|
roles: [
|
||||||
|
"moderator",
|
||||||
|
"admin",
|
||||||
|
"superuser",
|
||||||
|
"tour_admin",
|
||||||
|
"operator",
|
||||||
|
"buxgalter",
|
||||||
|
],
|
||||||
children: [
|
children: [
|
||||||
{ label: "Turlar", path: "/tours" },
|
{ label: "Turlar", path: "/tours" },
|
||||||
{ label: "Tur sozlamalari", path: "/tours/setting" },
|
{
|
||||||
|
label: "Tur sozlamalari",
|
||||||
|
path: "/tours/setting",
|
||||||
|
roles: ["moderator", "admin", "superuser"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: "Bronlar",
|
// label: "Bronlar",
|
||||||
icon: CalendarCheck2,
|
// icon: CalendarCheck2,
|
||||||
path: "/bookings",
|
// path: "/bookings",
|
||||||
roles: ["admin", "manager", "user"],
|
// roles: [
|
||||||
},
|
// "moderator",
|
||||||
|
// "admin",
|
||||||
|
// "superuser",
|
||||||
|
// "tour_admin",
|
||||||
|
// "operator",
|
||||||
|
// "buxgalter",
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
label: "Yangiliklar",
|
label: "Yangiliklar",
|
||||||
icon: Newspaper,
|
icon: Newspaper,
|
||||||
path: "/news",
|
path: "/news",
|
||||||
roles: ["admin", "manager"],
|
roles: ["moderator", "admin", "superuser"],
|
||||||
children: [
|
children: [
|
||||||
{ label: "Yangiliklar", path: "/news" },
|
{ label: "Yangiliklar", path: "/news" },
|
||||||
{ label: "Kategoriya", path: "/news/categories" },
|
{ label: "Kategoriya", path: "/news/categories" },
|
||||||
@@ -76,7 +114,7 @@ const MENU_ITEMS = [
|
|||||||
label: "FAQ",
|
label: "FAQ",
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
path: "/faq",
|
path: "/faq",
|
||||||
roles: ["admin"],
|
roles: ["moderator", "admin", "superuser"],
|
||||||
children: [
|
children: [
|
||||||
{ label: "Savollar ro‘yxati", path: "/faq" },
|
{ label: "Savollar ro‘yxati", path: "/faq" },
|
||||||
{ label: "Savollar kategoriyasi", path: "/faq/categories" },
|
{ label: "Savollar kategoriyasi", path: "/faq/categories" },
|
||||||
@@ -86,17 +124,32 @@ const MENU_ITEMS = [
|
|||||||
label: "Arizalar",
|
label: "Arizalar",
|
||||||
icon: HelpCircle,
|
icon: HelpCircle,
|
||||||
path: "/support",
|
path: "/support",
|
||||||
roles: ["admin", "manager"],
|
roles: ["moderator", "admin", "superuser", "tour_admin", "operator"],
|
||||||
children: [
|
children: [
|
||||||
{ label: "Agentlik arizalari", path: "/support/tours", roles: ["admin"] },
|
{
|
||||||
{ label: "Yordam arizalari", path: "/support/user", roles: ["admin"] },
|
label: "Agentlik arizalari",
|
||||||
|
path: "/support/tours",
|
||||||
|
roles: ["moderator", "admin", "superuser", "operator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Yordam arizalari",
|
||||||
|
path: "/support/user",
|
||||||
|
roles: [
|
||||||
|
"moderator",
|
||||||
|
"admin",
|
||||||
|
"superuser",
|
||||||
|
"tour_admin",
|
||||||
|
"operator",
|
||||||
|
"buxgalter",
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Tur sozlamalari",
|
label: "Sayt sozlamalari",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
path: "/tour-settings",
|
path: "/tour-settings",
|
||||||
roles: ["admin"],
|
roles: ["moderator", "admin", "superuser"],
|
||||||
children: [
|
children: [
|
||||||
{ label: "Sayt SEOsi", path: "/site-seo/" },
|
{ label: "Sayt SEOsi", path: "/site-seo/" },
|
||||||
{ label: "Offerta", path: "/site-pages/" },
|
{ label: "Offerta", path: "/site-pages/" },
|
||||||
@@ -201,7 +254,7 @@ export function Sidebar({ role }: SidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lg:border">
|
<div className="lg:border">
|
||||||
<div className="lg:hidden flex items-center justify-between bg-gray-900 p-4 sticky top-0 z-50">
|
<div className="lg:hidden flex items-center justify-end bg-gray-900 p-4 sticky top-0 z-50">
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import path from "path";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss(), tsconfigPaths()],
|
plugins: [react(), tailwindcss(), tsconfigPaths()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: [{ find: "@", replacement: path.resolve(__dirname, "src") }],
|
alias: [{ find: "@", replacement: path.resolve(__dirname, "src") }],
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
allowedHosts: ["71ad80caca04.ngrok-free.app"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user