Обновить интерфейс MDM
This commit is contained in:
parent
820bdb924f
commit
ee339bf301
|
|
@ -18,5 +18,9 @@ export default defineConfig([
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
'react-hooks/set-state-in-effect': 'warn',
|
||||||
|
'react-refresh/only-export-components': 'warn',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,12 @@
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.40.0",
|
||||||
"graphql": "^16.13.2",
|
"graphql": "^16.13.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.9.0",
|
"lucide-react": "^1.9.0",
|
||||||
|
|
@ -26,10 +27,13 @@
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-easy-crop": "^5.5.7",
|
||||||
"react-hook-form": "^7.73.1",
|
"react-hook-form": "^7.73.1",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.14.2",
|
||||||
"sass": "^1.99.0",
|
"sass": "^1.99.0",
|
||||||
|
"simplebar": "^6.3.3",
|
||||||
|
"simplebar-react": "^3.3.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
|
|
@ -39,6 +43,7 @@
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
|
@ -1372,6 +1377,43 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-popover": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||||
|
"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-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"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-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
|
|
@ -2323,6 +2365,19 @@
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitejs/plugin-basic-ssl": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||||
|
|
@ -2986,13 +3041,13 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "12.38.0",
|
"version": "12.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
|
||||||
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-dom": "^12.38.0",
|
"motion-dom": "^12.40.0",
|
||||||
"motion-utils": "^12.36.0",
|
"motion-utils": "^12.39.0",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
@ -3542,6 +3597,18 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
|
@ -3578,18 +3645,18 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "12.38.0",
|
"version": "12.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
|
||||||
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-utils": "^12.36.0"
|
"motion-utils": "^12.39.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-utils": {
|
"node_modules/motion-utils": {
|
||||||
"version": "12.36.0",
|
"version": "12.39.0",
|
||||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
|
|
@ -3639,6 +3706,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-wheel": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/optimism": {
|
"node_modules/optimism": {
|
||||||
"version": "0.18.1",
|
"version": "0.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz",
|
||||||
|
|
@ -3842,6 +3915,20 @@
|
||||||
"react": "^19.2.5"
|
"react": "^19.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-easy-crop": {
|
||||||
|
"version": "5.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz",
|
||||||
|
"integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"normalize-wheel": "^1.0.1",
|
||||||
|
"tslib": "^2.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.4.0",
|
||||||
|
"react-dom": ">=16.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.73.1",
|
"version": "7.73.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz",
|
||||||
|
|
@ -4108,6 +4195,37 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simplebar": {
|
||||||
|
"version": "6.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/simplebar/-/simplebar-6.3.3.tgz",
|
||||||
|
"integrity": "sha512-ni9cIiA8GRitiaenV7A/gh8nTStiVqO3gLxrfCMmq6CSy/pB+fz+QxqvJLo142QHb2Tvgvr8ILwZxf3qzAq7pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"simplebar-core": "^1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/simplebar-core": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/simplebar-core/-/simplebar-core-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-qKgTTuTqapjsFGkNhCjyPhysnbZGpQqNmjk0nOYjFN5ordC/Wjvg+RbYCyMSnW60l/Z0ZS82GbNltly6PMUH1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"lodash-es": "^4.17.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/simplebar-react": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/simplebar-react/-/simplebar-react-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-ZsgcQhKLtt5ra0BRIJeApfkTBQCa1vUPA/WXI4HcYReFt+oCEOvdVz6rR/XsGJcKxTlCRPmdGx1uJIUChupo+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"simplebar-core": "^1.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/size-sensor": {
|
"node_modules/size-sensor": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,12 @@
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.40.0",
|
||||||
"graphql": "^16.13.2",
|
"graphql": "^16.13.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.9.0",
|
"lucide-react": "^1.9.0",
|
||||||
|
|
@ -28,10 +29,13 @@
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-easy-crop": "^5.5.7",
|
||||||
"react-hook-form": "^7.73.1",
|
"react-hook-form": "^7.73.1",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.14.2",
|
||||||
"sass": "^1.99.0",
|
"sass": "^1.99.0",
|
||||||
|
"simplebar": "^6.3.3",
|
||||||
|
"simplebar-react": "^3.3.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
|
|
@ -41,6 +45,7 @@
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
@use './shared/styles/variables' as *;
|
||||||
|
|
||||||
.counter {
|
.counter {
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--accent-bg);
|
background: var(--accent-bg);
|
||||||
border: 2px solid transparent;
|
border: 2px solid $transparent;
|
||||||
transition: border-color 0.3s;
|
transition: border-color 0.3s;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
|
@ -117,7 +119,7 @@
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--social-bg);
|
background: var(--social-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -170,7 +172,7 @@
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4.5px;
|
top: -4.5px;
|
||||||
border: 5px solid transparent;
|
border: 5px solid $transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useState } from 'react'
|
|
||||||
//import reactLogo from './assets/react.svg'
|
//import reactLogo from './assets/react.svg'
|
||||||
//import viteLogo from './assets/vite.svg'
|
//import viteLogo from './assets/vite.svg'
|
||||||
//import heroImg from './assets/hero.png'
|
//import heroImg from './assets/hero.png'
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,288 @@
|
||||||
|
@use '../../shared/styles/variables' as *;
|
||||||
|
|
||||||
.app-layout {
|
.app-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100vh;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #EFF2F7;
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
52% 42% at 16% 18%,
|
||||||
|
rgba(45, 140, 255, 0.055) 0%,
|
||||||
|
transparent 72%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
50% 42% at 88% 16%,
|
||||||
|
rgba(3, 29, 154, 0.055) 0%,
|
||||||
|
transparent 74%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
54% 44% at 14% 80%,
|
||||||
|
rgba(51, 179, 67, 0.06) 0%,
|
||||||
|
transparent 72%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
56% 46% at 86% 78%,
|
||||||
|
rgba(66, 93, 232, 0.075) 0%,
|
||||||
|
transparent 74%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
74% 44% at 50% 106%,
|
||||||
|
rgba(3, 29, 154, 0.075) 0%,
|
||||||
|
rgba(66, 93, 232, 0.04) 42%,
|
||||||
|
transparent 78%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
#eff2f7 0%,
|
||||||
|
#f8fafc 40%,
|
||||||
|
#f4f7fb 70%,
|
||||||
|
#eff2f7 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
--color-bg: #242a33;
|
||||||
|
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
54% 46% at 16% 18%,
|
||||||
|
rgba(45, 140, 255, 0.085) 0%,
|
||||||
|
transparent 72%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
52% 44% at 86% 16%,
|
||||||
|
rgba(99, 102, 241, 0.085) 0%,
|
||||||
|
transparent 72%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
54% 44% at 14% 78%,
|
||||||
|
rgba(24, 184, 155, 0.07) 0%,
|
||||||
|
transparent 72%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
58% 48% at 86% 78%,
|
||||||
|
rgba(45, 140, 255, 0.095) 0%,
|
||||||
|
transparent 74%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
76% 46% at 50% 106%,
|
||||||
|
rgba(45, 140, 255, 0.16) 0%,
|
||||||
|
rgba(72, 96, 180, 0.085) 42%,
|
||||||
|
transparent 78%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
#242a33 0%,
|
||||||
|
#242a33 12%,
|
||||||
|
#222832 18%,
|
||||||
|
#1f252e 30%,
|
||||||
|
#1d232c 55%,
|
||||||
|
#171b22 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__sidebar {
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-layout__content {
|
.app-layout__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
//padding: 0px 20px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.simplebar-content {
|
||||||
|
min-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding:
|
||||||
|
$layout-page-padding-top
|
||||||
|
$layout-page-padding-right
|
||||||
|
$layout-page-padding-bottom
|
||||||
|
$layout-page-padding-left;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
}
|
||||||
padding: 20px 36px 36px 0;
|
|
||||||
max-height: calc(100vh - 56px);
|
.simplebar-track.simplebar-vertical {
|
||||||
|
top: 74px;
|
||||||
|
right: -8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar::before {
|
||||||
|
background: $gray50;
|
||||||
|
border-radius: 8px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__route-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__unsupported {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 800;
|
||||||
|
background: $color-backdrop-soft;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
|
||||||
|
&[data-state='open'] {
|
||||||
|
animation: appLayoutDrawerOverlayShow 0.18s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='closed'] {
|
||||||
|
animation: appLayoutDrawerOverlayHide 0.18s ease both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__drawer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
z-index: 801;
|
||||||
|
margin: 10px;
|
||||||
|
width: min(320px, calc(100vw - 32px));
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
//background: $color-bg;
|
||||||
|
box-shadow: $shadow-modal-dark;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&[data-state='open'] {
|
||||||
|
animation: appLayoutDrawerShow 0.24s cubic-bezier(0.34, 1.02, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='closed'] {
|
||||||
|
animation: appLayoutDrawerHide 0.2s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__drawer-scroll {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
width: 10px;
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar::before {
|
||||||
|
background: $gray50;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__drawer-title {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include wide-down {
|
||||||
|
.app-layout__sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__content {
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1279.98px) {
|
||||||
|
.app-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__sidebar,
|
||||||
|
.app-layout__content,
|
||||||
|
.app-layout__drawer-overlay,
|
||||||
|
.app-layout__drawer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout__unsupported {
|
||||||
|
display: flex !important;
|
||||||
|
min-height: 100vh !important;
|
||||||
|
padding: 32px !important;
|
||||||
|
background: $color-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes appLayoutDrawerOverlayShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes appLayoutDrawerOverlayHide {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes appLayoutDrawerShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes appLayoutDrawerHide {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-16px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,16 +1,99 @@
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type Dispatch,
|
||||||
|
type SetStateAction,
|
||||||
|
} from 'react'
|
||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import SimpleBar from 'simplebar-react'
|
||||||
|
import type SimpleBarCore from 'simplebar-core'
|
||||||
|
import 'simplebar-react/dist/simplebar.min.css'
|
||||||
import { Sidebar } from '../../widgets/Sidebar/Sidebar'
|
import { Sidebar } from '../../widgets/Sidebar/Sidebar'
|
||||||
import './AppLayout.scss'
|
import './AppLayout.scss'
|
||||||
import { Navbar } from '../../widgets/Navbar/Navbar'
|
import { Navbar } from '../../widgets/Navbar/Navbar'
|
||||||
|
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
|
||||||
|
import type { NavbarBreadcrumb } from '../../widgets/Navbar/Navbar'
|
||||||
|
|
||||||
|
export type AppLayoutOutletContext = {
|
||||||
|
setNavbarBreadcrumbs: Dispatch<SetStateAction<NavbarBreadcrumb[]>>
|
||||||
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
|
const [isSidebarDrawerOpen, setIsSidebarDrawerOpen] = useState(false)
|
||||||
|
const contentScrollRef = useRef<SimpleBarCore | null>(null)
|
||||||
|
const [isContentScrolled, setIsContentScrolled] = useState(false)
|
||||||
|
const [navbarBreadcrumbs, setNavbarBreadcrumbs] = useState<
|
||||||
|
NavbarBreadcrumb[]
|
||||||
|
>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollElement = contentScrollRef.current?.getScrollElement()
|
||||||
|
|
||||||
|
if (!scrollElement) return undefined
|
||||||
|
const scrollContainer = scrollElement
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
setIsContentScrolled(scrollContainer.scrollTop > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll()
|
||||||
|
scrollContainer.addEventListener('scroll', handleScroll, {
|
||||||
|
passive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<Sidebar />
|
<div className="app-layout__sidebar">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
<main className="app-layout__content">
|
<main className="app-layout__content">
|
||||||
<Navbar />
|
<SimpleBar ref={contentScrollRef} className="app-layout__scroll">
|
||||||
<Outlet />
|
<Navbar
|
||||||
|
breadcrumbs={navbarBreadcrumbs}
|
||||||
|
isScrolled={isContentScrolled}
|
||||||
|
onOpenSidebar={() => setIsSidebarDrawerOpen(true)}
|
||||||
|
/>
|
||||||
|
<div className="app-layout__route-content">
|
||||||
|
<Outlet context={{ setNavbarBreadcrumbs }} />
|
||||||
|
</div>
|
||||||
|
</SimpleBar>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
open={isSidebarDrawerOpen}
|
||||||
|
onOpenChange={setIsSidebarDrawerOpen}
|
||||||
|
>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="app-layout__drawer-overlay" />
|
||||||
|
<Dialog.Content className="app-layout__drawer">
|
||||||
|
<Dialog.Title className="app-layout__drawer-title">
|
||||||
|
Навигация
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
|
<SimpleBar className="app-layout__drawer-scroll">
|
||||||
|
<Sidebar
|
||||||
|
mode="drawer"
|
||||||
|
onNavigate={() => setIsSidebarDrawerOpen(false)}
|
||||||
|
/>
|
||||||
|
</SimpleBar>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
className="app-layout__unsupported"
|
||||||
|
variant="no-items"
|
||||||
|
title="Экран пока не поддерживается"
|
||||||
|
description="Для работы с системой используйте экран шириной от 1280 пикселей."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,42 @@
|
||||||
// src/app/router/router.tsx
|
// src/app/router/router.tsx
|
||||||
|
import { lazy, Suspense, type ReactNode } from 'react'
|
||||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { AppLayout } from '../layouts/AppLayout'
|
import { AppLayout } from '../layouts/AppLayout'
|
||||||
import { DevicesPage } from '../../pages/DevicesPage/DevicesPage'
|
|
||||||
import { DevicePage } from '../../pages/DevicePage/DevicePage'
|
const DevicesPage = lazy(() =>
|
||||||
import { MapPage } from '../../pages/MapPage/MapPage'
|
import('../../pages/DevicesPage/DevicesPage').then((module) => ({
|
||||||
import { EmployeesPage } from '../../pages/EmployeesPage/EmployeesPage'
|
default: module.DevicesPage,
|
||||||
import { OrganisationPage } from '../../pages/OrganisationPage/OrganisationPage'
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const DevicePage = lazy(() =>
|
||||||
|
import('../../pages/DevicePage/DevicePage').then((module) => ({
|
||||||
|
default: module.DevicePage,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const MapPage = lazy(() =>
|
||||||
|
import('../../pages/MapPage/MapPage').then((module) => ({
|
||||||
|
default: module.MapPage,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const EmployeesPage = lazy(() =>
|
||||||
|
import('../../pages/EmployeesPage/EmployeesPage').then((module) => ({
|
||||||
|
default: module.EmployeesPage,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const OrganisationPage = lazy(() =>
|
||||||
|
import('../../pages/OrganisationPage/OrganisationPage').then((module) => ({
|
||||||
|
default: module.OrganisationPage,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function lazyRoute(element: ReactNode) {
|
||||||
|
return <Suspense fallback={null}>{element}</Suspense>
|
||||||
|
}
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -19,23 +49,23 @@ export const router = createBrowserRouter([
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'devices',
|
path: 'devices',
|
||||||
element: <DevicesPage />,
|
element: lazyRoute(<DevicesPage />),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'devices/:deviceId',
|
path: 'devices/:deviceId',
|
||||||
element: <DevicePage />,
|
element: lazyRoute(<DevicePage />),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'map',
|
path: 'map',
|
||||||
element: <MapPage />,
|
element: lazyRoute(<MapPage />),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'employees',
|
path: 'employees',
|
||||||
element: <EmployeesPage />,
|
element: lazyRoute(<EmployeesPage />),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'employees/organisations/:organisationId',
|
path: 'employees/organisations/:organisationId',
|
||||||
element: <OrganisationPage />,
|
element: lazyRoute(<OrganisationPage />),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1714_21821)">
|
||||||
|
<mask id="mask0_1714_21821" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="32">
|
||||||
|
<path d="M31.1882 0H0V31.183H31.1882V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_1714_21821)">
|
||||||
|
<path d="M15.4632 -0.00547545C12.2092 0.0490096 9.55806 2.70874 9.5127 5.96443C9.46561 9.34955 12.1941 12.1079 15.5672 12.1079C15.6579 12.1079 15.7486 12.1061 15.8388 12.1007L15.8375 12.1135C16.377 12.1382 16.897 12.2357 17.3881 12.3971C19.7192 13.1624 21.4023 15.3564 21.4023 17.9448C21.4023 19.1885 21.0122 20.3427 20.3475 21.29C20.3004 21.3596 20.249 21.4292 20.1977 21.4964C20.1497 21.5592 20.0996 21.6204 20.05 21.6814C18.9803 22.9651 17.3699 23.7811 15.5672 23.7811C13.7664 23.7811 12.159 22.9663 11.0875 21.6857C11.0875 21.6857 11.0862 21.6833 11.085 21.6814C10.9835 21.5592 10.8872 21.4334 10.7966 21.3027C10.7924 21.2984 10.7894 21.2943 10.7869 21.29C9.67657 19.9508 7.99982 19.0985 6.12454 19.0985C2.75323 19.0985 0.0253552 21.8562 0.0718754 25.2407C0.115974 28.4662 2.72057 31.1139 5.9437 31.2084C7.94051 31.2663 9.72686 30.3571 10.8704 28.9163C10.8716 28.9145 10.8734 28.9121 10.8746 28.9102C10.9036 28.8715 10.9316 28.8341 10.9611 28.7978C10.9665 28.7904 10.9708 28.7838 10.9774 28.7766C12.0447 27.4167 13.7035 26.5431 15.5672 26.5431C17.431 26.5431 19.0897 27.4167 20.1571 28.7766C20.1637 28.7838 20.1679 28.7904 20.1741 28.7978C20.2012 28.8358 20.2302 28.8733 20.2593 28.909L20.2598 28.9102C21.369 30.3124 23.0833 31.2107 25.0099 31.2107C28.3541 31.2107 31.065 28.4994 31.065 25.1555C31.065 25.0999 31.0643 25.0448 31.0626 24.9903C31.0396 24.129 30.8134 23.2851 30.432 22.512L30.1791 21.9991L21.1319 3.65973L20.8561 3.09954L20.855 3.09655C19.8184 1.24518 17.8391 -0.00634766 15.5669 -0.00634766C15.5325 -0.00634766 15.4978 -0.00605785 15.4632 -0.00547545Z" fill="url(#paint0_linear_1714_21821)"/>
|
||||||
|
<path d="M18.6719 17.9433C18.6719 18.6049 18.4645 19.2184 18.1112 19.7223C18.086 19.7593 18.0586 19.7963 18.0311 19.8319C18.0061 19.8653 17.9793 19.8978 17.9527 19.9305C17.3838 20.6129 16.5275 21.047 15.569 21.047C14.6114 21.047 13.7566 20.6136 13.187 19.9327L13.1855 19.9305C13.1315 19.8653 13.0803 19.7986 13.0322 19.7289C13.03 19.7268 13.0284 19.7244 13.027 19.7223C12.6736 19.2184 12.4663 18.6049 12.4663 17.9433C12.4663 16.2287 13.8552 14.8394 15.569 14.8394C15.6172 14.8394 15.6654 14.8402 15.7127 14.8424C17.3601 14.9179 18.6719 16.2769 18.6719 17.9433Z" fill="#DF5D13"/>
|
||||||
|
<path d="M30.4319 22.511C28.3985 19.543 22.0154 13.2158 18.0389 12.4679C17.6189 12.3889 17.1968 12.3204 16.7785 12.2321C16.4731 12.1678 16.1588 12.1272 15.8376 12.1123L15.8387 12.0996C15.7487 12.1052 15.658 12.107 15.5673 12.107C12.1942 12.107 9.46554 9.34859 9.51281 5.96344C9.55817 2.70775 12.2092 0.0480264 15.4632 -0.00645855C17.7787 -0.0451429 19.8027 1.21608 20.8549 3.09556L20.8562 3.09856L21.1319 3.65874L30.1791 21.998L30.4319 22.511Z" fill="white"/>
|
||||||
|
<path d="M20.2593 28.9091C20.2303 28.8735 20.2012 28.836 20.174 28.7979C20.1679 28.7906 20.1637 28.7839 20.1571 28.7768C19.0897 27.4169 17.431 26.5433 15.5672 26.5433C13.7035 26.5433 12.0447 27.4169 10.9774 28.7768C10.9708 28.7839 10.9665 28.7906 10.9611 28.7979C10.9315 28.8341 10.9036 28.8717 10.8747 28.9103C10.8734 28.9122 10.8716 28.9146 10.8704 28.9165C9.72677 30.3573 7.94051 31.2665 5.9437 31.2084C2.72056 31.1141 0.115976 28.4664 0.0718778 25.2409C0.0252597 21.8564 2.75323 19.0986 6.12454 19.0986C7.99973 19.0986 9.67657 19.951 10.7869 21.2902C10.7894 21.2945 10.7924 21.2986 10.7966 21.3029C10.8873 21.4336 10.9835 21.5594 11.085 21.6816C11.085 21.6816 11.9886 23.8877 15.5684 25.3704C19.0922 26.8301 20.2247 28.8462 20.2593 28.9091Z" fill="#DF5D13"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_1714_21821" x1="21.7418" y1="13.7739" x2="13.647" y2="23.985" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#E0E0E0"/>
|
||||||
|
<stop offset="1" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_1714_21821">
|
||||||
|
<rect width="32" height="32" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1,26 @@
|
||||||
|
<svg width="171" height="32" viewBox="0 0 171 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_1714_21830" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="32">
|
||||||
|
<path d="M31.7346 0.457031H0.546387V31.64H31.7346V0.457031Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_1714_21830)">
|
||||||
|
<path d="M16.0097 0.452044C12.7556 0.506529 10.1045 3.16626 10.0591 6.42195C10.012 9.80707 12.7405 12.5655 16.1136 12.5655C16.2043 12.5655 16.2951 12.5637 16.3852 12.5583L16.3839 12.5711C16.9234 12.5957 17.4434 12.6932 17.9345 12.8547C20.2656 13.6199 21.9487 15.8139 21.9487 18.4024C21.9487 19.6461 21.5586 20.8003 20.8939 21.7476C20.8469 21.8171 20.7954 21.8867 20.7441 21.9539C20.6962 22.0167 20.646 22.0779 20.5964 22.139C19.5267 23.4226 17.9164 24.2386 16.1136 24.2386C14.3128 24.2386 12.7054 23.4238 11.6339 22.1433C11.6339 22.1433 11.6326 22.1408 11.6314 22.139C11.5299 22.0167 11.4336 21.8909 11.343 21.7603C11.3388 21.756 11.3358 21.7519 11.3333 21.7476C10.223 20.4083 8.54621 19.556 6.67093 19.556C3.29962 19.556 0.571742 22.3138 0.618262 25.6983C0.662361 28.9237 3.26695 31.5715 6.49008 31.6659C8.4869 31.7239 10.2733 30.8146 11.4168 29.3738C11.418 29.3721 11.4198 29.3696 11.421 29.3678C11.45 29.329 11.478 29.2916 11.5075 29.2553C11.5129 29.2479 11.5172 29.2413 11.5238 29.2341C12.5911 27.8742 14.2499 27.0007 16.1136 27.0007C17.9774 27.0007 19.6361 27.8742 20.7035 29.2341C20.7101 29.2413 20.7144 29.2479 20.7205 29.2553C20.7476 29.2933 20.7766 29.3308 20.8057 29.3665L20.8063 29.3678C21.9154 30.7699 23.6297 31.6682 25.5563 31.6682C28.9005 31.6682 31.6114 28.957 31.6114 25.6131C31.6114 25.5574 31.6107 25.5023 31.609 25.4479C31.586 24.5865 31.3598 23.7426 30.9784 22.9695L30.7255 22.4566L21.6784 4.11725L21.4026 3.55707L21.4014 3.55407C20.3649 1.7027 18.3855 0.451172 16.1134 0.451172C16.0789 0.451172 16.0442 0.451461 16.0097 0.452044Z" fill="url(#paint0_linear_1714_21830)"/>
|
||||||
|
<path d="M19.2183 18.4003C19.2183 19.062 19.0109 19.6755 18.6576 20.1793C18.6324 20.2164 18.605 20.2534 18.5775 20.2889C18.5525 20.3223 18.5257 20.3549 18.4991 20.3875C17.9302 21.0699 17.0739 21.504 16.1154 21.504C15.1578 21.504 14.303 21.0707 13.7334 20.3897L13.7319 20.3875C13.6779 20.3223 13.6267 20.2557 13.5786 20.1859C13.5764 20.1838 13.5748 20.1814 13.5734 20.1793C13.22 19.6755 13.0127 19.062 13.0127 18.4003C13.0127 16.6857 14.4016 15.2964 16.1154 15.2964C16.1636 15.2964 16.2118 15.2972 16.2592 15.2994C17.9065 15.3749 19.2183 16.7339 19.2183 18.4003Z" fill="#DF5D13"/>
|
||||||
|
<path d="M30.9783 22.968C28.9449 20.0001 22.5618 13.6728 18.5853 12.9249C18.1653 12.846 17.7432 12.7775 17.3249 12.6892C17.0195 12.6248 16.7052 12.5843 16.384 12.5694L16.3851 12.5567C16.2951 12.5622 16.2044 12.564 16.1137 12.564C12.7406 12.564 10.012 9.80562 10.0592 6.42047C10.1046 3.16478 12.7556 0.505058 16.0096 0.450573C18.3251 0.411888 20.3491 1.67311 21.4013 3.55259L21.4026 3.5556L21.6783 4.11577L30.7256 22.455L30.9783 22.968Z" fill="white"/>
|
||||||
|
<path d="M20.8057 29.3663C20.7767 29.3306 20.7476 29.2931 20.7204 29.255C20.7144 29.2477 20.7101 29.241 20.7035 29.2339C19.6361 27.874 17.9774 27.0004 16.1136 27.0004C14.2499 27.0004 12.5911 27.874 11.5238 29.2339C11.5172 29.241 11.5129 29.2477 11.5075 29.255C11.4779 29.2912 11.45 29.3288 11.4211 29.3675C11.4198 29.3693 11.418 29.3718 11.4168 29.3736C10.2732 30.8144 8.4869 31.7237 6.49008 31.6655C3.26695 31.5713 0.662363 28.9235 0.618264 25.6981C0.571647 22.3136 3.29962 19.5557 6.67093 19.5557C8.54612 19.5557 10.223 20.4081 11.3333 21.7474C11.3358 21.7517 11.3388 21.7558 11.343 21.7601C11.4337 21.8907 11.5299 22.0165 11.6314 22.1388C11.6314 22.1388 12.535 24.3448 16.1148 25.8275C19.6386 27.2872 20.7711 29.3034 20.8057 29.3663Z" fill="#DF5D13"/>
|
||||||
|
</g>
|
||||||
|
<mask id="mask1_1714_21830" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="44" y="6" width="127" height="20">
|
||||||
|
<path d="M170.633 6.61426H44.2109V25.4801H170.633V6.61426Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask1_1714_21830)">
|
||||||
|
<path d="M50.9664 7.59375H53.6171L60.5189 25.3654H57.5682L55.6927 20.5346H48.8409L46.9904 25.3654H44.0146L50.9664 7.59375ZM55.1426 18.457L52.2918 10.6725L49.341 18.457H55.1426Z" fill="white"/>
|
||||||
|
<path d="M62.7148 25.3654V7.59375H70.1667C70.9669 7.59375 71.7005 7.76062 72.3673 8.09436C73.0341 8.4281 73.6176 8.87031 74.1177 9.42094C74.6179 9.97161 75.0014 10.5891 75.2681 11.2732C75.5348 11.9575 75.6682 12.6583 75.6682 13.3758C75.6682 14.3604 75.4431 15.3032 74.993 16.2042C74.5596 17.0887 73.9344 17.8063 73.1175 18.3569C72.3173 18.9076 71.3837 19.1829 70.3168 19.1829H65.5155V25.3654H62.7148ZM65.5155 16.7049H70.1418C70.6752 16.7049 71.142 16.563 71.5421 16.2794C71.9422 15.979 72.2506 15.5785 72.4674 15.0779C72.7007 14.5773 72.8174 14.01 72.8174 13.3758C72.8174 12.725 72.6841 12.1493 72.4173 11.6487C72.1506 11.1482 71.8005 10.7643 71.367 10.4974C70.9503 10.2137 70.4918 10.0718 69.9917 10.0718H65.5155V16.7049Z" fill="white"/>
|
||||||
|
<path d="M93.2234 25.3654V12.7L87.9975 22.3117H86.347L81.0956 12.7V25.3654H78.2949V7.59375H81.2956L87.1722 18.457L93.0487 7.59375H96.0499V25.3654H93.2234Z" fill="white"/>
|
||||||
|
<path d="M105.18 7.59375H107.831L114.733 25.3654H111.782L109.906 20.5346H103.054L101.204 25.3654H98.228L105.18 7.59375ZM109.356 18.457L106.506 10.6725L103.555 18.457H109.356Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_1714_21830" x1="22.2882" y1="14.2314" x2="14.1934" y2="24.4425" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#E0E0E0"/>
|
||||||
|
<stop offset="1" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
|
|
@ -0,0 +1,13 @@
|
||||||
|
<svg width="170" height="105" viewBox="0 0 170 105" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<ellipse cx="32.0584" cy="32.2273" rx="32.0584" ry="32.2273" fill="var(--empty-orb)"/>
|
||||||
|
<path d="M32.0605 11.2188C43.5945 11.2188 52.9541 20.62 52.9541 32.2275C52.9538 43.8349 43.5943 53.2354 32.0605 53.2354C20.527 53.2351 11.1682 43.8347 11.168 32.2275C11.168 20.6201 20.5268 11.219 32.0605 11.2188Z" fill="var(--empty-ring-fill)" stroke="var(--empty-ring-stroke)" stroke-width="2"/>
|
||||||
|
<ellipse cx="32.0621" cy="32.2272" rx="13.9136" ry="13.9869" fill="var(--empty-core)"/>
|
||||||
|
<ellipse cx="28.3626" cy="28.2433" rx="5.62041" ry="5.65001" fill="var(--empty-highlight)"/>
|
||||||
|
|
||||||
|
<ellipse cx="137.941" cy="32.2273" rx="32.0584" ry="32.2273" fill="var(--empty-orb)"/>
|
||||||
|
<path d="M137.943 11.2188C149.477 11.2188 158.837 20.62 158.837 32.2275C158.837 43.8349 149.477 53.2354 137.943 53.2354C126.41 53.2351 117.051 43.8347 117.051 32.2275C117.051 20.6201 126.41 11.219 137.943 11.2188Z" fill="var(--empty-ring-fill)" stroke="var(--empty-ring-stroke)" stroke-width="2"/>
|
||||||
|
<ellipse cx="137.945" cy="32.2272" rx="13.9136" ry="13.9869" fill="var(--empty-core)"/>
|
||||||
|
<ellipse cx="134.245" cy="28.2433" rx="5.62041" ry="5.65001" fill="var(--empty-highlight)"/>
|
||||||
|
|
||||||
|
<path d="M108.898 104.685C109.663 104.685 110.287 104.064 110.245 103.3C109.907 97.0904 107.304 91.204 102.907 86.7839C98.1846 82.0363 91.7792 79.3691 85.1003 79.3691C78.4214 79.3691 72.016 82.0363 67.2933 86.7839C62.8963 91.204 60.2933 97.0904 59.9551 103.3C59.9135 104.064 60.5377 104.685 61.3028 104.685C62.0679 104.685 62.6838 104.064 62.7305 103.3C63.0654 97.8298 65.3753 92.6511 69.2525 88.7534C73.4556 84.5282 79.1562 82.1545 85.1003 82.1545C91.0443 82.1545 96.7449 84.5282 100.948 88.7534C104.825 92.6511 107.135 97.8298 107.47 103.3C107.517 104.064 108.133 104.685 108.898 104.685Z" fill="var(--empty-mouth)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function Apps({ className, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect x="2" y="2" width="9.5" height="9.5" rx="2.5" fill="currentColor" />
|
||||||
|
<rect x="13" y="2" width="9.5" height="9.5" rx="2.5" fill="currentColor" />
|
||||||
|
<rect x="2" y="13" width="9.5" height="9.5" rx="2.5" fill="currentColor" />
|
||||||
|
<rect x="13" y="13" width="9.5" height="9.5" rx="2.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function LoadingTwotoneLoopIcon({ className, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path strokeDasharray="18" d="M12 3c4.97 0 9 4.03 9 9" />
|
||||||
|
<path
|
||||||
|
strokeDasharray="60"
|
||||||
|
d="M12 3c4.97 0 9 4.03 9 9c0 4.97 -4.03 9 -9 9c-4.97 0 -9 -4.03 -9 -9c0 -4.97 4.03 -9 9 -9Z"
|
||||||
|
opacity=".3"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<svg width="171" height="108" viewBox="0 0 171 108" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<ellipse cx="32.5584" cy="32.2273" rx="32.0584" ry="32.2273" fill="var(--empty-orb)"/>
|
||||||
|
<path d="M53.452 32.2271C53.452 43.8346 44.0929 53.2354 32.559 53.2354C21.0251 53.2354 11.666 43.8346 11.666 32.2271C11.666 20.6195 21.0251 11.2188 32.559 11.2188C44.0929 11.2188 53.452 20.6195 53.452 32.2271Z" fill="var(--empty-ring-fill)" stroke="var(--empty-ring-stroke)" stroke-width="2"/>
|
||||||
|
<ellipse cx="32.5601" cy="32.2272" rx="13.9136" ry="13.9869" fill="var(--empty-core)"/>
|
||||||
|
<ellipse cx="28.8626" cy="28.2438" rx="5.62041" ry="5.65001" fill="var(--empty-highlight)"/>
|
||||||
|
|
||||||
|
<ellipse cx="138.441" cy="32.2273" rx="32.0584" ry="32.2273" fill="var(--empty-orb)"/>
|
||||||
|
<path d="M159.335 32.2271C159.335 43.8346 149.976 53.2354 138.442 53.2354C126.908 53.2354 117.549 43.8346 117.549 32.2271C117.549 20.6195 126.908 11.2188 138.442 11.2188C149.976 11.2188 159.335 20.6195 159.335 32.2271Z" fill="var(--empty-ring-fill)" stroke="var(--empty-ring-stroke)" stroke-width="2"/>
|
||||||
|
<ellipse cx="138.443" cy="32.2272" rx="13.9136" ry="13.9869" fill="var(--empty-core)"/>
|
||||||
|
<ellipse cx="134.745" cy="28.2438" rx="5.62041" ry="5.65001" fill="var(--empty-highlight)"/>
|
||||||
|
|
||||||
|
<path d="M66.7188 87.3417C66.1117 87.3417 65.6164 87.8343 65.6494 88.4405C65.9177 93.3675 67.9832 98.0381 71.472 101.545C75.2194 105.312 80.3018 107.429 85.6013 107.429C90.9008 107.429 95.9832 105.312 99.7305 101.545C103.219 98.0381 105.285 93.3675 105.553 88.4405C105.586 87.8343 105.091 87.3417 104.484 87.3417C103.877 87.3417 103.388 87.8345 103.351 88.4404C103.085 92.7808 101.252 96.8899 98.1759 99.9826C94.8409 103.335 90.3177 105.219 85.6013 105.219C80.8849 105.219 76.3616 103.335 73.0266 99.9826C69.9502 96.8899 68.1173 92.7808 67.8516 88.4404C67.8145 87.8345 67.3259 87.3417 66.7188 87.3417Z" fill="var(--empty-mouth)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,38 +1,32 @@
|
||||||
import { gql } from '@apollo/client'
|
import { gql } from '@apollo/client'
|
||||||
|
|
||||||
export const GET_PHONES_PAGE_QUERY = gql`
|
export const GET_PHONES_QUERY = gql`
|
||||||
query GetPhonesPage($key: String) {
|
query GetPhones(
|
||||||
getPhonesPage(key: $key) {
|
$page: Int!
|
||||||
|
$query: String
|
||||||
|
$locked: Boolean
|
||||||
|
$needMaintenance: Boolean
|
||||||
|
$networkStatus: [PhoneNetworkStatus!]
|
||||||
|
$orgs: [ID!]
|
||||||
|
$sortDirection: SortDirection!
|
||||||
|
$sortField: PhoneSortField!
|
||||||
|
) {
|
||||||
|
getPhones(
|
||||||
|
page: $page
|
||||||
|
query: $query
|
||||||
|
locked: $locked
|
||||||
|
needMaintenance: $needMaintenance
|
||||||
|
networkStatus: $networkStatus
|
||||||
|
orgs: $orgs
|
||||||
|
sortDirection: $sortDirection
|
||||||
|
sortField: $sortField
|
||||||
|
) {
|
||||||
page {
|
page {
|
||||||
id
|
id
|
||||||
imei
|
imei
|
||||||
imei2
|
imei2
|
||||||
serial
|
serial
|
||||||
orgId
|
networkStatus
|
||||||
registerDate
|
|
||||||
|
|
||||||
org {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
creationDate
|
|
||||||
policy {
|
|
||||||
canUseBluetooth
|
|
||||||
canUseGPS
|
|
||||||
canUseCamera
|
|
||||||
canUseSim
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
policy {
|
|
||||||
canUseBluetooth
|
|
||||||
canUseCamera
|
|
||||||
canUseGPS
|
|
||||||
canUseSim
|
|
||||||
}
|
|
||||||
|
|
||||||
techState {
|
|
||||||
needMaintenance
|
|
||||||
}
|
|
||||||
|
|
||||||
lastLocation {
|
lastLocation {
|
||||||
alt
|
alt
|
||||||
|
|
@ -40,9 +34,102 @@ export const GET_PHONES_PAGE_QUERY = gql`
|
||||||
lat
|
lat
|
||||||
lng
|
lng
|
||||||
}
|
}
|
||||||
|
|
||||||
|
org {
|
||||||
|
id
|
||||||
|
logoUrl
|
||||||
|
name
|
||||||
|
|
||||||
|
policy {
|
||||||
|
bluetooth
|
||||||
|
bluetoothEditable
|
||||||
|
camera
|
||||||
|
cameraEditable
|
||||||
|
GPS
|
||||||
|
gpsEditable
|
||||||
|
sim
|
||||||
|
simEditable
|
||||||
|
offlineTimeThreshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId
|
||||||
|
|
||||||
|
policy {
|
||||||
|
bluetooth
|
||||||
|
camera
|
||||||
|
GPS
|
||||||
|
sim
|
||||||
|
locked
|
||||||
|
}
|
||||||
|
|
||||||
|
techState {
|
||||||
|
batteryLevel
|
||||||
|
needMaintenance
|
||||||
|
temperature
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextKey
|
totalElements
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_PHONES_TABS_STATS_QUERY = gql`
|
||||||
|
query GetPhonesTabsStats($query: String, $orgs: [ID!]) {
|
||||||
|
totalPhones: getPhones(
|
||||||
|
page: 0
|
||||||
|
query: $query
|
||||||
|
orgs: $orgs
|
||||||
|
sortDirection: DESC
|
||||||
|
sortField: ID
|
||||||
|
) {
|
||||||
|
totalElements
|
||||||
|
}
|
||||||
|
|
||||||
|
onlinePhones: getPhones(
|
||||||
|
page: 0
|
||||||
|
query: $query
|
||||||
|
networkStatus: [Online]
|
||||||
|
orgs: $orgs
|
||||||
|
sortDirection: DESC
|
||||||
|
sortField: ID
|
||||||
|
) {
|
||||||
|
totalElements
|
||||||
|
}
|
||||||
|
|
||||||
|
lockedPhones: getPhones(
|
||||||
|
page: 0
|
||||||
|
query: $query
|
||||||
|
locked: true
|
||||||
|
orgs: $orgs
|
||||||
|
sortDirection: DESC
|
||||||
|
sortField: ID
|
||||||
|
) {
|
||||||
|
totalElements
|
||||||
|
}
|
||||||
|
|
||||||
|
lostPhones: getPhones(
|
||||||
|
page: 0
|
||||||
|
query: $query
|
||||||
|
networkStatus: [Lost]
|
||||||
|
orgs: $orgs
|
||||||
|
sortDirection: DESC
|
||||||
|
sortField: ID
|
||||||
|
) {
|
||||||
|
totalElements
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenancePhones: getPhones(
|
||||||
|
page: 0
|
||||||
|
query: $query
|
||||||
|
needMaintenance: true
|
||||||
|
orgs: $orgs
|
||||||
|
sortDirection: DESC
|
||||||
|
sortField: ID
|
||||||
|
) {
|
||||||
|
totalElements
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
@ -53,6 +140,7 @@ export const GET_PHONE_QUERY = gql`
|
||||||
id
|
id
|
||||||
imei
|
imei
|
||||||
imei2
|
imei2
|
||||||
|
networkStatus
|
||||||
|
|
||||||
lastLocation {
|
lastLocation {
|
||||||
alt
|
alt
|
||||||
|
|
@ -66,20 +154,26 @@ export const GET_PHONE_QUERY = gql`
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
policy {
|
policy {
|
||||||
canUseBluetooth
|
bluetooth
|
||||||
canUseCamera
|
bluetoothEditable
|
||||||
canUseGPS
|
camera
|
||||||
canUseSim
|
cameraEditable
|
||||||
|
GPS
|
||||||
|
gpsEditable
|
||||||
|
sim
|
||||||
|
simEditable
|
||||||
|
offlineTimeThreshold
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
orgId
|
orgId
|
||||||
|
|
||||||
policy {
|
policy {
|
||||||
canUseBluetooth
|
bluetooth
|
||||||
canUseCamera
|
camera
|
||||||
canUseGPS
|
GPS
|
||||||
canUseSim
|
sim
|
||||||
|
locked
|
||||||
}
|
}
|
||||||
|
|
||||||
registerDate
|
registerDate
|
||||||
|
|
@ -109,15 +203,62 @@ export const GET_PHONE_GPS_TRACK_QUERY = gql`
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const GET_PHONE_PACKAGES_QUERY = gql`
|
||||||
|
query GetPhonePackages($phoneId: ID!) {
|
||||||
|
getPhonePackages(phoneId: $phoneId) {
|
||||||
|
category
|
||||||
|
iconUrl
|
||||||
|
installDate
|
||||||
|
name
|
||||||
|
packageName
|
||||||
|
version
|
||||||
|
versionName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const GET_TELEMETRY_QUERY = gql`
|
export const GET_TELEMETRY_QUERY = gql`
|
||||||
query GetTelemetry($phoneId: Int!, $startDate: Float!, $endDate: Float) {
|
query GetTelemetry(
|
||||||
|
$phoneId: Int!
|
||||||
|
$packagesPhoneId: ID!
|
||||||
|
$startDate: Float!
|
||||||
|
$endDate: Float
|
||||||
|
) {
|
||||||
getTelemetry(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
|
getTelemetry(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
|
||||||
|
batteryCapacity
|
||||||
|
batteryCycles
|
||||||
batteryLevel
|
batteryLevel
|
||||||
chargeType
|
|
||||||
date
|
date
|
||||||
screenOn
|
|
||||||
temperature
|
temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPhoneStateEvents(phoneId: $packagesPhoneId, startDate: $startDate, endDate: $endDate) {
|
||||||
|
date
|
||||||
|
phoneId
|
||||||
|
type
|
||||||
|
}
|
||||||
|
|
||||||
|
getPhonePackagesUseEvents(
|
||||||
|
phoneId: $packagesPhoneId
|
||||||
|
startDate: $startDate
|
||||||
|
endDate: $endDate
|
||||||
|
) {
|
||||||
|
events {
|
||||||
|
type
|
||||||
|
packageName
|
||||||
|
date
|
||||||
|
}
|
||||||
|
|
||||||
|
phonePackage {
|
||||||
|
category
|
||||||
|
iconUrl
|
||||||
|
installDate
|
||||||
|
name
|
||||||
|
packageName
|
||||||
|
version
|
||||||
|
versionName
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -134,6 +275,7 @@ export const GET_DEVICE_PAGE_QUERY = gql`
|
||||||
id
|
id
|
||||||
imei
|
imei
|
||||||
imei2
|
imei2
|
||||||
|
networkStatus
|
||||||
|
|
||||||
lastLocation {
|
lastLocation {
|
||||||
alt
|
alt
|
||||||
|
|
@ -147,33 +289,40 @@ export const GET_DEVICE_PAGE_QUERY = gql`
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
policy {
|
policy {
|
||||||
canUseBluetooth
|
bluetooth
|
||||||
canUseCamera
|
bluetoothEditable
|
||||||
canUseGPS
|
camera
|
||||||
canUseSim
|
cameraEditable
|
||||||
|
GPS
|
||||||
|
gpsEditable
|
||||||
|
sim
|
||||||
|
simEditable
|
||||||
|
offlineTimeThreshold
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
orgId
|
orgId
|
||||||
|
|
||||||
policy {
|
policy {
|
||||||
canUseBluetooth
|
bluetooth
|
||||||
canUseCamera
|
camera
|
||||||
canUseGPS
|
GPS
|
||||||
canUseSim
|
sim
|
||||||
|
locked
|
||||||
}
|
}
|
||||||
|
|
||||||
registerDate
|
registerDate
|
||||||
serial
|
serial
|
||||||
|
|
||||||
techState {
|
techState {
|
||||||
batteryCycles
|
batteryCycles
|
||||||
batteryLevel
|
batteryLevel
|
||||||
batteryRemainingCapacity
|
batteryRemainingCapacity
|
||||||
hits
|
hits
|
||||||
needMaintenance
|
needMaintenance
|
||||||
overheats
|
overheats
|
||||||
worktime
|
temperature
|
||||||
|
worktime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,10 +331,10 @@ export const GET_DEVICE_PAGE_QUERY = gql`
|
||||||
startDate: $telemetryStartDate
|
startDate: $telemetryStartDate
|
||||||
endDate: $telemetryEndDate
|
endDate: $telemetryEndDate
|
||||||
) {
|
) {
|
||||||
|
batteryCapacity
|
||||||
|
batteryCycles
|
||||||
batteryLevel
|
batteryLevel
|
||||||
chargeType
|
|
||||||
date
|
date
|
||||||
screenOn
|
|
||||||
temperature
|
temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,19 +364,27 @@ export const CREATE_PHONE_REGISTRATION_TOKEN_MUTATION = gql`
|
||||||
export const CHANGE_PHONE_POLICY_MUTATION = gql`
|
export const CHANGE_PHONE_POLICY_MUTATION = gql`
|
||||||
mutation ChangePhonePolicy(
|
mutation ChangePhonePolicy(
|
||||||
$id: ID!
|
$id: ID!
|
||||||
$canUseBluetooth: Boolean!
|
$bluetooth: ServiceUseOption!
|
||||||
$canUseCamera: Boolean!
|
$camera: ComponentUseOption!
|
||||||
$canUseGPS: Boolean!
|
$GPS: ServiceUseOption!
|
||||||
$canUseSim: Boolean!
|
$sim: ServiceUseOption!
|
||||||
|
$locked: Boolean!
|
||||||
) {
|
) {
|
||||||
changePhonePolicy(
|
changePhonePolicy(
|
||||||
id: $id
|
id: $id
|
||||||
policy: {
|
policy: {
|
||||||
canUseBluetooth: $canUseBluetooth
|
bluetooth: $bluetooth
|
||||||
canUseCamera: $canUseCamera
|
camera: $camera
|
||||||
canUseGPS: $canUseGPS
|
GPS: $GPS
|
||||||
canUseSim: $canUseSim
|
sim: $sim
|
||||||
|
locked: $locked
|
||||||
}
|
}
|
||||||
)
|
) {
|
||||||
|
bluetooth
|
||||||
|
camera
|
||||||
|
GPS
|
||||||
|
sim
|
||||||
|
locked
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import type {
|
||||||
|
ComponentUseOption,
|
||||||
|
LegacyPhoneUsePolicyOption,
|
||||||
|
PhoneUsePolicyOption,
|
||||||
|
ServiceUseOption,
|
||||||
|
} from '../model/types'
|
||||||
|
|
||||||
|
type PhonePolicyValue =
|
||||||
|
| PhoneUsePolicyOption
|
||||||
|
| LegacyPhoneUsePolicyOption
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
export function isPhonePolicyOptionEnabled(value: PhonePolicyValue) {
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
|
||||||
|
return value === 'Enabled' || value === 'Allowed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function booleanToPhonePolicyOption(
|
||||||
|
enabled: boolean,
|
||||||
|
): ServiceUseOption {
|
||||||
|
return enabled ? 'Enabled' : 'Disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function booleanToServiceUseOption(enabled: boolean): ServiceUseOption {
|
||||||
|
return enabled ? 'Enabled' : 'Disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function booleanToComponentUseOption(
|
||||||
|
enabled: boolean,
|
||||||
|
): ComponentUseOption {
|
||||||
|
return enabled ? 'Allowed' : 'Disallowed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServiceUseOption(
|
||||||
|
value?: ServiceUseOption | LegacyPhoneUsePolicyOption | boolean | null,
|
||||||
|
fallbackBoolean?: boolean | null,
|
||||||
|
): ServiceUseOption {
|
||||||
|
if (value === 'Disabled' || value === 'Enabled' || value === 'Unspecified') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'Allowed') return 'Enabled'
|
||||||
|
if (value === 'Disallowed') return 'Disabled'
|
||||||
|
if (value === 'Changeable') return 'Unspecified'
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return booleanToServiceUseOption(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fallbackBoolean === 'boolean') {
|
||||||
|
return booleanToServiceUseOption(fallbackBoolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unspecified'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeComponentUseOption(
|
||||||
|
value?:
|
||||||
|
| ComponentUseOption
|
||||||
|
| ServiceUseOption
|
||||||
|
| LegacyPhoneUsePolicyOption
|
||||||
|
| boolean
|
||||||
|
| null,
|
||||||
|
fallbackBoolean?: boolean | null,
|
||||||
|
): ComponentUseOption {
|
||||||
|
if (value === 'Allowed' || value === 'Disallowed') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'Enabled') return 'Allowed'
|
||||||
|
if (value === 'Disabled') return 'Disallowed'
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return booleanToComponentUseOption(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fallbackBoolean === 'boolean') {
|
||||||
|
return booleanToComponentUseOption(fallbackBoolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Disallowed'
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,45 @@ export type DeviceLocation = {
|
||||||
lng: number
|
lng: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServiceUseOption =
|
||||||
|
| 'Disabled'
|
||||||
|
| 'Enabled'
|
||||||
|
| 'Unspecified'
|
||||||
|
|
||||||
|
export type ComponentUseOption =
|
||||||
|
| 'Allowed'
|
||||||
|
| 'Disallowed'
|
||||||
|
|
||||||
|
export type PhoneUsePolicyOption = ServiceUseOption | ComponentUseOption
|
||||||
|
|
||||||
|
export type LegacyPhoneUsePolicyOption =
|
||||||
|
| 'Allowed'
|
||||||
|
| 'Disallowed'
|
||||||
|
| 'Changeable'
|
||||||
|
|
||||||
|
export type GroupUsePolicy = {
|
||||||
|
bluetooth?: ServiceUseOption | LegacyPhoneUsePolicyOption
|
||||||
|
bluetoothEditable?: boolean
|
||||||
|
camera?: ComponentUseOption | LegacyPhoneUsePolicyOption
|
||||||
|
cameraEditable?: boolean
|
||||||
|
GPS?: ServiceUseOption | LegacyPhoneUsePolicyOption
|
||||||
|
gpsEditable?: boolean
|
||||||
|
sim?: ServiceUseOption | LegacyPhoneUsePolicyOption
|
||||||
|
simEditable?: boolean
|
||||||
|
offlineTimeThreshold?: number
|
||||||
|
date?: number
|
||||||
|
}
|
||||||
|
|
||||||
export type DevicePolicy = {
|
export type DevicePolicy = {
|
||||||
canUseBluetooth: boolean
|
bluetooth?: ServiceUseOption | LegacyPhoneUsePolicyOption
|
||||||
canUseCamera: boolean
|
camera?: ComponentUseOption | LegacyPhoneUsePolicyOption
|
||||||
canUseGPS: boolean
|
GPS?: ServiceUseOption | LegacyPhoneUsePolicyOption
|
||||||
canUseSim: boolean
|
sim?: ServiceUseOption | LegacyPhoneUsePolicyOption
|
||||||
|
canUseBluetooth?: boolean
|
||||||
|
canUseCamera?: boolean
|
||||||
|
canUseGPS?: boolean
|
||||||
|
canUseSim?: boolean
|
||||||
|
locked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceTechState = {
|
export type DeviceTechState = {
|
||||||
|
|
@ -19,14 +53,18 @@ export type DeviceTechState = {
|
||||||
hits: number | null
|
hits: number | null
|
||||||
needMaintenance: boolean
|
needMaintenance: boolean
|
||||||
overheats: number | null
|
overheats: number | null
|
||||||
|
temperature?: number | null
|
||||||
worktime: number | null
|
worktime: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeviceNetworkStatus = 'Online' | 'Offline' | 'Lost'
|
||||||
|
|
||||||
export type DeviceOrganisation = {
|
export type DeviceOrganisation = {
|
||||||
creationDate?: number
|
creationDate?: number
|
||||||
id: number
|
id: number
|
||||||
|
logoUrl?: string | null
|
||||||
name: string
|
name: string
|
||||||
policy?: DevicePolicy | null
|
policy?: GroupUsePolicy | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Device = {
|
export type Device = {
|
||||||
|
|
@ -34,6 +72,7 @@ export type Device = {
|
||||||
imei: string
|
imei: string
|
||||||
imei2: string
|
imei2: string
|
||||||
serial: string
|
serial: string
|
||||||
|
networkStatus?: DeviceNetworkStatus
|
||||||
orgId: number
|
orgId: number
|
||||||
registerDate: number
|
registerDate: number
|
||||||
org: DeviceOrganisation | null
|
org: DeviceOrganisation | null
|
||||||
|
|
@ -42,15 +81,40 @@ export type Device = {
|
||||||
lastLocation: DeviceLocation | null
|
lastLocation: DeviceLocation | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetPhonesPageData = {
|
export type GetPhonesData = {
|
||||||
getPhonesPage: {
|
getPhones: {
|
||||||
page: Device[]
|
page: Device[]
|
||||||
nextKey: string | null
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetPhonesPageVariables = {
|
export type GetPhonesVariables = {
|
||||||
key?: string
|
page: number
|
||||||
|
query?: string
|
||||||
|
locked?: boolean
|
||||||
|
needMaintenance?: boolean
|
||||||
|
networkStatus?: DeviceNetworkStatus[]
|
||||||
|
orgs?: string[]
|
||||||
|
sortDirection: 'ASC' | 'DESC'
|
||||||
|
sortField: 'ID' | 'Serial' | 'Date'
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetPhonesStatsPage = {
|
||||||
|
totalElements: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhonesTabsStatsData = {
|
||||||
|
totalPhones: GetPhonesStatsPage
|
||||||
|
onlinePhones: GetPhonesStatsPage
|
||||||
|
lockedPhones: GetPhonesStatsPage
|
||||||
|
lostPhones: GetPhonesStatsPage
|
||||||
|
maintenancePhones: GetPhonesStatsPage
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhonesTabsStatsVariables = {
|
||||||
|
query?: string
|
||||||
|
orgs?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetPhoneData = {
|
export type GetPhoneData = {
|
||||||
|
|
@ -61,27 +125,89 @@ export type GetPhoneVariables = {
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceChargeType =
|
export type DeviceTelemetryItem = {
|
||||||
| 'USB'
|
batteryCapacity: number | null
|
||||||
| 'Wireless'
|
batteryCycles: number | null
|
||||||
| 'AC'
|
batteryLevel: number | null
|
||||||
| 'Disconnected'
|
date: number
|
||||||
|
temperature: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceTelemetryEventItem = {
|
||||||
|
date: number;
|
||||||
|
type: 'event';
|
||||||
|
eventType: DevicePhoneStateEventType;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeviceHistoryChartItem =
|
||||||
|
| DeviceTelemetryItem
|
||||||
|
| DeviceTelemetryEventItem;
|
||||||
|
|
||||||
|
export type DevicePhoneStateEventType =
|
||||||
|
| 'ScreenOn'
|
||||||
|
| 'ScreenOff'
|
||||||
|
| 'EnterNetwork'
|
||||||
|
| 'LeaveNetwork'
|
||||||
|
| 'PowerOn'
|
||||||
|
| 'PowerOff'
|
||||||
|
| 'Hit'
|
||||||
|
| 'Overheat'
|
||||||
|
| 'PowerAC'
|
||||||
|
| 'PowerUSB'
|
||||||
|
| 'PowerWireless'
|
||||||
|
| 'PowerDisconnected'
|
||||||
|
|
||||||
|
export type DevicePhoneStateEvent = {
|
||||||
|
date: number
|
||||||
|
phoneId: string
|
||||||
|
type: DevicePhoneStateEventType
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DevicePackage = {
|
||||||
|
category: string | null
|
||||||
|
iconUrl: string | null
|
||||||
|
installDate: number | null
|
||||||
|
name: string | null
|
||||||
|
packageName: string
|
||||||
|
version: number | null
|
||||||
|
versionName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhonePackagesData = {
|
||||||
|
getPhonePackages: DevicePackage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhonePackagesVariables = {
|
||||||
|
phoneId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DevicePackageUseEventType =
|
||||||
|
| 'ResumeActivity'
|
||||||
|
| 'PauseActivity'
|
||||||
|
| 'StopActivity'
|
||||||
| string
|
| string
|
||||||
|
|
||||||
export type DeviceTelemetryItem = {
|
export type DevicePackageUseEvent = {
|
||||||
batteryLevel: number
|
|
||||||
chargeType: DeviceChargeType
|
|
||||||
date: number
|
date: number
|
||||||
screenOn: boolean
|
packageName: string
|
||||||
temperature: number
|
type: DevicePackageUseEventType
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DevicePackageUseEventsGroup = {
|
||||||
|
events: DevicePackageUseEvent[]
|
||||||
|
phonePackage: DevicePackage | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetTelemetryData = {
|
export type GetTelemetryData = {
|
||||||
getTelemetry: DeviceTelemetryItem[]
|
getTelemetry: DeviceTelemetryItem[]
|
||||||
|
getPhoneStateEvents: DevicePhoneStateEvent[]
|
||||||
|
getPhonePackagesUseEvents: DevicePackageUseEventsGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetTelemetryVariables = {
|
export type GetTelemetryVariables = {
|
||||||
phoneId: number
|
phoneId: number
|
||||||
|
packagesPhoneId: string
|
||||||
startDate: number
|
startDate: number
|
||||||
endDate?: number
|
endDate?: number
|
||||||
}
|
}
|
||||||
|
|
@ -119,15 +245,16 @@ export type GetDevicePageVariables = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChangePhonePolicyData = {
|
export type ChangePhonePolicyData = {
|
||||||
changePhonePolicy: boolean
|
changePhonePolicy: DevicePolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChangePhonePolicyVariables = {
|
export type ChangePhonePolicyVariables = {
|
||||||
id: string
|
id: string
|
||||||
canUseBluetooth: boolean
|
bluetooth: ServiceUseOption
|
||||||
canUseCamera: boolean
|
camera: ComponentUseOption
|
||||||
canUseGPS: boolean
|
GPS: ServiceUseOption
|
||||||
canUseSim: boolean
|
sim: ServiceUseOption
|
||||||
|
locked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreatePhoneRegistrationTokenData = {
|
export type CreatePhoneRegistrationTokenData = {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,40 @@
|
||||||
import { gql } from '@apollo/client'
|
import { gql } from '@apollo/client'
|
||||||
|
|
||||||
export const GET_USERS_PAGE_QUERY = gql`
|
export const GET_USERS_QUERY = gql`
|
||||||
query GetUsersPage($key: String) {
|
query GetUsers(
|
||||||
getUsersPage(key: $key) {
|
$page: Int!
|
||||||
|
$query: String
|
||||||
|
$orgs: [ID!]
|
||||||
|
$roles: [Role!]
|
||||||
|
$sortDirection: SortDirection!
|
||||||
|
$sortField: UserSortField!
|
||||||
|
) {
|
||||||
|
getUsers(
|
||||||
|
page: $page
|
||||||
|
query: $query
|
||||||
|
orgs: $orgs
|
||||||
|
roles: $roles
|
||||||
|
sortDirection: $sortDirection
|
||||||
|
sortField: $sortField
|
||||||
|
) {
|
||||||
|
totalPages
|
||||||
|
totalElements
|
||||||
page {
|
page {
|
||||||
id
|
avatarUrl
|
||||||
firstName
|
firstName
|
||||||
|
id
|
||||||
lastName
|
lastName
|
||||||
middleName
|
middleName
|
||||||
org {
|
org {
|
||||||
|
creationDate
|
||||||
id
|
id
|
||||||
|
logoUrl
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
orgId
|
orgId
|
||||||
role
|
role
|
||||||
|
username
|
||||||
}
|
}
|
||||||
nextKey
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
@ -26,16 +45,28 @@ export const GET_ORGANISATION_QUERY = gql`
|
||||||
creationDate
|
creationDate
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
logoUrl
|
||||||
policy {
|
policy {
|
||||||
canUseBluetooth
|
bluetooth
|
||||||
canUseCamera
|
bluetoothEditable
|
||||||
canUseGPS
|
camera
|
||||||
canUseSim
|
cameraEditable
|
||||||
|
GPS
|
||||||
|
gpsEditable
|
||||||
|
sim
|
||||||
|
simEditable
|
||||||
|
offlineTimeThreshold
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const DELETE_ORGANISATION_MUTATION = gql`
|
||||||
|
mutation DeleteOrganisation($id: ID!) {
|
||||||
|
deleteOrganisation(id: $id)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const GET_ORGANISATIONS_QUERY = gql`
|
export const GET_ORGANISATIONS_QUERY = gql`
|
||||||
query GetOrganisations(
|
query GetOrganisations(
|
||||||
$page: Int!
|
$page: Int!
|
||||||
|
|
@ -55,13 +86,7 @@ export const GET_ORGANISATIONS_QUERY = gql`
|
||||||
creationDate
|
creationDate
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
policy {
|
logoUrl
|
||||||
canUseBluetooth
|
|
||||||
canUseCamera
|
|
||||||
canUseGPS
|
|
||||||
canUseSim
|
|
||||||
date
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,18 +95,28 @@ export const GET_ORGANISATIONS_QUERY = gql`
|
||||||
export const CREATE_ORGANISATION_MUTATION = gql`
|
export const CREATE_ORGANISATION_MUTATION = gql`
|
||||||
mutation CreateOrganisation(
|
mutation CreateOrganisation(
|
||||||
$name: String!
|
$name: String!
|
||||||
$canUseBluetooth: Boolean!
|
$bluetooth: ServiceUseOption!
|
||||||
$canUseCamera: Boolean!
|
$bluetoothEditable: Boolean!
|
||||||
$canUseGPS: Boolean!
|
$camera: ComponentUseOption!
|
||||||
$canUseSim: Boolean!
|
$cameraEditable: Boolean!
|
||||||
|
$GPS: ServiceUseOption!
|
||||||
|
$gpsEditable: Boolean!
|
||||||
|
$sim: ServiceUseOption!
|
||||||
|
$simEditable: Boolean!
|
||||||
|
$offlineTimeThreshold: Float!
|
||||||
) {
|
) {
|
||||||
createOrganisation(
|
createOrganisation(
|
||||||
name: $name
|
name: $name
|
||||||
policy: {
|
policy: {
|
||||||
canUseBluetooth: $canUseBluetooth
|
bluetooth: $bluetooth
|
||||||
canUseCamera: $canUseCamera
|
bluetoothEditable: $bluetoothEditable
|
||||||
canUseGPS: $canUseGPS
|
camera: $camera
|
||||||
canUseSim: $canUseSim
|
cameraEditable: $cameraEditable
|
||||||
|
GPS: $GPS
|
||||||
|
gpsEditable: $gpsEditable
|
||||||
|
sim: $sim
|
||||||
|
simEditable: $simEditable
|
||||||
|
offlineTimeThreshold: $offlineTimeThreshold
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
id
|
id
|
||||||
|
|
@ -94,19 +129,29 @@ export const CHANGE_ORGANISATION_MUTATION = gql`
|
||||||
mutation ChangeOrganisation(
|
mutation ChangeOrganisation(
|
||||||
$id: ID!
|
$id: ID!
|
||||||
$name: String!
|
$name: String!
|
||||||
$canUseBluetooth: Boolean!
|
$bluetooth: ServiceUseOption!
|
||||||
$canUseCamera: Boolean!
|
$bluetoothEditable: Boolean!
|
||||||
$canUseGPS: Boolean!
|
$camera: ComponentUseOption!
|
||||||
$canUseSim: Boolean!
|
$cameraEditable: Boolean!
|
||||||
|
$GPS: ServiceUseOption!
|
||||||
|
$gpsEditable: Boolean!
|
||||||
|
$sim: ServiceUseOption!
|
||||||
|
$simEditable: Boolean!
|
||||||
|
$offlineTimeThreshold: Float!
|
||||||
) {
|
) {
|
||||||
changeOrganisation(
|
changeOrganisation(
|
||||||
id: $id
|
id: $id
|
||||||
name: $name
|
name: $name
|
||||||
policy: {
|
policy: {
|
||||||
canUseBluetooth: $canUseBluetooth
|
bluetooth: $bluetooth
|
||||||
canUseCamera: $canUseCamera
|
bluetoothEditable: $bluetoothEditable
|
||||||
canUseGPS: $canUseGPS
|
camera: $camera
|
||||||
canUseSim: $canUseSim
|
cameraEditable: $cameraEditable
|
||||||
|
GPS: $GPS
|
||||||
|
gpsEditable: $gpsEditable
|
||||||
|
sim: $sim
|
||||||
|
simEditable: $simEditable
|
||||||
|
offlineTimeThreshold: $offlineTimeThreshold
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
creationDate
|
creationDate
|
||||||
|
|
@ -116,7 +161,43 @@ export const CHANGE_ORGANISATION_MUTATION = gql`
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export const SIGN_UP_MUTATION = gql`
|
export const CHANGE_ORGANISATION_POLICY_MUTATION = gql`
|
||||||
|
mutation ChangeOrganisationPolicy(
|
||||||
|
$id: ID!
|
||||||
|
$name: String!
|
||||||
|
$bluetooth: ServiceUseOption!
|
||||||
|
$bluetoothEditable: Boolean!
|
||||||
|
$camera: ComponentUseOption!
|
||||||
|
$cameraEditable: Boolean!
|
||||||
|
$GPS: ServiceUseOption!
|
||||||
|
$gpsEditable: Boolean!
|
||||||
|
$sim: ServiceUseOption!
|
||||||
|
$simEditable: Boolean!
|
||||||
|
$offlineTimeThreshold: Float!
|
||||||
|
) {
|
||||||
|
changeOrganisation(
|
||||||
|
id: $id
|
||||||
|
name: $name
|
||||||
|
policy: {
|
||||||
|
bluetooth: $bluetooth
|
||||||
|
bluetoothEditable: $bluetoothEditable
|
||||||
|
camera: $camera
|
||||||
|
cameraEditable: $cameraEditable
|
||||||
|
GPS: $GPS
|
||||||
|
gpsEditable: $gpsEditable
|
||||||
|
sim: $sim
|
||||||
|
simEditable: $simEditable
|
||||||
|
offlineTimeThreshold: $offlineTimeThreshold
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
creationDate
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CREATE_USER_MUTATION = gql`
|
||||||
mutation createUser(
|
mutation createUser(
|
||||||
$orgId: ID!
|
$orgId: ID!
|
||||||
$firstName: String!
|
$firstName: String!
|
||||||
|
|
@ -150,3 +231,95 @@ export const SIGN_UP_MUTATION = gql`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const CHANGE_USER_MUTATION = gql`
|
||||||
|
mutation ChangeUser(
|
||||||
|
$userId: ID!
|
||||||
|
$orgId: ID!
|
||||||
|
$firstName: String!
|
||||||
|
$lastName: String!
|
||||||
|
$middleName: String!
|
||||||
|
$username: String!
|
||||||
|
$role: Role!
|
||||||
|
) {
|
||||||
|
changeUser(
|
||||||
|
userId: $userId
|
||||||
|
form: {
|
||||||
|
orgId: $orgId
|
||||||
|
firstName: $firstName
|
||||||
|
lastName: $lastName
|
||||||
|
middleName: $middleName
|
||||||
|
username: $username
|
||||||
|
role: $role
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
avatarUrl
|
||||||
|
firstName
|
||||||
|
id
|
||||||
|
lastName
|
||||||
|
middleName
|
||||||
|
org {
|
||||||
|
creationDate
|
||||||
|
id
|
||||||
|
logoUrl
|
||||||
|
name
|
||||||
|
}
|
||||||
|
orgId
|
||||||
|
role
|
||||||
|
username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CHANGE_USER_WITH_PASSWORD_MUTATION = gql`
|
||||||
|
mutation ChangeUserWithPassword(
|
||||||
|
$userId: ID!
|
||||||
|
$orgId: ID!
|
||||||
|
$firstName: String!
|
||||||
|
$lastName: String!
|
||||||
|
$middleName: String!
|
||||||
|
$username: String!
|
||||||
|
$password: String!
|
||||||
|
$role: Role!
|
||||||
|
) {
|
||||||
|
changeUser(
|
||||||
|
userId: $userId
|
||||||
|
form: {
|
||||||
|
orgId: $orgId
|
||||||
|
firstName: $firstName
|
||||||
|
lastName: $lastName
|
||||||
|
middleName: $middleName
|
||||||
|
username: $username
|
||||||
|
password: $password
|
||||||
|
role: $role
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
avatarUrl
|
||||||
|
firstName
|
||||||
|
id
|
||||||
|
lastName
|
||||||
|
middleName
|
||||||
|
org {
|
||||||
|
creationDate
|
||||||
|
id
|
||||||
|
logoUrl
|
||||||
|
name
|
||||||
|
}
|
||||||
|
orgId
|
||||||
|
role
|
||||||
|
username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CREATE_UPLOAD_USER_AVATAR_URL_MUTATION = gql`
|
||||||
|
mutation CreateUploadUserAvatarUrl($userId: ID!) {
|
||||||
|
createUploadUserAvatarUrl(userId: $userId)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION = gql`
|
||||||
|
mutation CreateUploadOrganisationLogoUrl($orgId: ID!) {
|
||||||
|
createUploadOrganisationLogoUrl(orgId: $orgId)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,71 @@
|
||||||
export type EmployeeRole = 'User' | 'Admin' | string
|
export type EmployeeRole = 'User' | 'Admin' | string
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
|
avatarUrl?: string | null
|
||||||
id: number
|
id: number
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
middleName: string
|
middleName: string
|
||||||
|
username?: string
|
||||||
orgId: number
|
orgId: number
|
||||||
role: EmployeeRole
|
role: EmployeeRole
|
||||||
org: {
|
org: {
|
||||||
|
creationDate?: number
|
||||||
id: number
|
id: number
|
||||||
|
logoUrl?: string | null
|
||||||
name: string
|
name: string
|
||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserSortDirection = 'ASC' | 'DESC'
|
||||||
|
|
||||||
|
export type UserSortField = 'ID' | 'Name' | 'Date'
|
||||||
|
|
||||||
|
export type ServiceUseOption =
|
||||||
|
| 'Disabled'
|
||||||
|
| 'Enabled'
|
||||||
|
| 'Unspecified'
|
||||||
|
|
||||||
|
export type ComponentUseOption =
|
||||||
|
| 'Allowed'
|
||||||
|
| 'Disallowed'
|
||||||
|
|
||||||
|
export type GroupUsePolicyOption =
|
||||||
|
| 'Allowed'
|
||||||
|
| 'Disallowed'
|
||||||
|
| 'Changeable'
|
||||||
|
|
||||||
|
export type GetUsersData = {
|
||||||
|
getUsers: {
|
||||||
|
totalPages: number
|
||||||
|
totalElements: number
|
||||||
|
page: Employee[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetUsersVariables = {
|
||||||
|
page: number
|
||||||
|
query?: string
|
||||||
|
orgs?: string[]
|
||||||
|
roles?: EmployeeRole[]
|
||||||
|
sortDirection: UserSortDirection
|
||||||
|
sortField: UserSortField
|
||||||
|
}
|
||||||
|
|
||||||
export type OrganisationPolicy = {
|
export type OrganisationPolicy = {
|
||||||
canUseBluetooth: boolean
|
bluetooth?: ServiceUseOption | GroupUsePolicyOption
|
||||||
canUseCamera: boolean
|
bluetoothEditable?: boolean
|
||||||
canUseGPS: boolean
|
camera?: ComponentUseOption | GroupUsePolicyOption
|
||||||
canUseSim: boolean
|
cameraEditable?: boolean
|
||||||
|
GPS?: ServiceUseOption | GroupUsePolicyOption
|
||||||
|
gpsEditable?: boolean
|
||||||
|
sim?: ServiceUseOption | GroupUsePolicyOption
|
||||||
|
simEditable?: boolean
|
||||||
|
canUseBluetooth?: boolean
|
||||||
|
canUseCamera?: boolean
|
||||||
|
canUseGPS?: boolean
|
||||||
|
canUseSim?: boolean
|
||||||
|
offlineTimeThreshold: number
|
||||||
date?: number
|
date?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,6 +74,7 @@ export type Organisation = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
policy?: OrganisationPolicy | null
|
policy?: OrganisationPolicy | null
|
||||||
|
logoUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetOrganisationData = {
|
export type GetOrganisationData = {
|
||||||
|
|
@ -46,10 +95,29 @@ export type ChangeOrganisationData = {
|
||||||
export type ChangeOrganisationVariables = {
|
export type ChangeOrganisationVariables = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
canUseBluetooth: boolean
|
bluetooth: ServiceUseOption
|
||||||
canUseCamera: boolean
|
bluetoothEditable: boolean
|
||||||
canUseGPS: boolean
|
camera: ComponentUseOption
|
||||||
canUseSim: boolean
|
cameraEditable: boolean
|
||||||
|
GPS: ServiceUseOption
|
||||||
|
gpsEditable: boolean
|
||||||
|
sim: ServiceUseOption
|
||||||
|
simEditable: boolean
|
||||||
|
offlineTimeThreshold: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangeOrganisationPolicyVariables = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
bluetooth: ServiceUseOption
|
||||||
|
bluetoothEditable: boolean
|
||||||
|
camera: ComponentUseOption
|
||||||
|
cameraEditable: boolean
|
||||||
|
GPS: ServiceUseOption
|
||||||
|
gpsEditable: boolean
|
||||||
|
sim: ServiceUseOption
|
||||||
|
simEditable: boolean
|
||||||
|
offlineTimeThreshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetUsersPageData = {
|
export type GetUsersPageData = {
|
||||||
|
|
@ -78,19 +146,44 @@ export type GetOrganisationsVariables = {
|
||||||
sortField: OrganisationSortField
|
sortField: OrganisationSortField
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SignUpData = {
|
export type DeleteOrganisationData = {
|
||||||
signUp: {
|
deleteOrganisation: boolean
|
||||||
id: number
|
}
|
||||||
firstName: string
|
|
||||||
lastName: string
|
export type DeleteOrganisationVariables = {
|
||||||
middleName: string
|
id: string
|
||||||
orgId: number
|
}
|
||||||
role: EmployeeRole
|
|
||||||
org: {
|
export type CreateUserData = {
|
||||||
id: number
|
createUser: Employee
|
||||||
name: string
|
}
|
||||||
} | null
|
|
||||||
}
|
export type CreateUserVariables = {
|
||||||
|
orgId: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
middleName: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
role: EmployeeRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangeUserData = {
|
||||||
|
changeUser: Employee
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangeUserVariables = {
|
||||||
|
userId: string
|
||||||
|
orgId: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
middleName: string
|
||||||
|
username: string
|
||||||
|
role: EmployeeRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangeUserWithPasswordVariables = ChangeUserVariables & {
|
||||||
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SignUpVariables = {
|
export type SignUpVariables = {
|
||||||
|
|
@ -112,12 +205,33 @@ export type CreateOrganisationData = {
|
||||||
|
|
||||||
export type CreateOrganisationVariables = {
|
export type CreateOrganisationVariables = {
|
||||||
name: string
|
name: string
|
||||||
canUseBluetooth: boolean
|
bluetooth: ServiceUseOption
|
||||||
canUseCamera: boolean
|
bluetoothEditable: boolean
|
||||||
canUseGPS: boolean
|
camera: ComponentUseOption
|
||||||
canUseSim: boolean
|
cameraEditable: boolean
|
||||||
|
GPS: ServiceUseOption
|
||||||
|
gpsEditable: boolean
|
||||||
|
sim: ServiceUseOption
|
||||||
|
simEditable: boolean
|
||||||
|
offlineTimeThreshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrganisationSortDirection = 'ASC' | 'DESC'
|
export type OrganisationSortDirection = 'ASC' | 'DESC'
|
||||||
|
|
||||||
export type OrganisationSortField = 'ID' | 'Name' | 'Date'
|
export type OrganisationSortField = 'ID' | 'Name' | 'Date'
|
||||||
|
|
||||||
|
export type CreateUploadUserAvatarUrlData = {
|
||||||
|
createUploadUserAvatarUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateUploadUserAvatarUrlVariables = {
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateUploadOrganisationLogoUrlData = {
|
||||||
|
createUploadOrganisationLogoUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateUploadOrganisationLogoUrlVariables = {
|
||||||
|
orgId: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,17 @@ export const REFRESH_SESSION_MUTATION = gql`
|
||||||
export const CURRENT_USER_QUERY = gql`
|
export const CURRENT_USER_QUERY = gql`
|
||||||
query CurrentUser {
|
query CurrentUser {
|
||||||
currentUser {
|
currentUser {
|
||||||
|
avatarUrl
|
||||||
id
|
id
|
||||||
role
|
role
|
||||||
|
firstName
|
||||||
|
middleName
|
||||||
|
lastName
|
||||||
|
username
|
||||||
|
org {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
@ -11,6 +11,15 @@ import { LoginPage } from '../../../pages/LoginPage/LoginPage'
|
||||||
type CurrentUser = {
|
type CurrentUser = {
|
||||||
id: string
|
id: string
|
||||||
role: string
|
role: string
|
||||||
|
avatarUrl: string
|
||||||
|
firstName: string
|
||||||
|
middleName: string
|
||||||
|
lastName: string
|
||||||
|
username: string
|
||||||
|
org: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type CurrentUserQueryData = {
|
type CurrentUserQueryData = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
@use './shared/styles/variables' as *;
|
@use './shared/styles/variables' as *;
|
||||||
|
@use './shared/styles/themes';
|
||||||
|
@use './shared/styles/table-surfaces';
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Montserrat';
|
font-family: 'Montserrat';
|
||||||
|
|
@ -9,25 +11,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
|
||||||
--text-h: #08060d;
|
|
||||||
--bg: #fff;
|
|
||||||
--border: #e5e4e7;
|
|
||||||
--code-bg: #f4f3ec;
|
|
||||||
--accent: #aa3bff;
|
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
|
||||||
|
|
||||||
--sans: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;;
|
--sans: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;;
|
||||||
--heading: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;;
|
--heading: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;;
|
||||||
--mono: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;;
|
--mono: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;;
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
font: var(--font-size-18) / 145% var(--sans);
|
||||||
letter-spacing: 0.18px;
|
letter-spacing: 0.18px;
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: $color-bg;
|
background: $color-bg;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
|
|
@ -36,27 +25,7 @@
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +43,12 @@
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: $color-bg;
|
||||||
}
|
}
|
||||||
|
html{
|
||||||
|
background: $color-bg;
|
||||||
|
}
|
||||||
|
|
||||||
button{
|
button{
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
@ -82,28 +56,32 @@ a{
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.simplebar-content{
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2 {
|
h2 {
|
||||||
font-family: var(--heading);
|
font-family: var(--heading);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: black;
|
color: $color-text-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 32px;
|
font-size: $font-size-32;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
font-size: 36px;
|
font-size: $font-size-36;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 24px;
|
font-size: $font-size-24;
|
||||||
line-height: 118%;
|
line-height: 118%;
|
||||||
letter-spacing: -0.24px;
|
letter-spacing: -0.24px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
font-size: 20px;
|
font-size: $font-size-20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
|
|
@ -119,7 +97,7 @@ code,
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
line-height: 135%;
|
line-height: 135%;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--code-bg);
|
background: var(--code-bg);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { RouterProvider } from 'react-router-dom'
|
|
||||||
import { ApolloProvider } from '@apollo/client/react'
|
|
||||||
import { apolloClient } from './shared/api/apolloClient'
|
|
||||||
import { router } from './app/router/router.tsx'
|
|
||||||
import 'react-day-picker/style.css'
|
import 'react-day-picker/style.css'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import { RouterProvider } from 'react-router-dom'
|
||||||
|
import { ApolloProvider } from '@apollo/client/react'
|
||||||
|
import { LazyMotion, domAnimation } from 'framer-motion'
|
||||||
|
import { apolloClient } from './shared/api/apolloClient'
|
||||||
|
import { router } from './app/router/router.tsx'
|
||||||
import { AuthGate } from './features/auth/ui/AuthGate'
|
import { AuthGate } from './features/auth/ui/AuthGate'
|
||||||
|
import { initTheme } from './shared/theme/theme'
|
||||||
|
|
||||||
const isAuthEnabled = import.meta.env.VITE_AUTH_ENABLED === 'true'
|
const isAuthEnabled = import.meta.env.VITE_AUTH_ENABLED === 'true'
|
||||||
|
|
||||||
|
initTheme()
|
||||||
|
|
||||||
const app = isAuthEnabled ? (
|
const app = isAuthEnabled ? (
|
||||||
<AuthGate>
|
<AuthGate>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
|
@ -22,7 +26,9 @@ const app = isAuthEnabled ? (
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ApolloProvider client={apolloClient}>
|
<ApolloProvider client={apolloClient}>
|
||||||
{app}
|
<LazyMotion features={domAnimation}>
|
||||||
|
{app}
|
||||||
|
</LazyMotion>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,56 +2,57 @@
|
||||||
|
|
||||||
.device-page {
|
.device-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: calc(80% - 40px);
|
||||||
|
max-height: calc(100% - 40px);
|
||||||
|
height: auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
overflow: visible;
|
||||||
|
padding: 20px 20px 36px 20px;
|
||||||
.device-breadcrumbs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
|
|
||||||
color: $gray50;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 11px 0;
|
|
||||||
|
|
||||||
button, a {
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #738098;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: .2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $blue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span:last-child {
|
|
||||||
color: #30394b;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-page__grid {
|
.device-page__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 420px 260px minmax(360px, 1fr);
|
grid-template-columns: 340px 340px minmax(360px, 1fr);
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
width: 100%;
|
||||||
|
//min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page__motion-view {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-card {
|
.device-card {
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #ffffff;
|
background: rgba($color-surface-rgb, .5);
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
box-shadow: $shadow-card;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-card--main {
|
.device-card--main {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-page__details-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
>.device-card,
|
||||||
|
>.device-card-stats {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.device-main {
|
.device-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
@ -60,10 +61,10 @@
|
||||||
|
|
||||||
.device-main__image {
|
.device-main__image {
|
||||||
height: calc(50% - 40px);
|
height: calc(50% - 40px);
|
||||||
width: 30%;
|
width: 27.1%;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #f1f4f8;
|
background: $color-surface-subtle;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -88,8 +89,8 @@
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
color: black;
|
color: $color-text-strong;
|
||||||
font-size: 30px;
|
font-size: $font-size-30;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +98,7 @@
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 17px;
|
font-size: $font-size-17;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
@ -120,8 +121,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
|
|
||||||
color: black;
|
color: $color-text-strong;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
|
@ -134,12 +135,12 @@
|
||||||
|
|
||||||
&.is-success svg {
|
&.is-success svg {
|
||||||
color: $green;
|
color: $green;
|
||||||
background: #eaf8ee;
|
background: $color-success-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-danger svg {
|
&.is-danger svg {
|
||||||
color: $red;
|
color: $red;
|
||||||
background: #ffe8e8;
|
background: $color-danger-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-muted svg {
|
&.is-muted svg {
|
||||||
|
|
@ -159,17 +160,46 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
color: black;
|
color: $color-text-strong;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
.device-registered-info {
|
.device-registered-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
height: inherit;
|
height: inherit;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-bottom: 1px solid $gray20;
|
border-bottom: 1px solid $gray20;
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: $gray50;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-primary-hover;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $color-primary-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
transition: .2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
|
@ -185,6 +215,7 @@
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-card__actions {
|
.device-card__actions {
|
||||||
|
|
@ -202,10 +233,10 @@
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #dfe6ff;
|
background: $color-primary-soft;
|
||||||
|
|
||||||
color: $blue;
|
color: $blue;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: .2s ease;
|
transition: .2s ease;
|
||||||
|
|
@ -213,7 +244,8 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $blue;
|
background-color: $blue;
|
||||||
color: white;
|
color: $color-text-inverse;
|
||||||
|
box-shadow: var(--shadow-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +254,7 @@
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #ffdede;
|
background: $color-danger-bg-strong;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -238,7 +270,8 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $red;
|
background-color: $red;
|
||||||
color: white;
|
color: $color-text-inverse;
|
||||||
|
box-shadow: var(--shadow-danger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,8 +285,8 @@
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -265,8 +298,8 @@
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,8 +308,8 @@
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,66 +321,13 @@
|
||||||
height: calc(100% - 40px);
|
height: calc(100% - 40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: calc(100% - 40px);
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid #e3e8f0;
|
|
||||||
text-align: left;
|
|
||||||
color: #30394b;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-button-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: $color-bg;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
color: $gray50;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 550;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: .2s ease;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $gray20;
|
|
||||||
}
|
|
||||||
|
|
||||||
&+button {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-permissions {
|
.device-permissions {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
height: calc(100% - 20px);
|
height: calc(100% - 20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,8 +337,8 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&:nth-child(1) {
|
&:nth-child(1) {
|
||||||
|
|
@ -373,7 +353,7 @@
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -394,6 +374,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-battery {
|
.device-battery {
|
||||||
|
--device-battery-progress-empty: #cfd5df;
|
||||||
|
--device-battery-progress-danger: #e00000;
|
||||||
|
--device-battery-progress-warning: #ff8a34;
|
||||||
|
--device-battery-progress-strong: #031d9a;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
@ -401,8 +386,8 @@
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid $gray20;
|
border-bottom: 1px solid $gray20;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,7 +405,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svg{
|
svg {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
|
@ -447,9 +432,9 @@
|
||||||
.device-battery__circle {
|
.device-battery__circle {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
width: 110px;
|
width: 120px;
|
||||||
height: 110px;
|
height: 120px;
|
||||||
flex: 0 0 110px;
|
flex: 0 0 120px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -466,15 +451,15 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
color: $color-text;
|
color: $color-text;
|
||||||
font-size: 26px;
|
font-size: $font-size-26;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-battery__circle-svg {
|
.device-battery__circle-svg {
|
||||||
width: 110px;
|
width: 120px;
|
||||||
height: 110px;
|
height: 120px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -506,8 +491,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,12 +501,12 @@
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #31b24a;
|
color: $color-success-strong;
|
||||||
background: #eaf8ee;
|
background: $color-success-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
b {
|
b {
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -532,11 +517,11 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
.device-stats-text{
|
.device-stats-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
@ -551,7 +536,7 @@
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: $green;
|
color: $green;
|
||||||
background: #eaf8ee;
|
background: $color-success-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
b {
|
b {
|
||||||
|
|
@ -562,7 +547,7 @@
|
||||||
.device-page__empty {
|
.device-page__empty {
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -580,7 +565,278 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: $blue;
|
background: $blue;
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
.device-battery {
|
||||||
|
--device-battery-progress-empty: #36414f;
|
||||||
|
--device-battery-progress-danger: #ff4f5e;
|
||||||
|
--device-battery-progress-warning: #ff9f43;
|
||||||
|
--device-battery-progress-strong: #2d8cff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@include desktop-down {
|
||||||
|
.device-page__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 280px minmax(380px, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
.device-page__details-row {
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: 1.25fr 1fr 1.25fr;
|
||||||
|
|
||||||
|
.device-card-stats {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions {
|
||||||
|
button {
|
||||||
|
font-size: var(--font-size-16);
|
||||||
|
padding: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.future-label {
|
||||||
|
font-size: var(--font-size-12);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission {
|
||||||
|
font-size: var(--font-size-16) !important;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__icon-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-battery__content {
|
||||||
|
grid-template-columns: 100px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-battery__circle {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: var(--font-size-22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-stats {
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
font-size: var(--font-size-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-stats-card {
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-impacts {
|
||||||
|
font-size: var(--font-size-16);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-main {
|
||||||
|
&__info {
|
||||||
|
h2 {
|
||||||
|
font-size: var(--font-size-28);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: var(--font-size-15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-main__image {
|
||||||
|
width: 26.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status {
|
||||||
|
font-size: var(--font-size-16);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-registered {
|
||||||
|
font-size: var(--font-size-16);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-registered-info {
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
a {
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-btn {
|
||||||
|
font-size: var(--font-size-16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-delete-btn {
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-period-tabs__item {
|
||||||
|
font-size: var(--font-size-12);
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@include laptop-down {
|
||||||
|
.device-page {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page__grid {
|
||||||
|
grid-template-columns: 540px minmax(360px, 1fr);;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card--main {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page__grid,
|
||||||
|
.device-page__details-row {
|
||||||
|
gap: $layout-card-gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
padding: 18px;
|
||||||
|
height: calc(100% - 36px);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
.device-card-stats{
|
||||||
|
height: calc(100%);
|
||||||
|
}
|
||||||
|
.device-impacts{
|
||||||
|
flex: 0;
|
||||||
|
}
|
||||||
|
.device-battery{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.device-permissions{
|
||||||
|
height: calc(100% - 12px);
|
||||||
|
padding: 8px 8px 4px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-main {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-main__image {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map {
|
||||||
|
min-height: 252px;
|
||||||
|
}
|
||||||
|
.history-period-tabs{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.device-card__title{
|
||||||
|
gap: 8px;
|
||||||
|
svg{
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
h3{
|
||||||
|
font-size: var(--font-size-16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.device-card__header{
|
||||||
|
p{
|
||||||
|
font-size: var(--font-size-14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet-down {
|
||||||
|
.device-main {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-main__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 178px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-main__info {
|
||||||
|
h2 {
|
||||||
|
font-size: $font-size-26;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map {
|
||||||
|
grid-column: span 1;
|
||||||
|
min-height: 300px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page__details-row {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__actions {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,26 @@
|
||||||
import { useMemo, useState } from 'react'
|
import {
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
useNavigate,
|
||||||
|
useOutletContext,
|
||||||
|
useParams,
|
||||||
|
useSearchParams,
|
||||||
|
} from 'react-router-dom'
|
||||||
|
import { AnimatePresence, m } from 'framer-motion'
|
||||||
import { useQuery } from '@apollo/client/react'
|
import { useQuery } from '@apollo/client/react'
|
||||||
import { DeviceHistoryModal } from './components/DeviceHistoryModal/DeviceHistoryModal'
|
|
||||||
|
|
||||||
import type { Device as PageDevice } from './types'
|
import type { Device as PageDevice } from './types'
|
||||||
|
|
||||||
import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
||||||
import type {
|
import type {
|
||||||
Device as ApiDevice,
|
Device as ApiDevice,
|
||||||
|
DeviceNetworkStatus,
|
||||||
GetPhoneGpsTrackData,
|
GetPhoneGpsTrackData,
|
||||||
GetTelemetryData,
|
GetTelemetryData,
|
||||||
GetDevicePageData,
|
GetDevicePageData,
|
||||||
|
|
@ -19,8 +32,27 @@ import { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard'
|
||||||
import { DeviceActionsCard } from './components/DeviceActionsCard/DeviceActionsCard'
|
import { DeviceActionsCard } from './components/DeviceActionsCard/DeviceActionsCard'
|
||||||
import { DevicePermissionsCard } from './components/DevicePermissionsCard/DevicePermissionsCard'
|
import { DevicePermissionsCard } from './components/DevicePermissionsCard/DevicePermissionsCard'
|
||||||
import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards'
|
import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards'
|
||||||
|
import { DeviceAppsView } from './components/DeviceAppsView/DeviceAppsView'
|
||||||
|
import { pageSwapVariants, uiFadeTransition } from '../../shared/lib/motion'
|
||||||
|
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
|
||||||
|
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
|
||||||
|
|
||||||
import './DevicePage.scss'
|
import './DevicePage.scss'
|
||||||
|
import { DevicePageSkeleton } from './components/DevicePageSkeleton/DevicePageSkeleton'
|
||||||
|
import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout'
|
||||||
|
import {
|
||||||
|
preloadOnIdle,
|
||||||
|
useLazyMount,
|
||||||
|
} from '../../shared/lib/lazyMount'
|
||||||
|
|
||||||
|
const loadDeviceHistoryModal = () =>
|
||||||
|
import('./components/DeviceHistoryModal/DeviceHistoryModal').then(
|
||||||
|
(module) => ({
|
||||||
|
default: module.DeviceHistoryModal,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const DeviceHistoryModal = lazy(loadDeviceHistoryModal)
|
||||||
|
|
||||||
function formatLocationDate(timestamp: number) {
|
function formatLocationDate(timestamp: number) {
|
||||||
if (!timestamp) return 'Нет данных'
|
if (!timestamp) return 'Нет данных'
|
||||||
|
|
@ -48,15 +80,39 @@ function getSortedGpsTrack(track: GetPhoneGpsTrackData['getPhoneGpsTrack']) {
|
||||||
return [...track].sort((a, b) => a.date - b.date)
|
return [...track].sort((a, b) => a.date - b.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDeviceNetworkConnection(status?: DeviceNetworkStatus) {
|
||||||
|
if (status === 'Online') return 'online'
|
||||||
|
if (status === 'Lost') return 'offlineDanger'
|
||||||
|
|
||||||
|
return 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
|
||||||
|
if (status === 'Online') return 'В сети'
|
||||||
|
if (status === 'Lost') return 'Потерян'
|
||||||
|
|
||||||
|
return 'Не в сети'
|
||||||
|
}
|
||||||
|
|
||||||
function mapApiDeviceToPageDevice(
|
function mapApiDeviceToPageDevice(
|
||||||
device: ApiDevice,
|
device: ApiDevice,
|
||||||
batteryLevel?: number,
|
batteryLevel?: number,
|
||||||
gpsTrack: GetPhoneGpsTrackData['getPhoneGpsTrack'] = [],
|
gpsTrack: GetPhoneGpsTrackData['getPhoneGpsTrack'] = [],
|
||||||
|
telemetry: GetTelemetryData['getTelemetry'] = [],
|
||||||
): PageDevice {
|
): PageDevice {
|
||||||
const sortedGpsTrack = getSortedGpsTrack(gpsTrack)
|
const sortedGpsTrack = getSortedGpsTrack(gpsTrack)
|
||||||
const policy = device.policy
|
const policy = device.policy
|
||||||
const techState = device.techState
|
const techState = device.techState
|
||||||
const needMaintenance = techState?.needMaintenance ?? false
|
const needMaintenance = techState?.needMaintenance ?? false
|
||||||
|
const bluetoothEnabled = isPhonePolicyOptionEnabled(
|
||||||
|
policy?.bluetooth ?? policy?.canUseBluetooth,
|
||||||
|
)
|
||||||
|
const gpsEnabled = isPhonePolicyOptionEnabled(policy?.GPS ?? policy?.canUseGPS)
|
||||||
|
const cameraEnabled = isPhonePolicyOptionEnabled(
|
||||||
|
policy?.camera ?? policy?.canUseCamera,
|
||||||
|
)
|
||||||
|
const simEnabled = isPhonePolicyOptionEnabled(policy?.sim ?? policy?.canUseSim)
|
||||||
|
const isLocked = policy?.locked ?? false
|
||||||
|
|
||||||
const currentGpsPoint =
|
const currentGpsPoint =
|
||||||
sortedGpsTrack.length > 0
|
sortedGpsTrack.length > 0
|
||||||
|
|
@ -97,10 +153,13 @@ function mapApiDeviceToPageDevice(
|
||||||
|
|
||||||
employee: device.org?.name ?? null,
|
employee: device.org?.name ?? null,
|
||||||
organisation: device.org?.name,
|
organisation: device.org?.name,
|
||||||
|
organisationId: device.org?.id ?? device.orgId,
|
||||||
|
policy,
|
||||||
|
organisationPolicy: device.org?.policy ?? null,
|
||||||
|
|
||||||
condition: needMaintenance ? 'inspection' : 'ok',
|
condition: needMaintenance ? 'inspection' : 'ok',
|
||||||
connection: location ? 'online' : 'offline',
|
connection: getDeviceNetworkConnection(device.networkStatus),
|
||||||
connectionText: location ? 'В сети' : 'Не в сети',
|
connectionText: getDeviceNetworkLabel(device.networkStatus),
|
||||||
|
|
||||||
workTime: null,
|
workTime: null,
|
||||||
registeredAt: device.registerDate
|
registeredAt: device.registerDate
|
||||||
|
|
@ -121,26 +180,28 @@ function mapApiDeviceToPageDevice(
|
||||||
|
|
||||||
permissions: {
|
permissions: {
|
||||||
wifi: false,
|
wifi: false,
|
||||||
bluetooth: policy?.canUseBluetooth ?? false,
|
bluetooth: bluetoothEnabled,
|
||||||
gps: policy?.canUseGPS ?? false,
|
gps: gpsEnabled,
|
||||||
camera: policy?.canUseCamera ?? false,
|
camera: cameraEnabled,
|
||||||
sim: policy?.canUseSim ?? false,
|
sim: simEnabled,
|
||||||
speaker: false,
|
speaker: false,
|
||||||
|
locked: isLocked,
|
||||||
},
|
},
|
||||||
|
|
||||||
statusIcons: {
|
statusIcons: {
|
||||||
gps: policy?.canUseGPS ?? false,
|
gps: gpsEnabled,
|
||||||
wifi: false,
|
wifi: false,
|
||||||
bluetooth: policy?.canUseBluetooth ?? false,
|
bluetooth: bluetoothEnabled,
|
||||||
lock: false,
|
lock: isLocked,
|
||||||
camera: policy?.canUseCamera ?? false,
|
camera: cameraEnabled,
|
||||||
sim: policy?.canUseSim ?? false,
|
sim: simEnabled,
|
||||||
sound: false,
|
sound: false,
|
||||||
kiosk: false,
|
kiosk: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
battery: techState?.batteryLevel ?? batteryLevel,
|
battery: techState?.batteryLevel ?? batteryLevel,
|
||||||
batteryMaxCapacity: techState?.batteryRemainingCapacity ?? undefined,
|
batteryMaxCapacity: techState?.batteryRemainingCapacity ?? undefined,
|
||||||
|
telemetry,
|
||||||
chargeCycles: formatTechValue(techState?.batteryCycles),
|
chargeCycles: formatTechValue(techState?.batteryCycles),
|
||||||
totalWorkTime: techState?.worktime ?? null,
|
totalWorkTime: techState?.worktime ?? null,
|
||||||
mediumImpacts: formatTechValue(techState?.hits),
|
mediumImpacts: formatTechValue(techState?.hits),
|
||||||
|
|
@ -151,103 +212,227 @@ function mapApiDeviceToPageDevice(
|
||||||
export function DevicePage() {
|
export function DevicePage() {
|
||||||
const { deviceId } = useParams()
|
const { deviceId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { setNavbarBreadcrumbs } =
|
||||||
|
useOutletContext<AppLayoutOutletContext>()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||||
|
const shouldMountHistoryModal = useLazyMount(isHistoryOpen)
|
||||||
|
|
||||||
const numericDeviceId = Number(deviceId)
|
const numericDeviceId = Number(deviceId)
|
||||||
|
const isAppsView = searchParams.get('view') === 'apps'
|
||||||
|
|
||||||
const { data, loading, error } = useQuery<
|
useEffect(() => {
|
||||||
GetDevicePageData,
|
return preloadOnIdle([loadDeviceHistoryModal])
|
||||||
GetDevicePageVariables
|
}, [])
|
||||||
>(GET_DEVICE_PAGE_QUERY, {
|
|
||||||
variables: {
|
const {
|
||||||
id: numericDeviceId,
|
data,
|
||||||
phoneId: String(numericDeviceId),
|
loading,
|
||||||
telemetryStartDate: 0,
|
error,
|
||||||
gpsStartDate: 0,
|
refetch,
|
||||||
},
|
startPolling,
|
||||||
skip: !numericDeviceId,
|
stopPolling,
|
||||||
fetchPolicy: 'network-only',
|
} = useQuery<
|
||||||
pollInterval: 15000,
|
GetDevicePageData,
|
||||||
})
|
GetDevicePageVariables
|
||||||
|
>(GET_DEVICE_PAGE_QUERY, {
|
||||||
|
variables: {
|
||||||
|
id: numericDeviceId,
|
||||||
|
phoneId: String(numericDeviceId),
|
||||||
|
telemetryStartDate: 0,
|
||||||
|
gpsStartDate: 0,
|
||||||
|
},
|
||||||
|
skip: !numericDeviceId,
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
pollInterval: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePermissionsEditingChange = useCallback(
|
||||||
|
(isEditing: boolean) => {
|
||||||
|
if (isEditing) {
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericDeviceId) {
|
||||||
|
startPolling(15000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[numericDeviceId, startPolling, stopPolling],
|
||||||
|
)
|
||||||
|
|
||||||
const latestTelemetryItem = useMemo(() => {
|
const latestTelemetryItem = useMemo(() => {
|
||||||
return getLatestTelemetryItem(data?.getTelemetry ?? [])
|
return getLatestTelemetryItem(data?.getTelemetry ?? [])
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const device = useMemo(() => {
|
const device = useMemo(() => {
|
||||||
if (!data?.getPhone) return null
|
if (!data?.getPhone) return null
|
||||||
|
|
||||||
return mapApiDeviceToPageDevice(
|
return mapApiDeviceToPageDevice(
|
||||||
data.getPhone,
|
data.getPhone,
|
||||||
latestTelemetryItem?.batteryLevel,
|
latestTelemetryItem?.batteryLevel ?? undefined,
|
||||||
data.getPhoneGpsTrack ?? [],
|
data.getPhoneGpsTrack ?? [],
|
||||||
)
|
data.getTelemetry ?? [],
|
||||||
}, [data, latestTelemetryItem])
|
)
|
||||||
|
}, [data, latestTelemetryItem])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!device) {
|
||||||
|
setNavbarBreadcrumbs([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setNavbarBreadcrumbs([
|
||||||
|
{
|
||||||
|
label: device.factoryNumber,
|
||||||
|
to: isAppsView ? `/devices/${device.id}` : undefined,
|
||||||
|
},
|
||||||
|
...(isAppsView ? [{ label: 'Приложения' }] : []),
|
||||||
|
])
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setNavbarBreadcrumbs([])
|
||||||
|
}
|
||||||
|
}, [device, isAppsView, setNavbarBreadcrumbs])
|
||||||
|
|
||||||
|
const handleOpenApps = useCallback(() => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams)
|
||||||
|
nextParams.set('view', 'apps')
|
||||||
|
|
||||||
|
setSearchParams(nextParams)
|
||||||
|
}, [searchParams, setSearchParams])
|
||||||
|
|
||||||
|
const handleCloseApps = useCallback(() => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams)
|
||||||
|
nextParams.delete('view')
|
||||||
|
|
||||||
|
setSearchParams(nextParams, {
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
}, [searchParams, setSearchParams])
|
||||||
|
|
||||||
|
const handleOpenHistory = useCallback(() => {
|
||||||
|
setIsHistoryOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRefetchPolicy = useCallback(async () => {
|
||||||
|
const result = await refetch()
|
||||||
|
|
||||||
|
return result.data?.getPhone?.policy
|
||||||
|
}, [refetch])
|
||||||
|
|
||||||
|
const handleLockStateChange = useCallback(async () => {
|
||||||
|
const result = await refetch()
|
||||||
|
|
||||||
|
return result.data?.getPhone?.policy?.locked
|
||||||
|
}, [refetch])
|
||||||
|
|
||||||
if (!numericDeviceId) {
|
if (!numericDeviceId) {
|
||||||
return (
|
return (
|
||||||
<section className="device-page">
|
<section className="device-page">
|
||||||
<div className="device-page__empty">
|
<EmptyState
|
||||||
<h2>Некорректный ID устройства</h2>
|
className="device-page__empty"
|
||||||
|
variant="not-found"
|
||||||
<button type="button" onClick={() => navigate('/devices')}>
|
title="Некорректный ID устройства"
|
||||||
|
description="Проверьте ссылку или вернитесь к списку устройств."
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/devices')}
|
||||||
|
>
|
||||||
Вернуться к списку
|
Вернуться к списку
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</EmptyState>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && !device) {
|
if (loading && !device) {
|
||||||
return (
|
return <DevicePageSkeleton />
|
||||||
<section className="device-page">
|
|
||||||
<div className="device-page__empty">
|
|
||||||
<h2>Загрузка устройства...</h2>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !device) {
|
if (error || !device) {
|
||||||
return (
|
return (
|
||||||
<section className="device-page">
|
<section className="device-page">
|
||||||
<div className="device-page__empty">
|
<EmptyState
|
||||||
<h2>Устройство не найдено</h2>
|
className="device-page__empty"
|
||||||
|
variant="not-found"
|
||||||
<button type="button" onClick={() => navigate('/devices')}>
|
title="Устройство не найдено"
|
||||||
|
description="Возможно, устройство было удалено или ссылка устарела."
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/devices')}
|
||||||
|
>
|
||||||
Вернуться к списку
|
Вернуться к списку
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</EmptyState>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="device-page">
|
<section className="device-page">
|
||||||
<div className="device-breadcrumbs">
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
<Link to="/devices">
|
{isAppsView ? (
|
||||||
Все устройства
|
<m.div
|
||||||
</Link>
|
key="apps"
|
||||||
|
className="device-page__motion-view"
|
||||||
|
variants={pageSwapVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={uiFadeTransition}
|
||||||
|
>
|
||||||
|
<DeviceAppsView
|
||||||
|
deviceId={device.id}
|
||||||
|
deviceName={device.factoryNumber}
|
||||||
|
onBack={handleCloseApps}
|
||||||
|
/>
|
||||||
|
</m.div>
|
||||||
|
) : (
|
||||||
|
<m.div
|
||||||
|
key="overview"
|
||||||
|
className="device-page__motion-view"
|
||||||
|
variants={pageSwapVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={uiFadeTransition}
|
||||||
|
>
|
||||||
|
<div className="device-page__grid">
|
||||||
|
<DeviceMainCard
|
||||||
|
device={device}
|
||||||
|
onOpenHistory={handleOpenHistory}
|
||||||
|
/>
|
||||||
|
<DeviceMapCard device={device} />
|
||||||
|
|
||||||
<span>/</span>
|
<div className="device-page__details-row">
|
||||||
<span>{device.factoryNumber}</span>
|
<DeviceStatsCards device={device} />
|
||||||
</div>
|
<DevicePermissionsCard
|
||||||
|
device={device}
|
||||||
<div className="device-page__grid">
|
onEditingChange={handlePermissionsEditingChange}
|
||||||
<DeviceMainCard
|
onRefetchPolicy={handleRefetchPolicy}
|
||||||
device={device}
|
/>
|
||||||
onOpenHistory={() => setIsHistoryOpen(true)}
|
<DeviceActionsCard
|
||||||
/>
|
device={device}
|
||||||
<DeviceMapCard device={device} />
|
onOpenApps={handleOpenApps}
|
||||||
<DeviceActionsCard />
|
onLockStateChange={handleLockStateChange}
|
||||||
<DevicePermissionsCard device={device} />
|
/>
|
||||||
<DeviceStatsCards device={device} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DeviceHistoryModal
|
</m.div>
|
||||||
open={isHistoryOpen}
|
)}
|
||||||
onOpenChange={setIsHistoryOpen}
|
</AnimatePresence>
|
||||||
device={device}
|
{shouldMountHistoryModal && (
|
||||||
/>
|
<Suspense fallback={null}>
|
||||||
|
<DeviceHistoryModal
|
||||||
|
open={isHistoryOpen}
|
||||||
|
onOpenChange={setIsHistoryOpen}
|
||||||
|
device={device}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
.battery-circle {
|
.battery-circle {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
|
|
@ -18,7 +20,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.battery-circle__track {
|
.battery-circle__track {
|
||||||
stroke: #cfd5df;
|
stroke: $color-border-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battery-circle__progress {
|
.battery-circle__progress {
|
||||||
|
|
@ -35,8 +37,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
font-size: 44px;
|
font-size: $font-size-44;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #000;
|
color: $black;
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
|
||||||
|
.device-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: calc(100% - 40px);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid $color-border;
|
||||||
|
text-align: left;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-18;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-button-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-18;
|
||||||
|
font-weight: 550;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .2s ease;
|
||||||
|
small{
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
.actions-button-text{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
background-color: rgba($gray20-rgb, .9);
|
||||||
|
//color: $blue;
|
||||||
|
box-shadow: inset 0 0 0 1.5px rgba($gray50-rgb, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: .74;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&+button {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions__lock-button {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.device-actions__lock-content {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions__lock-state {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions__lock-icon {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
transform 0.24s cubic-bezier(0.34, 1.42, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-loading {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: wait;
|
||||||
|
|
||||||
|
.device-actions__loading-icon {
|
||||||
|
color: $blue;
|
||||||
|
transform: none;
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
will-change: transform;
|
||||||
|
animation: device-actions-loading-spin 0.85s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled:hover {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-lock:not(.is-loading) {
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
.device-actions__lock-icon {
|
||||||
|
color: $red;
|
||||||
|
transform: translateY(-1px) scale(1.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-unlock:not(.is-loading) {
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
.device-actions__lock-icon {
|
||||||
|
color: $blue;
|
||||||
|
transform: rotate(-8deg) translateY(-1px) scale(1.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions__error {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $color-danger-tint;
|
||||||
|
|
||||||
|
color: $red;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.future-label{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 550;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
gap: 14px;
|
||||||
|
color: $blue;
|
||||||
|
background-color: $blue20;
|
||||||
|
border-radius: 14px;
|
||||||
|
svg{
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions__apps-icon {
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
rect {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 0.22s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions__apps-button:not(:disabled):hover {
|
||||||
|
.device-actions__apps-icon {
|
||||||
|
color: $blue;
|
||||||
|
|
||||||
|
rect:nth-child(2) {
|
||||||
|
--tile-pop-x: 1.7px;
|
||||||
|
--tile-pop-y: -1.7px;
|
||||||
|
--tile-rest-x: 0.8px;
|
||||||
|
--tile-rest-y: -0.8px;
|
||||||
|
animation: device-actions-app-tile 0.48s cubic-bezier(0.34, 1.42, 0.64, 1);
|
||||||
|
transform: translate(var(--tile-rest-x), var(--tile-rest-y)) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes device-actions-app-tile {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
45% {
|
||||||
|
transform: translate(var(--tile-pop-x), var(--tile-pop-y)) scale(0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(var(--tile-rest-x, 0), var(--tile-rest-y, 0)) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes device-actions-loading-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.device-actions {
|
||||||
|
.device-actions__lock-button.is-loading {
|
||||||
|
.device-actions__loading-icon {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,34 +1,272 @@
|
||||||
import { BlockIcon } from '../../../../assets/icons/Block'
|
import { memo, useEffect, useState } from 'react'
|
||||||
import { KioskIcon } from '../../../../assets/icons/Kiosk'
|
import { AnimatePresence, m, useReducedMotion } from 'framer-motion'
|
||||||
import { RebootIcon } from '../../../../assets/icons/Reboot'
|
import { useMutation } from '@apollo/client/react'
|
||||||
import { MessageIcon } from '../../../../assets/icons/Message'
|
import { CircleAlert, Unlock } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Apps } from '../../../../assets/icons/Apps'
|
||||||
|
import { BlockIcon } from '../../../../assets/icons/Block'
|
||||||
|
import { LoadingTwotoneLoopIcon } from '../../../../assets/icons/LoadingTwotoneLoop'
|
||||||
|
import type { Device } from '../../types'
|
||||||
|
import {
|
||||||
|
CHANGE_PHONE_POLICY_MUTATION,
|
||||||
|
} from '../../../../entities/device/api/device.graphql'
|
||||||
|
import type {
|
||||||
|
ChangePhonePolicyData,
|
||||||
|
ChangePhonePolicyVariables,
|
||||||
|
} from '../../../../entities/device/model/types'
|
||||||
|
import {
|
||||||
|
normalizeComponentUseOption,
|
||||||
|
normalizeServiceUseOption,
|
||||||
|
} from '../../../../entities/device/lib/phonePolicy'
|
||||||
|
|
||||||
|
import './DeviceActionsCard.scss'
|
||||||
|
|
||||||
|
type DeviceActionsCardProps = {
|
||||||
|
device: Device
|
||||||
|
onOpenApps: () => void
|
||||||
|
onLockStateChange?: () => Promise<boolean | null | undefined> | boolean | null | void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChangeLockVariables(
|
||||||
|
device: Device,
|
||||||
|
locked: boolean,
|
||||||
|
): ChangePhonePolicyVariables {
|
||||||
|
const policy = device.policy
|
||||||
|
const permissions = device.permissions
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(device.id),
|
||||||
|
bluetooth: normalizeServiceUseOption(
|
||||||
|
policy?.bluetooth ?? policy?.canUseBluetooth,
|
||||||
|
permissions?.bluetooth,
|
||||||
|
),
|
||||||
|
camera: normalizeComponentUseOption(
|
||||||
|
policy?.camera ?? policy?.canUseCamera,
|
||||||
|
permissions?.camera,
|
||||||
|
),
|
||||||
|
GPS: normalizeServiceUseOption(
|
||||||
|
policy?.GPS ?? policy?.canUseGPS,
|
||||||
|
permissions?.gps,
|
||||||
|
),
|
||||||
|
sim: normalizeServiceUseOption(
|
||||||
|
policy?.sim ?? policy?.canUseSim,
|
||||||
|
permissions?.sim,
|
||||||
|
),
|
||||||
|
locked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function areDeviceActionsCardPropsEqual(
|
||||||
|
prev: DeviceActionsCardProps,
|
||||||
|
next: DeviceActionsCardProps,
|
||||||
|
) {
|
||||||
|
const prevPolicy = prev.device.policy
|
||||||
|
const nextPolicy = next.device.policy
|
||||||
|
const prevPermissions = prev.device.permissions
|
||||||
|
const nextPermissions = next.device.permissions
|
||||||
|
|
||||||
|
return (
|
||||||
|
prev.onOpenApps === next.onOpenApps &&
|
||||||
|
prev.onLockStateChange === next.onLockStateChange &&
|
||||||
|
prev.device.id === next.device.id &&
|
||||||
|
prev.device.connection === next.device.connection &&
|
||||||
|
prevPolicy?.bluetooth === nextPolicy?.bluetooth &&
|
||||||
|
prevPolicy?.camera === nextPolicy?.camera &&
|
||||||
|
prevPolicy?.GPS === nextPolicy?.GPS &&
|
||||||
|
prevPolicy?.sim === nextPolicy?.sim &&
|
||||||
|
prevPolicy?.locked === nextPolicy?.locked &&
|
||||||
|
prevPermissions?.bluetooth === nextPermissions?.bluetooth &&
|
||||||
|
prevPermissions?.camera === nextPermissions?.camera &&
|
||||||
|
prevPermissions?.gps === nextPermissions?.gps &&
|
||||||
|
prevPermissions?.sim === nextPermissions?.sim &&
|
||||||
|
prevPermissions?.locked === nextPermissions?.locked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeviceActionsCard = memo(function DeviceActionsCard({
|
||||||
|
device,
|
||||||
|
onOpenApps,
|
||||||
|
onLockStateChange,
|
||||||
|
}: DeviceActionsCardProps) {
|
||||||
|
const deviceLocked = device.permissions?.locked ?? device.policy?.locked ?? false
|
||||||
|
const isLost = device.connection === 'offlineDanger'
|
||||||
|
const [displayLocked, setDisplayLocked] = useState(deviceLocked)
|
||||||
|
const [pendingLockTarget, setPendingLockTarget] = useState<boolean | null>(null)
|
||||||
|
const [lockError, setLockError] = useState(false)
|
||||||
|
const [changePhonePolicy] = useMutation<
|
||||||
|
ChangePhonePolicyData,
|
||||||
|
ChangePhonePolicyVariables
|
||||||
|
>(CHANGE_PHONE_POLICY_MUTATION)
|
||||||
|
const shouldReduceMotion = useReducedMotion()
|
||||||
|
const isLockLoading = pendingLockTarget !== null
|
||||||
|
const lockStateTransition = shouldReduceMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: {
|
||||||
|
duration: 0.18,
|
||||||
|
ease: [0.34, 1.42, 0.64, 1] as [number, number, number, number],
|
||||||
|
}
|
||||||
|
const lockLoadingTarget = pendingLockTarget ?? !displayLocked
|
||||||
|
const lockButtonView = isLockLoading
|
||||||
|
? {
|
||||||
|
key: 'loading',
|
||||||
|
icon: (
|
||||||
|
<LoadingTwotoneLoopIcon
|
||||||
|
className="device-actions__lock-icon device-actions__loading-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: lockLoadingTarget ? 'Блокируем...' : 'Разблокируем...',
|
||||||
|
description: 'Отправляем команду на устройство',
|
||||||
|
}
|
||||||
|
: isLost
|
||||||
|
? {
|
||||||
|
key: 'lost',
|
||||||
|
icon: (
|
||||||
|
<BlockIcon
|
||||||
|
className="device-actions__lock-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: 'Долго не в сети',
|
||||||
|
description: 'Управление блокировкой недоступно',
|
||||||
|
}
|
||||||
|
: displayLocked
|
||||||
|
? {
|
||||||
|
key: 'unlock',
|
||||||
|
icon: (
|
||||||
|
<Unlock
|
||||||
|
className="device-actions__lock-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: 'Разблокировать',
|
||||||
|
description: 'Вернуть устройству доступ к работе',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
key: 'lock',
|
||||||
|
icon: (
|
||||||
|
<BlockIcon
|
||||||
|
className="device-actions__lock-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: 'Заблокировать',
|
||||||
|
description: 'Удаленно заблокировать устройство',
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayLocked(deviceLocked)
|
||||||
|
}, [device.id, deviceLocked])
|
||||||
|
|
||||||
|
async function handleToggleLock() {
|
||||||
|
if (isLost || isLockLoading) return
|
||||||
|
|
||||||
|
const nextLocked = !displayLocked
|
||||||
|
let changedLocked = nextLocked
|
||||||
|
|
||||||
|
setLockError(false)
|
||||||
|
setPendingLockTarget(nextLocked)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await changePhonePolicy({
|
||||||
|
variables: getChangeLockVariables(device, nextLocked),
|
||||||
|
})
|
||||||
|
changedLocked = result.data?.changePhonePolicy.locked ?? nextLocked
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Не удалось изменить блокировку устройства', error)
|
||||||
|
setLockError(true)
|
||||||
|
setPendingLockTarget(null)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refetchResult = onLockStateChange?.()
|
||||||
|
const refetchedLocked = await Promise.resolve(refetchResult)
|
||||||
|
|
||||||
|
setDisplayLocked(
|
||||||
|
typeof refetchedLocked === 'boolean'
|
||||||
|
? refetchedLocked
|
||||||
|
: changedLocked,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Не удалось обновить данные устройства', error)
|
||||||
|
setDisplayLocked(changedLocked)
|
||||||
|
} finally {
|
||||||
|
setPendingLockTarget(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function DeviceActionsCard() {
|
|
||||||
return (
|
return (
|
||||||
<div className="device-card device-actions">
|
<div className="device-card device-actions">
|
||||||
<h3>Действия</h3>
|
<h3>Управление</h3>
|
||||||
|
|
||||||
<div className="actions-button-container">
|
<div className="actions-button-container">
|
||||||
<button type="button" disabled>
|
<button
|
||||||
<BlockIcon />
|
className={`device-actions__lock-button ${
|
||||||
Заблокировать
|
displayLocked ? 'is-unlock' : 'is-lock'
|
||||||
|
} ${isLockLoading ? 'is-loading' : ''}`}
|
||||||
|
type="button"
|
||||||
|
disabled={isLost || isLockLoading}
|
||||||
|
onClick={handleToggleLock}
|
||||||
|
>
|
||||||
|
<span className="device-actions__lock-content">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
<m.span
|
||||||
|
key={lockButtonView.key}
|
||||||
|
className="device-actions__lock-state"
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
x: shouldReduceMotion ? 0 : 10,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: lockStateTransition,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
x: shouldReduceMotion ? 0 : -10,
|
||||||
|
transition: {
|
||||||
|
duration: shouldReduceMotion ? 0 : 0.14,
|
||||||
|
ease: 'easeOut',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lockButtonView.icon}
|
||||||
|
|
||||||
|
<span className="actions-button-text">
|
||||||
|
<span>{lockButtonView.title}</span>
|
||||||
|
<small>{lockButtonView.description}</small>
|
||||||
|
</span>
|
||||||
|
</m.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" disabled>
|
<button
|
||||||
<KioskIcon />
|
className="device-actions__apps-button"
|
||||||
Включить режим киоска
|
type="button"
|
||||||
|
onClick={onOpenApps}
|
||||||
|
>
|
||||||
|
<Apps className="device-actions__apps-icon" aria-hidden="true" />
|
||||||
|
<div className="actions-button-text">
|
||||||
|
<span>Приложения</span>
|
||||||
|
<small>Управление установленными приложениями</small>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" disabled>
|
<div className="future-label">
|
||||||
<RebootIcon />
|
<CircleAlert />
|
||||||
Перезагрузить
|
Дополнительные функции будут доступны позже
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<button type="button" disabled>
|
{lockError && (
|
||||||
<MessageIcon />
|
<div className="device-actions__error">
|
||||||
Отправить сообщение
|
Не удалось изменить блокировку
|
||||||
</button>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}, areDeviceActionsCardPropsEqual)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.device-apps {
|
||||||
|
margin-top: 34px;
|
||||||
|
padding: 0 90px;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps__header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-20;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-surface;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
background: $color-danger-tint;
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps__empty {
|
||||||
|
min-height: 240px;
|
||||||
|
padding: 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item {
|
||||||
|
border: 1px solid $gray20;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: $color-surface;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__header {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: none;
|
||||||
|
background: $transparent;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-surface-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='open'] {
|
||||||
|
&:hover{
|
||||||
|
background-color: $color-surface;
|
||||||
|
}
|
||||||
|
.device-app-item__chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__main {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text-inverse;
|
||||||
|
background: $blue;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
&.is-radio {
|
||||||
|
background: $accent-purple;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-message {
|
||||||
|
background: $accent-sky;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-system {
|
||||||
|
background: $color-text-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-security {
|
||||||
|
background: $accent-teal;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-package {
|
||||||
|
background: $color-warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__info {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__usage {
|
||||||
|
min-width: 84px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-20;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__chevron {
|
||||||
|
color: $gray50;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__content {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&[data-state='open'] {
|
||||||
|
animation: deviceAppAccordionDown 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='closed'] {
|
||||||
|
animation: deviceAppAccordionUp 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__body {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-item__meta {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 18px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-chart {
|
||||||
|
height: 220px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: $color-surface;
|
||||||
|
border: 1px solid $gray20;
|
||||||
|
|
||||||
|
.echarts-for-react {
|
||||||
|
width: 100%;
|
||||||
|
height: 220px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes deviceAppAccordionDown {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes deviceAppAccordionUp {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,535 @@
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import * as Accordion from '@radix-ui/react-accordion'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
import {
|
||||||
|
AppWindow,
|
||||||
|
Box,
|
||||||
|
ChevronDown,
|
||||||
|
MessageCircle,
|
||||||
|
Radio,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { DevicePackage } from '../../../../entities/device/model/types'
|
||||||
|
import type { DeviceHistoryPeriodValue } from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl'
|
||||||
|
import { EmptyState } from '../../../../shared/ui/EmptyState/EmptyState'
|
||||||
|
|
||||||
|
import './DeviceAppsAccordion.scss'
|
||||||
|
|
||||||
|
type DeviceAppsAccordionProps = {
|
||||||
|
packages: DevicePackage[]
|
||||||
|
period: DeviceHistoryPeriodValue | null
|
||||||
|
isLoading?: boolean
|
||||||
|
isError?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppEventCategory = 'active' | 'background' | 'network'
|
||||||
|
|
||||||
|
type MockAppEvent = {
|
||||||
|
category: AppEventCategory
|
||||||
|
name: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RectShape = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppEventRenderParams = {
|
||||||
|
coordSys: RectShape
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppEventRenderApi = {
|
||||||
|
value: (index: number) => number
|
||||||
|
coord: (value: [number, number]) => [number, number]
|
||||||
|
style: () => Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppEventTooltipParams = {
|
||||||
|
data?: {
|
||||||
|
meta?: MockAppEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_FAMILY = 'Montserrat Variable, Montserrat, sans-serif'
|
||||||
|
const DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const EVENT_CATEGORIES: Array<{
|
||||||
|
key: AppEventCategory
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ key: 'active', label: 'Открыто' },
|
||||||
|
{ key: 'background', label: 'Фон' },
|
||||||
|
{ key: 'network', label: 'Сеть' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EVENT_COLORS: Record<AppEventCategory, string> = {
|
||||||
|
active: '#031d9a',
|
||||||
|
background: '#18b89b',
|
||||||
|
network: '#ff8a34',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHash(value: string) {
|
||||||
|
return value.split('').reduce((sum, char) => {
|
||||||
|
return sum + char.charCodeAt(0)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeriodBounds(period: DeviceHistoryPeriodValue | null) {
|
||||||
|
const end = period?.periodEnd.getTime() ?? Date.now()
|
||||||
|
const start = period?.periodStart.getTime() ?? end - DAY
|
||||||
|
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppName(app: DevicePackage) {
|
||||||
|
return app.name || app.packageName || 'Без названия'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp?: number | null) {
|
||||||
|
if (!timestamp) return 'Дата установки не указана'
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTooltipDate(value: number) {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(start: number, end: number) {
|
||||||
|
const totalMinutes = Math.max(1, Math.round((end - start) / 60000))
|
||||||
|
const hours = Math.floor(totalMinutes / 60)
|
||||||
|
const minutes = totalMinutes % 60
|
||||||
|
|
||||||
|
if (!hours) return `${minutes} мин`
|
||||||
|
if (!minutes) return `${hours} ч`
|
||||||
|
|
||||||
|
return `${hours} ч ${minutes} мин`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsagePercent(app: DevicePackage, period: DeviceHistoryPeriodValue | null) {
|
||||||
|
const { start, end } = getPeriodBounds(period)
|
||||||
|
const periodSeed = Math.round((end - start) / DAY)
|
||||||
|
const hash = getHash(`${app.packageName}-${app.name}-${periodSeed}`)
|
||||||
|
|
||||||
|
return clamp(4 + (hash % 72), 1, 96)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppVisual(app: DevicePackage): {
|
||||||
|
Icon: LucideIcon
|
||||||
|
modifier: string
|
||||||
|
} {
|
||||||
|
const value = `${app.name} ${app.packageName}`.toLowerCase()
|
||||||
|
|
||||||
|
if (value.includes('radio') || value.includes('fm')) {
|
||||||
|
return { Icon: Radio, modifier: 'is-radio' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes('sms') || value.includes('ims') || value.includes('message')) {
|
||||||
|
return { Icon: MessageCircle, modifier: 'is-message' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes('settings') || value.includes('android')) {
|
||||||
|
return { Icon: Settings, modifier: 'is-system' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes('security') || value.includes('cts')) {
|
||||||
|
return { Icon: ShieldCheck, modifier: 'is-security' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes('package')) {
|
||||||
|
return { Icon: Box, modifier: 'is-package' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { Icon: AppWindow, modifier: 'is-default' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockEvents(
|
||||||
|
app: DevicePackage,
|
||||||
|
period: DeviceHistoryPeriodValue | null,
|
||||||
|
usagePercent: number,
|
||||||
|
): MockAppEvent[] {
|
||||||
|
const { start, end } = getPeriodBounds(period)
|
||||||
|
const duration = end - start
|
||||||
|
const hash = getHash(app.packageName || app.name || '')
|
||||||
|
const eventsCount = clamp(Math.round(usagePercent / 12), 2, 8)
|
||||||
|
|
||||||
|
return Array.from({ length: eventsCount }).map((_, index) => {
|
||||||
|
const category = EVENT_CATEGORIES[(hash + index) % EVENT_CATEGORIES.length].key
|
||||||
|
|
||||||
|
const eventStart =
|
||||||
|
start +
|
||||||
|
duration * ((index + 1) / (eventsCount + 1)) +
|
||||||
|
((hash + index * 17) % 30) * 60 * 1000
|
||||||
|
|
||||||
|
const eventDuration =
|
||||||
|
duration * (0.015 + ((hash + index * 13) % 9) / 100)
|
||||||
|
|
||||||
|
const safeStart = clamp(eventStart, start, end)
|
||||||
|
const safeEnd = clamp(eventStart + eventDuration, safeStart + 60 * 1000, end)
|
||||||
|
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
name:
|
||||||
|
category === 'active'
|
||||||
|
? 'Приложение открыто'
|
||||||
|
: category === 'background'
|
||||||
|
? 'Фоновая активность'
|
||||||
|
: 'Сетевое событие',
|
||||||
|
start: safeStart,
|
||||||
|
end: safeEnd,
|
||||||
|
color: EVENT_COLORS[category],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipRectByRect(shape: RectShape, rect: RectShape) {
|
||||||
|
const x = Math.max(shape.x, rect.x)
|
||||||
|
const y = Math.max(shape.y, rect.y)
|
||||||
|
const x2 = Math.min(shape.x + shape.width, rect.x + rect.width)
|
||||||
|
const y2 = Math.min(shape.y + shape.height, rect.y + rect.height)
|
||||||
|
|
||||||
|
if (x2 < x || y2 < y) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: x2 - x,
|
||||||
|
height: y2 - y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAppEventItem(
|
||||||
|
params: AppEventRenderParams,
|
||||||
|
api: AppEventRenderApi,
|
||||||
|
) {
|
||||||
|
const categoryIndex = api.value(0)
|
||||||
|
const start = api.coord([api.value(1), categoryIndex])
|
||||||
|
const end = api.coord([api.value(2), categoryIndex])
|
||||||
|
|
||||||
|
const height = 12
|
||||||
|
|
||||||
|
const rectShape = clipRectByRect(
|
||||||
|
{
|
||||||
|
x: start[0],
|
||||||
|
y: start[1] - height / 2,
|
||||||
|
width: end[0] - start[0],
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: params.coordSys.x,
|
||||||
|
y: params.coordSys.y,
|
||||||
|
width: params.coordSys.width,
|
||||||
|
height: params.coordSys.height,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rectShape) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'rect',
|
||||||
|
shape: {
|
||||||
|
...rectShape,
|
||||||
|
r: 999,
|
||||||
|
},
|
||||||
|
style: api.style(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartOption(
|
||||||
|
events: MockAppEvent[],
|
||||||
|
period: DeviceHistoryPeriodValue | null,
|
||||||
|
) {
|
||||||
|
const { start, end } = getPeriodBounds(period)
|
||||||
|
|
||||||
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: 220,
|
||||||
|
|
||||||
|
textStyle: {
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
borderWidth: 0,
|
||||||
|
backgroundColor: '#20263a',
|
||||||
|
padding: 12,
|
||||||
|
textStyle: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
},
|
||||||
|
extraCssText:
|
||||||
|
'border-radius: 14px; box-shadow: 0 12px 32px rgba(16, 24, 40, 0.22);',
|
||||||
|
formatter: (params: AppEventTooltipParams) => {
|
||||||
|
const event = params.data?.meta
|
||||||
|
|
||||||
|
if (!event) return ''
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="min-width:260px;">
|
||||||
|
<div style="color:#aeb8cc;font-size:12px;margin-bottom:8px;">
|
||||||
|
${formatTooltipDate(event.start)} — ${formatTooltipDate(event.end)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:18px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="width:10px;height:10px;border-radius:999px;background:${event.color};display:inline-block;"></span>
|
||||||
|
<span>${event.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<strong>${formatDuration(event.start, event.end)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
grid: {
|
||||||
|
left: 92,
|
||||||
|
right: 24,
|
||||||
|
top: 24,
|
||||||
|
bottom: 44,
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
|
||||||
|
dataZoom: [
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
height: 22,
|
||||||
|
bottom: 8,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
backgroundColor: '#eff2f7',
|
||||||
|
fillerColor: 'rgba(3, 28, 154, 0.15)',
|
||||||
|
showDataShadow: false,
|
||||||
|
showDetail: false,
|
||||||
|
brushSelect: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'inside',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
xAxis: {
|
||||||
|
type: 'time',
|
||||||
|
min: start,
|
||||||
|
max: end,
|
||||||
|
axisLabel: {
|
||||||
|
color: '#738098',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
hideOverlap: true,
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: '#dbe2ee',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#edf0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
inverse: true,
|
||||||
|
data: EVENT_CATEGORIES.map((category) => category.label),
|
||||||
|
axisLabel: {
|
||||||
|
color: '#738098',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#edf0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
name: 'События приложения',
|
||||||
|
renderItem: renderAppEventItem,
|
||||||
|
encode: {
|
||||||
|
x: [1, 2],
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
data: events.map((event) => {
|
||||||
|
const categoryIndex = EVENT_CATEGORIES.findIndex(
|
||||||
|
(category) => category.key === event.category,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: [categoryIndex, event.start, event.end],
|
||||||
|
itemStyle: {
|
||||||
|
color: event.color,
|
||||||
|
},
|
||||||
|
meta: event,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceAppsAccordion({
|
||||||
|
packages,
|
||||||
|
period,
|
||||||
|
isLoading = false,
|
||||||
|
isError = false,
|
||||||
|
}: DeviceAppsAccordionProps) {
|
||||||
|
const [openedApps, setOpenedApps] = useState<string[]>([])
|
||||||
|
|
||||||
|
const apps = useMemo(() => {
|
||||||
|
return packages
|
||||||
|
.map((app) => {
|
||||||
|
const usagePercent = getUsagePercent(app, period)
|
||||||
|
const events = createMockEvents(app, period, usagePercent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
usagePercent,
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.usagePercent - a.usagePercent)
|
||||||
|
}, [packages, period])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="device-apps">
|
||||||
|
<div className="device-apps__header">
|
||||||
|
<div>
|
||||||
|
<h3>Приложения</h3>
|
||||||
|
<p>Использование приложений за выбранный период</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <span>Загрузка...</span>}
|
||||||
|
{isError && <span className="is-error">Не удалось загрузить</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && apps.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
className="device-apps__empty"
|
||||||
|
variant="no-items"
|
||||||
|
title="Данных по приложениям пока нет"
|
||||||
|
description="На устройстве пока нет данных по использованию приложений за выбранный период."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apps.length > 0 && (
|
||||||
|
<Accordion.Root
|
||||||
|
className="device-apps__list"
|
||||||
|
type="multiple"
|
||||||
|
value={openedApps}
|
||||||
|
onValueChange={setOpenedApps}
|
||||||
|
>
|
||||||
|
{apps.map(({ app, usagePercent, events }) => {
|
||||||
|
const appName = getAppName(app)
|
||||||
|
const appKey = `${app.packageName}-${app.version ?? app.versionName ?? appName}`
|
||||||
|
const { Icon, modifier } = getAppVisual(app)
|
||||||
|
const isOpen = openedApps.includes(appKey)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Item
|
||||||
|
key={appKey}
|
||||||
|
className="device-app-item"
|
||||||
|
value={appKey}
|
||||||
|
>
|
||||||
|
<Accordion.Header className="device-app-item__header">
|
||||||
|
<Accordion.Trigger className="device-app-item__trigger">
|
||||||
|
<div className="device-app-item__main">
|
||||||
|
<span className={`device-app-item__icon ${modifier}`}>
|
||||||
|
<Icon size={22} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="device-app-item__info">
|
||||||
|
<strong>{appName}</strong>
|
||||||
|
<small>
|
||||||
|
{app.packageName}
|
||||||
|
{app.versionName ? ` · v${app.versionName}` : ''}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-app-item__right">
|
||||||
|
<div className="device-app-item__usage">
|
||||||
|
<strong>{usagePercent}%</strong>
|
||||||
|
<span>за период</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronDown className="device-app-item__chevron" size={20} />
|
||||||
|
</div>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
|
||||||
|
<Accordion.Content className="device-app-item__content">
|
||||||
|
<div className="device-app-item__body">
|
||||||
|
<div className="device-app-item__meta">
|
||||||
|
<span>
|
||||||
|
Категория: <b>{app.category || 'Не указана'}</b>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Установлено: <b>{formatDate(app.installDate)}</b>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Код версии: <b>{app.version ?? '—'}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-app-chart">
|
||||||
|
{isOpen && (
|
||||||
|
<ReactECharts
|
||||||
|
option={getChartOption(events, period)}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Accordion.Root>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,679 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.device-apps-view {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr) 180px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__back,
|
||||||
|
.device-apps-view__install {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__back {
|
||||||
|
background: $color-surface;
|
||||||
|
color: $color-text-heading;
|
||||||
|
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0 14px 0 8px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $blue;
|
||||||
|
width: 24px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__install {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $gray50;
|
||||||
|
cursor: not-allowed;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__title {
|
||||||
|
height: 42px;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-22;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: $color-surface;
|
||||||
|
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: $transparent;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font: inherit;
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__toolbar-right {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__sort {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 13px 0 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: $color-surface;
|
||||||
|
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transition: .2s ease;
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&[data-state='open'] {
|
||||||
|
color: $blue;
|
||||||
|
background: $blue20;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__sort-menu {
|
||||||
|
z-index: 200;
|
||||||
|
min-width: 210px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: $color-surface;
|
||||||
|
box-shadow: $shadow-popover;
|
||||||
|
animation: deviceAppsSortMenuShow 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__sort-item {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 500;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&[data-highlighted] {
|
||||||
|
background: $color-surface-subtle;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__sort-check {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $blue;
|
||||||
|
flex: 0 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__summary {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: $color-surface;
|
||||||
|
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__panel {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: $color-surface;
|
||||||
|
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__scroll {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.simplebar-content {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
top: 14px;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 14px;
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar::before {
|
||||||
|
background: $gray50;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__state {
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__item {
|
||||||
|
border-bottom: 1px solid $gray20;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__item-header {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border: none;
|
||||||
|
background: $color-surface;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-surface-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba($color-primary-rgb, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='open'] {
|
||||||
|
background: $color-surface-muted;
|
||||||
|
|
||||||
|
.device-apps-view__chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__app-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__app-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: 0 0 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $blue;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-fallback {
|
||||||
|
background: $blue20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__app-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
strong,
|
||||||
|
small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-17;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__app-meta {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__chevron {
|
||||||
|
color: $gray50;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__content {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&[data-state='open'] {
|
||||||
|
animation: deviceAppsViewDown 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='closed'] {
|
||||||
|
animation: deviceAppsViewUp 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__details {
|
||||||
|
padding: 0 20px 18px 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__detail-card {
|
||||||
|
min-width: 0;
|
||||||
|
//min-height: 58px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: $color-surface-muted;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba($gray20-rgb, 0.8);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__detail-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $color-bg;
|
||||||
|
flex: 0 0 40px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $blue;
|
||||||
|
svg{
|
||||||
|
width: 20px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__detail-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
b {
|
||||||
|
min-width: 0;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__stats {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: $color-surface;
|
||||||
|
border: 1px dashed $color-border-accent;
|
||||||
|
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes deviceAppsViewDown {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes deviceAppsSortMenuShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes deviceAppsViewUp {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
.device-apps-view__back,
|
||||||
|
.device-apps-view__search,
|
||||||
|
.device-apps-view__sort,
|
||||||
|
.device-apps-view__summary,
|
||||||
|
.device-apps-view__panel {
|
||||||
|
box-shadow: none;
|
||||||
|
outline: 1px solid rgba($gray50-rgb, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__sort:hover,
|
||||||
|
.device-apps-view__sort[data-state='open'] {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
color: $color-primary-hover;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $color-primary-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__trigger:hover {
|
||||||
|
background: rgba($color-surface-rgb, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__trigger[data-state='open'] {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__detail-card {
|
||||||
|
background: rgba($color-surface-rgb, 0.58);
|
||||||
|
box-shadow: none;
|
||||||
|
outline: 1px solid rgba($gray50-rgb, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__stats {
|
||||||
|
background: rgba($color-surface-rgb, 0.58);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include laptop-down {
|
||||||
|
.device-apps-view__header {
|
||||||
|
grid-template-columns: 160px minmax(0, 1fr) 160px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__toolbar {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__summary {
|
||||||
|
padding-inline: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet-down {
|
||||||
|
.device-apps-view__header {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__back,
|
||||||
|
.device-apps-view__install {
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__search {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__toolbar-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__trigger {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__app-meta {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__details {
|
||||||
|
padding: 0 16px 16px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-view__details-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,522 @@
|
||||||
|
import {
|
||||||
|
memo,
|
||||||
|
type ChangeEvent,
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
import * as Accordion from '@radix-ui/react-accordion'
|
||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
|
import SimpleBar from 'simplebar-react'
|
||||||
|
import 'simplebar-react/dist/simplebar.min.css'
|
||||||
|
import {
|
||||||
|
AppWindow,
|
||||||
|
ArrowUpDown,
|
||||||
|
CalendarDays,
|
||||||
|
ChevronDown,
|
||||||
|
Download,
|
||||||
|
Hash,
|
||||||
|
Package,
|
||||||
|
Search,
|
||||||
|
Square,
|
||||||
|
Tags,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { GET_PHONE_PACKAGES_QUERY } from '../../../../entities/device/api/device.graphql'
|
||||||
|
import type {
|
||||||
|
DevicePackage,
|
||||||
|
GetPhonePackagesData,
|
||||||
|
GetPhonePackagesVariables,
|
||||||
|
} from '../../../../entities/device/model/types'
|
||||||
|
import { ListSkeleton } from '../../../../shared/ui/ListSkeleton/ListSkeleton'
|
||||||
|
import { RevealContent } from '../../../../shared/ui/RevealContent/RevealContent'
|
||||||
|
import { EmptyState } from '../../../../shared/ui/EmptyState/EmptyState'
|
||||||
|
|
||||||
|
import './DeviceAppsView.scss'
|
||||||
|
|
||||||
|
type DeviceAppsViewProps = {
|
||||||
|
deviceId: number
|
||||||
|
deviceName: string
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceAppIconProps = {
|
||||||
|
app: DevicePackage
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceAppsListProps = {
|
||||||
|
apps: DevicePackage[]
|
||||||
|
openedApp?: string
|
||||||
|
onOpenedAppChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceAppDetailsProps = {
|
||||||
|
app: DevicePackage
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceAppListItemProps = {
|
||||||
|
app: DevicePackage
|
||||||
|
isOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppsSortValue =
|
||||||
|
| 'name-asc'
|
||||||
|
| 'name-desc'
|
||||||
|
| 'package-asc'
|
||||||
|
| 'install-desc'
|
||||||
|
| 'install-asc'
|
||||||
|
|
||||||
|
type AppsSortOption = {
|
||||||
|
value: AppsSortValue
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const appsSortOptions: AppsSortOption[] = [
|
||||||
|
{
|
||||||
|
value: 'name-asc',
|
||||||
|
label: 'Название А-Я',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'name-desc',
|
||||||
|
label: 'Название Я-А',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'package-asc',
|
||||||
|
label: 'Пакет А-Я',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'install-desc',
|
||||||
|
label: 'Сначала новые',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'install-asc',
|
||||||
|
label: 'Сначала старые',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function getAppName(app: DevicePackage) {
|
||||||
|
return app.name || app.packageName || 'Без названия'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppCategory(app: DevicePackage) {
|
||||||
|
if (!app.category || app.category === 'Undefined') return 'Не указана'
|
||||||
|
|
||||||
|
return app.category
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppVersion(app: DevicePackage) {
|
||||||
|
if (app.versionName && app.version) {
|
||||||
|
return `${app.versionName} (${app.version})`
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.versionName || app.version || 'Не указана'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInstallDate(timestamp?: number | null) {
|
||||||
|
if (!timestamp) return 'Не указана'
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareText(a: string, b: string) {
|
||||||
|
return a.localeCompare(b, 'ru', {
|
||||||
|
sensitivity: 'base',
|
||||||
|
numeric: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareInstallDate(
|
||||||
|
a: DevicePackage,
|
||||||
|
b: DevicePackage,
|
||||||
|
direction: 'ASC' | 'DESC',
|
||||||
|
) {
|
||||||
|
const firstDate = a.installDate ?? 0
|
||||||
|
const secondDate = b.installDate ?? 0
|
||||||
|
|
||||||
|
if (firstDate === secondDate) {
|
||||||
|
return compareText(getAppName(a), getAppName(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
return direction === 'ASC'
|
||||||
|
? firstDate - secondDate
|
||||||
|
: secondDate - firstDate
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeviceAppIcon = memo(function DeviceAppIcon({ app }: DeviceAppIconProps) {
|
||||||
|
const [isImageFailed, setIsImageFailed] = useState(false)
|
||||||
|
const appName = getAppName(app)
|
||||||
|
|
||||||
|
if (app.iconUrl && !isImageFailed) {
|
||||||
|
return (
|
||||||
|
<span className="device-apps-view__app-icon">
|
||||||
|
<img
|
||||||
|
src={app.iconUrl}
|
||||||
|
alt=""
|
||||||
|
onError={() => setIsImageFailed(true)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="device-apps-view__app-icon is-fallback">
|
||||||
|
<AppWindow size={22} aria-label={appName} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const DeviceAppDetails = memo(function DeviceAppDetails({
|
||||||
|
app,
|
||||||
|
}: DeviceAppDetailsProps) {
|
||||||
|
return (
|
||||||
|
<div className="device-apps-view__details">
|
||||||
|
<div className="device-apps-view__details-grid">
|
||||||
|
<div className="device-apps-view__detail-card">
|
||||||
|
<span className="device-apps-view__detail-icon">
|
||||||
|
<Package size={17} />
|
||||||
|
</span>
|
||||||
|
<span className="device-apps-view__detail-text">
|
||||||
|
<small>Пакет</small>
|
||||||
|
<b>{app.packageName}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-apps-view__detail-card">
|
||||||
|
<span className="device-apps-view__detail-icon">
|
||||||
|
<Tags size={17} />
|
||||||
|
</span>
|
||||||
|
<span className="device-apps-view__detail-text">
|
||||||
|
<small>Категория</small>
|
||||||
|
<b>{getAppCategory(app)}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-apps-view__detail-card">
|
||||||
|
<span className="device-apps-view__detail-icon">
|
||||||
|
<CalendarDays size={17} />
|
||||||
|
</span>
|
||||||
|
<span className="device-apps-view__detail-text">
|
||||||
|
<small>Установлено</small>
|
||||||
|
<b>{formatInstallDate(app.installDate)}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-apps-view__detail-card">
|
||||||
|
<span className="device-apps-view__detail-icon">
|
||||||
|
<Hash size={17} />
|
||||||
|
</span>
|
||||||
|
<span className="device-apps-view__detail-text">
|
||||||
|
<small>Версия</small>
|
||||||
|
<b>{getAppVersion(app)}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-apps-view__stats">
|
||||||
|
<h3>Статистика</h3>
|
||||||
|
<p>Сводных данных по использованию пока нет</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-apps-view__actions">
|
||||||
|
<button type="button" disabled>
|
||||||
|
<Square size={17} />
|
||||||
|
Остановить
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" disabled>
|
||||||
|
<Trash2 size={17} />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" disabled>
|
||||||
|
<Download size={17} />
|
||||||
|
Установить обновление
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const DeviceAppListItem = memo(function DeviceAppListItem({
|
||||||
|
app,
|
||||||
|
isOpen,
|
||||||
|
}: DeviceAppListItemProps) {
|
||||||
|
const appName = getAppName(app)
|
||||||
|
const appKey = app.packageName || appName
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Item
|
||||||
|
className="device-apps-view__item"
|
||||||
|
value={appKey}
|
||||||
|
>
|
||||||
|
<Accordion.Header className="device-apps-view__item-header">
|
||||||
|
<Accordion.Trigger className="device-apps-view__trigger">
|
||||||
|
<span className="device-apps-view__app-main">
|
||||||
|
<DeviceAppIcon app={app} />
|
||||||
|
|
||||||
|
<span className="device-apps-view__app-text">
|
||||||
|
<strong>{appName}</strong>
|
||||||
|
<small>{app.packageName}</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="device-apps-view__app-meta">
|
||||||
|
<span>{getAppCategory(app)}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className="device-apps-view__chevron"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
|
||||||
|
<Accordion.Content className="device-apps-view__content">
|
||||||
|
{isOpen && <DeviceAppDetails app={app} />}
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const DeviceAppsList = memo(function DeviceAppsList({
|
||||||
|
apps,
|
||||||
|
openedApp,
|
||||||
|
onOpenedAppChange,
|
||||||
|
}: DeviceAppsListProps) {
|
||||||
|
return (
|
||||||
|
<Accordion.Root
|
||||||
|
className="device-apps-view__list"
|
||||||
|
type="single"
|
||||||
|
collapsible
|
||||||
|
value={openedApp}
|
||||||
|
onValueChange={onOpenedAppChange}
|
||||||
|
>
|
||||||
|
{apps.map((app) => {
|
||||||
|
const appName = getAppName(app)
|
||||||
|
const appKey = app.packageName || appName
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeviceAppListItem
|
||||||
|
key={appKey}
|
||||||
|
app={app}
|
||||||
|
isOpen={openedApp === appKey}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Accordion.Root>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function DeviceAppsView({
|
||||||
|
deviceId,
|
||||||
|
onBack,
|
||||||
|
}: DeviceAppsViewProps) {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const deferredSearch = useDeferredValue(search)
|
||||||
|
const [openedApp, setOpenedApp] = useState<string>()
|
||||||
|
const [sortValue, setSortValue] = useState<AppsSortValue>('name-asc')
|
||||||
|
|
||||||
|
const { data, loading, error } = useQuery<
|
||||||
|
GetPhonePackagesData,
|
||||||
|
GetPhonePackagesVariables
|
||||||
|
>(GET_PHONE_PACKAGES_QUERY, {
|
||||||
|
variables: {
|
||||||
|
phoneId: String(deviceId),
|
||||||
|
},
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
})
|
||||||
|
|
||||||
|
const packages = useMemo(
|
||||||
|
() => data?.getPhonePackages ?? [],
|
||||||
|
[data?.getPhonePackages],
|
||||||
|
)
|
||||||
|
const searchablePackages = useMemo(() => {
|
||||||
|
return packages.map((app) => ({
|
||||||
|
app,
|
||||||
|
searchText: [
|
||||||
|
app.name,
|
||||||
|
app.packageName,
|
||||||
|
app.category,
|
||||||
|
app.versionName,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase(),
|
||||||
|
}))
|
||||||
|
}, [packages])
|
||||||
|
|
||||||
|
const normalizedSearch = useMemo(() => {
|
||||||
|
return deferredSearch.trim().toLowerCase()
|
||||||
|
}, [deferredSearch])
|
||||||
|
const selectedSort = useMemo(() => {
|
||||||
|
return (
|
||||||
|
appsSortOptions.find((option) => option.value === sortValue) ??
|
||||||
|
appsSortOptions[0]
|
||||||
|
)
|
||||||
|
}, [sortValue])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((
|
||||||
|
event: ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
setSearch(event.target.value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredPackages = useMemo(() => {
|
||||||
|
return searchablePackages
|
||||||
|
.filter(({ searchText }) => {
|
||||||
|
if (!normalizedSearch) return true
|
||||||
|
|
||||||
|
return searchText.includes(normalizedSearch)
|
||||||
|
})
|
||||||
|
.map(({ app }) => app)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sortValue === 'name-desc') {
|
||||||
|
return compareText(getAppName(b), getAppName(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortValue === 'package-asc') {
|
||||||
|
return compareText(a.packageName, b.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortValue === 'install-desc') {
|
||||||
|
return compareInstallDate(a, b, 'DESC')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortValue === 'install-asc') {
|
||||||
|
return compareInstallDate(a, b, 'ASC')
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareText(getAppName(a), getAppName(b))
|
||||||
|
})
|
||||||
|
}, [normalizedSearch, searchablePackages, sortValue])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="device-apps-view">
|
||||||
|
<div className="device-apps-view__header">
|
||||||
|
<button
|
||||||
|
className="device-apps-view__back"
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M15 18L9 12L15 6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
К устройству
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="device-apps-view__title">
|
||||||
|
<h2>Приложения устройства</h2>
|
||||||
|
{/* <span>{deviceName}</span> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="device-apps-view__install" type="button" disabled>
|
||||||
|
<Download size={18} />
|
||||||
|
Установить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-apps-view__toolbar">
|
||||||
|
<label className="device-apps-view__search">
|
||||||
|
<Search size={18} />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
placeholder="Поиск по названию или пакету"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="device-apps-view__toolbar-right">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button className="device-apps-view__sort" type="button">
|
||||||
|
<span>{selectedSort.label}</span>
|
||||||
|
<ArrowUpDown size={17} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
className="device-apps-view__sort-menu"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
{appsSortOptions.map((option) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={option.value}
|
||||||
|
className="device-apps-view__sort-item"
|
||||||
|
onSelect={() => setSortValue(option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
|
||||||
|
{selectedSort.value === option.value && (
|
||||||
|
<span className="device-apps-view__sort-check" />
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
<div className="device-apps-view__summary">
|
||||||
|
<span>
|
||||||
|
Всего <b>{packages.length}</b>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Показано <b>{filteredPackages.length}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-apps-view__panel">
|
||||||
|
<SimpleBar className="device-apps-view__scroll">
|
||||||
|
<RevealContent loading={loading} fallback={<ListSkeleton />}>
|
||||||
|
{error && (
|
||||||
|
<div className="device-apps-view__state is-error">
|
||||||
|
Не удалось загрузить список приложений
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && filteredPackages.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
className="device-apps-view__state"
|
||||||
|
variant={normalizedSearch ? 'not-found' : 'no-items'}
|
||||||
|
title={
|
||||||
|
normalizedSearch
|
||||||
|
? 'Не найдено результатов'
|
||||||
|
: 'Приложений пока нет'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
normalizedSearch
|
||||||
|
? 'По заданному поиску не найдено ни одного приложения. Попробуйте изменить запрос.'
|
||||||
|
: 'На устройстве пока нет данных по установленным приложениям.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && filteredPackages.length > 0 && (
|
||||||
|
<DeviceAppsList
|
||||||
|
apps={filteredPackages}
|
||||||
|
openedApp={openedApp}
|
||||||
|
onOpenedAppChange={setOpenedApp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RevealContent>
|
||||||
|
</SimpleBar>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
.device-history-chart {
|
.device-history-chart {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 1000px;
|
min-height: 0;
|
||||||
margin-top: 40px;
|
margin-top: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-history-chart .echarts-for-react {
|
.device-history-chart .echarts-for-react {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1000px !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-history-chart__events-title {
|
.device-history-chart__events-title {
|
||||||
|
|
@ -21,15 +21,15 @@
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 450;
|
font-weight: 450;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
@ -49,16 +49,16 @@
|
||||||
|
|
||||||
.device-history-events-legend__item {
|
.device-history-events-legend__item {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: $transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
|
||||||
border: 1.5px solid #c9d2e3;
|
border: 1.5px solid $color-border-accent;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -96,13 +96,12 @@
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|
||||||
//border-radius: 2px;
|
color: $color-text-inverse;
|
||||||
//background: #ffffff;
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-history-chart__battery-title,
|
.device-history-chart__battery-title,
|
||||||
.device-history-chart__temperature-title {
|
.device-history-chart__temperature-title,
|
||||||
|
.device-history-chart__apps-title {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 90px;
|
left: 90px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
@ -110,15 +109,15 @@
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 450;
|
font-weight: 450;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
@ -140,14 +139,290 @@
|
||||||
|
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #f1f4f8;
|
background: $color-surface-subtle;
|
||||||
|
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-history-chart__state--error {
|
.device-history-chart__state--error {
|
||||||
background: rgba(224, 0, 0, 0.08);
|
background: $color-danger-tint;
|
||||||
color: $red;
|
color: $red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-history-app-events-legend {
|
||||||
|
position: absolute;
|
||||||
|
left: 270px;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-app-legend__item {
|
||||||
|
position: absolute;
|
||||||
|
left: 90px;
|
||||||
|
z-index: 4;
|
||||||
|
|
||||||
|
width: 164px;
|
||||||
|
border: none;
|
||||||
|
background: $transparent;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.48;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-app-legend__image{
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
box-shadow: 0 0 16px -4px rgba($black, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.device-history-app-legend__icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
color: $color-text-inverse;
|
||||||
|
background: $blue;
|
||||||
|
img{
|
||||||
|
width: 24px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-radio {
|
||||||
|
background: $accent-purple;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-message {
|
||||||
|
background: $accent-sky;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-health {
|
||||||
|
background: $accent-teal;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-home {
|
||||||
|
background: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-security {
|
||||||
|
background: $color-danger-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-system {
|
||||||
|
background: $color-text-heading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-app-legend__text {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-11;
|
||||||
|
font-weight: 550;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-apps-empty {
|
||||||
|
position: absolute;
|
||||||
|
left: 270px;
|
||||||
|
right: 90px;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 18px 24px;
|
||||||
|
//border-radius: 18px;
|
||||||
|
//background: rgba($color-surface-rgb, 0.1);
|
||||||
|
box-shadow: inset 0 0 0 1px $color-border-muted;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
max-width: 320px;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 550;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-app-selector {
|
||||||
|
position: absolute;
|
||||||
|
left: 90px;
|
||||||
|
right: 90px;
|
||||||
|
z-index: 4;
|
||||||
|
height: fit-content;
|
||||||
|
padding-top: 10px;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 164px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-app-selector__count {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 650;
|
||||||
|
text-align: left;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-app-selector__count {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-app-selector__scroll {
|
||||||
|
height: fit-content;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
right: -10px;
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar::before {
|
||||||
|
background: $gray50;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-app-selector__list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0px 6px 0px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-app-selector__item {
|
||||||
|
max-width: 230px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $color-surface;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.72;
|
||||||
|
font-size: $font-size-11;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue;
|
||||||
|
background: $color-primary-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
color: $blue;
|
||||||
|
background: $blue20;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,61 +4,121 @@
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
background: rgba(15, 23, 42, 0.35);
|
background: $color-backdrop-soft;
|
||||||
animation: historyOverlayShow 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-history-modal {
|
.device-history-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 600;
|
z-index: 600;
|
||||||
inset: 24px;
|
inset: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
padding: 26px 28px;
|
padding: 20px;
|
||||||
|
|
||||||
overflow: auto;
|
//overflow: hidden;
|
||||||
|
|
||||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22);
|
box-shadow: 0 24px 80px rgba($color-shadow-rgb, 0.22);
|
||||||
animation: historyModalShow 0.2s ease;
|
}
|
||||||
|
|
||||||
scrollbar-width: thin;
|
.device-history-modal__scroll {
|
||||||
scrollbar-color: $gray50 transparent;
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
.simplebar-content {
|
||||||
width: 6px;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
.simplebar-track.simplebar-vertical {
|
||||||
background: transparent;
|
top: 14px;
|
||||||
|
right: -18px;
|
||||||
|
bottom: 14px;
|
||||||
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
.simplebar-scrollbar::before {
|
||||||
background: $gray50;
|
background: $gray50;
|
||||||
border-radius: 999px;
|
border-radius: 4px;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-history-modal__chart-placeholder {
|
||||||
|
min-height: 720px;
|
||||||
|
padding-top: 220px;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.device-history-modal__header {
|
.device-history-modal__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
.device-history-modal__title{
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
.device-history-modal__meta{
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-modal {
|
||||||
|
.history-period-control__top {
|
||||||
|
//padding-left: 18px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-chart__zoom {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 50;
|
||||||
|
|
||||||
|
padding: 8px;
|
||||||
|
//margin: 0 64px;
|
||||||
|
//border-bottom-right-radius: 20px;
|
||||||
|
//border-bottom-left-radius: 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
|
background: rgba($white, 0.3);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 0 40px -6px rgba($gray50-rgb, .3);
|
||||||
|
.sticky-line{
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background-color: $gray20;
|
||||||
|
margin-top: 26px;
|
||||||
|
margin-right: 90px;
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-history-modal__title {
|
.device-history-modal__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 20px;
|
font-size: $font-size-20;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
@ -73,17 +133,21 @@
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
|
|
||||||
.is-green {
|
.is-green {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 12px;
|
padding-left: 20px;
|
||||||
color: #30394b;
|
padding-right: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $green;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
background-color: rgba($color-success-rgb, .1);
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 8px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|
@ -94,6 +158,53 @@
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.is-red {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $red;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
background-color: rgba($color-danger-rgb, .1);
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background: $red;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.is-gray {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $gray50;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
background-color: rgba($gray50-rgb, .1);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background: $gray50;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-history-modal__close {
|
.device-history-modal__close {
|
||||||
|
|
@ -101,13 +212,13 @@
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: transparent;
|
background: $transparent;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -139,17 +250,17 @@
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: $transparent;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 2px 8px rgba($color-shadow-rgb, 0.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,8 +276,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
|
@ -185,7 +296,7 @@
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|
||||||
color: $blue;
|
color: $blue;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-history-range__track {
|
.device-history-range__track {
|
||||||
|
|
@ -210,7 +321,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
background: $transparent;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,8 +355,8 @@
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 8px 100px;
|
margin: 0 0 8px 100px;
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -262,7 +373,7 @@
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
@ -289,12 +400,12 @@
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-row__line {
|
.history-row__line {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-top: 1px solid #dbe2ee;
|
border-top: 1px solid $color-border-muted;
|
||||||
|
|
||||||
&::before,
|
&::before,
|
||||||
&::after {
|
&::after {
|
||||||
|
|
@ -303,7 +414,7 @@
|
||||||
top: -1px;
|
top: -1px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: #dbe2ee;
|
background: $color-border-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
|
@ -327,15 +438,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-segment--purple {
|
.history-segment--purple {
|
||||||
background: #7a3fe8;
|
background: $accent-purple;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-segment--green {
|
.history-segment--green {
|
||||||
background: #18b89b;
|
background: $accent-teal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-segment--light-blue {
|
.history-segment--light-blue {
|
||||||
background: #2f80ed;
|
background: $accent-sky;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-point {
|
.history-point {
|
||||||
|
|
@ -354,20 +465,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-point--orange {
|
.history-point--orange {
|
||||||
background: #ff7a1a;
|
background: $color-warning-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battery-chart {
|
.battery-chart {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 110px;
|
height: 110px;
|
||||||
margin-left: 100px;
|
margin-left: 100px;
|
||||||
border-bottom: 1px solid #dbe2ee;
|
border-bottom: 1px solid $color-border-muted;
|
||||||
|
|
||||||
background:
|
background:
|
||||||
linear-gradient(to bottom, transparent 19%, #dbe2ee 20%, transparent 21%),
|
linear-gradient(to bottom, $transparent 19%, $color-border-muted 20%, $transparent 21%),
|
||||||
linear-gradient(to bottom, transparent 39%, #dbe2ee 40%, transparent 41%),
|
linear-gradient(to bottom, $transparent 39%, $color-border-muted 40%, $transparent 41%),
|
||||||
linear-gradient(to bottom, transparent 59%, #dbe2ee 60%, transparent 61%),
|
linear-gradient(to bottom, $transparent 59%, $color-border-muted 60%, $transparent 61%),
|
||||||
linear-gradient(to bottom, transparent 79%, #dbe2ee 80%, transparent 81%);
|
linear-gradient(to bottom, $transparent 79%, $color-border-muted 80%, $transparent 81%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.battery-chart__area {
|
.battery-chart__area {
|
||||||
|
|
@ -376,10 +487,10 @@
|
||||||
|
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
168deg,
|
168deg,
|
||||||
rgba(49, 178, 74, 0.38) 0%,
|
rgba($color-success-strong-rgb, 0.38) 0%,
|
||||||
rgba(49, 178, 74, 0.28) 45%,
|
rgba($color-success-strong-rgb, 0.28) 45%,
|
||||||
rgba(49, 178, 74, 0.05) 46%,
|
rgba($color-success-strong-rgb, 0.05) 46%,
|
||||||
rgba(49, 178, 74, 0.05) 100%
|
rgba($color-success-strong-rgb, 0.05) 100%
|
||||||
);
|
);
|
||||||
|
|
||||||
clip-path: polygon(0 10%, 100% 90%, 100% 100%, 0 100%);
|
clip-path: polygon(0 10%, 100% 90%, 100% 100%, 0 100%);
|
||||||
|
|
@ -396,7 +507,7 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-summary {
|
.history-summary {
|
||||||
|
|
@ -412,10 +523,10 @@
|
||||||
background: $color-bg;
|
background: $color-bg;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
|
|
||||||
b {
|
b {
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -429,17 +540,17 @@
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
border-bottom: 1px solid #dbe2ee;
|
border-bottom: 1px solid $color-border-muted;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-apps-list {
|
.device-apps-list {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,8 +560,8 @@
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid #e4e8f0;
|
border-bottom: 1px solid $color-border-muted;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 28px 220px 1fr auto 20px;
|
grid-template-columns: 28px 220px 1fr auto 20px;
|
||||||
|
|
@ -465,7 +576,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f8fafc;
|
background: $color-surface-muted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,56 +588,58 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-app-row__name {
|
.device-app-row__name {
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-app-row__time {
|
.device-app-row__time {
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-app-row__percent {
|
.device-app-row__percent {
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-app-row__arrow {
|
.device-app-row__arrow {
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 22px;
|
font-size: $font-size-22;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-apps-card__more {
|
.device-apps-card__more {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: $transparent;
|
||||||
|
|
||||||
color: $blue;
|
color: $blue;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes historyOverlayShow {
|
[data-theme='dark'] {
|
||||||
from {
|
.device-history-modal {
|
||||||
opacity: 0;
|
background: $color-surface;
|
||||||
|
box-shadow: 0 26px 80px rgba($color-shadow-rgb, 0.62);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
.device-history-chart__zoom {
|
||||||
opacity: 1;
|
background: rgba($color-surface-rgb, 0.76);
|
||||||
}
|
backdrop-filter: blur(14px);
|
||||||
}
|
box-shadow:
|
||||||
|
0 18px 44px rgba($color-shadow-rgb, 0.38),
|
||||||
@keyframes historyModalShow {
|
inset 0 0 0 1px rgba($gray50-rgb, 0.08);
|
||||||
from {
|
|
||||||
opacity: 0;
|
.sticky-line {
|
||||||
transform: translateY(10px) scale(0.99);
|
background-color: rgba($gray50-rgb, 0.16);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
to {
|
|
||||||
opacity: 1;
|
.device-history-modal__close:hover {
|
||||||
transform: translateY(0) scale(1);
|
background: $color-surface-hover;
|
||||||
|
color: $color-primary-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,22 +1,37 @@
|
||||||
import { useState } from 'react'
|
import {
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import * as Dialog from '@radix-ui/react-dialog'
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useQuery } from '@apollo/client/react'
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
import SimpleBar from 'simplebar-react'
|
||||||
|
import 'simplebar-react/dist/simplebar.min.css'
|
||||||
|
|
||||||
import type { Device } from '../../types'
|
import type { Device } from '../../types'
|
||||||
|
import { conditionText, connectionText } from '../../types'
|
||||||
import {
|
import {
|
||||||
GET_TELEMETRY_QUERY,
|
GET_TELEMETRY_QUERY,
|
||||||
} from '../../../../entities/device/api/device.graphql'
|
} from '../../../../entities/device/api/device.graphql'
|
||||||
import type {
|
import type {
|
||||||
GetTelemetryData,
|
GetTelemetryData,
|
||||||
GetTelemetryVariables,
|
GetTelemetryVariables,
|
||||||
|
DeviceHistoryChartItem,
|
||||||
|
DevicePackageUseEventsGroup,
|
||||||
|
DevicePhoneStateEventType
|
||||||
} from '../../../../entities/device/model/types'
|
} from '../../../../entities/device/model/types'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeviceHistoryPeriodControl,
|
DeviceHistoryPeriodControl,
|
||||||
type DeviceHistoryPeriodValue,
|
type DeviceHistoryPeriodValue,
|
||||||
} from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl'
|
} from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl'
|
||||||
import { DeviceHistoryChart } from '../DeviceHistoryChart/DeviceHistoryChart'
|
import { TimelineZoom } from '../TimelineZoom/TimelineZoom'
|
||||||
|
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
|
||||||
|
|
||||||
import './DeviceHistoryModal.scss'
|
import './DeviceHistoryModal.scss'
|
||||||
|
|
||||||
|
|
@ -26,6 +41,50 @@ type DeviceHistoryModalProps = {
|
||||||
device: Device
|
device: Device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PHONE_STATE_EVENT_LABELS: Record<DevicePhoneStateEventType, string> = {
|
||||||
|
ScreenOn: 'Экран включен',
|
||||||
|
ScreenOff: 'Экран выключен',
|
||||||
|
EnterNetwork: 'В сети',
|
||||||
|
LeaveNetwork: 'Не в сети',
|
||||||
|
PowerOn: 'Включение',
|
||||||
|
PowerOff: 'Выключение',
|
||||||
|
Hit: 'Удар',
|
||||||
|
Overheat: 'Перегрев',
|
||||||
|
PowerAC: 'Зарядка AC',
|
||||||
|
PowerUSB: 'Зарядка USB',
|
||||||
|
PowerWireless: 'Беспроводная зарядка',
|
||||||
|
PowerDisconnected: 'Зарядка отключена',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_MOUNT_DELAY_MS = 160
|
||||||
|
const EMPTY_HISTORY_ITEMS: DeviceHistoryChartItem[] = []
|
||||||
|
const EMPTY_PACKAGE_USE_EVENTS: DevicePackageUseEventsGroup[] = []
|
||||||
|
|
||||||
|
const DeviceHistoryChart = lazy(() =>
|
||||||
|
import('../DeviceHistoryChart/DeviceHistoryChart').then((module) => ({
|
||||||
|
default: module.DeviceHistoryChart,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function DeviceHistoryChartPlaceholder() {
|
||||||
|
return (
|
||||||
|
<div className="device-history-modal__chart-placeholder">
|
||||||
|
Подготавливаем графики...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoryConditionClass(device: Device) {
|
||||||
|
return device.condition === 'inspection' ? 'is-red' : 'is-green'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoryConnectionClass(device: Device) {
|
||||||
|
if (device.connection === 'online') return 'is-green'
|
||||||
|
if (device.connection === 'offlineDanger') return 'is-red'
|
||||||
|
|
||||||
|
return 'is-gray'
|
||||||
|
}
|
||||||
|
|
||||||
export function DeviceHistoryModal({
|
export function DeviceHistoryModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -34,59 +93,183 @@ export function DeviceHistoryModal({
|
||||||
const [selectedPeriod, setSelectedPeriod] =
|
const [selectedPeriod, setSelectedPeriod] =
|
||||||
useState<DeviceHistoryPeriodValue | null>(null)
|
useState<DeviceHistoryPeriodValue | null>(null)
|
||||||
|
|
||||||
|
const [zoomRange, setZoomRange] = useState<{
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
} | null>(null)
|
||||||
|
const [isChartReady, setIsChartReady] = useState(false)
|
||||||
|
const closeFrameRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPeriod) return
|
||||||
|
|
||||||
|
setZoomRange({
|
||||||
|
start: selectedPeriod.periodStart.getTime(),
|
||||||
|
end: selectedPeriod.periodEnd.getTime(),
|
||||||
|
})
|
||||||
|
}, [selectedPeriod])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setIsChartReady(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChartReady(false)
|
||||||
|
|
||||||
|
let firstFrameId = 0
|
||||||
|
let secondFrameId = 0
|
||||||
|
let timeoutId = 0
|
||||||
|
|
||||||
|
firstFrameId = window.requestAnimationFrame(() => {
|
||||||
|
secondFrameId = window.requestAnimationFrame(() => {
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
setIsChartReady(true)
|
||||||
|
}, CHART_MOUNT_DELAY_MS)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(firstFrameId)
|
||||||
|
window.cancelAnimationFrame(secondFrameId)
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [device.id, open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (closeFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(closeFrameRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(nextOpen: boolean) => {
|
||||||
|
if (nextOpen) {
|
||||||
|
onOpenChange(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChartReady(false)
|
||||||
|
|
||||||
|
if (closeFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(closeFrameRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
closeFrameRef.current = null
|
||||||
|
onOpenChange(false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[onOpenChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const shouldRenderChart = Boolean(open && isChartReady && selectedPeriod && zoomRange)
|
||||||
|
|
||||||
|
const telemetryVariables = useMemo<GetTelemetryVariables>(
|
||||||
|
() => ({
|
||||||
|
phoneId: Number(device.id),
|
||||||
|
packagesPhoneId: String(device.id),
|
||||||
|
startDate: selectedPeriod?.periodStart.getTime() ?? 0,
|
||||||
|
endDate: selectedPeriod?.periodEnd.getTime(),
|
||||||
|
}),
|
||||||
|
[device.id, selectedPeriod],
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: telemetryData,
|
data: telemetryData,
|
||||||
loading: telemetryLoading,
|
loading: telemetryLoading,
|
||||||
error: telemetryError,
|
error: telemetryError,
|
||||||
} = useQuery<GetTelemetryData, GetTelemetryVariables>(GET_TELEMETRY_QUERY, {
|
} = useQuery<GetTelemetryData, GetTelemetryVariables>(GET_TELEMETRY_QUERY, {
|
||||||
variables: {
|
variables: telemetryVariables,
|
||||||
phoneId: Number(device.id),
|
skip: !shouldRenderChart,
|
||||||
startDate: selectedPeriod?.periodStart.getTime() ?? 0,
|
|
||||||
endDate: selectedPeriod?.periodEnd.getTime(),
|
|
||||||
},
|
|
||||||
skip: !open || !selectedPeriod,
|
|
||||||
fetchPolicy: 'network-only',
|
fetchPolicy: 'network-only',
|
||||||
pollInterval: 15000,
|
//pollInterval: 15000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const telemetryWithEvents = useMemo<DeviceHistoryChartItem[]>(() => {
|
||||||
|
if (!telemetryData) return EMPTY_HISTORY_ITEMS
|
||||||
|
|
||||||
|
return [
|
||||||
|
...telemetryData.getTelemetry,
|
||||||
|
|
||||||
|
...telemetryData.getPhoneStateEvents.map(
|
||||||
|
(event): DeviceHistoryChartItem => ({
|
||||||
|
date: event.date,
|
||||||
|
type: 'event',
|
||||||
|
eventType: event.type,
|
||||||
|
label: PHONE_STATE_EVENT_LABELS[event.type] ?? event.type,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
].sort((a, b) => a.date - b.date)
|
||||||
|
}, [telemetryData])
|
||||||
|
|
||||||
|
const packageUseEvents =
|
||||||
|
telemetryData?.getPhonePackagesUseEvents ?? EMPTY_PACKAGE_USE_EVENTS
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
|
||||||
<Dialog.Portal>
|
<MotionDialogContent
|
||||||
<Dialog.Overlay className="device-history-modal__overlay" />
|
open={open}
|
||||||
|
overlayClassName="device-history-modal__overlay"
|
||||||
|
contentClassName="device-history-modal"
|
||||||
|
placement="inset"
|
||||||
|
>
|
||||||
|
<div className="device-history-chart__zoom">
|
||||||
|
<div className="device-history-modal__header">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title className="device-history-modal__title">
|
||||||
|
{device.factoryNumber}
|
||||||
|
<span>ID: {device.id}</span>
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
<Dialog.Content className="device-history-modal">
|
<div className="device-history-modal__meta">
|
||||||
<div className="device-history-modal__header">
|
<span>{device.imei} · {device.imei2}</span>
|
||||||
<div>
|
<span className={getHistoryConditionClass(device)}>
|
||||||
<Dialog.Title className="device-history-modal__title">
|
{conditionText[device.condition]}
|
||||||
{device.factoryNumber}
|
</span>
|
||||||
<span>ID: {device.id}</span>
|
<span className={getHistoryConnectionClass(device)}>
|
||||||
</Dialog.Title>
|
{device.connectionText || connectionText[device.connection]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="device-history-modal__meta">
|
<Dialog.Close className="device-history-modal__close" type="button">
|
||||||
<span>{device.imei}</span>
|
<X size={22} />
|
||||||
<span>Версия приложения: 244</span>
|
</Dialog.Close>
|
||||||
<span className="is-green">Исправно</span>
|
|
||||||
<span className="is-green">
|
|
||||||
{device.connectionText || 'В сети'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog.Close className="device-history-modal__close" type="button">
|
<DeviceHistoryPeriodControl onChange={setSelectedPeriod} />
|
||||||
<X size={22} />
|
{selectedPeriod && zoomRange && (
|
||||||
</Dialog.Close>
|
<TimelineZoom
|
||||||
|
min={selectedPeriod.periodStart.getTime()}
|
||||||
|
max={selectedPeriod.periodEnd.getTime()}
|
||||||
|
start={zoomRange.start}
|
||||||
|
end={zoomRange.end}
|
||||||
|
onChange={setZoomRange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DeviceHistoryPeriodControl onChange={setSelectedPeriod} />
|
<SimpleBar className="device-history-modal__scroll">
|
||||||
|
{shouldRenderChart && selectedPeriod && zoomRange ? (
|
||||||
<DeviceHistoryChart
|
<Suspense fallback={<DeviceHistoryChartPlaceholder />}>
|
||||||
period={selectedPeriod}
|
<DeviceHistoryChart
|
||||||
telemetry={telemetryData?.getTelemetry ?? []}
|
period={selectedPeriod}
|
||||||
isLoading={telemetryLoading}
|
zoomRange={zoomRange}
|
||||||
isError={Boolean(telemetryError)}
|
telemetry={telemetryWithEvents}
|
||||||
/>
|
packageUseEvents={packageUseEvents}
|
||||||
</Dialog.Content>
|
isLoading={telemetryLoading}
|
||||||
</Dialog.Portal>
|
isError={Boolean(telemetryError)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<DeviceHistoryChartPlaceholder />
|
||||||
|
)}
|
||||||
|
</SimpleBar>
|
||||||
|
</MotionDialogContent>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
.history-period-tabs {
|
.history-period-tabs {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: $color-bg;
|
//background: $color-bg;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -39,11 +39,17 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
background: rgba($color-surface-rgb, .6) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
box-shadow: 0 0 10px -6px $gray50;
|
||||||
|
}
|
||||||
|
[data-theme='dark'] &.is-active{
|
||||||
|
background: $color-surface;
|
||||||
|
box-shadow: 0 0 10px -6px rgba($gray50-rgb, .4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,13 +63,13 @@
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: $color-bg;
|
//background: $color-bg;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
color: #30394b;
|
//color: #30394b;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
|
|
@ -77,7 +83,7 @@
|
||||||
&:hover,
|
&:hover,
|
||||||
&.is-active {
|
&.is-active {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
box-shadow: inset 0 0 0 1px rgba(3, 29, 154, 0.16);
|
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.16);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
@ -91,94 +97,102 @@
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
|
|
||||||
|
min-width: 100%;
|
||||||
|
width: max-content;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
|
|
||||||
border: 1.5px solid $gray20;
|
border: 1.5px solid $gray20;
|
||||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
|
box-shadow: none;
|
||||||
|
|
||||||
button{
|
button{
|
||||||
transition: .18s ease;
|
transition: .18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-root {
|
.rdp-root {
|
||||||
--rdp-accent-color: $blue;
|
--rdp-accent-color: #{$blue};
|
||||||
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
|
--rdp-accent-background-color: #{$color-primary-soft};
|
||||||
--rdp-day_button-border-radius: 10px;
|
--rdp-day_button-border-radius: 12px;
|
||||||
|
--rdp-day_button-height: 34px;
|
||||||
|
--rdp-day_button-width: 34px;
|
||||||
|
--rdp-day-height: 36px;
|
||||||
|
--rdp-day-width: 36px;
|
||||||
--rdp-selected-border: 0;
|
--rdp-selected-border: 0;
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-months {
|
.rdp-months {
|
||||||
|
max-width: 100%;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rdp-month {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.rdp-month_caption {
|
.rdp-month_caption {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-caption_label {
|
.rdp-caption_label {
|
||||||
color: #30394b;
|
color: $color-text-strong;
|
||||||
font-size: 15px;
|
font-size: $font-size-14;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-nav {
|
.rdp-nav {
|
||||||
button {
|
gap: 4px;
|
||||||
width: 30px;
|
}
|
||||||
height: 30px;
|
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
color: $gray50;
|
.rdp-button_previous,
|
||||||
|
.rdp-button_next,
|
||||||
|
.rdp-nav button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
&:hover {
|
color: $blue;
|
||||||
background: $color-bg;
|
|
||||||
color: $blue;
|
&:hover {
|
||||||
}
|
background: $color-surface-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-weekday {
|
.rdp-weekday {
|
||||||
color: $gray50;
|
color: $color-text-muted;
|
||||||
font-size: 12px;
|
font-size: $font-size-12;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 14px;
|
font-size: $font-size-12;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $color-bg;
|
background: $color-surface-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-selected {
|
.rdp-selected {
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
background: $blue;
|
background: $blue;
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-range_middle {
|
.rdp-range_middle {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
background: #e8edff;
|
background: $color-primary-soft;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -186,19 +200,23 @@
|
||||||
|
|
||||||
.rdp-range_start {
|
.rdp-range_start {
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
border-radius: 10px;
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-range_end {
|
.rdp-range_end {
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
border-radius: 10px;
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-range_start.rdp-range_end {
|
.rdp-range_start.rdp-range_end {
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-today {
|
.rdp-today {
|
||||||
|
|
@ -241,7 +259,7 @@
|
||||||
|
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(3, 29, 154, 0.16);
|
background: rgba($color-primary-rgb, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-range__active {
|
.history-range__active {
|
||||||
|
|
@ -293,51 +311,3 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* react-day-picker */
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-root {
|
|
||||||
--rdp-accent-color: $blue;
|
|
||||||
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
|
|
||||||
--rdp-day_button-border-radius: 10px;
|
|
||||||
--rdp-selected-border: 0;
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-months {
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-caption_label {
|
|
||||||
color: #30394b;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-weekday {
|
|
||||||
color: $gray50;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-day_button {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
|
|
||||||
color: #30394b;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-selected .rdp-day_button {
|
|
||||||
background: $blue;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-range_middle .rdp-day_button {
|
|
||||||
background: #e8edff;
|
|
||||||
color: $blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-today .rdp-day_button {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +1,109 @@
|
||||||
import { useState } from 'react'
|
import { memo, useState } from 'react'
|
||||||
import { ShieldCheck, Signal, Smartphone, Trash2 } from 'lucide-react'
|
import { AnimatePresence, m, useReducedMotion } from 'framer-motion'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Lock,
|
||||||
|
ShieldCheck,
|
||||||
|
Signal,
|
||||||
|
Smartphone,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
import type { Device } from '../../types'
|
import type { Device } from '../../types'
|
||||||
import { conditionText, connectionText, getStatusClass } from '../../types'
|
import { conditionText, connectionText, getStatusClass } from '../../types'
|
||||||
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
||||||
|
import { useDeferredModalPayload } from '../../../../shared/lib/lazyMount'
|
||||||
|
|
||||||
type DeviceMainCardProps = {
|
type DeviceMainCardProps = {
|
||||||
device: Device
|
device: Device
|
||||||
onOpenHistory: () => void
|
onOpenHistory: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDeviceFullName(employee: Device) {
|
function getDeviceFullName(device: Device) {
|
||||||
return [employee.serialNumber]
|
return [device.serialNumber].filter(Boolean).join(' ')
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areDeviceMainCardPropsEqual(
|
||||||
|
prev: DeviceMainCardProps,
|
||||||
|
next: DeviceMainCardProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
prev.onOpenHistory === next.onOpenHistory &&
|
||||||
|
prev.device.id === next.device.id &&
|
||||||
|
prev.device.image === next.device.image &&
|
||||||
|
prev.device.model === next.device.model &&
|
||||||
|
prev.device.factoryNumber === next.device.factoryNumber &&
|
||||||
|
prev.device.serialNumber === next.device.serialNumber &&
|
||||||
|
prev.device.imei === next.device.imei &&
|
||||||
|
prev.device.imei2 === next.device.imei2 &&
|
||||||
|
prev.device.condition === next.device.condition &&
|
||||||
|
prev.device.connection === next.device.connection &&
|
||||||
|
prev.device.connectionText === next.device.connectionText &&
|
||||||
|
prev.device.permissions?.locked === next.device.permissions?.locked &&
|
||||||
|
prev.device.workTime === next.device.workTime &&
|
||||||
|
prev.device.organisationId === next.device.organisationId &&
|
||||||
|
prev.device.organisation === next.device.organisation &&
|
||||||
|
prev.device.registeredAt === next.device.registeredAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps) {
|
export const DeviceMainCard = memo(function DeviceMainCard({
|
||||||
|
device,
|
||||||
|
onOpenHistory,
|
||||||
|
}: DeviceMainCardProps) {
|
||||||
const [deletingDevice, setDeletingDevice] = useState<Device | null>(null)
|
const [deletingDevice, setDeletingDevice] = useState<Device | null>(null)
|
||||||
|
const renderDeletingDevice = useDeferredModalPayload(deletingDevice)
|
||||||
|
const shouldReduceMotion = useReducedMotion()
|
||||||
|
const statusTransition = shouldReduceMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: {
|
||||||
|
duration: 0.24,
|
||||||
|
ease: [0.34, 1.42, 0.64, 1] as [number, number, number, number],
|
||||||
|
}
|
||||||
|
const statusItems = [
|
||||||
|
{
|
||||||
|
key: `condition-${device.condition}`,
|
||||||
|
className: getStatusClass(device.condition),
|
||||||
|
icon: <ShieldCheck size={17} />,
|
||||||
|
content: conditionText[device.condition],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `connection-${device.connection}-${device.connectionText ?? ''}`,
|
||||||
|
className: getStatusClass(device.connection),
|
||||||
|
icon: <Signal size={17} />,
|
||||||
|
content: device.connectionText ?? connectionText[device.connection],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (device.permissions?.locked) {
|
||||||
|
statusItems.push({
|
||||||
|
key: 'locked',
|
||||||
|
className: 'is-danger',
|
||||||
|
icon: <Lock size={17} />,
|
||||||
|
content: 'Заблокирован',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.workTime) {
|
||||||
|
statusItems.push({
|
||||||
|
key: 'work-time',
|
||||||
|
className: 'is-muted',
|
||||||
|
icon: <Smartphone size={17} />,
|
||||||
|
content: `В работе: ${device.workTime}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function handleConfirmDeleteDevice() {
|
function handleConfirmDeleteDevice() {
|
||||||
if (!deletingDevice) return
|
if (!deletingDevice) return
|
||||||
|
|
||||||
console.log('Удаление устройства пока без мутации', deletingDevice)
|
console.log('Удаление устройства пока без мутации', deletingDevice)
|
||||||
|
|
||||||
setDeletingDevice(null)
|
setDeletingDevice(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteDevice(device: Device) {
|
function handleDeleteDevice(device: Device) {
|
||||||
setDeletingDevice(device)
|
setDeletingDevice(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="device-card device-card--main">
|
<div className="device-card device-card--main">
|
||||||
|
|
@ -39,7 +112,7 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
|
||||||
{device.image ? (
|
{device.image ? (
|
||||||
<img src={device.image} alt={device.model ?? 'Устройство'} />
|
<img src={device.image} alt={device.model ?? 'Устройство'} />
|
||||||
) : (
|
) : (
|
||||||
<img src='/devices/армафон3.3+.webp'></img>
|
<img src="/devices/армафон3.3+.webp" alt="АРМАФОН" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -49,33 +122,60 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
|
||||||
<p>{device.factoryNumber}</p>
|
<p>{device.factoryNumber}</p>
|
||||||
<p>
|
<p>
|
||||||
{device.imei}
|
{device.imei}
|
||||||
{device.serialNumber ? ` · ${device.serialNumber}` : ''}
|
{device.imei2 ? ` · ${device.imei2}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="device-main__divider" />
|
<div className="device-main__divider" />
|
||||||
|
|
||||||
<div className="device-main__statuses">
|
<div className="device-main__statuses">
|
||||||
<div className={`device-status ${getStatusClass(device.condition)}`}>
|
<AnimatePresence>
|
||||||
<ShieldCheck size={17} />
|
{statusItems.map((status, index) => (
|
||||||
{conditionText[device.condition]}
|
<m.div
|
||||||
</div>
|
key={status.key}
|
||||||
|
layout={!shouldReduceMotion}
|
||||||
<div className={`device-status ${getStatusClass(device.connection)}`}>
|
className={`device-status ${status.className}`}
|
||||||
<Signal size={17} />
|
initial={{ opacity: 0, x: shouldReduceMotion ? 0 : -8 }}
|
||||||
{device.connectionText ?? connectionText[device.connection]}
|
animate={{
|
||||||
</div>
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
{device.workTime && (
|
transition: {
|
||||||
<div className="device-status is-muted">
|
...statusTransition,
|
||||||
<Smartphone size={17} />
|
delay: shouldReduceMotion ? 0 : index * 0.04,
|
||||||
В работе: {device.workTime}
|
},
|
||||||
</div>
|
}}
|
||||||
)}
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
x: shouldReduceMotion ? 0 : -8,
|
||||||
|
transition: statusTransition,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status.icon}
|
||||||
|
{status.content}
|
||||||
|
</m.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="device-main__bottom">
|
<div className="device-main__bottom">
|
||||||
|
<div className="device-registered device-organisation">
|
||||||
|
<Building2 size={17} />
|
||||||
|
<div className="device-registered-info">
|
||||||
|
<span>Организация</span>
|
||||||
|
{device.organisationId ? (
|
||||||
|
<Link to={`/employees/organisations/${device.organisationId}`}>
|
||||||
|
{device.organisation ?? `Организация #${device.organisationId}`}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18.607 11.035v7.929a2.27 2.27 0 0 1-2.3 2.286H5.05a2.27 2.27 0 0 1-2.299-2.3V7.693a2.273 2.273 0 0 1 2.3-2.3h7.928M21.25 2.75L10.679 13.321M15.964 2.75h5.286v5.286"></path>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<b>{device.organisation ?? 'Не указана'}</b>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="device-registered">
|
<div className="device-registered">
|
||||||
<Smartphone size={17} />
|
<Smartphone size={17} />
|
||||||
<div className="device-registered-info">
|
<div className="device-registered-info">
|
||||||
|
|
@ -85,21 +185,31 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="device-card__actions">
|
<div className="device-card__actions">
|
||||||
<button className="device-history-btn" type="button" onClick={onOpenHistory}>
|
<button
|
||||||
|
className="device-history-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenHistory}
|
||||||
|
>
|
||||||
Подробная история эксплуатации
|
Подробная история эксплуатации
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="device-delete-btn" type="button" aria-label="Удалить" onClick={() => handleDeleteDevice(device)}>
|
<button
|
||||||
|
className="device-delete-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Удалить"
|
||||||
|
onClick={() => handleDeleteDevice(device)}
|
||||||
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDangerDialog
|
<ConfirmDangerDialog
|
||||||
open={Boolean(deletingDevice)}
|
open={Boolean(deletingDevice)}
|
||||||
title="Удалить сотрудника?"
|
title="Удалить смартфон?"
|
||||||
description={
|
description={
|
||||||
deletingDevice
|
renderDeletingDevice
|
||||||
? `Устройство ${getDeviceFullName(deletingDevice)} будет удалено. Это действие нельзя будет отменить.`
|
? `Устройство ${getDeviceFullName(renderDeletingDevice)} будет удалено. Это действие нельзя будет отменить.`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
confirmText="Удалить"
|
confirmText="Удалить"
|
||||||
|
|
@ -112,4 +222,4 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}, areDeviceMainCardPropsEqual)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
background: #eef1f6;
|
background: $color-media-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-map__leaflet {
|
.device-map__leaflet {
|
||||||
|
|
@ -27,17 +27,17 @@
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba($color-surface-rgb, 0.92);
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.12);
|
box-shadow: 0 8px 22px rgba($color-shadow-rgb, 0.12);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(3, 29, 154, 0.18);
|
background: rgba($color-primary-rgb, 0.18);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -65,8 +65,41 @@
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: $blue;
|
background: $blue;
|
||||||
border: 3px solid #ffffff;
|
border: 3px solid $color-surface;
|
||||||
box-shadow: 0 8px 18px rgba(3, 29, 154, 0.35);
|
box-shadow: 0 8px 18px rgba($color-primary-rgb, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker.is-online {
|
||||||
|
.device-map-marker__pin {
|
||||||
|
background: rgba($color-success-rgb, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker__inner {
|
||||||
|
background: $green;
|
||||||
|
box-shadow: 0 8px 18px rgba($color-success-rgb, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker.is-offline {
|
||||||
|
.device-map-marker__pin {
|
||||||
|
background: rgba($gray50-rgb, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker__inner {
|
||||||
|
background: $gray50;
|
||||||
|
box-shadow: 0 8px 18px rgba($gray50-rgb, 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker.is-lost {
|
||||||
|
.device-map-marker__pin {
|
||||||
|
background: rgba($color-danger-rgb, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker__inner {
|
||||||
|
background: $red;
|
||||||
|
box-shadow: 0 8px 18px rgba($color-danger-rgb, 0.28);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-map-popup {
|
.device-map-popup {
|
||||||
|
|
@ -75,13 +108,13 @@
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
|
|
||||||
b {
|
b {
|
||||||
color: #151a24;
|
color: $color-text-heading;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +128,179 @@
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-map-info-card {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 70px;
|
||||||
|
z-index: 430;
|
||||||
|
//transform: translateY(-50%);
|
||||||
|
|
||||||
|
width: min(180px, calc(100% - 24px));
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba($color-border-rgb, 0.72);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba($color-surface-rgb, 0.94);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-shadow: $shadow-popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $color-surface-muted;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding-right: 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
b,
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
b {
|
||||||
|
overflow: hidden;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-top: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $color-primary-tint;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__status {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 22px;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-11;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-online {
|
||||||
|
background: rgba($color-success-rgb, 0.14);
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-offline {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-lost {
|
||||||
|
background: rgba($color-danger-rgb, 0.14);
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba($color-border-rgb, 0.72);
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-11;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.device-map--modal {
|
.device-map--modal {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,21 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@apollo/client/react'
|
import { useQuery } from '@apollo/client/react'
|
||||||
import {
|
import {
|
||||||
CircleMarker,
|
CircleMarker,
|
||||||
MapContainer,
|
MapContainer,
|
||||||
Marker,
|
Marker,
|
||||||
Polyline,
|
Polyline,
|
||||||
Popup,
|
|
||||||
TileLayer,
|
TileLayer,
|
||||||
useMap,
|
useMap,
|
||||||
} from 'react-leaflet'
|
} from 'react-leaflet'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { Map } from 'lucide-react'
|
import { Battery, Clock3, Map as MapIcon, MapPin, Thermometer, X } from 'lucide-react'
|
||||||
|
|
||||||
import type { Device } from '../../types'
|
import type { Device } from '../../types'
|
||||||
import {
|
import { GET_PHONE_GPS_TRACK_QUERY } from '../../../../entities/device/api/device.graphql'
|
||||||
GET_PHONE_GPS_TRACK_QUERY,
|
|
||||||
} from '../../../../entities/device/api/device.graphql'
|
|
||||||
import type {
|
import type {
|
||||||
DeviceGpsTrackPoint,
|
DeviceGpsTrackPoint,
|
||||||
|
DeviceTelemetryItem,
|
||||||
GetPhoneGpsTrackData,
|
GetPhoneGpsTrackData,
|
||||||
GetPhoneGpsTrackVariables,
|
GetPhoneGpsTrackVariables,
|
||||||
} from '../../../../entities/device/model/types'
|
} from '../../../../entities/device/model/types'
|
||||||
|
|
@ -34,24 +32,63 @@ type DeviceMapCardProps = {
|
||||||
variant?: 'card' | 'modal'
|
variant?: 'card' | 'modal'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TelemetryLine = Array<[number, number]>
|
||||||
|
|
||||||
|
type DeviceMapPoint = {
|
||||||
|
key: string
|
||||||
|
kind: 'current' | 'route'
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
date?: number
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapFitBoundsProps = {
|
||||||
|
positions: [number, number][]
|
||||||
|
fitKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeColor = 'var(--color-primary)'
|
||||||
|
|
||||||
const defaultLocation = {
|
const defaultLocation = {
|
||||||
lat: 54.7558,
|
lat: 54.7558,
|
||||||
lng: 87.1099,
|
lng: 87.1099,
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceMarkerIcon = L.divIcon({
|
const markerIconCache = new Map<string, L.DivIcon>()
|
||||||
className: 'device-map-marker',
|
|
||||||
html: `
|
|
||||||
<div class="device-map-marker__pin">
|
|
||||||
<div class="device-map-marker__inner"></div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
iconSize: [34, 34],
|
|
||||||
iconAnchor: [17, 17],
|
|
||||||
popupAnchor: [0, -18],
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatLocationDate(timestamp: number) {
|
function getConnectionStatusClass(connection: Device['connection']) {
|
||||||
|
if (connection === 'online') return 'is-online'
|
||||||
|
if (connection === 'offlineDanger') return 'is-lost'
|
||||||
|
|
||||||
|
return 'is-offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeviceMarkerIcon(connection: Device['connection']) {
|
||||||
|
const className = getConnectionStatusClass(connection)
|
||||||
|
const cachedIcon = markerIconCache.get(className)
|
||||||
|
|
||||||
|
if (cachedIcon) return cachedIcon
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: `device-map-marker ${className}`,
|
||||||
|
html: `
|
||||||
|
<div class="device-map-marker__pin">
|
||||||
|
<div class="device-map-marker__inner"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [34, 34],
|
||||||
|
iconAnchor: [17, 17],
|
||||||
|
popupAnchor: [0, -18],
|
||||||
|
})
|
||||||
|
|
||||||
|
markerIconCache.set(className, icon)
|
||||||
|
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocationDate(timestamp?: number) {
|
||||||
if (!timestamp) return 'Нет данных'
|
if (!timestamp) return 'Нет данных'
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
|
@ -63,11 +100,79 @@ function formatLocationDate(timestamp: number) {
|
||||||
}).format(new Date(timestamp))
|
}).format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number) {
|
||||||
|
return value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetric(value: number | null, suffix: string) {
|
||||||
|
if (value === null) return '-'
|
||||||
|
|
||||||
|
return `${value}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
|
function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
|
||||||
return [...track].sort((a, b) => a.date - b.date)
|
return [...track].sort((a, b) => a.date - b.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDateInPeriod(date: number | undefined, period: MapTrackPeriodValue | null) {
|
function sortTelemetry(telemetry: DeviceTelemetryItem[]) {
|
||||||
|
return [...telemetry].sort((a, b) => a.date - b.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTelemetryLine(
|
||||||
|
telemetry: DeviceTelemetryItem[],
|
||||||
|
getValue: (item: DeviceTelemetryItem) => number | null,
|
||||||
|
): TelemetryLine {
|
||||||
|
return telemetry
|
||||||
|
.map((item) => [item.date, getValue(item)] as const)
|
||||||
|
.filter((item): item is [number, number] => typeof item[1] === 'number')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineValueAtTime(data: TelemetryLine, timestamp?: number) {
|
||||||
|
if (!timestamp || !data.length) return null
|
||||||
|
|
||||||
|
const firstPoint = data[0]
|
||||||
|
const lastPoint = data[data.length - 1]
|
||||||
|
|
||||||
|
if (timestamp <= firstPoint[0]) return firstPoint[1]
|
||||||
|
if (timestamp >= lastPoint[0]) return lastPoint[1]
|
||||||
|
|
||||||
|
let left = 0
|
||||||
|
let right = data.length - 1
|
||||||
|
|
||||||
|
while (right - left > 1) {
|
||||||
|
const middle = Math.floor((left + right) / 2)
|
||||||
|
|
||||||
|
if (data[middle][0] <= timestamp) {
|
||||||
|
left = middle
|
||||||
|
} else {
|
||||||
|
right = middle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPoint = data[left]
|
||||||
|
const nextPoint = data[right]
|
||||||
|
const currentTime = currentPoint[0]
|
||||||
|
const currentValue = currentPoint[1]
|
||||||
|
const nextTime = nextPoint[0]
|
||||||
|
const nextValue = nextPoint[1]
|
||||||
|
|
||||||
|
if (nextTime === currentTime) return Math.round(currentValue)
|
||||||
|
|
||||||
|
const progress = (timestamp - currentTime) / (nextTime - currentTime)
|
||||||
|
const interpolatedValue =
|
||||||
|
currentValue + (nextValue - currentValue) * progress
|
||||||
|
|
||||||
|
return Math.round(interpolatedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPointKey(kind: DeviceMapPoint['kind'], lat: number, lng: number, date?: number) {
|
||||||
|
return `${kind}-${date ?? 'no-date'}-${lat}-${lng}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateInPeriod(
|
||||||
|
date: number | undefined,
|
||||||
|
period: MapTrackPeriodValue | null,
|
||||||
|
) {
|
||||||
if (!date || !period) return false
|
if (!date || !period) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -90,15 +195,14 @@ function MapResizeWatcher() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
type MapFitBoundsProps = {
|
function MapFitBounds({ positions, fitKey }: MapFitBoundsProps) {
|
||||||
positions: [number, number][]
|
|
||||||
}
|
|
||||||
|
|
||||||
function MapFitBounds({ positions }: MapFitBoundsProps) {
|
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
|
const lastFitKeyRef = useRef<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!positions.length) return
|
if (!positions.length || lastFitKeyRef.current === fitKey) return
|
||||||
|
|
||||||
|
lastFitKeyRef.current = fitKey
|
||||||
|
|
||||||
if (positions.length === 1) {
|
if (positions.length === 1) {
|
||||||
map.setView(positions[0], 14)
|
map.setView(positions[0], 14)
|
||||||
|
|
@ -112,14 +216,23 @@ function MapFitBounds({ positions }: MapFitBoundsProps) {
|
||||||
maxZoom: 15,
|
maxZoom: 15,
|
||||||
animate: true,
|
animate: true,
|
||||||
})
|
})
|
||||||
}, [map, positions])
|
}, [fitKey, map, positions])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps) {
|
export function DeviceMapCard({
|
||||||
|
device,
|
||||||
|
variant = 'card',
|
||||||
|
}: DeviceMapCardProps) {
|
||||||
const [selectedPeriod, setSelectedPeriod] =
|
const [selectedPeriod, setSelectedPeriod] =
|
||||||
useState<MapTrackPeriodValue | null>(null)
|
useState<MapTrackPeriodValue | null>(null)
|
||||||
|
const [selectedPointKey, setSelectedPointKey] = useState<string | null>(null)
|
||||||
|
const networkStatusClass = getConnectionStatusClass(device.connection)
|
||||||
|
const deviceMarkerIcon = useMemo(
|
||||||
|
() => createDeviceMarkerIcon(device.connection),
|
||||||
|
[device.connection],
|
||||||
|
)
|
||||||
|
|
||||||
const { data: gpsTrackData } = useQuery<
|
const { data: gpsTrackData } = useQuery<
|
||||||
GetPhoneGpsTrackData,
|
GetPhoneGpsTrackData,
|
||||||
|
|
@ -139,6 +252,19 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
||||||
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
||||||
}, [gpsTrackData])
|
}, [gpsTrackData])
|
||||||
|
|
||||||
|
const telemetry = useMemo(() => {
|
||||||
|
return sortTelemetry(device.telemetry ?? [])
|
||||||
|
}, [device.telemetry])
|
||||||
|
|
||||||
|
const telemetryLines = useMemo(() => {
|
||||||
|
return {
|
||||||
|
battery: createTelemetryLine(telemetry, (item) => item.batteryLevel),
|
||||||
|
temperature: createTelemetryLine(telemetry, (item) => item.temperature),
|
||||||
|
capacity: createTelemetryLine(telemetry, (item) => item.batteryCapacity),
|
||||||
|
cycles: createTelemetryLine(telemetry, (item) => item.batteryCycles),
|
||||||
|
}
|
||||||
|
}, [telemetry])
|
||||||
|
|
||||||
const shouldShowCurrentLocation = isDateInPeriod(
|
const shouldShowCurrentLocation = isDateInPeriod(
|
||||||
device.lastLocationDate,
|
device.lastLocationDate,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
|
|
@ -147,10 +273,10 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
||||||
const selectedCurrentLocation =
|
const selectedCurrentLocation =
|
||||||
shouldShowCurrentLocation && device.location
|
shouldShowCurrentLocation && device.location
|
||||||
? {
|
? {
|
||||||
lat: device.location.lat,
|
lat: device.location.lat,
|
||||||
lng: device.location.lng,
|
lng: device.location.lng,
|
||||||
time: device.lastLocationAt,
|
date: device.lastLocationDate,
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const selectedRoutePoints = useMemo(() => {
|
const selectedRoutePoints = useMemo(() => {
|
||||||
|
|
@ -187,7 +313,81 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
||||||
return [location.lat, location.lng]
|
return [location.lat, location.lng]
|
||||||
}, [location.lat, location.lng])
|
}, [location.lat, location.lng])
|
||||||
|
|
||||||
const fitPositions = routePositions.length > 0 ? routePositions : [currentPosition]
|
const fitPositions =
|
||||||
|
routePositions.length > 0 ? routePositions : [currentPosition]
|
||||||
|
|
||||||
|
const fitKey = useMemo(() => {
|
||||||
|
if (!selectedPeriod) {
|
||||||
|
return `${device.id}-${device.location ? 'located' : 'fallback'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
device.id,
|
||||||
|
selectedPeriod.periodStart.getTime(),
|
||||||
|
selectedPeriod.periodEnd.getTime(),
|
||||||
|
routePositions.length > 0 ? 'route' : 'point',
|
||||||
|
].join('-')
|
||||||
|
}, [device.id, device.location, routePositions.length, selectedPeriod])
|
||||||
|
|
||||||
|
const routeMapPoints = useMemo<DeviceMapPoint[]>(() => {
|
||||||
|
return selectedRoutePoints.map((point) => ({
|
||||||
|
key: createPointKey('route', point.lat, point.lng, point.date),
|
||||||
|
kind: 'route',
|
||||||
|
title: 'Точка маршрута',
|
||||||
|
date: point.date,
|
||||||
|
lat: point.lat,
|
||||||
|
lng: point.lng,
|
||||||
|
}))
|
||||||
|
}, [selectedRoutePoints])
|
||||||
|
|
||||||
|
const currentMapPoint = useMemo<DeviceMapPoint | null>(() => {
|
||||||
|
if (!selectedCurrentLocation) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: createPointKey(
|
||||||
|
'current',
|
||||||
|
selectedCurrentLocation.lat,
|
||||||
|
selectedCurrentLocation.lng,
|
||||||
|
selectedCurrentLocation.date,
|
||||||
|
),
|
||||||
|
kind: 'current',
|
||||||
|
title: device.model ?? 'Устройство',
|
||||||
|
subtitle: device.factoryNumber,
|
||||||
|
date: selectedCurrentLocation.date,
|
||||||
|
lat: selectedCurrentLocation.lat,
|
||||||
|
lng: selectedCurrentLocation.lng,
|
||||||
|
}
|
||||||
|
}, [device.factoryNumber, device.model, selectedCurrentLocation])
|
||||||
|
|
||||||
|
const selectedMapPoint = useMemo(() => {
|
||||||
|
if (!selectedPointKey) return null
|
||||||
|
|
||||||
|
if (currentMapPoint?.key === selectedPointKey) {
|
||||||
|
return currentMapPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
routeMapPoints.find((point) => point.key === selectedPointKey) ?? null
|
||||||
|
)
|
||||||
|
}, [currentMapPoint, routeMapPoints, selectedPointKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPointKey && !selectedMapPoint) {
|
||||||
|
setSelectedPointKey(null)
|
||||||
|
}
|
||||||
|
}, [selectedMapPoint, selectedPointKey])
|
||||||
|
|
||||||
|
const selectedTelemetry = useMemo(() => {
|
||||||
|
return {
|
||||||
|
battery: getLineValueAtTime(telemetryLines.battery, selectedMapPoint?.date),
|
||||||
|
temperature: getLineValueAtTime(
|
||||||
|
telemetryLines.temperature,
|
||||||
|
selectedMapPoint?.date,
|
||||||
|
),
|
||||||
|
capacity: getLineValueAtTime(telemetryLines.capacity, selectedMapPoint?.date),
|
||||||
|
cycles: getLineValueAtTime(telemetryLines.cycles, selectedMapPoint?.date),
|
||||||
|
}
|
||||||
|
}, [selectedMapPoint, telemetryLines])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -199,7 +399,7 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
||||||
>
|
>
|
||||||
<div className="device-card__header">
|
<div className="device-card__header">
|
||||||
<div className="device-card__title">
|
<div className="device-card__title">
|
||||||
<Map size={18} />
|
<MapIcon size={18} />
|
||||||
<h3>Устройство на карте</h3>
|
<h3>Устройство на карте</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -214,6 +414,63 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
||||||
<MapTrackPeriodControl onChange={setSelectedPeriod} />
|
<MapTrackPeriodControl onChange={setSelectedPeriod} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedMapPoint && (
|
||||||
|
<div className="device-map-info-card">
|
||||||
|
<button
|
||||||
|
className="device-map-info-card__close"
|
||||||
|
type="button"
|
||||||
|
aria-label="Закрыть информацию о точке"
|
||||||
|
onClick={() => setSelectedPointKey(null)}
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__header">
|
||||||
|
<b>{selectedMapPoint.title}</b>
|
||||||
|
<span>
|
||||||
|
{selectedMapPoint.subtitle ?? device.factoryNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__rows">
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<span
|
||||||
|
className={`device-map-info-card__status ${networkStatusClass}`}
|
||||||
|
>
|
||||||
|
{device.connectionText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<Clock3 size={15} />
|
||||||
|
<span>{formatLocationDate(selectedMapPoint.date)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<Battery size={15} />
|
||||||
|
<span>
|
||||||
|
Заряд АКБ: {formatMetric(selectedTelemetry.battery, '%')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<Thermometer size={15} />
|
||||||
|
<span>
|
||||||
|
Температура: {formatMetric(selectedTelemetry.temperature, '°C')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<MapPin size={15} />
|
||||||
|
<span>
|
||||||
|
{formatCoordinate(selectedMapPoint.lat)},{' '}
|
||||||
|
{formatCoordinate(selectedMapPoint.lng)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={currentPosition}
|
center={currentPosition}
|
||||||
zoom={14}
|
zoom={14}
|
||||||
|
|
@ -223,7 +480,7 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
||||||
id="device-map"
|
id="device-map"
|
||||||
>
|
>
|
||||||
<MapResizeWatcher />
|
<MapResizeWatcher />
|
||||||
<MapFitBounds positions={fitPositions} />
|
<MapFitBounds positions={fitPositions} fitKey={fitKey} />
|
||||||
|
|
||||||
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
|
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
|
||||||
|
|
||||||
|
|
@ -231,45 +488,44 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
||||||
<Polyline
|
<Polyline
|
||||||
positions={routePositions}
|
positions={routePositions}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
color: '#031d9a',
|
color: routeColor,
|
||||||
weight: 4,
|
weight: 4,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedRoutePoints.map((point, index) => (
|
{routeMapPoints.map((point) => (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
key={`${point.lat}-${point.lng}-${point.date}-${index}`}
|
key={point.key}
|
||||||
center={[point.lat, point.lng]}
|
center={[point.lat, point.lng]}
|
||||||
radius={4}
|
radius={4}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
color: '#031d9a',
|
color: routeColor,
|
||||||
fillColor: '#031d9a',
|
fillColor: routeColor,
|
||||||
fillOpacity: 1,
|
fillOpacity: 1,
|
||||||
weight: 2,
|
weight: 2,
|
||||||
}}
|
}}
|
||||||
>
|
eventHandlers={{
|
||||||
<Popup>
|
click: (event) => {
|
||||||
<div className="device-map-popup">
|
event.originalEvent.stopPropagation()
|
||||||
<b>Точка маршрута</b>
|
setSelectedPointKey(point.key)
|
||||||
<span>{formatLocationDate(point.date)}</span>
|
},
|
||||||
</div>
|
}}
|
||||||
</Popup>
|
/>
|
||||||
</CircleMarker>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{selectedCurrentLocation && (
|
{currentMapPoint && (
|
||||||
<Marker position={currentPosition} icon={deviceMarkerIcon}>
|
<Marker
|
||||||
<Popup>
|
position={currentPosition}
|
||||||
<div className="device-map-popup">
|
icon={deviceMarkerIcon}
|
||||||
<b>{device.model ?? 'Устройство'}</b>
|
eventHandlers={{
|
||||||
<span>{device.factoryNumber}</span>
|
click: (event) => {
|
||||||
<span>{device.imei}</span>
|
event.originalEvent.stopPropagation()
|
||||||
<span>{device.lastLocationAt ?? 'Время не указано'}</span>
|
setSelectedPointKey(currentMapPoint.key)
|
||||||
</div>
|
},
|
||||||
</Popup>
|
}}
|
||||||
</Marker>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FullscreenControl targetId="device-map" />
|
<FullscreenControl targetId="device-map" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.device-page-skeleton {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $color-skeleton;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
$transparent,
|
||||||
|
$color-skeleton-highlight,
|
||||||
|
$transparent);
|
||||||
|
animation: device-page-skeleton-shimmer 1.35s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-skeleton--text {
|
||||||
|
height: 1em;
|
||||||
|
min-width: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-skeleton--icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-skeleton--button {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-skeleton--square-button {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-skeleton--mini-button {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-skeleton--switch {
|
||||||
|
width: 48px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-skeleton--battery-circle {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__grid {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__main-card,
|
||||||
|
.device-page-loading__map-card,
|
||||||
|
.device-page-loading__actions-card,
|
||||||
|
.device-page-loading__permissions-card,
|
||||||
|
.device-page-loading__battery-card,
|
||||||
|
.device-page-loading__impacts-card {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__device-image {
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__phone {
|
||||||
|
width: 42%;
|
||||||
|
min-width: 92px;
|
||||||
|
max-width: 138px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 180px;
|
||||||
|
border-radius: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__main-info {
|
||||||
|
padding-top: 2px;
|
||||||
|
|
||||||
|
.device-page-skeleton--text {
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__title {
|
||||||
|
height: 30px !important;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__status-row {
|
||||||
|
.device-page-skeleton--text {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__registered {
|
||||||
|
.device-registered-info {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__history-button {
|
||||||
|
max-width: 286px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__map-card {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__map-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.device-card__title {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.device-page-skeleton--text {
|
||||||
|
height: 15px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__map-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 332px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__period-control {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.device-page-skeleton--button {
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba($white, 0.86);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__map {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 332px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba($color-primary-rgb, 0.05), $transparent 48%),
|
||||||
|
$color-skeleton;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__actions-card {
|
||||||
|
min-height: 174px;
|
||||||
|
|
||||||
|
.device-page-loading__card-title {
|
||||||
|
height: 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-button-container {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__permissions-card {
|
||||||
|
min-height: 236px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__permissions-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.device-permissions__header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__card-title {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__permission {
|
||||||
|
.device-permission__label {
|
||||||
|
min-height: 43px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__stats-column {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__battery-card {
|
||||||
|
min-height: 194px;
|
||||||
|
|
||||||
|
>.device-page-loading__card-title {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__stat {
|
||||||
|
.device-stats-text {
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-page-loading__impacts-card {
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes device-page-skeleton-shimmer {
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.device-page-skeleton::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
import './DevicePageSkeleton.scss'
|
||||||
|
|
||||||
|
type SkeletonProps = {
|
||||||
|
className?: string
|
||||||
|
width?: CSSProperties['width']
|
||||||
|
height?: CSSProperties['height']
|
||||||
|
}
|
||||||
|
|
||||||
|
function Skeleton({ className = '', width, height }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`device-page-skeleton ${className}`}
|
||||||
|
style={{ width, height }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonText({
|
||||||
|
className = '',
|
||||||
|
width = '100%',
|
||||||
|
}: Omit<SkeletonProps, 'height'>) {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
className={`device-page-skeleton--text ${className}`}
|
||||||
|
width={width}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonIcon({ className = '' }: Pick<SkeletonProps, 'className'>) {
|
||||||
|
return <Skeleton className={`device-page-skeleton--icon ${className}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonButton({ className = '' }: Pick<SkeletonProps, 'className'>) {
|
||||||
|
return <Skeleton className={`device-page-skeleton--button ${className}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevicePageSkeleton() {
|
||||||
|
return (
|
||||||
|
<section className="device-page device-page-loading" aria-busy="true">
|
||||||
|
<div className="device-page__grid device-page-loading__grid">
|
||||||
|
<div className="device-card device-card--main device-page-loading__main-card">
|
||||||
|
<div className="device-main">
|
||||||
|
<div className="device-main__image device-page-loading__device-image">
|
||||||
|
<Skeleton className="device-page-loading__phone" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-main__info device-page-loading__main-info">
|
||||||
|
<SkeletonText className="device-page-loading__title" width="210px" />
|
||||||
|
<SkeletonText width="260px" />
|
||||||
|
<SkeletonText width="360px" />
|
||||||
|
|
||||||
|
<div className="device-main__divider" />
|
||||||
|
|
||||||
|
<div className="device-main__statuses">
|
||||||
|
<div className="device-status device-page-loading__status-row">
|
||||||
|
<SkeletonIcon />
|
||||||
|
<SkeletonText width="176px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-status device-page-loading__status-row">
|
||||||
|
<SkeletonIcon />
|
||||||
|
<SkeletonText width="136px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-status device-page-loading__status-row">
|
||||||
|
<SkeletonIcon />
|
||||||
|
<SkeletonText width="158px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-main__bottom">
|
||||||
|
<div className="device-registered device-page-loading__registered">
|
||||||
|
<SkeletonIcon />
|
||||||
|
|
||||||
|
<div className="device-registered-info">
|
||||||
|
<SkeletonText width="142px" />
|
||||||
|
<SkeletonText width="150px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-card__actions">
|
||||||
|
<SkeletonButton className="device-page-loading__history-button" />
|
||||||
|
<Skeleton className="device-page-skeleton--square-button" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-card device-map device-page-loading__map-card">
|
||||||
|
<div className="device-card__header device-page-loading__map-header">
|
||||||
|
<div className="device-card__title">
|
||||||
|
<SkeletonIcon />
|
||||||
|
<SkeletonText width="184px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SkeletonText width="230px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map__container device-page-loading__map-container">
|
||||||
|
<div className="device-page-loading__period-control">
|
||||||
|
<SkeletonButton />
|
||||||
|
<SkeletonButton />
|
||||||
|
<SkeletonButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="device-page-loading__map" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-page__details-row">
|
||||||
|
<div className="device-card device-actions device-page-loading__actions-card">
|
||||||
|
<SkeletonText className="device-page-loading__card-title" width="118px" />
|
||||||
|
|
||||||
|
<div className="actions-button-container">
|
||||||
|
<SkeletonButton />
|
||||||
|
<SkeletonButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-card device-permissions device-page-loading__permissions-card">
|
||||||
|
<div className="device-permissions__header device-page-loading__permissions-header">
|
||||||
|
<SkeletonText className="device-page-loading__card-title" width="72px" />
|
||||||
|
|
||||||
|
<div className="device-permissions__header-actions">
|
||||||
|
<Skeleton className="device-page-skeleton--mini-button" />
|
||||||
|
<Skeleton className="device-page-skeleton--mini-button" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-permissions__list">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div className="device-permission device-page-loading__permission" key={index}>
|
||||||
|
<SkeletonIcon />
|
||||||
|
|
||||||
|
<div className="device-permission__label">
|
||||||
|
<SkeletonText width={index === 3 ? '108px' : '86px'} />
|
||||||
|
<Skeleton className="device-page-skeleton--switch" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-card-stats device-page-loading__stats-column">
|
||||||
|
<div className="device-card device-battery device-page-loading__battery-card">
|
||||||
|
<SkeletonText className="device-page-loading__card-title" width="126px" />
|
||||||
|
|
||||||
|
<div className="device-battery__content">
|
||||||
|
<Skeleton className="device-page-skeleton--battery-circle" />
|
||||||
|
|
||||||
|
<div className="device-stats">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div className="device-stats-card device-page-loading__stat" key={index}>
|
||||||
|
<SkeletonIcon />
|
||||||
|
|
||||||
|
<div className="device-stats-text">
|
||||||
|
<SkeletonText width={index === 0 ? '150px' : '132px'} />
|
||||||
|
<SkeletonText width={index === 2 ? '54px' : '42px'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-card device-impacts device-page-loading__impacts-card">
|
||||||
|
<SkeletonIcon />
|
||||||
|
|
||||||
|
<div className="device-stats-text">
|
||||||
|
<SkeletonText width="132px" />
|
||||||
|
<SkeletonText width="38px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,358 @@
|
||||||
@use '../../../../shared/styles/variables' as *;
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.device-permissions {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid $gray20;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-18;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__title {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__help {
|
||||||
|
position: relative;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
cursor: help;
|
||||||
|
outline: none;
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke-width: 2.2;
|
||||||
|
transition: .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .device-permissions__help-tooltip,
|
||||||
|
&:focus-visible .device-permissions__help-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__help-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
top: 100%;
|
||||||
|
left: -34px;
|
||||||
|
|
||||||
|
width: 400px;
|
||||||
|
padding: 11px;
|
||||||
|
border-radius: 12px;
|
||||||
|
//background: rgba($color-surface-rgb, 0.94);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow:
|
||||||
|
0 14px 30px rgba($color-shadow-rgb, 0.16),
|
||||||
|
inset 0 0 0 1px rgba($color-primary-rgb, 0.08);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(2px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
visibility 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 38px;
|
||||||
|
bottom: 100%;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
background: rgba($color-surface-rgb, 0.94);
|
||||||
|
box-shadow: -1px -1px 0 rgba($color-primary-rgb, 0.08);
|
||||||
|
transform: translateY(5px) rotate(45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__help-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px 1fr;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
>svg {
|
||||||
|
margin-top: 1px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.28;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__header-actions {
|
||||||
|
position: relative;
|
||||||
|
width: 82px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 82px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__icon-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: $gray20;
|
||||||
|
color: $color-text;
|
||||||
|
//transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__icon-btn--edit {
|
||||||
|
z-index: 2;
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
box-shadow: 0 8px 20px rgba($color-primary-rgb, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__icon-btn--reset {
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(0) scale(0.92);
|
||||||
|
transition:
|
||||||
|
background-color 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.28s cubic-bezier(0.34, 1.42, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions.is-editing {
|
||||||
|
.device-permissions__icon-btn--reset {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(-46px) scale(1);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions:not(.is-policy-editing) {
|
||||||
|
.device-permission {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__switch {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__controls {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__policy-lock {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: 0 0 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: $color-surface-hover;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
cursor: help;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
background-color 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px !important;
|
||||||
|
height: 18px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: $transparent !important;
|
||||||
|
color: currentColor;
|
||||||
|
stroke-width: 2.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
z-index: 120;
|
||||||
|
color: $blue;
|
||||||
|
background: $blue20;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .device-permission__policy-lock-tooltip,
|
||||||
|
&:focus-visible .device-permission__policy-lock-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translate(-50%, -8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__policy-lock-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 110;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
|
||||||
|
width: 260px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border-radius: 10px;
|
||||||
|
//background: rgba($color-surface-rgb, 0.94);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 28px rgba($color-shadow-rgb, 0.14),
|
||||||
|
inset 0 0 0 1px rgba($color-primary-rgb, 0.08);
|
||||||
|
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.32;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -2px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
visibility 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
background: rgba($color-surface-rgb, 0.94);
|
||||||
|
box-shadow: 1px 1px 0 rgba($color-primary-rgb, 0.08);
|
||||||
|
transform: translate(-50%, -5px) rotate(45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.device-permission__switch {
|
.device-permission__switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
width: 48px;
|
width: 118px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
flex: 0 0 48px;
|
flex: 0 0 118px;
|
||||||
padding: 0;
|
padding: 3px;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
display: inline-flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: $gray20;
|
background: $color-bg;
|
||||||
box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
|
box-shadow: inset 0 0 0 1px rgba($gray50-rgb, 0.08);
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
@ -25,19 +362,29 @@
|
||||||
box-shadow 0.28s ease,
|
box-shadow 0.28s ease,
|
||||||
transform 0.18s ease;
|
transform 0.18s ease;
|
||||||
|
|
||||||
> span {
|
&--component {
|
||||||
|
width: 78px;
|
||||||
|
flex-basis: 78px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
||||||
|
.device-permission__switch-thumb {
|
||||||
|
width: calc((100% - 6px) / 2) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__switch-thumb {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 3px;
|
top: 3px;
|
||||||
left: 3px;
|
left: 3px;
|
||||||
|
|
||||||
width: 26px;
|
width: calc((100% - 6px) / 3);
|
||||||
height: 18px;
|
height: calc(100% - 6px);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 2px 5px rgba(15, 23, 42, 0.18),
|
0 2px 5px rgba($color-shadow-rgb, 0.18),
|
||||||
0 1px 1px rgba(15, 23, 42, 0.08);
|
0 1px 1px rgba($color-shadow-rgb, 0.08);
|
||||||
|
|
||||||
transition:
|
transition:
|
||||||
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
|
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
|
@ -45,40 +392,124 @@
|
||||||
width 0.18s ease;
|
width 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-enabled {
|
.device-permission__switch-option {
|
||||||
background: $blue;
|
position: relative;
|
||||||
box-shadow: inset 0 0 0 1px rgba($blue, 0.12);
|
z-index: 1;
|
||||||
|
|
||||||
> span {
|
min-width: 0;
|
||||||
transform: translateX(16px);
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: $transparent;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
-webkit-tap-highlight-color: $transparent;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
border-radius: 999px;
|
||||||
|
//box-shadow: 0 0 0 2px rgba($color-primary-rgb, 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__switch-option[aria-disabled='true'] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__switch-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke-width: 2.35;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-unspecified {
|
||||||
|
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.1);
|
||||||
|
|
||||||
|
.device-permission__switch-thumb {
|
||||||
|
transform: translateX(100%);
|
||||||
|
background: $blue20;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 3px 7px rgba(3, 29, 154, 0.24),
|
0 3px 7px -4px rgba($color-primary-rgb),
|
||||||
0 1px 1px rgba(15, 23, 42, 0.08);
|
0 1px 1px rgba($color-shadow-rgb, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__switch-option.is-active .device-permission__switch-label {
|
||||||
|
color: $blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&.is-enabled,
|
||||||
box-shadow:
|
&.is-allowed {
|
||||||
inset 0 0 0 1px rgba($gray50, 0.12),
|
background: rgba($color-primary-rgb, 0.1);
|
||||||
0 4px 10px rgba(15, 23, 42, 0.06);
|
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.12);
|
||||||
|
|
||||||
|
.device-permission__switch-thumb {
|
||||||
|
background: $blue;
|
||||||
|
box-shadow:
|
||||||
|
0 3px 7px rgba($color-primary-rgb, 0.24),
|
||||||
|
0 1px 1px rgba($color-shadow-rgb, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__switch-option.is-active .device-permission__switch-label {
|
||||||
|
color: $color-text-inverse;
|
||||||
|
svg{
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&.is-enabled {
|
||||||
|
.device-permission__switch-thumb {
|
||||||
|
transform: translateX(200%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-allowed {
|
||||||
|
.device-permission__switch-thumb {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled,
|
||||||
|
&.is-disallowed {
|
||||||
|
.device-permission__switch-option.is-active .device-permission__switch-label {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.is-disabled) {
|
||||||
|
//box-shadow:
|
||||||
|
//inset 0 0 0 1px rgba($gray50-rgb, 0.12),
|
||||||
|
//0 4px 10px rgba($color-shadow-rgb, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(.is-disabled) {
|
||||||
transform: scale(0.96);
|
transform: scale(0.96);
|
||||||
|
|
||||||
> span {
|
|
||||||
width: 23px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-enabled:active > span {
|
&:focus-within {
|
||||||
transform: translateX(17px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px rgba($blue, 0.18),
|
//0 0 0 3px rgba($color-primary-rgb, 0.18),
|
||||||
inset 0 0 0 1px rgba($blue, 0.18);
|
inset 0 0 0 1px rgba($color-primary-rgb, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,19 +518,91 @@
|
||||||
opacity 0.2s ease,
|
opacity 0.2s ease,
|
||||||
filter 0.2s ease;
|
filter 0.2s ease;
|
||||||
|
|
||||||
&.is-disabled {
|
&.is-policy-locked {
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
||||||
svg,
|
>svg,
|
||||||
span,
|
.device-permission__label>span:first-child,
|
||||||
button {
|
.device-permission__switch {
|
||||||
pointer-events: none;
|
opacity: 0.52;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-permission__switch {
|
.device-permission__switch {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
|
box-shadow: inset 0 0 0 1px rgba($gray50-rgb, 0.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-permissions:not(.is-policy-editing) {
|
||||||
|
.device-permission.is-policy-locked {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__error {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba($color-danger-rgb, 0.1);
|
||||||
|
|
||||||
|
color: $red;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
.device-permission__switch:not(.is-enabled):not(.is-allowed):not(.is-unspecified) {
|
||||||
|
svg {
|
||||||
|
color: $blue !important;
|
||||||
|
stroke-width: 3px;
|
||||||
|
|
||||||
|
path {
|
||||||
|
box-shadow: 0 0 1px 0px $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__switch-thumb {
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__switch {
|
||||||
|
|
||||||
|
&.is-disabled,
|
||||||
|
&.is-disallowed {
|
||||||
|
.device-permission__switch-option.is-active .device-permission__switch-label {
|
||||||
|
color: $neutral-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.is-disabled) {
|
||||||
|
//box-shadow:
|
||||||
|
//inset 0 0 0 1px rgba($gray50-rgb, 0.16),
|
||||||
|
//0 4px 10px rgba($color-shadow-rgb, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__icon-btn--reset {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__icon-btn:not(.device-permissions__icon-btn--edit):hover:not(:disabled) {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
color: $color-text-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__icon-btn--reset:hover:not(:disabled) {
|
||||||
|
background: $color-surface-active;
|
||||||
|
color: $color-text-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permissions__icon-btn--edit:hover:not(:disabled) {
|
||||||
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
box-shadow: 0 8px 20px rgba($color-primary-rgb, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useMutation } from '@apollo/client/react'
|
import { useMutation } from '@apollo/client/react'
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
CircleHelp,
|
||||||
|
Lock,
|
||||||
|
Pencil,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
Smartphone,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
import type { Device } from '../../types'
|
import type { Device } from '../../types'
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,186 +18,573 @@ import {
|
||||||
import type {
|
import type {
|
||||||
ChangePhonePolicyData,
|
ChangePhonePolicyData,
|
||||||
ChangePhonePolicyVariables,
|
ChangePhonePolicyVariables,
|
||||||
|
ComponentUseOption,
|
||||||
|
DevicePolicy,
|
||||||
|
GroupUsePolicy,
|
||||||
|
ServiceUseOption,
|
||||||
} from '../../../../entities/device/model/types'
|
} from '../../../../entities/device/model/types'
|
||||||
|
import {
|
||||||
|
normalizeComponentUseOption,
|
||||||
|
normalizeServiceUseOption,
|
||||||
|
} from '../../../../entities/device/lib/phonePolicy'
|
||||||
|
|
||||||
import { WifiIcon } from '../../../../assets/icons/Wifi'
|
|
||||||
import { BluetoothIcon } from '../../../../assets/icons/Bluetooth'
|
import { BluetoothIcon } from '../../../../assets/icons/Bluetooth'
|
||||||
import { GpsIcon } from '../../../../assets/icons/Gps'
|
|
||||||
import { CameraIcon } from '../../../../assets/icons/Camera'
|
import { CameraIcon } from '../../../../assets/icons/Camera'
|
||||||
|
import { GpsIcon } from '../../../../assets/icons/Gps'
|
||||||
import { SimIcon } from '../../../../assets/icons/Sim'
|
import { SimIcon } from '../../../../assets/icons/Sim'
|
||||||
import { VolumeIcon } from '../../../../assets/icons/Volume'
|
import {
|
||||||
|
Notification,
|
||||||
|
type NotificationVariant,
|
||||||
|
} from '../../../../widgets/Notification/Notification'
|
||||||
|
|
||||||
import './DevicePermissionsCard.scss'
|
import './DevicePermissionsCard.scss'
|
||||||
|
|
||||||
type DevicePermissionsCardProps = {
|
type DevicePermissionsCardProps = {
|
||||||
device: Device
|
device: Device
|
||||||
|
onEditingChange?: (isEditing: boolean) => void
|
||||||
|
onRefetchPolicy?: () => Promise<DevicePolicy | null | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
type DevicePolicyKey = 'wifi' | 'bluetooth' | 'gps' | 'camera' | 'sim' | 'speaker'
|
type DevicePolicyKey = 'bluetooth' | 'gps' | 'camera' | 'sim'
|
||||||
|
type ServicePolicyKey = Exclude<DevicePolicyKey, 'camera'>
|
||||||
|
type PolicyKind = 'service' | 'component'
|
||||||
|
type PolicyValue = ServiceUseOption | ComponentUseOption
|
||||||
|
type OrganisationPolicyEditableKey =
|
||||||
|
| 'bluetoothEditable'
|
||||||
|
| 'gpsEditable'
|
||||||
|
| 'cameraEditable'
|
||||||
|
| 'simEditable'
|
||||||
|
|
||||||
type DevicePolicyState = Record<DevicePolicyKey, boolean>
|
type DevicePolicyState = {
|
||||||
|
bluetooth: ServiceUseOption
|
||||||
|
gps: ServiceUseOption
|
||||||
|
camera: ComponentUseOption
|
||||||
|
sim: ServiceUseOption
|
||||||
|
locked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type PermissionItemConfig = {
|
type PermissionItemConfig = {
|
||||||
key: DevicePolicyKey
|
key: DevicePolicyKey
|
||||||
|
editableKey: OrganisationPolicyEditableKey
|
||||||
|
kind: PolicyKind
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
label: string
|
label: string
|
||||||
disabled?: boolean
|
}
|
||||||
|
|
||||||
|
type PolicyNotification = {
|
||||||
|
id: number
|
||||||
|
variant: NotificationVariant
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
const SERVICE_POLICY_OPTION_ORDER: ServiceUseOption[] = [
|
||||||
|
'Disabled',
|
||||||
|
'Unspecified',
|
||||||
|
'Enabled',
|
||||||
|
]
|
||||||
|
const COMPONENT_POLICY_OPTION_ORDER: ComponentUseOption[] = [
|
||||||
|
'Disallowed',
|
||||||
|
'Allowed',
|
||||||
|
]
|
||||||
|
|
||||||
|
const policyOptionFullLabels: Record<PolicyValue, string> = {
|
||||||
|
Disabled: 'Выключено',
|
||||||
|
Unspecified: 'Свободно',
|
||||||
|
Enabled: 'Включено',
|
||||||
|
Allowed: 'Включено',
|
||||||
|
Disallowed: 'Выключено',
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyOptionDescriptions: Record<PolicyValue, string> = {
|
||||||
|
Disabled: 'Опция отключена политикой устройства.',
|
||||||
|
Unspecified: 'Политика устройства не фиксирует значение. Можно менять на смартфоне.',
|
||||||
|
Enabled: 'Опция включена политикой устройства.',
|
||||||
|
Allowed: 'Компонент разрешен политикой устройства.',
|
||||||
|
Disallowed: 'Компонент запрещен политикой устройства.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissionItems: PermissionItemConfig[] = [
|
const permissionItems: PermissionItemConfig[] = [
|
||||||
{
|
|
||||||
key: 'wifi',
|
|
||||||
icon: <WifiIcon />,
|
|
||||||
label: 'Wi-Fi',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'bluetooth',
|
key: 'bluetooth',
|
||||||
|
editableKey: 'bluetoothEditable',
|
||||||
|
kind: 'service',
|
||||||
icon: <BluetoothIcon />,
|
icon: <BluetoothIcon />,
|
||||||
label: 'Bluetooth',
|
label: 'Bluetooth',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'gps',
|
key: 'gps',
|
||||||
|
editableKey: 'gpsEditable',
|
||||||
|
kind: 'service',
|
||||||
icon: <GpsIcon />,
|
icon: <GpsIcon />,
|
||||||
label: 'GPS',
|
label: 'GPS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'camera',
|
key: 'camera',
|
||||||
|
editableKey: 'cameraEditable',
|
||||||
|
kind: 'component',
|
||||||
icon: <CameraIcon />,
|
icon: <CameraIcon />,
|
||||||
label: 'Камера',
|
label: 'Камера',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sim',
|
key: 'sim',
|
||||||
|
editableKey: 'simEditable',
|
||||||
|
kind: 'service',
|
||||||
icon: <SimIcon />,
|
icon: <SimIcon />,
|
||||||
label: 'SIM-карта',
|
label: 'SIM-карта',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'speaker',
|
|
||||||
icon: <VolumeIcon />,
|
|
||||||
label: 'Динамик',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function getServicePolicyKey(key: DevicePolicyKey): ServicePolicyKey | null {
|
||||||
|
return key === 'camera' ? null : key
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPolicyOptions(kind: PolicyKind): PolicyValue[] {
|
||||||
|
return kind === 'service'
|
||||||
|
? SERVICE_POLICY_OPTION_ORDER
|
||||||
|
: COMPONENT_POLICY_OPTION_ORDER
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPolicyOptionIcon(option: PolicyValue, size = 14) {
|
||||||
|
if (option === 'Unspecified') {
|
||||||
|
return <Smartphone size={size} aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option === 'Enabled' || option === 'Allowed') {
|
||||||
|
return <Power size={size} aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PowerOff size={size} aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOrganisationPolicyLocked(
|
||||||
|
policy: GroupUsePolicy | null | undefined,
|
||||||
|
key: OrganisationPolicyEditableKey,
|
||||||
|
) {
|
||||||
|
return policy?.[key] === false
|
||||||
|
}
|
||||||
|
|
||||||
function mapDevicePermissionsToState(device: Device): DevicePolicyState {
|
function mapDevicePermissionsToState(device: Device): DevicePolicyState {
|
||||||
|
const policy = device.policy
|
||||||
|
const permissions = device.permissions
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wifi: device.permissions?.wifi ?? false,
|
bluetooth: normalizeServiceUseOption(
|
||||||
bluetooth: device.permissions?.bluetooth ?? false,
|
policy?.bluetooth ?? policy?.canUseBluetooth,
|
||||||
gps: device.permissions?.gps ?? false,
|
permissions?.bluetooth,
|
||||||
camera: device.permissions?.camera ?? false,
|
),
|
||||||
sim: device.permissions?.sim ?? false,
|
gps: normalizeServiceUseOption(
|
||||||
speaker: device.permissions?.speaker ?? false,
|
policy?.GPS ?? policy?.canUseGPS,
|
||||||
|
permissions?.gps,
|
||||||
|
),
|
||||||
|
camera: normalizeComponentUseOption(
|
||||||
|
policy?.camera ?? policy?.canUseCamera,
|
||||||
|
permissions?.camera,
|
||||||
|
),
|
||||||
|
sim: normalizeServiceUseOption(
|
||||||
|
policy?.sim ?? policy?.canUseSim,
|
||||||
|
permissions?.sim,
|
||||||
|
),
|
||||||
|
locked: policy?.locked ?? permissions?.locked ?? false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStateToMutationVariables(policy: DevicePolicyState) {
|
function mapApiPolicyToState(
|
||||||
|
policy: DevicePolicy | null | undefined,
|
||||||
|
fallback: DevicePolicyState,
|
||||||
|
): DevicePolicyState {
|
||||||
return {
|
return {
|
||||||
canUseBluetooth: policy.bluetooth,
|
bluetooth: normalizeServiceUseOption(
|
||||||
canUseCamera: policy.camera,
|
policy?.bluetooth ?? policy?.canUseBluetooth ?? fallback.bluetooth,
|
||||||
canUseGPS: policy.gps,
|
),
|
||||||
canUseSim: policy.sim,
|
gps: normalizeServiceUseOption(
|
||||||
|
policy?.GPS ?? policy?.canUseGPS ?? fallback.gps,
|
||||||
|
),
|
||||||
|
camera: normalizeComponentUseOption(
|
||||||
|
policy?.camera ?? policy?.canUseCamera ?? fallback.camera,
|
||||||
|
),
|
||||||
|
sim: normalizeServiceUseOption(
|
||||||
|
policy?.sim ?? policy?.canUseSim ?? fallback.sim,
|
||||||
|
),
|
||||||
|
locked: policy?.locked ?? fallback.locked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevicePermissionsCard({ device }: DevicePermissionsCardProps) {
|
function mapStateToMutationVariables(
|
||||||
|
policy: DevicePolicyState,
|
||||||
|
): Omit<ChangePhonePolicyVariables, 'id'> {
|
||||||
|
return {
|
||||||
|
bluetooth: policy.bluetooth,
|
||||||
|
camera: policy.camera,
|
||||||
|
GPS: policy.gps,
|
||||||
|
sim: policy.sim,
|
||||||
|
locked: policy.locked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPolicyChanged(
|
||||||
|
current: DevicePolicyState,
|
||||||
|
saved: DevicePolicyState,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
current.bluetooth !== saved.bluetooth ||
|
||||||
|
current.gps !== saved.gps ||
|
||||||
|
current.camera !== saved.camera ||
|
||||||
|
current.sim !== saved.sim
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function areDevicePermissionsCardPropsEqual(
|
||||||
|
prev: DevicePermissionsCardProps,
|
||||||
|
next: DevicePermissionsCardProps,
|
||||||
|
) {
|
||||||
|
const prevPolicy = prev.device.policy
|
||||||
|
const nextPolicy = next.device.policy
|
||||||
|
const prevPermissions = prev.device.permissions
|
||||||
|
const nextPermissions = next.device.permissions
|
||||||
|
const prevOrganisationPolicy = prev.device.organisationPolicy
|
||||||
|
const nextOrganisationPolicy = next.device.organisationPolicy
|
||||||
|
|
||||||
|
return (
|
||||||
|
prev.onEditingChange === next.onEditingChange &&
|
||||||
|
prev.onRefetchPolicy === next.onRefetchPolicy &&
|
||||||
|
prev.device.id === next.device.id &&
|
||||||
|
prevPolicy?.bluetooth === nextPolicy?.bluetooth &&
|
||||||
|
prevPolicy?.camera === nextPolicy?.camera &&
|
||||||
|
prevPolicy?.GPS === nextPolicy?.GPS &&
|
||||||
|
prevPolicy?.sim === nextPolicy?.sim &&
|
||||||
|
prevPolicy?.locked === nextPolicy?.locked &&
|
||||||
|
prevPermissions?.bluetooth === nextPermissions?.bluetooth &&
|
||||||
|
prevPermissions?.camera === nextPermissions?.camera &&
|
||||||
|
prevPermissions?.gps === nextPermissions?.gps &&
|
||||||
|
prevPermissions?.sim === nextPermissions?.sim &&
|
||||||
|
prevPermissions?.locked === nextPermissions?.locked &&
|
||||||
|
prevOrganisationPolicy?.bluetoothEditable ===
|
||||||
|
nextOrganisationPolicy?.bluetoothEditable &&
|
||||||
|
prevOrganisationPolicy?.cameraEditable ===
|
||||||
|
nextOrganisationPolicy?.cameraEditable &&
|
||||||
|
prevOrganisationPolicy?.gpsEditable ===
|
||||||
|
nextOrganisationPolicy?.gpsEditable &&
|
||||||
|
prevOrganisationPolicy?.simEditable === nextOrganisationPolicy?.simEditable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DevicePermissionsCard = memo(function DevicePermissionsCard({
|
||||||
|
device,
|
||||||
|
onEditingChange,
|
||||||
|
onRefetchPolicy,
|
||||||
|
}: DevicePermissionsCardProps) {
|
||||||
const initialPolicy = useMemo(() => {
|
const initialPolicy = useMemo(() => {
|
||||||
return mapDevicePermissionsToState(device)
|
return mapDevicePermissionsToState(device)
|
||||||
}, [device])
|
}, [device])
|
||||||
|
|
||||||
const [policyState, setPolicyState] = useState<DevicePolicyState>(initialPolicy)
|
const [policyState, setPolicyState] = useState<DevicePolicyState>(initialPolicy)
|
||||||
const [savingKey, setSavingKey] = useState<DevicePolicyKey | null>(null)
|
const [savedPolicyState, setSavedPolicyState] =
|
||||||
|
useState<DevicePolicyState>(initialPolicy)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [saveError, setSaveError] = useState(false)
|
const [saveError, setSaveError] = useState(false)
|
||||||
|
const [notification, setNotification] =
|
||||||
|
useState<PolicyNotification>(null)
|
||||||
|
|
||||||
const [changePhonePolicy] = useMutation<
|
const [changePhonePolicy] = useMutation<
|
||||||
ChangePhonePolicyData,
|
ChangePhonePolicyData,
|
||||||
ChangePhonePolicyVariables
|
ChangePhonePolicyVariables
|
||||||
>(CHANGE_PHONE_POLICY_MUTATION)
|
>(CHANGE_PHONE_POLICY_MUTATION)
|
||||||
|
|
||||||
|
const hasChanges = isPolicyChanged(policyState, savedPolicyState)
|
||||||
|
const hasHeaderActionsOpen = isEditing || hasChanges
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEditing || isSaving) return
|
||||||
|
|
||||||
setPolicyState(initialPolicy)
|
setPolicyState(initialPolicy)
|
||||||
}, [initialPolicy])
|
setSavedPolicyState(initialPolicy)
|
||||||
|
setSaveError(false)
|
||||||
|
setIsEditing(false)
|
||||||
|
}, [initialPolicy, isEditing, isSaving])
|
||||||
|
|
||||||
async function handleToggle(key: DevicePolicyKey, disabled?: boolean) {
|
useEffect(() => {
|
||||||
if (disabled || savingKey) return
|
onEditingChange?.(isEditing)
|
||||||
|
|
||||||
const previousPolicy = policyState
|
return () => {
|
||||||
const nextPolicy = {
|
if (isEditing) {
|
||||||
...policyState,
|
onEditingChange?.(false)
|
||||||
[key]: !policyState[key],
|
}
|
||||||
|
}
|
||||||
|
}, [isEditing, onEditingChange])
|
||||||
|
|
||||||
|
function showNotification(
|
||||||
|
variant: NotificationVariant,
|
||||||
|
title: string,
|
||||||
|
description?: string,
|
||||||
|
) {
|
||||||
|
setNotification({
|
||||||
|
id: Date.now(),
|
||||||
|
variant,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePolicyOptionChange(
|
||||||
|
item: PermissionItemConfig,
|
||||||
|
option: PolicyValue,
|
||||||
|
disabled?: boolean,
|
||||||
|
) {
|
||||||
|
if (disabled || !isEditing || isSaving) return
|
||||||
|
|
||||||
|
setPolicyState((currentPolicy) => {
|
||||||
|
if (item.kind === 'component') {
|
||||||
|
if (!COMPONENT_POLICY_OPTION_ORDER.includes(option as ComponentUseOption)) {
|
||||||
|
return currentPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentPolicy,
|
||||||
|
camera: option as ComponentUseOption,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getServicePolicyKey(item.key)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!key ||
|
||||||
|
!SERVICE_POLICY_OPTION_ORDER.includes(option as ServiceUseOption)
|
||||||
|
) {
|
||||||
|
return currentPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentPolicy,
|
||||||
|
[key]: option as ServiceUseOption,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setSaveError(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
if ((!isEditing && !hasChanges) || isSaving) return
|
||||||
|
|
||||||
|
setPolicyState(savedPolicyState)
|
||||||
|
setSaveError(false)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditClick() {
|
||||||
|
setIsEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmClick() {
|
||||||
|
if (isSaving) return
|
||||||
|
|
||||||
|
if (!hasChanges) {
|
||||||
|
setIsEditing(false)
|
||||||
|
setSaveError(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setPolicyState(nextPolicy)
|
await handleSave()
|
||||||
setSavingKey(key)
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!hasChanges || isSaving) return
|
||||||
|
|
||||||
|
const requestedPolicy = policyState
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
setSaveError(false)
|
setSaveError(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await changePhonePolicy({
|
const result = await changePhonePolicy({
|
||||||
variables: {
|
variables: {
|
||||||
id: String(device.id),
|
id: String(device.id),
|
||||||
...mapStateToMutationVariables(nextPolicy),
|
...mapStateToMutationVariables(requestedPolicy),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const changedApiPolicy = result.data?.changePhonePolicy
|
||||||
|
|
||||||
|
if (!changedApiPolicy) {
|
||||||
|
throw new Error('Phone policy was not saved')
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedApiPolicy = await onRefetchPolicy?.()
|
||||||
|
const refreshedPolicy = mapApiPolicyToState(
|
||||||
|
refreshedApiPolicy ?? changedApiPolicy,
|
||||||
|
requestedPolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
setPolicyState(refreshedPolicy)
|
||||||
|
setSavedPolicyState(refreshedPolicy)
|
||||||
|
|
||||||
|
if (isPolicyChanged(requestedPolicy, refreshedPolicy)) {
|
||||||
|
setSaveError(true)
|
||||||
|
showNotification(
|
||||||
|
'error',
|
||||||
|
'Политика не изменилась',
|
||||||
|
'Сервер ответил без ошибки, но после повторной загрузки вернулась прежняя политика.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditing(false)
|
||||||
|
showNotification('success', 'Политика устройства сохранена')
|
||||||
} catch {
|
} catch {
|
||||||
setPolicyState(previousPolicy)
|
|
||||||
setSaveError(true)
|
setSaveError(true)
|
||||||
|
showNotification(
|
||||||
|
'error',
|
||||||
|
'Не удалось сохранить политику устройства',
|
||||||
|
'Изменения остались в черновике. Попробуйте сохранить еще раз.',
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setSavingKey(null)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="device-card device-permissions">
|
<div
|
||||||
{permissionItems.map((item) => {
|
className={`device-card device-permissions ${
|
||||||
const enabled = policyState[item.key]
|
hasHeaderActionsOpen ? 'is-editing' : ''
|
||||||
const isSaving = savingKey === item.key
|
} ${isEditing ? 'is-policy-editing' : ''}`}
|
||||||
const isDisabled = item.disabled || Boolean(savingKey)
|
>
|
||||||
|
<div className="device-permissions__header">
|
||||||
|
<div className="device-permissions__title">
|
||||||
|
<h3>Опции</h3>
|
||||||
|
|
||||||
return (
|
<span
|
||||||
<PermissionItem
|
className="device-permissions__help"
|
||||||
key={item.key}
|
tabIndex={0}
|
||||||
icon={item.icon}
|
aria-label="Пояснение значений опций"
|
||||||
label={item.label}
|
>
|
||||||
enabled={enabled}
|
<CircleHelp size={16} />
|
||||||
disabled={isDisabled}
|
|
||||||
isUnavailable={item.disabled}
|
<span className="device-permissions__help-tooltip" role="tooltip">
|
||||||
isSaving={isSaving}
|
<span className="device-permissions__help-row">
|
||||||
onToggle={() => handleToggle(item.key, item.disabled)}
|
{getPolicyOptionIcon('Disabled', 15)}
|
||||||
/>
|
<span>
|
||||||
)
|
<b>Выключено</b>
|
||||||
})}
|
<small>Опция отключена политикой устройства.</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="device-permissions__help-row">
|
||||||
|
{getPolicyOptionIcon('Unspecified', 15)}
|
||||||
|
<span>
|
||||||
|
<b>Свободно</b>
|
||||||
|
<small>Можно включить или выключить на смартфоне.</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="device-permissions__help-row">
|
||||||
|
{getPolicyOptionIcon('Enabled', 15)}
|
||||||
|
<span>
|
||||||
|
<b>Включено</b>
|
||||||
|
<small>Опция включена политикой устройства.</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-permissions__header-actions">
|
||||||
|
<button
|
||||||
|
className="device-permissions__icon-btn device-permissions__icon-btn--reset"
|
||||||
|
type="button"
|
||||||
|
disabled={(!isEditing && !hasChanges) || isSaving}
|
||||||
|
onClick={handleReset}
|
||||||
|
aria-label="Отменить редактирование"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="device-permissions__icon-btn device-permissions__icon-btn--edit"
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={
|
||||||
|
hasHeaderActionsOpen ? handleConfirmClick : handleEditClick
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
hasHeaderActionsOpen
|
||||||
|
? 'Сохранить изменения'
|
||||||
|
: 'Редактировать опции'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasHeaderActionsOpen ? <Check size={18} /> : <Pencil size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-permissions__list">
|
||||||
|
{permissionItems.map((item) => {
|
||||||
|
const value = policyState[item.key]
|
||||||
|
const isPolicyLocked = isOrganisationPolicyLocked(
|
||||||
|
device.organisationPolicy,
|
||||||
|
item.editableKey,
|
||||||
|
)
|
||||||
|
const isDisabled = isPolicyLocked || !isEditing || isSaving
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionItem
|
||||||
|
key={item.key}
|
||||||
|
icon={item.icon}
|
||||||
|
label={item.label}
|
||||||
|
kind={item.kind}
|
||||||
|
value={value}
|
||||||
|
options={getPolicyOptions(item.kind)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
isPolicyLocked={isPolicyLocked}
|
||||||
|
onChange={(option) => {
|
||||||
|
handlePolicyOptionChange(item, option, isPolicyLocked)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div className="device-permissions__error">
|
<div className="device-permissions__error">
|
||||||
Не удалось сохранить политику устройства
|
Не удалось сохранить политику устройства
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{notification && (
|
||||||
|
<Notification
|
||||||
|
key={notification.id}
|
||||||
|
variant={notification.variant}
|
||||||
|
title={notification.title}
|
||||||
|
description={notification.description}
|
||||||
|
onClose={() => setNotification(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}, areDevicePermissionsCardPropsEqual)
|
||||||
|
|
||||||
type PermissionItemProps = {
|
type PermissionItemProps = {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
label: string
|
label: string
|
||||||
enabled: boolean
|
kind: PolicyKind
|
||||||
|
value: PolicyValue
|
||||||
|
options: PolicyValue[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
isUnavailable?: boolean
|
isPolicyLocked?: boolean
|
||||||
isSaving?: boolean
|
onChange: (value: PolicyValue) => void
|
||||||
onToggle: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PermissionItem({
|
function PermissionItem({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
enabled,
|
kind,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isUnavailable = false,
|
isPolicyLocked = false,
|
||||||
isSaving = false,
|
onChange,
|
||||||
onToggle,
|
|
||||||
}: PermissionItemProps) {
|
}: PermissionItemProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`device-permission ${isUnavailable ? 'is-disabled' : ''} ${
|
className={`device-permission ${
|
||||||
isSaving ? 'is-saving' : ''
|
isPolicyLocked ? 'is-policy-locked' : ''
|
||||||
}`}
|
}`}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|
@ -196,16 +593,66 @@ function PermissionItem({
|
||||||
<div className="device-permission__label">
|
<div className="device-permission__label">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
|
||||||
<button
|
<span className="device-permission__controls">
|
||||||
type="button"
|
{isPolicyLocked && (
|
||||||
className={`device-permission__switch ${enabled ? 'is-enabled' : ''}`}
|
<span
|
||||||
onClick={onToggle}
|
className="device-permission__policy-lock"
|
||||||
disabled={disabled}
|
tabIndex={0}
|
||||||
aria-pressed={enabled}
|
aria-label="Опция зафиксирована политикой организации"
|
||||||
aria-label={`${enabled ? 'Выключить' : 'Включить'} ${label}`}
|
>
|
||||||
>
|
<Lock size={14} aria-hidden="true" />
|
||||||
<span />
|
|
||||||
</button>
|
<span
|
||||||
|
className="device-permission__policy-lock-tooltip"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
Опция зафиксирована политикой организации. Изменить ее на странице устройства нельзя.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`device-permission__switch device-permission__switch--${kind} is-${value.toLowerCase()} ${
|
||||||
|
disabled ? 'is-disabled' : ''
|
||||||
|
}`}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={`${label}: ${policyOptionFullLabels[value]}`}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="device-permission__switch-thumb"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{options.map((option) => {
|
||||||
|
const isActive = value === option
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`device-permission__switch-option ${
|
||||||
|
isActive ? 'is-active' : ''
|
||||||
|
}`}
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
aria-label={`${policyOptionFullLabels[option]}: ${policyOptionDescriptions[option]}`}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled && !isActive) {
|
||||||
|
onChange(option)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="device-permission__switch-label">
|
||||||
|
{getPolicyOptionIcon(option)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Battery, RotateCcw, ShieldCheck, Smartphone, ThermometerSun } from 'lucide-react'
|
import { memo } from 'react'
|
||||||
|
import { Battery, RotateCcw, ShieldCheck, Smartphone } from 'lucide-react'
|
||||||
import type { Device } from '../../types'
|
import type { Device } from '../../types'
|
||||||
|
|
||||||
type DeviceStatsCardsProps = {
|
type DeviceStatsCardsProps = {
|
||||||
|
|
@ -23,7 +24,22 @@ function formatWorkTimeHours(worktime?: number | null) {
|
||||||
return `${Math.round(hours)} ч`
|
return `${Math.round(hours)} ч`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
function areStatsCardsPropsEqual(
|
||||||
|
prev: DeviceStatsCardsProps,
|
||||||
|
next: DeviceStatsCardsProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
prev.device.battery === next.device.battery &&
|
||||||
|
prev.device.batteryMaxCapacity === next.device.batteryMaxCapacity &&
|
||||||
|
prev.device.chargeCycles === next.device.chargeCycles &&
|
||||||
|
prev.device.totalWorkTime === next.device.totalWorkTime &&
|
||||||
|
prev.device.mediumImpacts === next.device.mediumImpacts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeviceStatsCards = memo(function DeviceStatsCards({
|
||||||
|
device,
|
||||||
|
}: DeviceStatsCardsProps) {
|
||||||
|
|
||||||
const batteryValue =
|
const batteryValue =
|
||||||
typeof device.battery === 'number'
|
typeof device.battery === 'number'
|
||||||
|
|
@ -31,18 +47,18 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const batterySize = 110
|
const batterySize = 110
|
||||||
const batteryStroke = 10
|
const batteryStroke = 12
|
||||||
const batteryRadius = (batterySize - batteryStroke) / 2
|
const batteryRadius = (batterySize - batteryStroke) / 2
|
||||||
const batteryLength = 2 * Math.PI * batteryRadius
|
const batteryLength = 2 * Math.PI * batteryRadius
|
||||||
const batteryProgress = batteryValue !== null ? batteryValue / 100 : 0
|
const batteryProgress = batteryValue !== null ? batteryValue / 100 : 0
|
||||||
const batteryDashOffset = batteryLength * (1 - batteryProgress)
|
const batteryDashOffset = batteryLength * (1 - batteryProgress)
|
||||||
|
|
||||||
function getBatteryColor(value: number | null) {
|
function getBatteryColor(value: number | null) {
|
||||||
if (value === null) return '#cfd5df'
|
if (value === null) return 'var(--device-battery-progress-empty)'
|
||||||
if (value <= 20) return '#e00000'
|
if (value <= 20) return 'var(--device-battery-progress-danger)'
|
||||||
if (value <= 50) return '#ff8a34'
|
if (value <= 50) return 'var(--device-battery-progress-warning)'
|
||||||
|
|
||||||
return '#031d9a'
|
return 'var(--device-battery-progress-strong)'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -124,4 +140,4 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}, areStatsCardsPropsEqual)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.timeline-zoom {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
|
||||||
|
padding: 10px 20px 20px 20px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__track {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
height: 28px;
|
||||||
|
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $color-surface-active;
|
||||||
|
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__selection {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
min-width: 24px;
|
||||||
|
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $color-primary-focus;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__window {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
background: $transparent;
|
||||||
|
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__window:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__label--left {
|
||||||
|
transform: translate(50%, -50%);
|
||||||
|
&.out-place{
|
||||||
|
transform: translate(-90%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__label--right {
|
||||||
|
transform: translate(-150%, -50%);
|
||||||
|
|
||||||
|
&.out-place{
|
||||||
|
transform: translate(-5%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
|
||||||
|
width: 6px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
background: $blue;
|
||||||
|
|
||||||
|
cursor: ew-resize;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__handle--left {
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__handle--right {
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 550;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: transform .3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__label--left {
|
||||||
|
margin-left: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-zoom__label--right {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import './TimelineZoom.scss'
|
||||||
|
|
||||||
|
type TimelineZoomProps = {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
|
||||||
|
onChange: (range: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}) => void
|
||||||
|
|
||||||
|
formatLabel?: (value: number) => string
|
||||||
|
|
||||||
|
minWindow?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragType =
|
||||||
|
| 'left'
|
||||||
|
| 'right'
|
||||||
|
| 'move'
|
||||||
|
| null
|
||||||
|
|
||||||
|
const CHANGE_THROTTLE_MS = 90
|
||||||
|
|
||||||
|
export function TimelineZoom({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
onChange,
|
||||||
|
formatLabel = (value) =>
|
||||||
|
new Date(value).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
minWindow = 5 * 60 * 1000,
|
||||||
|
}: TimelineZoomProps) {
|
||||||
|
|
||||||
|
const STEP = 5 * 60 * 1000
|
||||||
|
|
||||||
|
function snap(value: number) {
|
||||||
|
return Math.round(value / STEP) * STEP
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const dragTypeRef = useRef<DragType>(null)
|
||||||
|
|
||||||
|
const startXRef = useRef(0)
|
||||||
|
|
||||||
|
const initialRangeRef = useRef({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [draftRange, setDraftRange] = useState({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
const draftRangeRef = useRef(draftRange)
|
||||||
|
|
||||||
|
const pendingRangeRef = useRef<{
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const changeTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const lastChangeAtRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dragTypeRef.current) return
|
||||||
|
|
||||||
|
const nextRange = {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
draftRangeRef.current = nextRange
|
||||||
|
setDraftRange(nextRange)
|
||||||
|
}, [end, start])
|
||||||
|
|
||||||
|
function setNextDraftRange(nextRange: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}) {
|
||||||
|
if (
|
||||||
|
draftRangeRef.current.start === nextRange.start &&
|
||||||
|
draftRangeRef.current.end === nextRange.end
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
draftRangeRef.current = nextRange
|
||||||
|
setDraftRange(nextRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitRange(nextRange: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}) {
|
||||||
|
pendingRangeRef.current = nextRange
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const remaining = CHANGE_THROTTLE_MS - (now - lastChangeAtRef.current)
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
if (changeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(changeTimerRef.current)
|
||||||
|
changeTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRangeRef.current = null
|
||||||
|
lastChangeAtRef.current = now
|
||||||
|
onChange(nextRange)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeTimerRef.current !== null) return
|
||||||
|
|
||||||
|
changeTimerRef.current = window.setTimeout(() => {
|
||||||
|
changeTimerRef.current = null
|
||||||
|
|
||||||
|
const range = pendingRangeRef.current
|
||||||
|
if (!range) return
|
||||||
|
|
||||||
|
pendingRangeRef.current = null
|
||||||
|
lastChangeAtRef.current = Date.now()
|
||||||
|
onChange(range)
|
||||||
|
}, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDraftRange(nextRange: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}) {
|
||||||
|
setNextDraftRange(nextRange)
|
||||||
|
emitRange(nextRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = max - min
|
||||||
|
|
||||||
|
const leftPercent = ((draftRange.start - min) / total) * 100
|
||||||
|
const widthPercent = ((draftRange.end - draftRange.start) / total) * 100
|
||||||
|
|
||||||
|
const leftLabel = useMemo(
|
||||||
|
() => formatLabel(draftRange.start),
|
||||||
|
[draftRange.start, formatLabel],
|
||||||
|
)
|
||||||
|
|
||||||
|
const rightLabel = useMemo(
|
||||||
|
() => formatLabel(draftRange.end),
|
||||||
|
[draftRange.end, formatLabel],
|
||||||
|
)
|
||||||
|
|
||||||
|
function beginDrag(
|
||||||
|
type: DragType,
|
||||||
|
clientX: number,
|
||||||
|
) {
|
||||||
|
dragTypeRef.current = type
|
||||||
|
|
||||||
|
startXRef.current = clientX
|
||||||
|
|
||||||
|
initialRangeRef.current = {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
setNextDraftRange({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
pendingRangeRef.current = null
|
||||||
|
lastChangeAtRef.current = 0
|
||||||
|
|
||||||
|
if (changeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(changeTimerRef.current)
|
||||||
|
changeTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor =
|
||||||
|
type === 'move'
|
||||||
|
? 'grabbing'
|
||||||
|
: 'ew-resize'
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMove(event: MouseEvent) {
|
||||||
|
if (!dragTypeRef.current) return
|
||||||
|
if (!trackRef.current) return
|
||||||
|
|
||||||
|
const rect =
|
||||||
|
trackRef.current.getBoundingClientRect()
|
||||||
|
|
||||||
|
const deltaPx =
|
||||||
|
event.clientX - startXRef.current
|
||||||
|
|
||||||
|
const deltaPercent =
|
||||||
|
deltaPx / rect.width
|
||||||
|
|
||||||
|
const deltaValue =
|
||||||
|
deltaPercent * total
|
||||||
|
|
||||||
|
const initial =
|
||||||
|
initialRangeRef.current
|
||||||
|
|
||||||
|
if (dragTypeRef.current === 'move') {
|
||||||
|
const windowSize =
|
||||||
|
initial.end - initial.start
|
||||||
|
|
||||||
|
let nextStart =
|
||||||
|
snap(initial.start + deltaValue)
|
||||||
|
|
||||||
|
let nextEnd =
|
||||||
|
snap(initial.end + deltaValue)
|
||||||
|
|
||||||
|
if (nextStart < min) {
|
||||||
|
nextStart = min
|
||||||
|
nextEnd = min + windowSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextEnd > max) {
|
||||||
|
nextEnd = max
|
||||||
|
nextStart = max - windowSize
|
||||||
|
}
|
||||||
|
|
||||||
|
changeDraftRange({
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragTypeRef.current === 'left') {
|
||||||
|
let nextStart =
|
||||||
|
snap(
|
||||||
|
initial.start + deltaValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
nextStart = Math.max(
|
||||||
|
min,
|
||||||
|
Math.min(
|
||||||
|
nextStart,
|
||||||
|
initial.end - minWindow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
changeDraftRange({
|
||||||
|
start: nextStart,
|
||||||
|
end: initial.end,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragTypeRef.current === 'right') {
|
||||||
|
let nextEnd = snap(
|
||||||
|
initial.end + deltaValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
nextEnd = Math.min(
|
||||||
|
max,
|
||||||
|
Math.max(
|
||||||
|
nextEnd,
|
||||||
|
initial.start + minWindow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
changeDraftRange({
|
||||||
|
start: initial.start,
|
||||||
|
end: nextEnd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUp() {
|
||||||
|
if (dragTypeRef.current) {
|
||||||
|
if (changeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(changeTimerRef.current)
|
||||||
|
changeTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRangeRef.current = null
|
||||||
|
lastChangeAtRef.current = Date.now()
|
||||||
|
onChange(draftRangeRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
dragTypeRef.current = null
|
||||||
|
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
'mousemove',
|
||||||
|
handleMove,
|
||||||
|
)
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
'mouseup',
|
||||||
|
handleUp,
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
'mousemove',
|
||||||
|
handleMove,
|
||||||
|
)
|
||||||
|
|
||||||
|
window.removeEventListener(
|
||||||
|
'mouseup',
|
||||||
|
handleUp,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (changeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(changeTimerRef.current)
|
||||||
|
changeTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
minWindow,
|
||||||
|
onChange,
|
||||||
|
total,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="timeline-zoom">
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="timeline-zoom__track"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`timeline-zoom__selection`}
|
||||||
|
style={{
|
||||||
|
left: `${leftPercent}%`,
|
||||||
|
width: `${widthPercent}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="timeline-zoom__handle timeline-zoom__handle--left"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
beginDrag(
|
||||||
|
'left',
|
||||||
|
event.clientX,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="timeline-zoom__window"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
beginDrag(
|
||||||
|
'move',
|
||||||
|
event.clientX,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="timeline-zoom__handle timeline-zoom__handle--right"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
beginDrag(
|
||||||
|
'right',
|
||||||
|
event.clientX,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`timeline-zoom__label timeline-zoom__label--left ${leftPercent > 5 ? 'out-place' : ''}`}
|
||||||
|
style={{
|
||||||
|
left: `${leftPercent}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{leftLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`timeline-zoom__label timeline-zoom__label--right ${(leftPercent + widthPercent) < 94 ? 'out-place' : ''}`}
|
||||||
|
style={{
|
||||||
|
left: `${leftPercent + widthPercent}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rightLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
|
import type {
|
||||||
|
DevicePolicy,
|
||||||
|
DeviceTelemetryItem,
|
||||||
|
GroupUsePolicy,
|
||||||
|
} from '../../entities/device/model/types'
|
||||||
|
|
||||||
export type DeviceCondition = 'ok' | 'inspection'
|
export type DeviceCondition = 'ok' | 'inspection'
|
||||||
export type DeviceConnection = 'online' | 'offline' | 'offlineDanger'
|
export type DeviceConnection = 'online' | 'offline' | 'offlineDanger'
|
||||||
|
|
||||||
|
|
@ -9,6 +15,9 @@ export type Device = {
|
||||||
imei2: string
|
imei2: string
|
||||||
serialNumber?: string
|
serialNumber?: string
|
||||||
organisation?: string
|
organisation?: string
|
||||||
|
organisationId?: number
|
||||||
|
policy?: DevicePolicy | null
|
||||||
|
organisationPolicy?: GroupUsePolicy | null
|
||||||
workTime: string | null
|
workTime: string | null
|
||||||
employee: string | null
|
employee: string | null
|
||||||
condition: DeviceCondition
|
condition: DeviceCondition
|
||||||
|
|
@ -19,6 +28,7 @@ export type Device = {
|
||||||
lastLocationDate?: number
|
lastLocationDate?: number
|
||||||
battery?: number
|
battery?: number
|
||||||
batteryMaxCapacity?: number
|
batteryMaxCapacity?: number
|
||||||
|
telemetry?: DeviceTelemetryItem[]
|
||||||
chargeCycles?: string
|
chargeCycles?: string
|
||||||
totalWorkTime?: number | null
|
totalWorkTime?: number | null
|
||||||
mediumImpacts?: string
|
mediumImpacts?: string
|
||||||
|
|
@ -40,6 +50,7 @@ export type Device = {
|
||||||
camera: boolean
|
camera: boolean
|
||||||
sim: boolean
|
sim: boolean
|
||||||
speaker: boolean
|
speaker: boolean
|
||||||
|
locked?: boolean
|
||||||
}
|
}
|
||||||
statusIcons: {
|
statusIcons: {
|
||||||
gps: boolean
|
gps: boolean
|
||||||
|
|
@ -61,7 +72,7 @@ export const conditionText: Record<DeviceCondition, string> = {
|
||||||
export const connectionText: Record<DeviceConnection, string> = {
|
export const connectionText: Record<DeviceConnection, string> = {
|
||||||
online: 'В сети',
|
online: 'В сети',
|
||||||
offline: 'Не в сети',
|
offline: 'Не в сети',
|
||||||
offlineDanger: 'Долго не в сети',
|
offlineDanger: 'Потерян',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatusClass(status: DeviceCondition | DeviceConnection) {
|
export function getStatusClass(status: DeviceCondition | DeviceConnection) {
|
||||||
|
|
|
||||||
|
|
@ -5,51 +5,252 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 20px 20px 36px;
|
||||||
//overflow: hidden;
|
//overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-table-container {
|
.devices-table-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
//overflow: hidden;
|
||||||
//gap: 20px;
|
//gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.devices-table-container .employees-table-shadow {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.devices-table-filter-container {
|
.devices-table-filter-container {
|
||||||
|
--devices-filters-width: 292px;
|
||||||
|
--devices-filters-gap: 20px;
|
||||||
|
--devices-filters-duration: 360ms;
|
||||||
|
--devices-filters-easing: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
//overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-slot {
|
||||||
|
flex: 0 0 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: flex-basis;
|
||||||
|
transition:
|
||||||
|
flex-basis var(--devices-filters-duration) var(--devices-filters-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-slot--open {
|
||||||
|
flex-basis: calc(var(--devices-filters-width) + var(--devices-filters-gap));
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-slot--visible {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-drawer__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 820;
|
||||||
|
background: $color-backdrop-soft;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
|
||||||
|
&[data-state='open'] {
|
||||||
|
animation: devicesFiltersOverlayShow 0.18s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='closed'] {
|
||||||
|
animation: devicesFiltersOverlayHide 0.18s ease both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-drawer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 0 auto;
|
||||||
|
z-index: 821;
|
||||||
|
|
||||||
|
width: min(360px, calc(100vw - 28px));
|
||||||
|
padding: 18px;
|
||||||
|
margin: 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: $color-bg;
|
||||||
|
box-shadow: $shadow-modal-dark;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
|
||||||
|
&[data-state='open'] {
|
||||||
|
animation: devicesFiltersDrawerShow 0.24s cubic-bezier(0.34, 1.02, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='closed'] {
|
||||||
|
animation: devicesFiltersDrawerHide 0.2s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-drawer__scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.simplebar-content {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
width: 10px;
|
||||||
|
right: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simplebar-scrollbar::before {
|
||||||
|
background: $gray50;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-drawer__header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 34px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-drawer__header [data-radix-dialog-title],
|
||||||
|
.devices-filters-drawer__header h2 {
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-20;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-drawer__close {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $color-surface;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
color: $color-text-heading;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-table-card {
|
.devices-table-card {
|
||||||
flex: 1;
|
position: relative;
|
||||||
overflow: auto;
|
flex: 1 1 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
//overflow: hidden;
|
||||||
|
max-height: 100%;
|
||||||
|
height: 100%;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
scrollbar-width: thin;
|
min-height: 0px;
|
||||||
scrollbar-color: $gray50 transparent;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
.simplebar-wrapper,
|
||||||
width: 6px;
|
.simplebar-mask,
|
||||||
height: 6px;
|
.simplebar-offset,
|
||||||
|
.simplebar-content-wrapper,
|
||||||
|
.simplebar-content {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
.simplebar-content-wrapper{
|
||||||
background: transparent;
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.simplebar-track.simplebar-vertical {
|
||||||
|
top: 48px;
|
||||||
|
bottom: 14px;
|
||||||
|
width: 14px;
|
||||||
|
right: -7px;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
.simplebar-scrollbar::before {
|
||||||
background: $gray30;
|
background: color-mix(in srgb, var(--gray50) 85%, transparent);
|
||||||
border-radius: 999px;
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--gray30), -2px 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
.simplebar-track.simplebar-horizontal {
|
||||||
background: $gray50;
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-updating {
|
||||||
|
.devices-table {
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-state {
|
||||||
|
min-height: 260px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 32px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-state--error {
|
||||||
|
color: $red;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-table {
|
.devices-table {
|
||||||
|
|
@ -62,8 +263,8 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba($blue, 0.025);
|
background-color: $color-primary-tint-soft;
|
||||||
box-shadow: inset 3px 0 0 rgba($blue, 0.8);
|
box-shadow: inset 3px 0 0 rgba($color-primary-rgb, 0.8);
|
||||||
|
|
||||||
.device-info__number {
|
.device-info__number {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
@ -75,10 +276,10 @@
|
||||||
//height: 36px;
|
//height: 36px;
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
border-bottom: 1px solid #e3e8f0;
|
border-bottom: 1px solid $color-border;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 450;
|
font-weight: 450;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
@ -90,8 +291,8 @@
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
color: #151a24;
|
color: $color-text-strong;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +328,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-table__id {
|
.devices-table__id {
|
||||||
color: #1d2533;
|
color: $color-text-strong;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,16 +339,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-info__number {
|
.device-info__number {
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: .2s ease;
|
transition: .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-info__imei,
|
.device-info__imei,
|
||||||
.device-info__employee {
|
.device-info__employee {
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,8 +357,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
|
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,14 +367,14 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
padding: 0px 8px;
|
padding: 0px 8px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-weight: 550;
|
font-weight: 550;
|
||||||
|
|
||||||
&--green {
|
&--green {
|
||||||
background-color: hsla(128, 56%, 45%, 0.15);
|
background-color: $color-success-tint;
|
||||||
color: $green;
|
color: $green;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +384,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&--red {
|
&--red {
|
||||||
background-color: hsla(0, 100%, 43%, 0.15);
|
background-color: $color-danger-status-bg;
|
||||||
color: $red;
|
color: $red;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -209,25 +410,30 @@
|
||||||
|
|
||||||
.device-icons {
|
.device-icons {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 16px);
|
grid-template-columns: repeat(4, 20px);
|
||||||
gap: 4px 8px;
|
gap: 4px 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
background-color: $color-bg;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: auto;
|
height: auto;
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-active {
|
.is-active {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
background-color: $blue20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-danger {
|
.is-danger {
|
||||||
color: $red;
|
color: $red;
|
||||||
|
background-color: $color-danger-bg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,8 +447,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
color: #151a24;
|
color: $color-text-strong;
|
||||||
font-size: 18px;
|
font-size: $font-size-16;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: .2s ease;
|
transition: .2s ease;
|
||||||
|
|
@ -259,14 +465,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-pagination {
|
.devices-pagination {
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
padding: 12px 14px 0;
|
padding: 12px 14px 0;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-pagination__controls {
|
.devices-pagination__controls {
|
||||||
|
|
@ -281,15 +489,15 @@
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #e9edf5;
|
background: $color-surface-active;
|
||||||
|
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 17px;
|
font-size: $font-size-17;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: #031d9a;
|
background: $color-primary;
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|
@ -298,3 +506,122 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
.devices-table__row:hover {
|
||||||
|
background-color: rgba($color-primary-rgb, 0.03);
|
||||||
|
box-shadow: inset 3px 0 0 rgba($color-primary-rgb, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-icons svg:not(.is-active):not(.is-danger) {
|
||||||
|
background-color: $color-surface-active;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-map-btn:hover {
|
||||||
|
background-color: $color-surface-active;
|
||||||
|
color: $color-primary-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-pagination__controls button {
|
||||||
|
background: $color-surface-muted;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(.is-active) {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
color: $color-text-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include wide-down {
|
||||||
|
.devices-table-filter-container {
|
||||||
|
--devices-filters-width: 264px;
|
||||||
|
--devices-filters-gap: 14px;
|
||||||
|
}
|
||||||
|
.devices-filters-slot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-table {
|
||||||
|
min-width: 980px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include laptop-down {
|
||||||
|
.devices-table-filter-container {
|
||||||
|
--devices-filters-width: 264px;
|
||||||
|
--devices-filters-gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filters-slot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-table {
|
||||||
|
min-width: 980px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet-down {
|
||||||
|
.devices-table {
|
||||||
|
min-width: 980px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes devicesFiltersOverlayShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes devicesFiltersOverlayHide {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes devicesFiltersDrawerShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes devicesFiltersDrawerHide {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,17 +3,16 @@
|
||||||
.add-device-modal__overlay {
|
.add-device-modal__overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 80;
|
z-index: 110;
|
||||||
|
|
||||||
background: rgba(15, 23, 42, 0.42);
|
background: $color-backdrop;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
|
|
||||||
animation: addDeviceOverlayShow 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-device-modal {
|
.add-device-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 90;
|
z-index: 120;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
||||||
|
|
@ -21,11 +20,10 @@
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
|
box-shadow: $shadow-modal;
|
||||||
|
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
animation: addDeviceModalShow 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-device-modal__header {
|
.add-device-modal__header {
|
||||||
|
|
@ -40,8 +38,8 @@
|
||||||
.add-device-modal__title {
|
.add-device-modal__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 24px;
|
font-size: $font-size-24;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,7 +47,7 @@
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +83,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +92,7 @@
|
||||||
.add-device-qr__box {
|
.add-device-qr__box {
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px dashed rgba(3, 29, 154, 0.32);
|
border: 1px dashed rgba($color-primary-rgb, 0.32);
|
||||||
background: $color-bg;
|
background: $color-bg;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -106,8 +104,8 @@
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.add-device-qr__state, .add-device-qr__content{
|
.add-device-qr__state, .add-device-qr__content{
|
||||||
|
|
@ -118,6 +116,73 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-device-token-timer {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba($color-primary-rgb, 0.08);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $blue;
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
height: 30px;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $color-surface;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
color: $blue;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-device-token-timer.is-expired {
|
||||||
|
background: rgba($color-danger-rgb, 0.1);
|
||||||
|
color: $red;
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: $red;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $red;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.add-device-modal__footer {
|
.add-device-modal__footer {
|
||||||
margin-top: 22px;
|
margin-top: 22px;
|
||||||
|
|
||||||
|
|
@ -134,7 +199,7 @@
|
||||||
background: $color-bg;
|
background: $color-bg;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|
@ -142,28 +207,6 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $gray20;
|
background: $gray20;
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes addDeviceOverlayShow {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes addDeviceModalShow {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -48%) scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import * as Dialog from '@radix-ui/react-dialog'
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
import { QRCodeSVG } from 'qrcode.react'
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
import { QrCode, RefreshCw, X } from 'lucide-react'
|
import { Clock3, QrCode, RefreshCw, X } from 'lucide-react'
|
||||||
import { useMutation } from '@apollo/client/react'
|
import { useMutation } from '@apollo/client/react'
|
||||||
|
|
||||||
import { CREATE_PHONE_REGISTRATION_TOKEN_MUTATION } from '../../../../entities/device/api/device.graphql'
|
import { CREATE_PHONE_REGISTRATION_TOKEN_MUTATION } from '../../../../entities/device/api/device.graphql'
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
CreatePhoneRegistrationTokenData,
|
CreatePhoneRegistrationTokenData,
|
||||||
CreatePhoneRegistrationTokenVariables,
|
CreatePhoneRegistrationTokenVariables,
|
||||||
} from '../../../../entities/device/model/types'
|
} from '../../../../entities/device/model/types'
|
||||||
|
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
|
||||||
|
|
||||||
import './AddDeviceModal.scss'
|
import './AddDeviceModal.scss'
|
||||||
|
|
||||||
|
|
@ -17,24 +18,40 @@ type AddDeviceModalProps = {
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExpiresIn(expiresIn: number) {
|
const SECOND_MS = 1000
|
||||||
if (!expiresIn) return '—'
|
const DAY_SECONDS = 24 * 60 * 60
|
||||||
|
const UNIX_SECONDS_THRESHOLD = 1_000_000_000
|
||||||
|
const UNIX_MILLISECONDS_THRESHOLD = 1_000_000_000_000
|
||||||
|
|
||||||
const now = Date.now()
|
function getExpiresAt(expiresIn: number, receivedAt: number) {
|
||||||
const expiresAt = expiresIn > 10_000_000_000
|
if (!Number.isFinite(expiresIn) || expiresIn <= 0) return 0
|
||||||
? expiresIn
|
|
||||||
: now + expiresIn * 1000
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
if (expiresIn >= UNIX_MILLISECONDS_THRESHOLD) {
|
||||||
day: '2-digit',
|
return expiresIn
|
||||||
month: '2-digit',
|
}
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
if (expiresIn >= UNIX_SECONDS_THRESHOLD) {
|
||||||
minute: '2-digit',
|
return expiresIn * SECOND_MS
|
||||||
}).format(new Date(expiresAt))
|
}
|
||||||
|
|
||||||
|
if (expiresIn > DAY_SECONDS) {
|
||||||
|
return receivedAt + expiresIn
|
||||||
|
}
|
||||||
|
|
||||||
|
return receivedAt + expiresIn * SECOND_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenTimeLeft(milliseconds: number) {
|
||||||
|
const totalSeconds = Math.max(0, Math.ceil(milliseconds / SECOND_MS))
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
|
||||||
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
||||||
|
const [tokenReceivedAt, setTokenReceivedAt] = useState(0)
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
const [createToken, { data, loading, error }] = useMutation<
|
const [createToken, { data, loading, error }] = useMutation<
|
||||||
CreatePhoneRegistrationTokenData,
|
CreatePhoneRegistrationTokenData,
|
||||||
|
|
@ -44,6 +61,11 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const registrationData = data?.createPhoneRegistrationToken
|
const registrationData = data?.createPhoneRegistrationToken
|
||||||
|
const expiresAt = registrationData
|
||||||
|
? getExpiresAt(registrationData.expiresIn, tokenReceivedAt)
|
||||||
|
: 0
|
||||||
|
const timeLeft = expiresAt ? Math.max(0, expiresAt - now) : 0
|
||||||
|
const isTokenExpired = Boolean(registrationData && timeLeft <= 0)
|
||||||
|
|
||||||
const qrValue = useMemo(() => {
|
const qrValue = useMemo(() => {
|
||||||
if (!registrationData) return ''
|
if (!registrationData) return ''
|
||||||
|
|
@ -58,18 +80,51 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
createToken()
|
createToken()
|
||||||
|
.then(() => {
|
||||||
|
const receivedAt = Date.now()
|
||||||
|
|
||||||
|
setTokenReceivedAt(receivedAt)
|
||||||
|
setNow(receivedAt)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setTokenReceivedAt(0)
|
||||||
|
})
|
||||||
}, [open, createToken])
|
}, [open, createToken])
|
||||||
|
|
||||||
function handleRefreshToken() {
|
useEffect(() => {
|
||||||
createToken()
|
if (!open || !registrationData || isTokenExpired) return
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setNow(Date.now())
|
||||||
|
}, SECOND_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId)
|
||||||
|
}
|
||||||
|
}, [open, registrationData, isTokenExpired])
|
||||||
|
|
||||||
|
async function handleRefreshToken() {
|
||||||
|
setNow(Date.now())
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createToken()
|
||||||
|
const receivedAt = Date.now()
|
||||||
|
|
||||||
|
setTokenReceivedAt(receivedAt)
|
||||||
|
setNow(receivedAt)
|
||||||
|
} catch {
|
||||||
|
setTokenReceivedAt(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
<Dialog.Portal>
|
<MotionDialogContent
|
||||||
<Dialog.Overlay className="add-device-modal__overlay" />
|
open={open}
|
||||||
|
overlayClassName="add-device-modal__overlay"
|
||||||
<Dialog.Content className="add-device-modal">
|
contentClassName="add-device-modal"
|
||||||
|
motionPreset="fade"
|
||||||
|
>
|
||||||
<div className="add-device-modal__header">
|
<div className="add-device-modal__header">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title className="add-device-modal__title">
|
<Dialog.Title className="add-device-modal__title">
|
||||||
|
|
@ -109,13 +164,39 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
||||||
size={220}
|
size={220}
|
||||||
level="M"
|
level="M"
|
||||||
bgColor='transparent'
|
bgColor='transparent'
|
||||||
fgColor="#031D9A"
|
fgColor="currentColor"
|
||||||
includeMargin
|
includeMargin
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{registrationData && !loading && !error && (
|
||||||
|
<div
|
||||||
|
className={`add-device-token-timer ${
|
||||||
|
isTokenExpired ? 'is-expired' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock3 size={18} />
|
||||||
|
<span>
|
||||||
|
{isTokenExpired
|
||||||
|
? 'QR-код истек'
|
||||||
|
: `QR-код активен еще ${formatTokenTimeLeft(timeLeft)}`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isTokenExpired && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRefreshToken}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
После сканирования устройство появится в списке и будет доступно
|
После сканирования устройство появится в списке и будет доступно
|
||||||
для назначения сотруднику.
|
для назначения сотруднику.
|
||||||
|
|
@ -127,8 +208,7 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
||||||
Закрыть
|
Закрыть
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</MotionDialogContent>
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 90;
|
z-index: 90;
|
||||||
|
|
||||||
background: rgba(15, 23, 42, 0.42);
|
background: $color-backdrop-soft;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
|
|
||||||
animation: deviceMapModalOverlayShow 0.18s ease;
|
animation: deviceMapModalOverlayShow 0.18s ease;
|
||||||
|
|
@ -17,14 +17,15 @@
|
||||||
inset: 24px;
|
inset: 24px;
|
||||||
|
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
border: 1px solid $color-border-muted;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
|
||||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.24);
|
box-shadow: $shadow-modal-strong;
|
||||||
animation: deviceMapModalShow 0.2s ease;
|
animation: deviceMapModalShow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +41,7 @@
|
||||||
.device-map-modal__title {
|
.device-map-modal__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
color: $color-text;
|
color: $color-text-heading;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +49,7 @@
|
||||||
.device-map-modal__description {
|
.device-map-modal__description {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
|
|
||||||
color: $gray50;
|
color: $color-text-muted;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
@ -59,19 +60,19 @@
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: $color-bg;
|
background: $color-surface-muted;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
color: $gray50;
|
color: $color-text-muted;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 0.2s ease;
|
transition: 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $blue;
|
color: $color-primary;
|
||||||
background: $gray20;
|
background: $color-surface-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,6 +83,7 @@
|
||||||
.device-map {
|
.device-map {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-map__container {
|
.device-map__container {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { X } from 'lucide-react'
|
||||||
|
|
||||||
import type { Device } from '../../../DevicePage/types'
|
import type { Device } from '../../../DevicePage/types'
|
||||||
import { DeviceMapCard } from '../../../DevicePage/components/DeviceMapCard/DeviceMapCard'
|
import { DeviceMapCard } from '../../../DevicePage/components/DeviceMapCard/DeviceMapCard'
|
||||||
|
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
|
||||||
|
|
||||||
import './DeviceMapModal.scss'
|
import './DeviceMapModal.scss'
|
||||||
|
|
||||||
|
|
@ -19,33 +20,34 @@ export function DeviceMapModal({
|
||||||
}: DeviceMapModalProps) {
|
}: DeviceMapModalProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
<Dialog.Portal>
|
<MotionDialogContent
|
||||||
<Dialog.Overlay className="device-map-modal__overlay" />
|
open={open}
|
||||||
|
overlayClassName="device-map-modal__overlay"
|
||||||
|
contentClassName="device-map-modal"
|
||||||
|
placement="inset"
|
||||||
|
>
|
||||||
|
<div className="device-map-modal__header">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title className="device-map-modal__title">
|
||||||
|
Устройство на карте
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
<Dialog.Content className="device-map-modal">
|
<Dialog.Description className="device-map-modal__description">
|
||||||
<div className="device-map-modal__header">
|
{device?.factoryNumber || 'Выбранное устройство'}
|
||||||
<div>
|
</Dialog.Description>
|
||||||
<Dialog.Title className="device-map-modal__title">
|
|
||||||
Устройство на карте
|
|
||||||
</Dialog.Title>
|
|
||||||
|
|
||||||
<Dialog.Description className="device-map-modal__description">
|
|
||||||
{device?.factoryNumber || 'Выбранное устройство'}
|
|
||||||
</Dialog.Description>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog.Close className="device-map-modal__close" type="button">
|
|
||||||
<X size={22} />
|
|
||||||
</Dialog.Close>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{device && (
|
<Dialog.Close className="device-map-modal__close" type="button">
|
||||||
<div className="device-map-modal__body">
|
<X size={22} />
|
||||||
<DeviceMapCard device={device} variant="modal" />
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Dialog.Content>
|
{device && (
|
||||||
</Dialog.Portal>
|
<div className="device-map-modal__body">
|
||||||
|
<DeviceMapCard device={device} variant="modal" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MotionDialogContent>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -12,14 +12,14 @@
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
color: #4f5b73;
|
color: $color-text-soft;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-open {
|
&.is-open {
|
||||||
box-shadow: 0 0 0 2px rgba(3, 29, 154, 0.15);
|
box-shadow: 0 0 0 2px rgba($color-primary-rgb, 0.15);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,13 +53,13 @@
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
border: 1.5px solid $gray20;
|
border: 1.5px solid $gray20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-date-range__calendar {
|
.devices-date-range__calendar {
|
||||||
--rdp-accent-color: $blue;
|
--rdp-accent-color: #{$blue};
|
||||||
--rdp-accent-background-color: #e8edff;
|
--rdp-accent-background-color: #{$color-primary-soft};
|
||||||
--rdp-day_button-border-radius: 12px;
|
--rdp-day_button-border-radius: 12px;
|
||||||
--rdp-day_button-height: 34px;
|
--rdp-day_button-height: 34px;
|
||||||
--rdp-day_button-width: 34px;
|
--rdp-day_button-width: 34px;
|
||||||
|
|
@ -78,9 +78,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-caption_label {
|
.rdp-caption_label {
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #151a24;
|
color: $color-text-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-nav {
|
.rdp-nav {
|
||||||
|
|
@ -95,33 +95,33 @@
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-weekday {
|
.rdp-weekday {
|
||||||
color: #8c96aa;
|
color: $color-text-muted;
|
||||||
font-size: 12px;
|
font-size: $font-size-12;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 12px;
|
font-size: $font-size-12;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-day_button:hover {
|
.rdp-day_button:hover {
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-selected .rdp-day_button {
|
.rdp-selected .rdp-day_button {
|
||||||
background: $blue;
|
background: $blue;
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-range_middle .rdp-day_button {
|
.rdp-range_middle .rdp-day_button {
|
||||||
background: #e8edff;
|
background: $color-primary-soft;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +129,7 @@
|
||||||
.rdp-range_start .rdp-day_button,
|
.rdp-range_start .rdp-day_button,
|
||||||
.rdp-range_end .rdp-day_button {
|
.rdp-range_end .rdp-day_button {
|
||||||
background: $blue;
|
background: $blue;
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,8 +151,8 @@
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 11px;
|
font-size: $font-size-11;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,10 +163,10 @@
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
@ -185,15 +185,15 @@
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
|
|
||||||
color: #4f5b73;
|
color: $color-text-soft;
|
||||||
font-size: 12px;
|
font-size: $font-size-12;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #e8edff;
|
background: $color-primary-soft;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +202,7 @@
|
||||||
.devices-date-range__footer {
|
.devices-date-range__footer {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid #e3e8f0;
|
border-top: 1px solid $color-border;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -210,8 +210,8 @@
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 12px;
|
font-size: $font-size-12;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,17 +230,17 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|
||||||
font-size: 12px;
|
font-size: $font-size-12;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-date-range__reset {
|
.devices-date-range__reset {
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
color: #4f5b73;
|
color: $color-text-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-date-range__apply {
|
.devices-date-range__apply {
|
||||||
background: $blue;
|
background: $blue;
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
}
|
}
|
||||||
|
|
@ -4,9 +4,11 @@ import type { DateRange } from 'react-day-picker'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { CalendarDays, ChevronDown, X } from 'lucide-react'
|
import { CalendarDays, ChevronDown, X } from 'lucide-react'
|
||||||
|
import { AnimatePresence, m } from 'framer-motion'
|
||||||
|
|
||||||
import 'react-day-picker/style.css'
|
import 'react-day-picker/style.css'
|
||||||
import './DevicesDateRangePicker.scss'
|
import './DevicesDateRangePicker.scss'
|
||||||
|
import { popoverVariants, uiFadeTransition } from '../../../../shared/lib/motion'
|
||||||
|
|
||||||
export type DevicesDateRangePickerValue = {
|
export type DevicesDateRangePickerValue = {
|
||||||
from: Date | null
|
from: Date | null
|
||||||
|
|
@ -138,8 +140,16 @@ export function DevicesDateRangePicker({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
<AnimatePresence initial={false}>
|
||||||
<div className="devices-date-range__popover">
|
{isOpen && (
|
||||||
|
<m.div
|
||||||
|
className="devices-date-range__popover"
|
||||||
|
variants={popoverVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={uiFadeTransition}
|
||||||
|
>
|
||||||
<DayPicker
|
<DayPicker
|
||||||
mode="range"
|
mode="range"
|
||||||
selected={selectedRange}
|
selected={selectedRange}
|
||||||
|
|
@ -216,8 +226,9 @@ export function DevicesDateRangePicker({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</m.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
.react-datepicker {
|
.react-datepicker {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.15);
|
box-shadow: $shadow-popover-soft;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-datepicker__header {
|
.react-datepicker__header {
|
||||||
background: #f3f6fa;
|
background: $color-surface-muted;
|
||||||
border-bottom: 1px solid #e3e8f0;
|
border-bottom: 1px solid $color-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-datepicker__current-month,
|
.react-datepicker__current-month,
|
||||||
.react-datepicker-time__header,
|
.react-datepicker-time__header,
|
||||||
.react-datepicker-year-header {
|
.react-datepicker-year-header {
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,19 +27,19 @@
|
||||||
.react-datepicker__time-box
|
.react-datepicker__time-box
|
||||||
ul.react-datepicker__time-list
|
ul.react-datepicker__time-list
|
||||||
li.react-datepicker__time-list-item--selected {
|
li.react-datepicker__time-list-item--selected {
|
||||||
background: #031d9a;
|
background: $color-primary;
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-datepicker__day--in-range,
|
.react-datepicker__day--in-range,
|
||||||
.react-datepicker__day--in-selecting-range {
|
.react-datepicker__day--in-selecting-range {
|
||||||
background: #e8edff;
|
background: $color-primary-soft;
|
||||||
color: #031d9a;
|
color: $color-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-datepicker__day:hover,
|
.react-datepicker__day:hover,
|
||||||
.react-datepicker__month-text:hover,
|
.react-datepicker__month-text:hover,
|
||||||
.react-datepicker__quarter-text:hover,
|
.react-datepicker__quarter-text:hover,
|
||||||
.react-datepicker__year-text:hover {
|
.react-datepicker__year-text:hover {
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
}
|
}
|
||||||
|
|
@ -1,32 +1,43 @@
|
||||||
@use '../../../../shared/styles/variables' as *;
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
.devices-filters {
|
.devices-filters {
|
||||||
width: 0;
|
width: var(--devices-filters-width, 292px);
|
||||||
flex: 0 0 0;
|
min-width: var(--devices-filters-width, 292px);
|
||||||
|
margin-left: var(--devices-filters-gap, 20px);
|
||||||
|
box-sizing: border-box;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
overflow: hidden;
|
visibility: hidden;
|
||||||
|
overflow: visible;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transform: translateX(6px);
|
||||||
transform: translateX(16px);
|
will-change: opacity, transform;
|
||||||
|
|
||||||
transition:
|
transition:
|
||||||
width 0.25s ease,
|
|
||||||
flex-basis 0.25s ease,
|
|
||||||
opacity 0.2s ease,
|
opacity 0.2s ease,
|
||||||
transform 0.25s ease;
|
transform var(
|
||||||
|
--devices-filters-duration,
|
||||||
|
360ms
|
||||||
|
) var(--devices-filters-easing, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||||
|
visibility 0s linear var(--devices-filters-duration, 360ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-filters--open {
|
.devices-filters--open {
|
||||||
width: 292px;
|
|
||||||
flex-basis: 292px;
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
padding-left: 20px;
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform var(
|
||||||
|
--devices-filters-duration,
|
||||||
|
360ms
|
||||||
|
) var(--devices-filters-easing, cubic-bezier(0.22, 1, 0.36, 1)),
|
||||||
|
visibility 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-filters__accordion {
|
.devices-filters__accordion {
|
||||||
width: 292px;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -34,8 +45,9 @@
|
||||||
|
|
||||||
.devices-filter-item {
|
.devices-filter-item {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 2px 2px 30px -10px rgba($gray50-rgb, .2);
|
||||||
&:nth-child(1)[data-state=open]{
|
&:nth-child(1)[data-state=open]{
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
@ -51,16 +63,16 @@
|
||||||
margin: 0 16px;
|
margin: 0 16px;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid $transparent;
|
||||||
background: transparent;
|
background: $transparent;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
color: black;
|
color: $color-text-strong;
|
||||||
font-size: 18px;
|
font-size: $font-size-18;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|
@ -116,10 +128,10 @@
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #eef1f6;
|
background: $color-surface-hover;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
@ -131,10 +143,10 @@
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: $transparent;
|
||||||
|
|
||||||
color: $blue;
|
color: $blue;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -149,7 +161,7 @@
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
|
|
||||||
& + & {
|
& + & {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
|
@ -158,7 +170,7 @@
|
||||||
|
|
||||||
.devices-filter-row__label {
|
.devices-filter-row__label {
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
@ -174,8 +186,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
@ -189,15 +201,15 @@
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid #dfe5ef;
|
border: 2px solid $color-border-strong;
|
||||||
background: #f3f6fa;
|
background: $color-surface-muted;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 18px;
|
flex: 0 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-radio input:checked + .devices-radio__control {
|
.devices-radio input:checked + .devices-radio__control {
|
||||||
border-color: #031d9a;
|
border-color: $color-primary;
|
||||||
background: #031d9a;
|
background: $color-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-radio input:checked + .devices-radio__control::after {
|
.devices-radio input:checked + .devices-radio__control::after {
|
||||||
|
|
@ -205,7 +217,7 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 5px;
|
inset: 5px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-radio__label {
|
.devices-radio__label {
|
||||||
|
|
@ -224,15 +236,17 @@
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 2px solid #dfe5ef;
|
border: 2px solid $color-border-strong;
|
||||||
background: #f3f6fa;
|
background: $color-surface-muted;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 18px;
|
flex: 0 0 18px;
|
||||||
|
transition: .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-checkbox input:checked + .devices-checkbox__control {
|
.devices-checkbox input:checked + .devices-checkbox__control {
|
||||||
border-color: #031d9a;
|
border-color: $color-primary;
|
||||||
background: #031d9a;
|
background: $color-primary;
|
||||||
|
box-shadow: 0 2px 8px -2px rgba(var(--color-primary-rgb), 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-checkbox input:checked + .devices-checkbox__control::after {
|
.devices-checkbox input:checked + .devices-checkbox__control::after {
|
||||||
|
|
@ -242,7 +256,7 @@
|
||||||
top: 2px;
|
top: 2px;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border: solid #ffffff;
|
border: solid white;
|
||||||
border-width: 0 2px 2px 0;
|
border-width: 0 2px 2px 0;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
@ -266,3 +280,14 @@
|
||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
.devices-filter-item {
|
||||||
|
box-shadow: none;
|
||||||
|
outline: 1px solid rgba($gray50-rgb, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-filter-item__trigger:hover {
|
||||||
|
color: $color-primary-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,175 +1,295 @@
|
||||||
import { useState } from 'react'
|
import { memo, useCallback, useMemo, useState } from 'react'
|
||||||
import * as Accordion from '@radix-ui/react-accordion'
|
import * as Accordion from '@radix-ui/react-accordion'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { ChevronDown } from 'lucide-react'
|
||||||
import './Datepicker.scss'
|
|
||||||
import { DevicesDateRangePicker, type DevicesDateRangePickerValue } from '../DevicesDateRangePicker/DevicesDateRangePicker'
|
|
||||||
|
|
||||||
|
import type { DeviceNetworkStatus } from '../../../../entities/device/model/types'
|
||||||
|
import {
|
||||||
|
DevicesDateRangePicker,
|
||||||
|
type DevicesDateRangePickerValue,
|
||||||
|
} from '../DevicesDateRangePicker/DevicesDateRangePicker'
|
||||||
|
import { DevicesOrganisationFilter } from '../DevicesOrganisationFilter/DevicesOrganisationFilter'
|
||||||
|
|
||||||
|
import './Datepicker.scss'
|
||||||
import './DevicesFiltersPanel.scss'
|
import './DevicesFiltersPanel.scss'
|
||||||
|
|
||||||
|
const networkStatusOptions: {
|
||||||
|
value: DeviceNetworkStatus
|
||||||
|
label: string
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: 'Online',
|
||||||
|
label: 'В сети',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Offline',
|
||||||
|
label: 'Не в сети',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'Lost',
|
||||||
|
label: 'Потерян',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const maintenanceOptions = [
|
||||||
|
{
|
||||||
|
value: false,
|
||||||
|
label: 'Исправно',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: true,
|
||||||
|
label: 'Требует ТО',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function isDeviceNetworkStatus(value: string): value is DeviceNetworkStatus {
|
||||||
|
return networkStatusOptions.some((option) => option.value === value)
|
||||||
|
}
|
||||||
|
|
||||||
type DevicesFiltersPanelProps = {
|
type DevicesFiltersPanelProps = {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
|
export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
|
||||||
|
isOpen,
|
||||||
|
}: DevicesFiltersPanelProps) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
const [workPeriod, setWorkPeriod] = useState<DevicesDateRangePickerValue>({
|
const selectedOrganisationIds = useMemo(
|
||||||
from: new Date(2026, 3, 20),
|
() => searchParams.get('dOrgs')?.split(',').filter(Boolean) ?? [],
|
||||||
to: new Date(2026, 3, 22),
|
[searchParams],
|
||||||
fromTime: '07:00',
|
)
|
||||||
toTime: '16:00',
|
|
||||||
|
const networkStatusParam = searchParams.get('dNetwork')
|
||||||
|
const selectedNetworkStatuses = useMemo<DeviceNetworkStatus[]>(
|
||||||
|
() =>
|
||||||
|
networkStatusParam === 'none'
|
||||||
|
? []
|
||||||
|
: networkStatusParam
|
||||||
|
?.split(',')
|
||||||
|
.filter(isDeviceNetworkStatus) ??
|
||||||
|
networkStatusOptions.map((option) => option.value),
|
||||||
|
[networkStatusParam],
|
||||||
|
)
|
||||||
|
|
||||||
|
const needMaintenanceParam = searchParams.get('dNeedMaintenance')
|
||||||
|
const selectedMaintenanceStates = useMemo(
|
||||||
|
() =>
|
||||||
|
needMaintenanceParam === 'true'
|
||||||
|
? [true]
|
||||||
|
: needMaintenanceParam === 'false'
|
||||||
|
? [false]
|
||||||
|
: maintenanceOptions.map((option) => option.value),
|
||||||
|
[needMaintenanceParam],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [workPeriod, setWorkPeriod] = useState<DevicesDateRangePickerValue>({
|
||||||
|
from: new Date(2026, 3, 20),
|
||||||
|
to: new Date(2026, 3, 22),
|
||||||
|
fromTime: '07:00',
|
||||||
|
toTime: '16:00',
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateSearchParams = useCallback((updates: Record<string, string | null>) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
if (value === null || value === '') {
|
||||||
|
params.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set(key, value)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
setSearchParams(params)
|
||||||
<aside className={`devices-filters ${isOpen ? 'devices-filters--open' : ''}`}>
|
}, [searchParams, setSearchParams])
|
||||||
<Accordion.Root
|
|
||||||
className="devices-filters__accordion"
|
|
||||||
type="multiple"
|
|
||||||
defaultValue={['work-period', 'statuses']}
|
|
||||||
>
|
|
||||||
<Accordion.Item className="devices-filter-item" value="work-period">
|
|
||||||
<Accordion.Header className="devices-filter-item__header">
|
|
||||||
<Accordion.Trigger className="devices-filter-item__trigger">
|
|
||||||
<span>Период работы</span>
|
|
||||||
<ChevronDown className="devices-filter-item__chevron" size={16} />
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</Accordion.Header>
|
|
||||||
|
|
||||||
<Accordion.Content className="devices-filter-item__content">
|
const handleNetworkStatusChange = useCallback((
|
||||||
<div className="devices-filter-item__inner">
|
status: DeviceNetworkStatus,
|
||||||
<div className="devices-period">
|
checked: boolean,
|
||||||
<DevicesDateRangePicker value={workPeriod} onChange={setWorkPeriod} />
|
) => {
|
||||||
</div>
|
const currentStatuses = new Set(selectedNetworkStatuses)
|
||||||
|
|
||||||
</div>
|
if (checked) {
|
||||||
</Accordion.Content>
|
currentStatuses.add(status)
|
||||||
</Accordion.Item>
|
} else {
|
||||||
|
currentStatuses.delete(status)
|
||||||
|
}
|
||||||
|
|
||||||
{/* <Accordion.Item className="devices-filter-item" value="statuses">
|
const nextStatuses = networkStatusOptions
|
||||||
<Accordion.Header className="devices-filter-item__header">
|
.map((option) => option.value)
|
||||||
<Accordion.Trigger className="devices-filter-item__trigger">
|
.filter((optionStatus) => currentStatuses.has(optionStatus))
|
||||||
<span>Статусы</span>
|
|
||||||
<ChevronDown className="devices-filter-item__chevron" size={16} />
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</Accordion.Header>
|
|
||||||
|
|
||||||
<Accordion.Content className="devices-filter-item__content">
|
updateSearchParams({
|
||||||
<div className="devices-filter-item__inner">
|
dNetwork:
|
||||||
<div className="devices-filter-row">
|
nextStatuses.length === networkStatusOptions.length
|
||||||
<span className="devices-filter-row__label">GPS</span>
|
? null
|
||||||
|
: nextStatuses.length === 0
|
||||||
|
? 'none'
|
||||||
|
: nextStatuses.join(','),
|
||||||
|
dPage: '0',
|
||||||
|
})
|
||||||
|
}, [selectedNetworkStatuses, updateSearchParams])
|
||||||
|
|
||||||
<label className="devices-radio">
|
const handleMaintenanceChange = useCallback((value: boolean, checked: boolean) => {
|
||||||
<input type="radio" name="gps" />
|
const currentStates = new Set(selectedMaintenanceStates)
|
||||||
<span className="devices-radio__control" />
|
|
||||||
<span className="devices-radio__label">Вкл</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="devices-radio">
|
if (checked) {
|
||||||
<input type="radio" name="gps" defaultChecked />
|
currentStates.add(value)
|
||||||
<span className="devices-radio__control" />
|
} else {
|
||||||
<span className="devices-radio__label">Выкл</span>
|
currentStates.delete(value)
|
||||||
</label>
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="devices-filter-row">
|
if (currentStates.size === 0) return
|
||||||
<span className="devices-filter-row__label">Wi-Fi</span>
|
|
||||||
|
|
||||||
<label className="devices-radio">
|
const nextStates = maintenanceOptions
|
||||||
<input type="radio" name="wifi" defaultChecked />
|
.map((option) => option.value)
|
||||||
<span className="devices-radio__control" />
|
.filter((optionValue) => currentStates.has(optionValue))
|
||||||
<span className="devices-radio__label">Вкл</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="devices-radio">
|
updateSearchParams({
|
||||||
<input type="radio" name="wifi" />
|
dNeedMaintenance:
|
||||||
<span className="devices-radio__control" />
|
nextStates.length === maintenanceOptions.length
|
||||||
<span className="devices-radio__label">Выкл</span>
|
? null
|
||||||
</label>
|
: String(nextStates[0]),
|
||||||
</div>
|
dPage: '0',
|
||||||
|
})
|
||||||
|
}, [selectedMaintenanceStates, updateSearchParams])
|
||||||
|
|
||||||
<div className="devices-filter-row">
|
const handleOrganisationsChange = useCallback((ids: string[]) => {
|
||||||
<span className="devices-filter-row__label">Камера</span>
|
updateSearchParams({
|
||||||
|
dOrgs: ids.length > 0 ? ids.join(',') : null,
|
||||||
|
dPage: '0',
|
||||||
|
})
|
||||||
|
}, [updateSearchParams])
|
||||||
|
|
||||||
<label className="devices-radio">
|
return (
|
||||||
<input type="radio" name="camera" />
|
<aside
|
||||||
<span className="devices-radio__control" />
|
className={`devices-filters ${isOpen ? 'devices-filters--open' : ''}`}
|
||||||
<span className="devices-radio__label">Вкл</span>
|
aria-hidden={!isOpen}
|
||||||
</label>
|
>
|
||||||
|
<Accordion.Root
|
||||||
|
className="devices-filters__accordion"
|
||||||
|
type="multiple"
|
||||||
|
defaultValue={['work-period']}
|
||||||
|
>
|
||||||
|
<Accordion.Item className="devices-filter-item" value="work-period">
|
||||||
|
<Accordion.Header className="devices-filter-item__header">
|
||||||
|
<Accordion.Trigger className="devices-filter-item__trigger">
|
||||||
|
<span>Период работы</span>
|
||||||
|
<ChevronDown
|
||||||
|
className="devices-filter-item__chevron"
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
|
||||||
<label className="devices-radio">
|
<Accordion.Content className="devices-filter-item__content">
|
||||||
<input type="radio" name="camera" />
|
<div className="devices-filter-item__inner">
|
||||||
<span className="devices-radio__control" />
|
<div className="devices-period">
|
||||||
<span className="devices-radio__label">Выкл</span>
|
<DevicesDateRangePicker
|
||||||
</label>
|
value={workPeriod}
|
||||||
</div>
|
onChange={setWorkPeriod}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
|
||||||
<button className="devices-filter-reset" type="button">
|
<Accordion.Item
|
||||||
Сбросить
|
className="devices-filter-item"
|
||||||
</button>
|
value="organisations"
|
||||||
</div>
|
>
|
||||||
</Accordion.Content>
|
<Accordion.Header className="devices-filter-item__header">
|
||||||
</Accordion.Item> */}
|
<Accordion.Trigger className="devices-filter-item__trigger">
|
||||||
|
<span>Организации</span>
|
||||||
|
<ChevronDown
|
||||||
|
className="devices-filter-item__chevron"
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
|
||||||
<Accordion.Item className="devices-filter-item" value="network">
|
<Accordion.Content className="devices-filter-item__content">
|
||||||
<Accordion.Header className="devices-filter-item__header">
|
<div className="devices-filter-item__inner">
|
||||||
<Accordion.Trigger className="devices-filter-item__trigger">
|
<DevicesOrganisationFilter
|
||||||
<span>Сеть</span>
|
selectedOrganisationIds={selectedOrganisationIds}
|
||||||
<ChevronDown className="devices-filter-item__chevron" size={16} />
|
onChange={handleOrganisationsChange}
|
||||||
</Accordion.Trigger>
|
/>
|
||||||
</Accordion.Header>
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
|
||||||
<Accordion.Content className="devices-filter-item__content">
|
<Accordion.Item className="devices-filter-item" value="network">
|
||||||
<div className="devices-filter-item__inner">
|
<Accordion.Header className="devices-filter-item__header">
|
||||||
<div className='devices-checkbox-list'>
|
<Accordion.Trigger className="devices-filter-item__trigger">
|
||||||
<label className="devices-checkbox">
|
<span>Сеть</span>
|
||||||
<input type="checkbox" defaultChecked />
|
<ChevronDown
|
||||||
<span className="devices-checkbox__control" />
|
className="devices-filter-item__chevron"
|
||||||
<span>В сети</span>
|
size={16}
|
||||||
</label>
|
/>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
|
||||||
<label className="devices-checkbox">
|
<Accordion.Content className="devices-filter-item__content">
|
||||||
<input type="checkbox" />
|
<div className="devices-filter-item__inner">
|
||||||
<span className="devices-checkbox__control" />
|
<div className="devices-checkbox-list">
|
||||||
<span>Не в сети</span>
|
{networkStatusOptions.map((option) => (
|
||||||
</label>
|
<label className="devices-checkbox" key={option.value}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedNetworkStatuses.includes(option.value)}
|
||||||
|
onChange={(event) => {
|
||||||
|
handleNetworkStatusChange(
|
||||||
|
option.value,
|
||||||
|
event.target.checked,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="devices-checkbox__control" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
|
||||||
<label className="devices-checkbox">
|
<Accordion.Item className="devices-filter-item" value="condition">
|
||||||
<input type="checkbox" />
|
<Accordion.Header className="devices-filter-item__header">
|
||||||
<span className="devices-checkbox__control" />
|
<Accordion.Trigger className="devices-filter-item__trigger">
|
||||||
<span>Долго не в сети</span>
|
<span>Состояние</span>
|
||||||
</label>
|
<ChevronDown
|
||||||
</div>
|
className="devices-filter-item__chevron"
|
||||||
</div>
|
size={16}
|
||||||
</Accordion.Content>
|
/>
|
||||||
</Accordion.Item>
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
|
||||||
<Accordion.Item className="devices-filter-item" value="condition">
|
<Accordion.Content className="devices-filter-item__content">
|
||||||
<Accordion.Header className="devices-filter-item__header">
|
<div className="devices-filter-item__inner">
|
||||||
<Accordion.Trigger className="devices-filter-item__trigger">
|
<div className="devices-checkbox-list">
|
||||||
<span>Состояние</span>
|
{maintenanceOptions.map((option) => (
|
||||||
<ChevronDown className="devices-filter-item__chevron" size={16} />
|
<label className="devices-checkbox" key={String(option.value)}>
|
||||||
</Accordion.Trigger>
|
<input
|
||||||
</Accordion.Header>
|
type="checkbox"
|
||||||
|
checked={selectedMaintenanceStates.includes(option.value)}
|
||||||
<Accordion.Content className="devices-filter-item__content">
|
onChange={(event) => {
|
||||||
<div className="devices-filter-item__inner">
|
handleMaintenanceChange(
|
||||||
<div className='devices-checkbox-list'>
|
option.value,
|
||||||
<label className="devices-checkbox">
|
event.target.checked,
|
||||||
<input type="checkbox" defaultChecked />
|
)
|
||||||
<span className="devices-checkbox__control" />
|
}}
|
||||||
<span>Исправно</span>
|
/>
|
||||||
</label>
|
<span className="devices-checkbox__control" />
|
||||||
|
<span>{option.label}</span>
|
||||||
<label className="devices-checkbox">
|
</label>
|
||||||
<input type="checkbox" />
|
))}
|
||||||
<span className="devices-checkbox__control" />
|
</div>
|
||||||
<span>Требует осмотра</span>
|
</div>
|
||||||
</label>
|
</Accordion.Content>
|
||||||
</div>
|
</Accordion.Item>
|
||||||
</div>
|
</Accordion.Root>
|
||||||
</Accordion.Content>
|
</aside>
|
||||||
</Accordion.Item>
|
)
|
||||||
</Accordion.Root>
|
})
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.organisation-filter__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.organisation-select__dropdown{
|
||||||
|
width: 240px;
|
||||||
|
top: calc(100% + 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-filter__tag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
padding: 5px 5px 5px 12px;
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 450;
|
||||||
|
color: $gray50;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: $gray50;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
background: rgba($gray50-rgb, .1);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .2s ease;
|
||||||
|
&:hover{
|
||||||
|
background-color: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
import {
|
||||||
|
memo,
|
||||||
|
type ChangeEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import * as Popover from '@radix-ui/react-popover'
|
||||||
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { GET_ORGANISATIONS_QUERY } from '../../../../entities/employee/api/employee.graphql'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GetOrganisationsData,
|
||||||
|
GetOrganisationsVariables,
|
||||||
|
} from '../../../../entities/employee/model/types'
|
||||||
|
|
||||||
|
import './DevicesOrganisationFilter.scss'
|
||||||
|
|
||||||
|
type DevicesOrganisationFilterProps = {
|
||||||
|
selectedOrganisationIds: string[]
|
||||||
|
onChange: (ids: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganisationFilterItem = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_DIRECTION = 'ASC' as const
|
||||||
|
const SORT_FIELD = 'Name' as const
|
||||||
|
|
||||||
|
export const DevicesOrganisationFilter = memo(function DevicesOrganisationFilter({
|
||||||
|
selectedOrganisationIds = [],
|
||||||
|
onChange,
|
||||||
|
}: DevicesOrganisationFilterProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [debouncedSearch, setDebouncedSearch] =
|
||||||
|
useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
previousData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useQuery<
|
||||||
|
GetOrganisationsData,
|
||||||
|
GetOrganisationsVariables
|
||||||
|
>(GET_ORGANISATIONS_QUERY, {
|
||||||
|
variables: {
|
||||||
|
page: 0,
|
||||||
|
query: debouncedSearch || undefined,
|
||||||
|
sortDirection: SORT_DIRECTION,
|
||||||
|
sortField: SORT_FIELD,
|
||||||
|
},
|
||||||
|
skip: !open && selectedOrganisationIds.length === 0,
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
nextFetchPolicy: 'cache-first',
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response =
|
||||||
|
data?.getOrganisations ??
|
||||||
|
previousData?.getOrganisations
|
||||||
|
|
||||||
|
const organisations = useMemo(
|
||||||
|
() => response?.page ?? [],
|
||||||
|
[response?.page],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedOrganisationIdSet = useMemo(
|
||||||
|
() => new Set(selectedOrganisationIds),
|
||||||
|
[selectedOrganisationIds],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [
|
||||||
|
selectedOrganisations,
|
||||||
|
setSelectedOrganisations,
|
||||||
|
] = useState<
|
||||||
|
OrganisationFilterItem[]
|
||||||
|
>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (organisations.length === 0) return
|
||||||
|
|
||||||
|
setSelectedOrganisations((prev) => {
|
||||||
|
const map = new Map(
|
||||||
|
prev.map((item) => [item.id, item]),
|
||||||
|
)
|
||||||
|
|
||||||
|
organisations.forEach((organisation) => {
|
||||||
|
const id = String(organisation.id)
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedOrganisationIdSet.has(id)
|
||||||
|
) {
|
||||||
|
map.set(id, {
|
||||||
|
id,
|
||||||
|
name: organisation.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(map.values()).filter(
|
||||||
|
(item) =>
|
||||||
|
selectedOrganisationIdSet.has(item.id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
organisations,
|
||||||
|
selectedOrganisationIdSet,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((
|
||||||
|
event: ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
setSearch(event.target.value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelect = useCallback((
|
||||||
|
organisation: OrganisationFilterItem,
|
||||||
|
) => {
|
||||||
|
const alreadySelected =
|
||||||
|
selectedOrganisationIdSet.has(organisation.id)
|
||||||
|
|
||||||
|
if (alreadySelected) {
|
||||||
|
onChange(
|
||||||
|
selectedOrganisationIds.filter(
|
||||||
|
(item) =>
|
||||||
|
item !== organisation.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedOrganisations((prev) => {
|
||||||
|
const exists = prev.some(
|
||||||
|
(item) =>
|
||||||
|
item.id === organisation.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, organisation]
|
||||||
|
})
|
||||||
|
|
||||||
|
onChange([
|
||||||
|
...selectedOrganisationIds,
|
||||||
|
organisation.id,
|
||||||
|
])
|
||||||
|
|
||||||
|
setSearch('')
|
||||||
|
setDebouncedSearch('')
|
||||||
|
}, [
|
||||||
|
onChange,
|
||||||
|
selectedOrganisationIds,
|
||||||
|
selectedOrganisationIdSet,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleRemove = useCallback((id: string) => {
|
||||||
|
onChange(
|
||||||
|
selectedOrganisationIds.filter(
|
||||||
|
(item) => item !== id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, [onChange, selectedOrganisationIds])
|
||||||
|
|
||||||
|
const mergedOrganisations = useMemo(
|
||||||
|
() => [
|
||||||
|
...selectedOrganisations,
|
||||||
|
|
||||||
|
...organisations
|
||||||
|
.filter(
|
||||||
|
(organisation) =>
|
||||||
|
!selectedOrganisationIdSet.has(
|
||||||
|
String(organisation.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((organisation) => ({
|
||||||
|
id: String(organisation.id),
|
||||||
|
name: organisation.name,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
organisations,
|
||||||
|
selectedOrganisationIdSet,
|
||||||
|
selectedOrganisations,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="organisation-filter">
|
||||||
|
{selectedOrganisations.length > 0 && (
|
||||||
|
<div className="organisation-filter__tags">
|
||||||
|
{selectedOrganisations.map(
|
||||||
|
(organisation) => (
|
||||||
|
<div
|
||||||
|
className="organisation-filter__tag"
|
||||||
|
key={organisation.id}
|
||||||
|
>
|
||||||
|
<span>{organisation.name}</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleRemove(
|
||||||
|
String(organisation.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popover.Root
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
className="organisation-select__trigger"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="is-placeholder">
|
||||||
|
Выберите организацию
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ChevronDown size={18} />
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
className="organisation-select__dropdown"
|
||||||
|
sideOffset={8}
|
||||||
|
align="start"
|
||||||
|
collisionPadding={12}
|
||||||
|
>
|
||||||
|
<label className="organisation-select__search">
|
||||||
|
<Search size={16} />
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
placeholder="Поиск организации"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="organisation-select__list">
|
||||||
|
{loading &&
|
||||||
|
organisations.length === 0 && (
|
||||||
|
<div className="organisation-select__state">
|
||||||
|
Загрузка организаций...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="organisation-select__state is-error">
|
||||||
|
Не удалось загрузить организации
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading &&
|
||||||
|
!error &&
|
||||||
|
organisations.length === 0 && (
|
||||||
|
<div className="organisation-select__state">
|
||||||
|
Организации не найдены
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mergedOrganisations.map((organisation) => {
|
||||||
|
const isSelected =
|
||||||
|
selectedOrganisationIds.includes(
|
||||||
|
organisation.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`organisation-select__option ${isSelected
|
||||||
|
? 'is-selected'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
key={organisation.id}
|
||||||
|
onClick={() =>
|
||||||
|
handleSelect(organisation)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{organisation.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<small>
|
||||||
|
ID: {organisation.id}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
@use '../../../../shared/styles/variables' as *;
|
|
||||||
|
|
||||||
.devices-tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.devices-tabs__item {
|
|
||||||
position: relative;
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #4f5b73;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 400;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
b {
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1d2533;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.devices-tabs__item--active {
|
|
||||||
color: #4f5b73;
|
|
||||||
}
|
|
||||||
|
|
||||||
.devices-tabs__alert {
|
|
||||||
position: absolute;
|
|
||||||
top: -5px;
|
|
||||||
right: -5px;
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: $red;
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,174 @@
|
||||||
import './DevicesTabs.scss'
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
import { memo, useCallback, useMemo } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
export function DevicesTabs() {
|
import {
|
||||||
return (
|
GET_PHONES_TABS_STATS_QUERY,
|
||||||
<div className="devices-tabs">
|
} from '../../../../entities/device/api/device.graphql'
|
||||||
<button className="devices-tabs__item devices-tabs__item--active" type="button">
|
import type {
|
||||||
В работе: <b>15 из 77</b>
|
GetPhonesTabsStatsData,
|
||||||
</button>
|
GetPhonesTabsStatsVariables,
|
||||||
|
} from '../../../../entities/device/model/types'
|
||||||
|
import {
|
||||||
|
PageTabs,
|
||||||
|
type PageTabItem,
|
||||||
|
} from '../../../../shared/ui/PageTabs/PageTabs'
|
||||||
|
|
||||||
<button className="devices-tabs__item" type="button">
|
type DevicesTab = 'working' | 'lost' | 'locked' | 'maintenance'
|
||||||
Долгое время не в сети: <b>8</b>
|
type DevicesTabsValue = DevicesTab | 'none'
|
||||||
<span className="devices-tabs__alert" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="devices-tabs__item" type="button">
|
function getActiveTab(searchParams: URLSearchParams): DevicesTabsValue {
|
||||||
Требуют обслуживания: <b>9</b>
|
const locked = searchParams.get('dLocked')
|
||||||
<span className="devices-tabs__alert" />
|
const needMaintenance = searchParams.get('dNeedMaintenance')
|
||||||
</button>
|
const networkStatus = searchParams.get('dNetwork')
|
||||||
</div>
|
|
||||||
)
|
if (locked === 'true') return 'locked'
|
||||||
|
if (needMaintenance === 'true') return 'maintenance'
|
||||||
|
if (networkStatus === 'Lost') return 'lost'
|
||||||
|
if (networkStatus === 'Online') return 'working'
|
||||||
|
|
||||||
|
return 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCount(value?: number) {
|
||||||
|
if (typeof value !== 'number') return '...'
|
||||||
|
|
||||||
|
return value.toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DevicesTabs = memo(function DevicesTabs() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const activeTab = useMemo(() => getActiveTab(searchParams), [searchParams])
|
||||||
|
|
||||||
|
const query = searchParams.get('dQuery')?.trim() || undefined
|
||||||
|
const selectedOrganisationIds = useMemo(() => {
|
||||||
|
return searchParams.get('dOrgs')?.split(',').filter(Boolean) ?? []
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const queryVariables = useMemo<GetPhonesTabsStatsVariables>(() => {
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
orgs:
|
||||||
|
selectedOrganisationIds.length > 0
|
||||||
|
? selectedOrganisationIds
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}, [query, selectedOrganisationIds])
|
||||||
|
|
||||||
|
const { data, previousData } = useQuery<
|
||||||
|
GetPhonesTabsStatsData,
|
||||||
|
GetPhonesTabsStatsVariables
|
||||||
|
>(GET_PHONES_TABS_STATS_QUERY, {
|
||||||
|
variables: queryVariables,
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = data ?? previousData
|
||||||
|
const onlineCount = stats?.onlinePhones.totalElements
|
||||||
|
const totalCount = stats?.totalPhones.totalElements
|
||||||
|
const lockedCount = stats?.lockedPhones.totalElements
|
||||||
|
const lostCount = stats?.lostPhones.totalElements
|
||||||
|
const maintenanceCount = stats?.maintenancePhones.totalElements
|
||||||
|
|
||||||
|
const tabs = useMemo<Array<PageTabItem<DevicesTabsValue>>>(() => {
|
||||||
|
const nextTabs: Array<PageTabItem<DevicesTabsValue>> = [
|
||||||
|
{
|
||||||
|
value: 'working',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
В сети: <b>{formatCount(onlineCount)} из {formatCount(totalCount)}</b>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if ((lostCount ?? 0) > 0) {
|
||||||
|
nextTabs.push({
|
||||||
|
value: 'lost',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Потеряно: <b>{formatCount(lostCount)}</b>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
alert: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((lockedCount ?? 0) > 0) {
|
||||||
|
nextTabs.push({
|
||||||
|
value: 'locked',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Заблокировано: <b>{formatCount(lockedCount)}</b>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
alert: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((maintenanceCount ?? 0) > 0) {
|
||||||
|
nextTabs.push({
|
||||||
|
value: 'maintenance',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Требуют ТО: <b>{formatCount(maintenanceCount)}</b>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
alert: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextTabs
|
||||||
|
}, [lockedCount, lostCount, maintenanceCount, onlineCount, totalCount])
|
||||||
|
|
||||||
|
const applyQuickFilter = useCallback((tab: DevicesTabsValue) => {
|
||||||
|
if (tab === 'none') return
|
||||||
|
|
||||||
|
const nextParams = new URLSearchParams(searchParams)
|
||||||
|
|
||||||
|
nextParams.delete('dPage')
|
||||||
|
|
||||||
|
if (activeTab === tab) {
|
||||||
|
nextParams.delete('dLocked')
|
||||||
|
nextParams.delete('dNetwork')
|
||||||
|
nextParams.delete('dNeedMaintenance')
|
||||||
|
setSearchParams(nextParams, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'working') {
|
||||||
|
nextParams.set('dNetwork', 'Online')
|
||||||
|
nextParams.delete('dLocked')
|
||||||
|
nextParams.delete('dNeedMaintenance')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'locked') {
|
||||||
|
nextParams.set('dLocked', 'true')
|
||||||
|
nextParams.delete('dNetwork')
|
||||||
|
nextParams.delete('dNeedMaintenance')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'lost') {
|
||||||
|
nextParams.set('dNetwork', 'Lost')
|
||||||
|
nextParams.delete('dLocked')
|
||||||
|
nextParams.delete('dNeedMaintenance')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'maintenance') {
|
||||||
|
nextParams.delete('dLocked')
|
||||||
|
nextParams.delete('dNetwork')
|
||||||
|
nextParams.set('dNeedMaintenance', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(nextParams, { replace: true })
|
||||||
|
}, [activeTab, searchParams, setSearchParams])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTabs
|
||||||
|
activeValue={activeTab}
|
||||||
|
items={tabs}
|
||||||
|
onChange={applyQuickFilter}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
background-color: white;
|
background-color: $color-surface;
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
box-shadow: $shadow-toolbar;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-search {
|
.devices-search {
|
||||||
|
|
@ -32,9 +33,9 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: transparent;
|
background: $transparent;
|
||||||
color: black;
|
color: $color-text-strong;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
|
|
@ -42,7 +43,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-within{
|
&:focus-within{
|
||||||
box-shadow: inset 0 0 0 1.5px rgba($blue, .15);
|
box-shadow: inset 0 0 0 1.5px rgba($color-primary-rgb, .15);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,7 +54,7 @@
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background-color: $color-bg;
|
background-color: $color-bg;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
|
|
@ -62,8 +63,12 @@
|
||||||
|
|
||||||
.add-device {
|
.add-device {
|
||||||
background-color: $blue;
|
background-color: $blue;
|
||||||
color: white;
|
color: $color-text-inverse;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: .2s ease;
|
||||||
|
&:hover{
|
||||||
|
box-shadow: 0 4px 20px -6px rgba(var(--color-primary-rgb), 0.8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,14 +79,14 @@
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -108,9 +113,9 @@
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
|
||||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
|
box-shadow: $shadow-popover;
|
||||||
|
|
||||||
animation: devicesSortMenuShow 0.16s ease;
|
animation: devicesSortMenuShow 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
@ -125,15 +130,15 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&[data-highlighted] {
|
&[data-highlighted] {
|
||||||
background: #f1f4f8;
|
background: $color-surface-subtle;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -196,3 +201,76 @@
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: $blue;
|
background: $blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include desktop-down{
|
||||||
|
.devices-toolbar {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-search {
|
||||||
|
width: 180px !important;
|
||||||
|
input{
|
||||||
|
font-size: var(--font-size-14) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-toolbar__right {
|
||||||
|
button {
|
||||||
|
padding: 10px 14px !important;
|
||||||
|
font-size: var(--font-size-14) !important;
|
||||||
|
height: 38px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-sort {
|
||||||
|
padding: 10px 12px 10px 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include laptop-down {
|
||||||
|
.devices-toolbar {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-toolbar__right {
|
||||||
|
button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-sort {
|
||||||
|
padding: 10px 12px 10px 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet-down {
|
||||||
|
.devices-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-search {
|
||||||
|
order: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
min-height: 42px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-toolbar__right {
|
||||||
|
order: 2;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.add-device {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,114 @@
|
||||||
import { useState } from 'react'
|
import { memo } from 'react'
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
import { ChevronDown, Search, SlidersHorizontal } from 'lucide-react'
|
import { Search, SlidersHorizontal } from 'lucide-react'
|
||||||
|
|
||||||
import './DevicesToolbar.scss'
|
import './DevicesToolbar.scss'
|
||||||
|
|
||||||
|
export type DevicesSortField = 'ID' | 'Serial' | 'Date'
|
||||||
|
export type DevicesSortDirection = 'ASC' | 'DESC'
|
||||||
|
|
||||||
|
export type DevicesSortOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
sortField: DevicesSortField
|
||||||
|
sortDirection: DevicesSortDirection
|
||||||
|
}
|
||||||
|
|
||||||
type DevicesToolbarProps = {
|
type DevicesToolbarProps = {
|
||||||
isFiltersOpen: boolean
|
isFiltersOpen: boolean
|
||||||
onToggleFilters: () => void
|
onToggleFilters: () => void
|
||||||
|
showFilters?: boolean
|
||||||
onAddDevice: () => void
|
onAddDevice: () => void
|
||||||
|
|
||||||
|
search: string
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
|
||||||
|
sortField: DevicesSortField
|
||||||
|
sortDirection: DevicesSortDirection
|
||||||
|
onSortChange: (option: DevicesSortOption) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortOptions = [
|
export const devicesSortOptions: DevicesSortOption[] = [
|
||||||
{
|
{
|
||||||
value: 'default',
|
value: 'date-desc',
|
||||||
label: 'По умолчанию',
|
label: 'Сначала новые',
|
||||||
|
sortField: 'Date',
|
||||||
|
sortDirection: 'DESC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'id-asc',
|
value: 'id-asc',
|
||||||
label: 'ID по возрастанию',
|
label: 'ID по возрастанию',
|
||||||
|
sortField: 'ID',
|
||||||
|
sortDirection: 'ASC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'id-desc',
|
value: 'id-desc',
|
||||||
label: 'ID по убыванию',
|
label: 'ID по убыванию',
|
||||||
|
sortField: 'ID',
|
||||||
|
sortDirection: 'DESC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'serial-asc',
|
value: 'serial-asc',
|
||||||
label: 'Заводской номер А–Я',
|
label: 'Заводской номер А–Я',
|
||||||
|
sortField: 'Serial',
|
||||||
|
sortDirection: 'ASC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'serial-desc',
|
value: 'serial-desc',
|
||||||
label: 'Заводской номер Я–А',
|
label: 'Заводской номер Я–А',
|
||||||
}
|
sortField: 'Serial',
|
||||||
|
sortDirection: 'DESC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'date-asc',
|
||||||
|
label: 'Сначала старые',
|
||||||
|
sortField: 'Date',
|
||||||
|
sortDirection: 'ASC',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function DevicesToolbar({
|
export const DevicesToolbar = memo(function DevicesToolbar({
|
||||||
isFiltersOpen,
|
isFiltersOpen,
|
||||||
onToggleFilters,
|
onToggleFilters,
|
||||||
|
showFilters = true,
|
||||||
onAddDevice,
|
onAddDevice,
|
||||||
|
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
onSortChange,
|
||||||
}: DevicesToolbarProps) {
|
}: DevicesToolbarProps) {
|
||||||
const [selectedSort, setSelectedSort] = useState(sortOptions[0])
|
|
||||||
|
const selectedSort =
|
||||||
|
devicesSortOptions.find(
|
||||||
|
(option) =>
|
||||||
|
option.sortField === sortField &&
|
||||||
|
option.sortDirection === sortDirection,
|
||||||
|
) ?? devicesSortOptions[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="devices-toolbar">
|
<div className="devices-toolbar">
|
||||||
<label className="devices-search">
|
<label className="devices-search">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
<input type="text" placeholder="Поиск" />
|
<input
|
||||||
|
placeholder="Поиск"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) =>
|
||||||
|
onSearchChange(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="devices-toolbar__right">
|
<div className="devices-toolbar__right">
|
||||||
<button className="add-device" type="button" onClick={onAddDevice}>
|
|
||||||
Добавить устройство
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<button className="devices-sort" type="button">
|
<button className="devices-sort" type="button">
|
||||||
{selectedSort.label}
|
{selectedSort.label}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6"/>
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|
@ -68,11 +119,11 @@ export function DevicesToolbar({
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
{sortOptions.map((option) => (
|
{devicesSortOptions.map((option) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className="devices-sort-menu__item"
|
className="devices-sort-menu__item"
|
||||||
onSelect={() => setSelectedSort(option)}
|
onSelect={() => onSortChange(option)}
|
||||||
>
|
>
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
|
|
||||||
|
|
@ -84,17 +135,22 @@ export function DevicesToolbar({
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
<button className="add-device" type="button" onClick={onAddDevice}>
|
||||||
<button
|
Добавить устройство
|
||||||
className={`devices-filter ${isFiltersOpen ? 'devices-filter--active' : ''}`}
|
|
||||||
type="button"
|
|
||||||
aria-label="Фильтр"
|
|
||||||
onClick={onToggleFilters}
|
|
||||||
>
|
|
||||||
<SlidersHorizontal size={16} />
|
|
||||||
<span className="devices-filter__alert" />
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<button
|
||||||
|
className={`devices-filter ${isFiltersOpen ? 'devices-filter--active' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-label="Фильтр"
|
||||||
|
onClick={onToggleFilters}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={16} />
|
||||||
|
<span className="devices-filter__alert" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,37 +8,25 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
max-height: calc(100% - 56px);
|
||||||
|
overflow: visible;
|
||||||
|
padding: 20px 20px 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-sections {
|
.employees-page__section {
|
||||||
width: fit-content;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex: 1;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-sections__item {
|
.employees-page__table-motion {
|
||||||
padding: 10px 18px;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
border: none;
|
min-width: 0;
|
||||||
border-radius: 12px;
|
min-height: 0;
|
||||||
background: transparent;
|
flex-direction: column;
|
||||||
|
|
||||||
color: $gray50;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
transition: 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $gray20;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
background: #ffffff;
|
|
||||||
color: $blue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-toolbar--organisations {
|
.employees-toolbar--organisations {
|
||||||
|
|
@ -51,37 +39,47 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
//overflow: hidden;
|
||||||
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-table-card {
|
.employees-table-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
max-height: 100%;
|
||||||
|
//overflow: hidden;
|
||||||
|
//box-shadow: $shadow-card;
|
||||||
|
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
|
||||||
scrollbar-width: thin;
|
.simplebar-content-wrapper {
|
||||||
scrollbar-color: $gray50 transparent;
|
border-radius: 20px;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
.simplebar-track.simplebar-vertical {
|
||||||
|
top: 48px;
|
||||||
|
bottom: 14px;
|
||||||
|
width: 14px;
|
||||||
|
right: -7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
.simplebar-scrollbar::before {
|
||||||
background: $gray50;
|
background: color-mix(in srgb, var(--gray50) 85%, transparent);
|
||||||
border-radius: 999px;
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--gray30), -2px 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
.simplebar-track.simplebar-horizontal {
|
||||||
background: $gray50;
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,12 +88,14 @@
|
||||||
min-width: 1180px;
|
min-width: 1180px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
thead {
|
thead {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
|
|
@ -104,10 +104,10 @@
|
||||||
border-bottom: 1px solid $gray20;
|
border-bottom: 1px solid $gray20;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 450;
|
font-weight: 450;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
|
|
@ -118,7 +118,7 @@
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
color: $color-text;
|
color: $color-text;
|
||||||
font-size: 17px;
|
font-size: $font-size-17;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,23 +169,74 @@
|
||||||
|
|
||||||
.employee-person {
|
.employee-person {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
span {
|
.employee-person__avatar {
|
||||||
color: $color-text;
|
width: 48px;
|
||||||
font-size: 17px;
|
height: 48px;
|
||||||
font-weight: 500;
|
flex: 0 0 48px;
|
||||||
line-height: 1.2;
|
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 20%, rgba($white, 0.1), $transparent 28%),
|
||||||
|
linear-gradient(135deg, $blue, $color-primary-hover);
|
||||||
|
|
||||||
|
color: $color-text-inverse;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
|
||||||
|
box-shadow: inset 0 0 0 1px rgba($color-shadow-rgb, 0.06);
|
||||||
|
|
||||||
|
&.transparent-backdrop {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-person__info {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
>span {
|
||||||
|
color: $color-text;
|
||||||
|
font-size: $font-size-17;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-organisation,
|
.employee-organisation,
|
||||||
.employee-organisation-name {
|
.employee-organisation-name {
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-organisation-name {
|
.employee-organisation-name {
|
||||||
|
|
@ -204,13 +255,13 @@
|
||||||
background: $blue20;
|
background: $blue20;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-role--admin {
|
.employee-role--admin {
|
||||||
background: $orange;
|
background: $color-warning-tint !important;
|
||||||
color: #ffffff;
|
color: $orange !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-table-actions {
|
.employees-table-actions {
|
||||||
|
|
@ -230,7 +281,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|
@ -252,12 +303,12 @@
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: inset 0 0 0 1px $blue;
|
box-shadow: inset 0 0 0 1px $blue, var(--shadow-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-action-btn--delete {
|
.employees-action-btn--delete {
|
||||||
background: rgba(224, 0, 0, 0.12);
|
background: $color-danger-tint-strong;
|
||||||
color: $red;
|
color: $red;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -268,12 +319,19 @@
|
||||||
.employees-table__row {
|
.employees-table__row {
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: 0.2s ease;
|
transition: 0.2s ease;
|
||||||
box-shadow: inset 0px 0 0 transparent;
|
box-shadow: inset 0px 0 0 $transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba($blue, 0.025);
|
background-color: $color-primary-tint-soft;
|
||||||
box-shadow: inset 3px 0 0 rgba($blue, 0.8);
|
box-shadow: inset 3px 0 0 rgba($color-primary-rgb, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,10 +343,10 @@
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
||||||
background: rgba(3, 29, 154, 0.08);
|
background: $color-primary-tint;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
||||||
font-size: 16px;
|
font-size: $font-size-16;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,13 +367,13 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
|
|
||||||
box-shadow: inset 0px 0 0 transparent;
|
box-shadow: inset 0px 0 0 $transparent;
|
||||||
|
|
||||||
transition:
|
transition:
|
||||||
background-color 0.2s ease,
|
background-color 0.2s ease,
|
||||||
|
|
@ -329,105 +387,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-action-btn--edit {
|
.employees-action-btn--edit {
|
||||||
background: $blue20;
|
background-color: $blue20;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: inset 0 0 0 1px $blue;
|
background-color: $blue;
|
||||||
|
color: $white;
|
||||||
|
box-shadow: inset 0 0 0 1px $blue, 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-action-btn--delete {
|
.employees-action-btn--delete {
|
||||||
background: rgba(224, 0, 0, 0.12);
|
background-color: $color-danger-tint-strong;
|
||||||
color: $red;
|
color: $red;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: inset 0 0 0 1px $red;
|
background-color: $red;
|
||||||
}
|
color: $white;
|
||||||
}
|
box-shadow: inset 0 0 0 1px $red, 0 8px 20px -8px rgba(var(--color-danger-rgb), 0.8);
|
||||||
|
|
||||||
.employees-pagination {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 12px 14px 0;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
color: #738098;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employees-pagination__controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1;
|
|
||||||
min-width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
//padding: 0 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: $gray20;
|
|
||||||
|
|
||||||
color: $gray50;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: .2s ease;
|
|
||||||
&:hover{
|
|
||||||
background-color: rgba($blue, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
background: $blue;
|
|
||||||
color: #ffffff;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 1;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.employees-pagination__ellipsis-button {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employees-pagination__ellipsis-dots,
|
|
||||||
.employees-pagination__ellipsis-arrow {
|
|
||||||
transition:
|
|
||||||
opacity 0.18s ease,
|
|
||||||
transform 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employees-pagination__ellipsis-arrow {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.employees-pagination__ellipsis-button:hover {
|
|
||||||
.employees-pagination__ellipsis-dots {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.employees-pagination__ellipsis-arrow {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,11 +416,155 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-state--error {
|
.employees-state--error {
|
||||||
color: $red;
|
color: $red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
.employees-table__row:hover {
|
||||||
|
background-color: rgba($color-primary-rgb, 0.03);
|
||||||
|
box-shadow: inset 3px 0 0 rgba($color-primary-rgb, 0.36);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include laptop-down {
|
||||||
|
.employee-person .employee-person__info>span {
|
||||||
|
font-size: var(--font-size-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-person .employee-person__info small {
|
||||||
|
font-size: var(--font-size-14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-organisation,
|
||||||
|
.employee-organisation-name {
|
||||||
|
font-size: var(--font-size-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-role {
|
||||||
|
font-size: var(--font-size-14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-table {
|
||||||
|
min-width: 1080px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
font-size: var(--font-size-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(1),
|
||||||
|
td:nth-child(1) {
|
||||||
|
width: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(2),
|
||||||
|
td:nth-child(2) {
|
||||||
|
width: 36%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(3),
|
||||||
|
td:nth-child(3) {
|
||||||
|
width: 22%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(4),
|
||||||
|
td:nth-child(4) {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(5),
|
||||||
|
td:nth-child(5) {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisations-table {
|
||||||
|
min-width: 820px;
|
||||||
|
|
||||||
|
th:nth-child(1),
|
||||||
|
td:nth-child(1) {
|
||||||
|
width: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(2),
|
||||||
|
td:nth-child(2) {
|
||||||
|
width: 64%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(3),
|
||||||
|
td:nth-child(3) {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-person {
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.employee-person__avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex-basis: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet-down {
|
||||||
|
.employees-table {
|
||||||
|
min-width: 980px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(1),
|
||||||
|
td:nth-child(1) {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(2),
|
||||||
|
td:nth-child(2) {
|
||||||
|
width: 28%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(3),
|
||||||
|
td:nth-child(3) {
|
||||||
|
width: 24%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(4),
|
||||||
|
td:nth-child(4) {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(5),
|
||||||
|
td:nth-child(5) {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisations-table {
|
||||||
|
min-width: 760px;
|
||||||
|
|
||||||
|
th:nth-child(1),
|
||||||
|
td:nth-child(1) {
|
||||||
|
width: 86px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(3),
|
||||||
|
td:nth-child(3) {
|
||||||
|
width: 230px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,722 +1,54 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { useApolloClient, useQuery } from '@apollo/client/react'
|
|
||||||
import { Pencil, Trash2 } from 'lucide-react'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GET_ORGANISATIONS_QUERY,
|
EmployeesTabs,
|
||||||
GET_USERS_PAGE_QUERY,
|
type EmployeesSection,
|
||||||
} from '../../entities/employee/api/employee.graphql'
|
} from './components/EmployeesTabs/EmployeesTabs'
|
||||||
import type {
|
import { UsersSection } from './components/UsersSection/UsersSection'
|
||||||
Employee,
|
import { OrganisationsSection } from './components/OrganisationsSection/OrganisationsSection'
|
||||||
GetOrganisationsData,
|
|
||||||
GetOrganisationsVariables,
|
|
||||||
GetUsersPageData,
|
|
||||||
GetUsersPageVariables,
|
|
||||||
Organisation,
|
|
||||||
} from '../../entities/employee/model/types'
|
|
||||||
|
|
||||||
import {
|
|
||||||
EmployeesToolbar,
|
|
||||||
employeesSortOptions,
|
|
||||||
type EmployeesSortOption,
|
|
||||||
} from './components/EmployeesToolbar/EmployeesToolbar'
|
|
||||||
import { AddEmployeeModal } from './components/AddEmployeeModal/AddEmployeeModal'
|
|
||||||
import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
|
||||||
import { AddOrganisationModal } from './components/AddOrganisationModal/AddOrganisationModal'
|
|
||||||
|
|
||||||
import './EmployeesPage.scss'
|
import './EmployeesPage.scss'
|
||||||
|
|
||||||
type EmployeesSection = 'users' | 'organisations'
|
|
||||||
|
|
||||||
function getEmployeeFullName(employee: Employee) {
|
|
||||||
return [employee.lastName, employee.firstName, employee.middleName]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEmployeeRoleLabel(role: string) {
|
|
||||||
if (role === 'Admin') return 'Администратор'
|
|
||||||
if (role === 'User') return 'Пользователь'
|
|
||||||
|
|
||||||
return role
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaginationItem = number | 'prev-ellipsis' | 'next-ellipsis'
|
|
||||||
|
|
||||||
function getPaginationItems(currentPage: number, totalPages: number): PaginationItem[] {
|
|
||||||
if (totalPages <= 0) return []
|
|
||||||
|
|
||||||
const current = currentPage + 1
|
|
||||||
|
|
||||||
if (totalPages <= 7) {
|
|
||||||
return Array.from({ length: totalPages }, (_, index) => index + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = new Set<number>()
|
|
||||||
|
|
||||||
pages.add(1)
|
|
||||||
pages.add(totalPages)
|
|
||||||
pages.add(current)
|
|
||||||
pages.add(current - 1)
|
|
||||||
pages.add(current + 1)
|
|
||||||
|
|
||||||
if (current <= 4) {
|
|
||||||
pages.add(2)
|
|
||||||
pages.add(3)
|
|
||||||
pages.add(4)
|
|
||||||
pages.add(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current >= totalPages - 3) {
|
|
||||||
pages.add(totalPages - 1)
|
|
||||||
pages.add(totalPages - 2)
|
|
||||||
pages.add(totalPages - 3)
|
|
||||||
pages.add(totalPages - 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedPages = Array.from(pages)
|
|
||||||
.filter((page) => page >= 1 && page <= totalPages)
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
|
|
||||||
const result: PaginationItem[] = []
|
|
||||||
|
|
||||||
sortedPages.forEach((page, index) => {
|
|
||||||
const previousPage = sortedPages[index - 1]
|
|
||||||
|
|
||||||
if (previousPage && page - previousPage > 1) {
|
|
||||||
if (page - previousPage === 2) {
|
|
||||||
result.push(previousPage + 1)
|
|
||||||
} else {
|
|
||||||
result.push(page < current ? 'prev-ellipsis' : 'next-ellipsis')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(page)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmployeesPage() {
|
export function EmployeesPage() {
|
||||||
const navigate = useNavigate()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
const client = useApolloClient()
|
const activeSection = useMemo<EmployeesSection>(() => {
|
||||||
|
return searchParams.get('section') === 'organisations'
|
||||||
|
? 'organisations'
|
||||||
|
: 'users'
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState<EmployeesSection>('users')
|
const handleSectionChange = useCallback((section: EmployeesSection) => {
|
||||||
|
if (activeSection === section) return
|
||||||
|
|
||||||
const [usersCurrentPage, setUsersCurrentPage] = useState(1)
|
setSearchParams(
|
||||||
const [usersLoadedPages, setUsersLoadedPages] = useState<Employee[][]>([])
|
(prev) => {
|
||||||
const [usersLoadedNextKeys, setUsersLoadedNextKeys] = useState<Array<string | null>>([])
|
const next = new URLSearchParams(prev)
|
||||||
const [isUsersPageLoading, setIsUsersPageLoading] = useState(false)
|
next.set('section', section)
|
||||||
|
|
||||||
const [organisationsCurrentPage, setOrganisationsCurrentPage] = useState(0)
|
return next
|
||||||
const [organisationSearch, setOrganisationSearch] = useState('')
|
},
|
||||||
const [debouncedOrganisationSearch, setDebouncedOrganisationSearch] =
|
{
|
||||||
useState('')
|
replace: true,
|
||||||
const [organisationSort, setOrganisationSort] =
|
|
||||||
useState<EmployeesSortOption>(employeesSortOptions[0])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
setDebouncedOrganisationSearch(organisationSearch.trim())
|
|
||||||
}, 350)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
}, [organisationSearch])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOrganisationsCurrentPage(0)
|
|
||||||
}, [debouncedOrganisationSearch, organisationSort])
|
|
||||||
|
|
||||||
const [isAddEmployeeOpen, setIsAddEmployeeOpen] = useState(false)
|
|
||||||
const [isAddOrganisationOpen, setIsAddOrganisationOpen] = useState(false)
|
|
||||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
|
||||||
const [deletingEmployee, setDeletingEmployee] = useState<Employee | null>(null)
|
|
||||||
|
|
||||||
const [editingOrganisation, setEditingOrganisation] =
|
|
||||||
useState<Organisation | null>(null)
|
|
||||||
const [deletingOrganisation, setDeletingOrganisation] =
|
|
||||||
useState<Organisation | null>(null)
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: firstUsersPageData,
|
|
||||||
loading: isFirstUsersPageLoading,
|
|
||||||
error: usersError,
|
|
||||||
} = useQuery<GetUsersPageData, GetUsersPageVariables>(GET_USERS_PAGE_QUERY, {
|
|
||||||
variables: {},
|
|
||||||
fetchPolicy: 'network-only',
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: organisationsData,
|
|
||||||
previousData: previousOrganisationsData,
|
|
||||||
loading: organisationsLoading,
|
|
||||||
error: organisationsError,
|
|
||||||
refetch: refetchOrganisations,
|
|
||||||
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
|
|
||||||
GET_ORGANISATIONS_QUERY,
|
|
||||||
{
|
|
||||||
variables: {
|
|
||||||
page: organisationsCurrentPage,
|
|
||||||
query: debouncedOrganisationSearch || undefined,
|
|
||||||
sortDirection: organisationSort.sortDirection,
|
|
||||||
sortField: organisationSort.sortField,
|
|
||||||
},
|
},
|
||||||
fetchPolicy: 'network-only',
|
|
||||||
notifyOnNetworkStatusChange: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const firstPageEmployees = firstUsersPageData?.getUsersPage.page ?? []
|
|
||||||
const firstUsersPageNextKey = firstUsersPageData?.getUsersPage.nextKey ?? null
|
|
||||||
|
|
||||||
const usersPages = [firstPageEmployees, ...usersLoadedPages]
|
|
||||||
const employees = usersPages[usersCurrentPage - 1] ?? []
|
|
||||||
|
|
||||||
const usersNextKey =
|
|
||||||
usersCurrentPage === 1
|
|
||||||
? firstUsersPageNextKey
|
|
||||||
: usersLoadedNextKeys[usersCurrentPage - 2] ?? null
|
|
||||||
|
|
||||||
const usersLoading = isFirstUsersPageLoading || isUsersPageLoading
|
|
||||||
|
|
||||||
const organisationsResponse =
|
|
||||||
organisationsData?.getOrganisations ??
|
|
||||||
previousOrganisationsData?.getOrganisations
|
|
||||||
|
|
||||||
const organisations = organisationsResponse?.page ?? []
|
|
||||||
const organisationsTotalPages = organisationsResponse?.totalPages ?? 0
|
|
||||||
|
|
||||||
const isOrganisationsInitialLoading =
|
|
||||||
organisationsLoading && !organisationsResponse
|
|
||||||
|
|
||||||
const isOrganisationsUpdating =
|
|
||||||
organisationsLoading && Boolean(organisationsResponse)
|
|
||||||
//const organisationsTotalElements = organisationsData?.getOrganisations.totalElements ?? 0
|
|
||||||
|
|
||||||
const hasPrevOrganisationsPage = organisationsCurrentPage > 0
|
|
||||||
const hasNextOrganisationsPage =
|
|
||||||
organisationsTotalPages > 0 &&
|
|
||||||
organisationsCurrentPage < organisationsTotalPages - 1
|
|
||||||
|
|
||||||
const organisationsPaginationItems = getPaginationItems(
|
|
||||||
organisationsCurrentPage,
|
|
||||||
organisationsTotalPages,
|
|
||||||
)
|
|
||||||
|
|
||||||
async function handleNextUsersPage() {
|
|
||||||
const alreadyLoadedNextPage = usersPages[usersCurrentPage]
|
|
||||||
|
|
||||||
if (alreadyLoadedNextPage) {
|
|
||||||
setUsersCurrentPage((prev) => prev + 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!usersNextKey) return
|
|
||||||
|
|
||||||
setIsUsersPageLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await client.query<GetUsersPageData, GetUsersPageVariables>({
|
|
||||||
query: GET_USERS_PAGE_QUERY,
|
|
||||||
variables: {
|
|
||||||
key: usersNextKey,
|
|
||||||
},
|
|
||||||
fetchPolicy: 'network-only',
|
|
||||||
})
|
|
||||||
|
|
||||||
const nextPageData = result.data?.getUsersPage
|
|
||||||
|
|
||||||
if (!nextPageData) return
|
|
||||||
|
|
||||||
setUsersLoadedPages((prev) => [...prev, nextPageData.page])
|
|
||||||
setUsersLoadedNextKeys((prev) => [...prev, nextPageData.nextKey])
|
|
||||||
setUsersCurrentPage((prev) => prev + 1)
|
|
||||||
} finally {
|
|
||||||
setIsUsersPageLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePrevUsersPage() {
|
|
||||||
setUsersCurrentPage((prev) => Math.max(1, prev - 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNextOrganisationsPage() {
|
|
||||||
if (!hasNextOrganisationsPage || organisationsLoading) return
|
|
||||||
|
|
||||||
setOrganisationsCurrentPage((prev) => prev + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePrevOrganisationsPage() {
|
|
||||||
if (!hasPrevOrganisationsPage || organisationsLoading) return
|
|
||||||
|
|
||||||
setOrganisationsCurrentPage((prev) => Math.max(0, prev - 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleJumpPrevOrganisationsPage() {
|
|
||||||
if (organisationsLoading) return
|
|
||||||
|
|
||||||
setOrganisationsCurrentPage((prev) => Math.max(0, prev - 5))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleJumpNextOrganisationsPage() {
|
|
||||||
if (organisationsLoading) return
|
|
||||||
|
|
||||||
setOrganisationsCurrentPage((prev) =>
|
|
||||||
Math.min(organisationsTotalPages - 1, prev + 5),
|
|
||||||
)
|
)
|
||||||
}
|
}, [activeSection, setSearchParams])
|
||||||
|
|
||||||
function handleEditEmployee(employee: Employee) {
|
|
||||||
setEditingEmployee(employee)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteEmployee(employee: Employee) {
|
|
||||||
setDeletingEmployee(employee)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleConfirmDeleteEmployee() {
|
|
||||||
if (!deletingEmployee) return
|
|
||||||
|
|
||||||
console.log('Удаление сотрудника пока без мутации', deletingEmployee)
|
|
||||||
|
|
||||||
setDeletingEmployee(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEditOrganisation(organisation: Organisation) {
|
|
||||||
setEditingOrganisation(organisation)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteOrganisation(organisation: Organisation) {
|
|
||||||
setDeletingOrganisation(organisation)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleConfirmDeleteOrganisation() {
|
|
||||||
if (!deletingOrganisation) return
|
|
||||||
|
|
||||||
console.log('Удаление организации пока без мутации', deletingOrganisation)
|
|
||||||
|
|
||||||
setDeletingOrganisation(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="employees-page">
|
<section className="employees-page">
|
||||||
<div className="employees-sections">
|
<EmployeesTabs
|
||||||
<button
|
activeSection={activeSection}
|
||||||
className={
|
onChange={handleSectionChange}
|
||||||
activeSection === 'users'
|
|
||||||
? 'employees-sections__item is-active'
|
|
||||||
: 'employees-sections__item'
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveSection('users')}
|
|
||||||
>
|
|
||||||
Пользователи
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
activeSection === 'organisations'
|
|
||||||
? 'employees-sections__item is-active'
|
|
||||||
: 'employees-sections__item'
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveSection('organisations')}
|
|
||||||
>
|
|
||||||
Организации
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EmployeesToolbar
|
|
||||||
addButtonText={
|
|
||||||
activeSection === 'users'
|
|
||||||
? 'Добавить сотрудника'
|
|
||||||
: 'Добавить организацию'
|
|
||||||
}
|
|
||||||
searchValue={activeSection === 'organisations' ? organisationSearch : ''}
|
|
||||||
onSearchChange={(value) => {
|
|
||||||
if (activeSection === 'organisations') {
|
|
||||||
setOrganisationSearch(value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
selectedSort={organisationSort}
|
|
||||||
onSortChange={setOrganisationSort}
|
|
||||||
showSort={activeSection === 'organisations'}
|
|
||||||
onAdd={() => {
|
|
||||||
if (activeSection === 'users') {
|
|
||||||
setIsAddEmployeeOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsAddOrganisationOpen(true)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="employees-table-container">
|
<div className="employees-page__section">
|
||||||
<div
|
{activeSection === 'users' ? (
|
||||||
className={`employees-table-card ${activeSection === 'organisations' && isOrganisationsUpdating
|
<UsersSection />
|
||||||
? 'is-updating'
|
) : (
|
||||||
: ''
|
<OrganisationsSection />
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{activeSection === 'users' && (
|
|
||||||
<>
|
|
||||||
{usersLoading && employees.length === 0 && (
|
|
||||||
<div className="employees-state">Загрузка сотрудников...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{usersError && (
|
|
||||||
<div className="employees-state employees-state--error">
|
|
||||||
Не удалось загрузить сотрудников
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!usersLoading && !usersError && employees.length === 0 && (
|
|
||||||
<div className="employees-state">Сотрудники не найдены</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!usersError && employees.length > 0 && (
|
|
||||||
<table className="employees-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>ФИО</th>
|
|
||||||
<th>Организация</th>
|
|
||||||
<th>Роль</th>
|
|
||||||
<th>Управление</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{employees.map((employee) => (
|
|
||||||
<tr key={employee.id} className="employees-table__row">
|
|
||||||
<td>{employee.id}</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div className="employee-person">
|
|
||||||
<span>
|
|
||||||
{getEmployeeFullName(employee) || 'ФИО не указано'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<span className="employee-organisation">
|
|
||||||
{employee.org?.name || 'Организация не указана'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
employee.role === 'Admin'
|
|
||||||
? 'employee-role employee-role--admin'
|
|
||||||
: 'employee-role'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getEmployeeRoleLabel(employee.role)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div className="employees-table-actions">
|
|
||||||
<button
|
|
||||||
className="employees-action-btn employees-action-btn--edit"
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
handleEditEmployee(employee)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil size={16} />
|
|
||||||
Редактировать
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="employees-action-btn employees-action-btn--delete"
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
handleDeleteEmployee(employee)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeSection === 'organisations' && (
|
|
||||||
<>
|
|
||||||
{isOrganisationsInitialLoading && (
|
|
||||||
<div className="employees-state">Загрузка организаций...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{organisationsError && (
|
|
||||||
<div className="employees-state employees-state--error">
|
|
||||||
Не удалось загрузить организации
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isOrganisationsInitialLoading &&
|
|
||||||
!organisationsError &&
|
|
||||||
organisations.length === 0 && (
|
|
||||||
<div className="employees-state">Организации не найдены</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!organisationsError && organisations.length > 0 && (
|
|
||||||
<table className="employees-table organisations-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Название организации</th>
|
|
||||||
<th>Управление</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{organisations.map((organisation) => (
|
|
||||||
<tr
|
|
||||||
key={organisation.id}
|
|
||||||
className="employees-table__row employees-table__row--clickable"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => navigate(`/employees/organisations/${organisation.id}`)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
navigate(`/employees/organisations/${organisation.id}`)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td>{organisation.id}</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<span className="employee-organisation-name">
|
|
||||||
{organisation.name}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div className="employees-table-actions">
|
|
||||||
<button
|
|
||||||
className="employees-action-btn employees-action-btn--edit"
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
handleEditOrganisation(organisation)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil size={16} />
|
|
||||||
Редактировать
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="employees-action-btn employees-action-btn--delete"
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
handleDeleteOrganisation(organisation)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeSection === 'users' && (
|
|
||||||
<div className="employees-pagination">
|
|
||||||
<span>Страница {usersCurrentPage}</span>
|
|
||||||
|
|
||||||
<div className="employees-pagination__controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={usersCurrentPage === 1 || usersLoading}
|
|
||||||
onClick={handlePrevUsersPage}
|
|
||||||
>
|
|
||||||
Назад
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="is-active" type="button">
|
|
||||||
{usersCurrentPage}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!usersNextKey || usersLoading}
|
|
||||||
onClick={handleNextUsersPage}
|
|
||||||
>
|
|
||||||
Вперед
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeSection === 'organisations' && (
|
|
||||||
<div className="employees-pagination">
|
|
||||||
|
|
||||||
<div className="employees-pagination__controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasPrevOrganisationsPage || organisationsLoading}
|
|
||||||
onClick={handlePrevOrganisationsPage}
|
|
||||||
aria-label="Предыдущая страница"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{organisationsPaginationItems.map((item, index) => {
|
|
||||||
if (item === 'prev-ellipsis' || item === 'next-ellipsis') {
|
|
||||||
const isPrev = item === 'prev-ellipsis'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="employees-pagination__ellipsis-button"
|
|
||||||
type="button"
|
|
||||||
key={`${item}-${index}`}
|
|
||||||
disabled={organisationsLoading}
|
|
||||||
onClick={
|
|
||||||
isPrev
|
|
||||||
? handleJumpPrevOrganisationsPage
|
|
||||||
: handleJumpNextOrganisationsPage
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
isPrev
|
|
||||||
? 'Перейти на 5 страниц назад'
|
|
||||||
: 'Перейти на 5 страниц вперед'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="employees-pagination__ellipsis-dots">...</span>
|
|
||||||
<span className="employees-pagination__ellipsis-arrow">
|
|
||||||
{isPrev ? '«' : '»'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageIndex = item - 1
|
|
||||||
const isActive = pageIndex === organisationsCurrentPage
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={isActive ? 'is-active' : ''}
|
|
||||||
type="button"
|
|
||||||
key={item}
|
|
||||||
disabled={organisationsLoading || isActive}
|
|
||||||
onClick={() => setOrganisationsCurrentPage(pageIndex)}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasNextOrganisationsPage || organisationsLoading}
|
|
||||||
onClick={handleNextOrganisationsPage}
|
|
||||||
aria-label="Следующая страница"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddEmployeeModal
|
|
||||||
open={isAddEmployeeOpen}
|
|
||||||
mode="create"
|
|
||||||
onOpenChange={setIsAddEmployeeOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AddEmployeeModal
|
|
||||||
open={Boolean(editingEmployee)}
|
|
||||||
mode="edit"
|
|
||||||
employee={editingEmployee}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setEditingEmployee(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AddOrganisationModal
|
|
||||||
open={isAddOrganisationOpen}
|
|
||||||
mode="create"
|
|
||||||
onOpenChange={setIsAddOrganisationOpen}
|
|
||||||
onSuccess={async () => {
|
|
||||||
setOrganisationsCurrentPage(0)
|
|
||||||
|
|
||||||
await refetchOrganisations({
|
|
||||||
page: 0,
|
|
||||||
query: debouncedOrganisationSearch || undefined,
|
|
||||||
sortDirection: organisationSort.sortDirection,
|
|
||||||
sortField: organisationSort.sortField,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AddOrganisationModal
|
|
||||||
open={Boolean(editingOrganisation)}
|
|
||||||
mode="edit"
|
|
||||||
organisation={editingOrganisation}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setEditingOrganisation(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDangerDialog
|
|
||||||
open={Boolean(deletingOrganisation)}
|
|
||||||
title="Удалить организацию?"
|
|
||||||
description={
|
|
||||||
deletingOrganisation
|
|
||||||
? `Организация «${deletingOrganisation.name}» будет удалена из системы. Это действие нельзя будет отменить.`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
confirmText="Удалить"
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setDeletingOrganisation(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onConfirm={handleConfirmDeleteOrganisation}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDangerDialog
|
|
||||||
open={Boolean(deletingEmployee)}
|
|
||||||
title="Удалить сотрудника?"
|
|
||||||
description={
|
|
||||||
deletingEmployee
|
|
||||||
? `Сотрудник «${getEmployeeFullName(deletingEmployee)}» будет удалён из системы. Это действие нельзя будет отменить.`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
confirmText="Удалить"
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setDeletingEmployee(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onConfirm={handleConfirmDeleteEmployee}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3,31 +3,31 @@
|
||||||
.add-employee-modal__overlay {
|
.add-employee-modal__overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 80;
|
z-index: 110;
|
||||||
|
|
||||||
background: rgba(15, 23, 42, 0.42);
|
background: $color-backdrop;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
|
|
||||||
animation: addEmployeeOverlayShow 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-employee-modal {
|
.add-employee-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 90;
|
z-index: 120;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
||||||
width: min(520px, calc(100vw - 32px));
|
width: min(860px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
border-radius: 24px;
|
border-radius: 26px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
||||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
|
box-shadow: $shadow-modal;
|
||||||
|
|
||||||
animation: addEmployeeModalShow 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-employee-modal__header {
|
.add-employee-modal__header {
|
||||||
|
|
@ -42,8 +42,8 @@
|
||||||
.add-employee-modal__title {
|
.add-employee-modal__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 24px;
|
font-size: $font-size-24;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,17 +76,83 @@
|
||||||
color: $blue;
|
color: $blue;
|
||||||
background: $gray20;
|
background: $gray20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-employee-form {
|
.add-employee-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-employee-layout {
|
||||||
|
//display: grid;
|
||||||
|
//grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-employee-fields__section {
|
||||||
|
border: 1px solid $color-border-accent;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: $color-surface-muted;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.avatar-fio-container{
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-employee-fields__title {
|
||||||
|
span {
|
||||||
|
color: $color-text-strong;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-employee-fields {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
&__grid {
|
}
|
||||||
|
|
||||||
|
.add-employee-fields__section {
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-employee-form__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-employee-form__grid--credentials {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-employee-field__spacer {
|
||||||
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-employee-field {
|
.add-employee-field {
|
||||||
|
|
@ -94,50 +160,286 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
span {
|
> span {
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, select {
|
input,
|
||||||
|
select {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border: 1px solid #dfe5ef;
|
border: 1px solid $color-border-strong;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
|
||||||
background: #f8fafc;
|
background: $color-surface;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
transition: 0.2s ease;
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: $blue;
|
border-color: $blue;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
box-shadow: 0 0 0 3px rgba($color-primary-rgb, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-role-select {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.organisation-select__trigger {
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-role-select__dropdown {
|
||||||
|
z-index: 140;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-role-select__list {
|
||||||
|
margin-top: 0;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-role-select__option {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
small {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker {
|
||||||
|
--height: 44px;
|
||||||
|
--radius: 14px;
|
||||||
|
--motion: 320ms cubic-bezier(0.34, 1.42, 0.64, 1);
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: var(--height);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
color: $color-text-strong;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
gap var(--motion),
|
||||||
|
opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker__button,
|
||||||
|
.employee-password-picker__status,
|
||||||
|
.employee-password-picker__input,
|
||||||
|
.employee-password-picker__action {
|
||||||
|
height: var(--height);
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: $color-surface;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
height: 46px;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
flex-basis var(--motion),
|
||||||
|
width var(--motion),
|
||||||
|
padding var(--motion),
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform var(--motion),
|
||||||
|
background-color 220ms ease,
|
||||||
|
color 220ms ease,
|
||||||
|
box-shadow 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker__button,
|
||||||
|
.employee-password-picker__status {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 0 14px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
border: 1px solid $color-border-strong;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker__button {
|
||||||
|
border: 1px solid $color-border-strong;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: rgba($color-primary-rgb, 0.24);
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker__status {
|
||||||
|
border: 1px solid rgba($color-primary-rgb, 0.18);
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker__input {
|
||||||
|
width: auto !important;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 0 14px !important;
|
||||||
|
border: 1px solid $color-border-strong !important;
|
||||||
|
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: scaleX(1);
|
||||||
|
|
||||||
|
background: $color-surface !important;
|
||||||
|
font-size: $font-size-15 !important;
|
||||||
|
-webkit-text-fill-color: $color-text-strong;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $blue !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba($color-primary-rgb, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker__action {
|
||||||
|
width: var(--height);
|
||||||
|
flex: 0 0 var(--height);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
|
|
||||||
|
background: $color-surface;
|
||||||
|
color: $gray50;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba($color-danger-rgb, 0.08);
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker--open {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker--confirmed {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-picker__action--confirm {
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
box-shadow: 0 8px 20px rgba($color-primary-rgb, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-confirm {
|
||||||
|
--height: 46px;
|
||||||
|
--radius: 14px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: var(--height);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-password-confirm__input {
|
||||||
|
--motion: 320ms cubic-bezier(0.34, 1.42, 0.64, 1);
|
||||||
|
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
transform var(--motion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-employee-field--password-confirm-error {
|
||||||
|
.employee-password-confirm__input {
|
||||||
|
border-color: rgba($color-danger-rgb, 0.62) !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba($color-danger-rgb, 0.1) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-employee-error {
|
.add-employee-error {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(224, 0, 0, 0.08);
|
background: $color-danger-tint;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
|
||||||
color: $red;
|
color: $red;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-employee-modal__footer {
|
.add-employee-modal__footer {
|
||||||
margin-top: 8px;
|
padding-top: 4px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
@ -152,7 +454,7 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|
@ -165,16 +467,7 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $gray20;
|
background: $gray20;
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-employee-submit {
|
|
||||||
background: $blue;
|
|
||||||
color: #ffffff;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|
@ -183,24 +476,37 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes addEmployeeOverlayShow {
|
.add-employee-submit {
|
||||||
from {
|
background: $blue;
|
||||||
opacity: 0;
|
color: $color-text-inverse;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px $blue, 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
&:disabled {
|
||||||
opacity: 1;
|
opacity: 0.65;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
&:disabled:hover {
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes addEmployeeModalShow {
|
@media (max-width: 820px) {
|
||||||
from {
|
.add-employee-layout {
|
||||||
opacity: 0;
|
grid-template-columns: 1fr;
|
||||||
transform: translate(-50%, -48%) scale(0.98);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
@media (max-width: 560px) {
|
||||||
opacity: 1;
|
.add-employee-modal {
|
||||||
transform: translate(-50%, -50%) scale(1);
|
width: min(100vw - 20px, 520px);
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-employee-form__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,29 +3,31 @@
|
||||||
.add-organisation-modal__overlay {
|
.add-organisation-modal__overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 80;
|
z-index: 110;
|
||||||
|
|
||||||
background: rgba(15, 23, 42, 0.42);
|
background: $color-backdrop;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
|
|
||||||
animation: addOrganisationOverlayShow 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-organisation-modal {
|
.add-organisation-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 90;
|
z-index: 120;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
||||||
width: min(520px, calc(100vw - 32px));
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
|
box-shadow: $shadow-modal;
|
||||||
|
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
animation: addOrganisationModalShow 0.2s ease;
|
}
|
||||||
|
|
||||||
|
.add-organisation-modal--with-policy {
|
||||||
|
width: min(980px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-organisation-modal__header {
|
.add-organisation-modal__header {
|
||||||
|
|
@ -41,7 +43,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
color: $color-text;
|
color: $color-text;
|
||||||
font-size: 24px;
|
font-size: $font-size-24;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,7 +51,7 @@
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +84,44 @@
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-organisation-form--with-policy {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 0.82fr) minmax(460px, 1.18fr);
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.add-organisation-error,
|
||||||
|
.add-organisation-modal__footer {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.avatar-upload{
|
||||||
|
flex: 1;
|
||||||
|
width: inherit;
|
||||||
|
.avatar-upload__actions{
|
||||||
|
//display: none;
|
||||||
|
}
|
||||||
|
&__preview{
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
&__camera{
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.add-organisation-field{
|
||||||
|
span{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-form__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.add-organisation-field {
|
.add-organisation-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -89,28 +129,28 @@
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: $color-text;
|
color: $color-text;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border: 1px solid #dfe5ef;
|
border: 1px solid $color-border-strong;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
|
||||||
background: #f8fafc;
|
background: $color-surface-muted;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
color: $color-text;
|
color: $color-text;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
transition: 0.2s ease;
|
transition: 0.2s ease;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: $blue;
|
border-color: $blue;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
|
|
@ -121,11 +161,11 @@
|
||||||
|
|
||||||
.add-organisation-error {
|
.add-organisation-error {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(224, 0, 0, 0.08);
|
background: $color-danger-tint;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
|
||||||
color: $red;
|
color: $red;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +185,7 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|
@ -169,198 +209,23 @@
|
||||||
|
|
||||||
.add-organisation-submit {
|
.add-organisation-submit {
|
||||||
background: $blue;
|
background: $blue;
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.9;
|
box-shadow: 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
|
||||||
|
}
|
||||||
|
&:disabled:hover {
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-organisation-policy {
|
@media (max-width: 900px) {
|
||||||
margin-top: 2px;
|
.add-organisation-modal--with-policy {
|
||||||
padding: 14px;
|
width: min(560px, calc(100vw - 32px));
|
||||||
border: 1px solid #dfe5ef;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-organisation-policy__header {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: $color-text;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
.add-organisation-form--with-policy {
|
||||||
margin: 4px 0 0;
|
display: flex;
|
||||||
color: $gray50;
|
flex-direction: column;
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-organisation-policy__list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-organisation-policy-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: $blue;
|
|
||||||
background: #eef1f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
.add-organisation-policy-item__content {
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
|
|
||||||
svg,
|
|
||||||
span,
|
|
||||||
button {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-organisation-policy-item__content {
|
|
||||||
min-height: 44px;
|
|
||||||
flex: 1;
|
|
||||||
border-top: 1px solid $gray20;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
> span {
|
|
||||||
color: $color-text;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-organisation-policy-switch {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
width: 48px;
|
|
||||||
height: 24px;
|
|
||||||
flex: 0 0 48px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
border-radius: 999px;
|
|
||||||
background: $gray20;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
transition:
|
|
||||||
background-color 0.28s ease,
|
|
||||||
box-shadow 0.28s ease,
|
|
||||||
transform 0.18s ease;
|
|
||||||
|
|
||||||
> span {
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
|
|
||||||
width: 24px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 100px;
|
|
||||||
background: #ffffff;
|
|
||||||
|
|
||||||
box-shadow:
|
|
||||||
0 2px 5px rgba(15, 23, 42, 0.18),
|
|
||||||
0 1px 1px rgba(15, 23, 42, 0.08);
|
|
||||||
|
|
||||||
transition:
|
|
||||||
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
|
|
||||||
box-shadow 0.28s ease,
|
|
||||||
width 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-enabled {
|
|
||||||
background: $blue;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba($blue, 0.12);
|
|
||||||
|
|
||||||
> span {
|
|
||||||
transform: translateX(18px);
|
|
||||||
box-shadow:
|
|
||||||
0 3px 7px rgba(3, 29, 154, 0.24),
|
|
||||||
0 1px 1px rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba($gray50, 0.12),
|
|
||||||
0 4px 10px rgba(15, 23, 42, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.96);
|
|
||||||
|
|
||||||
> span {
|
|
||||||
width: 23px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-enabled:active > span {
|
|
||||||
transform: translateX(17px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 3px rgba($blue, 0.18),
|
|
||||||
inset 0 0 0 1px rgba($blue, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes addOrganisationOverlayShow {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes addOrganisationModalShow {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -48%) scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,30 +1,45 @@
|
||||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react'
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type FormEvent,
|
||||||
|
} from 'react'
|
||||||
import * as Dialog from '@radix-ui/react-dialog'
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useApolloClient, useMutation } from '@apollo/client/react'
|
import { useApolloClient, useMutation } from '@apollo/client/react'
|
||||||
|
|
||||||
import { WifiIcon } from '../../../../assets/icons/Wifi'
|
|
||||||
import { BluetoothIcon } from '../../../../assets/icons/Bluetooth'
|
|
||||||
import { GpsIcon } from '../../../../assets/icons/Gps'
|
|
||||||
import { CameraIcon } from '../../../../assets/icons/Camera'
|
|
||||||
import { SimIcon } from '../../../../assets/icons/Sim'
|
|
||||||
import { VolumeIcon } from '../../../../assets/icons/Volume'
|
|
||||||
import {
|
import {
|
||||||
CHANGE_ORGANISATION_MUTATION,
|
CHANGE_ORGANISATION_MUTATION,
|
||||||
CREATE_ORGANISATION_MUTATION,
|
CREATE_ORGANISATION_MUTATION,
|
||||||
|
CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION,
|
||||||
GET_ORGANISATION_QUERY,
|
GET_ORGANISATION_QUERY,
|
||||||
GET_ORGANISATIONS_QUERY,
|
|
||||||
} from '../../../../entities/employee/api/employee.graphql'
|
} from '../../../../entities/employee/api/employee.graphql'
|
||||||
import type {
|
import type {
|
||||||
ChangeOrganisationData,
|
ChangeOrganisationData,
|
||||||
ChangeOrganisationVariables,
|
ChangeOrganisationVariables,
|
||||||
CreateOrganisationData,
|
CreateOrganisationData,
|
||||||
CreateOrganisationVariables,
|
CreateOrganisationVariables,
|
||||||
|
CreateUploadOrganisationLogoUrlData,
|
||||||
|
CreateUploadOrganisationLogoUrlVariables,
|
||||||
GetOrganisationData,
|
GetOrganisationData,
|
||||||
GetOrganisationVariables,
|
GetOrganisationVariables,
|
||||||
Organisation,
|
Organisation,
|
||||||
OrganisationPolicy,
|
OrganisationPolicy,
|
||||||
} from '../../../../entities/employee/model/types'
|
} from '../../../../entities/employee/model/types'
|
||||||
|
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
|
||||||
|
import {
|
||||||
|
OrganisationPolicyCard,
|
||||||
|
defaultOrganisationPolicyDraft,
|
||||||
|
mapOrganisationPolicyDraftToVariables,
|
||||||
|
mapOrganisationPolicyToDraft,
|
||||||
|
type OrganisationPolicyDraft,
|
||||||
|
} from '../../../OrganisationPage/components/OrganisationPolicyCard/OrganisationPolicyCard'
|
||||||
|
|
||||||
|
import { AvatarUpload } from '../AvatarUpload/AvatarUpload'
|
||||||
|
import { uploadMultipartFile } from '../../lib/uploadMultipartFile'
|
||||||
|
|
||||||
import './AddOrganisationModal.scss'
|
import './AddOrganisationModal.scss'
|
||||||
|
|
||||||
|
|
@ -36,82 +51,16 @@ type AddOrganisationModalProps = {
|
||||||
onSuccess?: () => void | Promise<void>
|
onSuccess?: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModalPolicyKey =
|
type OrganisationPolicyVariables = Omit<
|
||||||
| keyof OrganisationPolicy
|
CreateOrganisationVariables,
|
||||||
| 'wifi'
|
'name'
|
||||||
| 'speaker'
|
>
|
||||||
|
|
||||||
type ModalPolicyItem = {
|
function getPolicyVariables(
|
||||||
key: ModalPolicyKey
|
policy?: OrganisationPolicy | OrganisationPolicyDraft | null,
|
||||||
label: string
|
): OrganisationPolicyVariables {
|
||||||
icon: ReactNode
|
return mapOrganisationPolicyDraftToVariables(
|
||||||
disabled?: boolean
|
mapOrganisationPolicyToDraft(policy),
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOrganisationPolicy: OrganisationPolicy = {
|
|
||||||
canUseBluetooth: true,
|
|
||||||
canUseCamera: true,
|
|
||||||
canUseGPS: true,
|
|
||||||
canUseSim: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const policyItems: ModalPolicyItem[] = [
|
|
||||||
{
|
|
||||||
key: 'wifi',
|
|
||||||
label: 'Wi-Fi',
|
|
||||||
icon: <WifiIcon />,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'canUseBluetooth',
|
|
||||||
label: 'Bluetooth',
|
|
||||||
icon: <BluetoothIcon />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'canUseGPS',
|
|
||||||
label: 'GPS',
|
|
||||||
icon: <GpsIcon />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'canUseCamera',
|
|
||||||
label: 'Камера',
|
|
||||||
icon: <CameraIcon />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'canUseSim',
|
|
||||||
label: 'SIM-карта',
|
|
||||||
icon: <SimIcon />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'speaker',
|
|
||||||
label: 'Динамик',
|
|
||||||
icon: <VolumeIcon />,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const defaultOrganisationsQueryVariables = {
|
|
||||||
page: 0,
|
|
||||||
query: undefined,
|
|
||||||
sortDirection: 'ASC' as const,
|
|
||||||
sortField: 'ID' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPolicyVariables(policy?: OrganisationPolicy | null): OrganisationPolicy {
|
|
||||||
return {
|
|
||||||
canUseBluetooth: policy?.canUseBluetooth ?? true,
|
|
||||||
canUseCamera: policy?.canUseCamera ?? true,
|
|
||||||
canUseGPS: policy?.canUseGPS ?? true,
|
|
||||||
canUseSim: policy?.canUseSim ?? true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBackendPolicyKey(key: ModalPolicyKey): key is keyof OrganisationPolicy {
|
|
||||||
return (
|
|
||||||
key === 'canUseBluetooth' ||
|
|
||||||
key === 'canUseCamera' ||
|
|
||||||
key === 'canUseGPS' ||
|
|
||||||
key === 'canUseSim'
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,10 +74,19 @@ export function AddOrganisationModal({
|
||||||
const client = useApolloClient()
|
const client = useApolloClient()
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [policy, setPolicy] = useState<OrganisationPolicy>(
|
const [policy, setPolicy] = useState<OrganisationPolicyDraft>(
|
||||||
defaultOrganisationPolicy,
|
defaultOrganisationPolicyDraft,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [logoFile, setLogoFile] = useState<File | null>(null)
|
||||||
|
const [logoPreviewUrl, setLogoPreviewUrl] = useState('')
|
||||||
|
const logoPreviewUrlRef = useRef('')
|
||||||
|
|
||||||
|
const [createUploadOrganisationLogoUrl] = useMutation<
|
||||||
|
CreateUploadOrganisationLogoUrlData,
|
||||||
|
CreateUploadOrganisationLogoUrlVariables
|
||||||
|
>(CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION)
|
||||||
|
|
||||||
const isEditMode = mode === 'edit'
|
const isEditMode = mode === 'edit'
|
||||||
|
|
||||||
const [createOrganisation, { loading: isCreating, error: createError }] =
|
const [createOrganisation, { loading: isCreating, error: createError }] =
|
||||||
|
|
@ -140,37 +98,121 @@ export function AddOrganisationModal({
|
||||||
useMutation<ChangeOrganisationData, ChangeOrganisationVariables>(
|
useMutation<ChangeOrganisationData, ChangeOrganisationVariables>(
|
||||||
CHANGE_ORGANISATION_MUTATION,
|
CHANGE_ORGANISATION_MUTATION,
|
||||||
)
|
)
|
||||||
|
const [isLogoUploading, setIsLogoUploading] = useState(false)
|
||||||
const isLoading = isCreating || isChanging
|
const isLoading = isCreating || isChanging || isLogoUploading
|
||||||
const mutationError = createError || changeError
|
const mutationError = createError || changeError
|
||||||
|
const modalClassName = useMemo(
|
||||||
|
() =>
|
||||||
|
`add-organisation-modal ${
|
||||||
|
!isEditMode ? 'add-organisation-modal--with-policy' : ''
|
||||||
|
}`,
|
||||||
|
[isEditMode],
|
||||||
|
)
|
||||||
|
const formClassName = useMemo(
|
||||||
|
() =>
|
||||||
|
`add-organisation-form ${
|
||||||
|
!isEditMode ? 'add-organisation-form--with-policy' : ''
|
||||||
|
}`,
|
||||||
|
[isEditMode],
|
||||||
|
)
|
||||||
|
const logoAvatarUrl = useMemo(
|
||||||
|
() => logoPreviewUrl || organisation?.logoUrl || '',
|
||||||
|
[logoPreviewUrl, organisation?.logoUrl],
|
||||||
|
)
|
||||||
|
const organisationInitials = useMemo(
|
||||||
|
() => getOrganisationInitials(name),
|
||||||
|
[name],
|
||||||
|
)
|
||||||
|
const handleNameChange = useCallback((
|
||||||
|
event: ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
setName(event.target.value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
logoPreviewUrlRef.current = logoPreviewUrl
|
||||||
|
}, [logoPreviewUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (logoPreviewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(logoPreviewUrlRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function getOrganisationInitials(nameValue: string) {
|
||||||
|
const words = nameValue.trim().split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
|
if (words.length >= 2) {
|
||||||
|
return `${words[0][0] ?? ''}${words[1][0] ?? ''}`.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nameValue.trim().slice(0, 2).toUpperCase() || 'ОР'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogoChange(file: File, previewUrl: string) {
|
||||||
|
if (logoPreviewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(logoPreviewUrlRef.current)
|
||||||
|
logoPreviewUrlRef.current = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogoFile(file)
|
||||||
|
setLogoPreviewUrl(previewUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogoReset() {
|
||||||
|
resetLogoDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLogoDraft() {
|
||||||
|
if (logoPreviewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(logoPreviewUrlRef.current)
|
||||||
|
logoPreviewUrlRef.current = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogoFile(null)
|
||||||
|
setLogoPreviewUrl('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLogoForOrganisation(orgId: string) {
|
||||||
|
if (!logoFile) return
|
||||||
|
|
||||||
|
setIsLogoUploading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadUrlResult = await createUploadOrganisationLogoUrl({
|
||||||
|
variables: {
|
||||||
|
orgId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadUrl = uploadUrlResult.data?.createUploadOrganisationLogoUrl
|
||||||
|
|
||||||
|
if (!uploadUrl) {
|
||||||
|
throw new Error('Не удалось получить ссылку для загрузки логотипа')
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadMultipartFile(uploadUrl, 'logo', logoFile)
|
||||||
|
} finally {
|
||||||
|
setIsLogoUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
|
resetLogoDraft()
|
||||||
|
|
||||||
if (isEditMode && organisation) {
|
if (isEditMode && organisation) {
|
||||||
setName(organisation.name ?? '')
|
setName(organisation.name ?? '')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setName('')
|
setName('')
|
||||||
setPolicy(defaultOrganisationPolicy)
|
setPolicy(defaultOrganisationPolicyDraft)
|
||||||
}, [open, isEditMode, organisation])
|
}, [open, isEditMode, organisation])
|
||||||
|
|
||||||
function handlePolicyToggle(key: ModalPolicyKey, disabled?: boolean) {
|
|
||||||
if (disabled || !isBackendPolicyKey(key)) return
|
|
||||||
|
|
||||||
setPolicy((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[key]: !prev[key],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPolicyValue(key: ModalPolicyKey) {
|
|
||||||
if (!isBackendPolicyKey(key)) return true
|
|
||||||
|
|
||||||
return policy[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
|
@ -200,170 +242,201 @@ export function AddOrganisationModal({
|
||||||
|
|
||||||
const policyVariables = getPolicyVariables(currentPolicy)
|
const policyVariables = getPolicyVariables(currentPolicy)
|
||||||
|
|
||||||
await changeOrganisation({
|
try {
|
||||||
variables: {
|
await changeOrganisation({
|
||||||
id: String(organisation.id),
|
variables: {
|
||||||
name: trimmedName,
|
id: String(organisation.id),
|
||||||
...policyVariables,
|
name: trimmedName,
|
||||||
},
|
...policyVariables,
|
||||||
refetchQueries: [
|
},
|
||||||
{
|
refetchQueries: [
|
||||||
query: GET_ORGANISATION_QUERY,
|
{
|
||||||
variables: {
|
query: GET_ORGANISATION_QUERY,
|
||||||
id: String(organisation.id),
|
variables: {
|
||||||
|
id: String(organisation.id),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
{
|
})
|
||||||
query: GET_ORGANISATIONS_QUERY,
|
|
||||||
variables: defaultOrganisationsQueryVariables,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await onSuccess?.()
|
if (logoFile) {
|
||||||
|
try {
|
||||||
|
await uploadLogoForOrganisation(String(organisation.id))
|
||||||
|
} catch (logoError) {
|
||||||
|
console.error(
|
||||||
|
'Организация изменена, но логотип не загрузился',
|
||||||
|
logoError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSuccess?.()
|
||||||
|
} catch (successError) {
|
||||||
|
console.error(
|
||||||
|
'Организация изменена, но refetch не сработал',
|
||||||
|
successError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (changeError) {
|
||||||
|
console.error('Не удалось изменить организацию', changeError)
|
||||||
|
}
|
||||||
|
|
||||||
onOpenChange(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('createOrganisation variables', {
|
const policyVariables = getPolicyVariables(policy)
|
||||||
name: trimmedName,
|
|
||||||
...policy,
|
|
||||||
})
|
|
||||||
|
|
||||||
await createOrganisation({
|
try {
|
||||||
variables: {
|
const result = await createOrganisation({
|
||||||
name: trimmedName,
|
variables: {
|
||||||
...policy,
|
name: trimmedName,
|
||||||
},
|
...policyVariables,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
|
||||||
await onSuccess?.()
|
const createdOrganisationId = result.data?.createOrganisation.id
|
||||||
|
|
||||||
setName('')
|
if (!createdOrganisationId) {
|
||||||
setPolicy(defaultOrganisationPolicy)
|
throw new Error('Организация создана, но id не вернулся')
|
||||||
onOpenChange(false)
|
}
|
||||||
|
|
||||||
|
if (logoFile) {
|
||||||
|
try {
|
||||||
|
await uploadLogoForOrganisation(String(createdOrganisationId))
|
||||||
|
} catch (logoError) {
|
||||||
|
console.error(
|
||||||
|
'Организация создана, но логотип не загрузился',
|
||||||
|
logoError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSuccess?.()
|
||||||
|
} catch (successError) {
|
||||||
|
console.error(
|
||||||
|
'Организация создана, но refetch не сработал',
|
||||||
|
successError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (createError) {
|
||||||
|
console.error('Не удалось создать организацию', createError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
<Dialog.Portal>
|
<MotionDialogContent
|
||||||
<Dialog.Overlay className="add-organisation-modal__overlay" />
|
open={open}
|
||||||
|
overlayClassName="add-organisation-modal__overlay"
|
||||||
|
contentClassName={modalClassName}
|
||||||
|
motionPreset="fade"
|
||||||
|
>
|
||||||
|
<div className="add-organisation-modal__header">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title className="add-organisation-modal__title">
|
||||||
|
{isEditMode
|
||||||
|
? 'Редактировать организацию'
|
||||||
|
: 'Добавить организацию'}
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
<Dialog.Content className="add-organisation-modal">
|
<Dialog.Description className="add-organisation-modal__description">
|
||||||
<div className="add-organisation-modal__header">
|
{isEditMode
|
||||||
<div>
|
? 'Изменение данных организации'
|
||||||
<Dialog.Title className="add-organisation-modal__title">
|
: 'Создание организации для привязки пользователей и устройств'}
|
||||||
{isEditMode ? 'Редактировать организацию' : 'Добавить организацию'}
|
</Dialog.Description>
|
||||||
</Dialog.Title>
|
|
||||||
|
|
||||||
<Dialog.Description className="add-organisation-modal__description">
|
|
||||||
{isEditMode
|
|
||||||
? 'Изменение данных организации'
|
|
||||||
: 'Создание организации для привязки пользователей и устройств'}
|
|
||||||
</Dialog.Description>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog.Close
|
|
||||||
className="add-organisation-modal__close"
|
|
||||||
type="button"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</Dialog.Close>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="add-organisation-form" onSubmit={handleSubmit}>
|
<Dialog.Close
|
||||||
|
className="add-organisation-modal__close"
|
||||||
|
type="button"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className={formClassName}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<div className="add-organisation-form__main">
|
||||||
|
<AvatarUpload
|
||||||
|
avatarUrl={logoAvatarUrl}
|
||||||
|
allowCamera={false}
|
||||||
|
initials={organisationInitials}
|
||||||
|
disabled={isLoading}
|
||||||
|
hasSelectedFile={Boolean(logoFile)}
|
||||||
|
title="Логотип организации"
|
||||||
|
description="PNG, JPG или GIF"
|
||||||
|
chooseText="Выбрать логотип"
|
||||||
|
replaceText="Заменить логотип"
|
||||||
|
alt="Логотип организации"
|
||||||
|
onChange={handleLogoChange}
|
||||||
|
onReset={handleLogoReset}
|
||||||
|
/>
|
||||||
|
|
||||||
<label className="add-organisation-field">
|
<label className="add-organisation-field">
|
||||||
<span>Название организации</span>
|
<span>Название организации</span>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={handleNameChange}
|
||||||
placeholder="Например: Organisation 1"
|
placeholder="Название организации"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isEditMode && (
|
{!isEditMode && (
|
||||||
<div className="add-organisation-policy">
|
<OrganisationPolicyCard
|
||||||
<div className="add-organisation-policy__header">
|
variant="form"
|
||||||
<h3>Начальная политика</h3>
|
title="Начальная политика"
|
||||||
<p>Будет применяться к сотрудникам этой организации</p>
|
description="Будет применяться к устройствам этой организации"
|
||||||
</div>
|
value={policy}
|
||||||
|
disabled={isLoading}
|
||||||
|
onChange={setPolicy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="add-organisation-policy__list">
|
{mutationError && (
|
||||||
{policyItems.map((item) => {
|
<div className="add-organisation-error">
|
||||||
const enabled = getPolicyValue(item.key)
|
{isEditMode
|
||||||
|
? 'Не удалось сохранить организацию. Проверьте данные.'
|
||||||
return (
|
: 'Не удалось добавить организацию. Проверьте данные.'}
|
||||||
<div
|
|
||||||
className={`add-organisation-policy-item ${item.disabled ? 'is-disabled' : ''
|
|
||||||
}`}
|
|
||||||
key={item.key}
|
|
||||||
aria-disabled={item.disabled}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
|
|
||||||
<div className="add-organisation-policy-item__content">
|
|
||||||
<span>{item.label}</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`add-organisation-policy-switch ${enabled ? 'is-enabled' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
handlePolicyToggle(item.key, item.disabled)
|
|
||||||
}
|
|
||||||
disabled={item.disabled || isLoading}
|
|
||||||
aria-pressed={enabled}
|
|
||||||
aria-label={`${enabled ? 'Выключить' : 'Включить'
|
|
||||||
} ${item.label}`}
|
|
||||||
>
|
|
||||||
<span />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mutationError && (
|
|
||||||
<div className="add-organisation-error">
|
|
||||||
{isEditMode
|
|
||||||
? 'Не удалось сохранить организацию. Проверьте данные.'
|
|
||||||
: 'Не удалось добавить организацию. Проверьте данные.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="add-organisation-modal__footer">
|
|
||||||
<Dialog.Close
|
|
||||||
className="add-organisation-cancel"
|
|
||||||
type="button"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Dialog.Close>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="add-organisation-submit"
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || !name.trim()}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? isEditMode
|
|
||||||
? 'Сохранение...'
|
|
||||||
: 'Добавление...'
|
|
||||||
: isEditMode
|
|
||||||
? 'Сохранить'
|
|
||||||
: 'Добавить'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
<div className="add-organisation-modal__footer">
|
||||||
|
<Dialog.Close
|
||||||
|
className="add-organisation-cancel"
|
||||||
|
type="button"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Dialog.Close>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="add-organisation-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !name.trim()}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? isEditMode
|
||||||
|
? 'Сохранение...'
|
||||||
|
: 'Добавление...'
|
||||||
|
: isEditMode
|
||||||
|
? 'Сохранить'
|
||||||
|
: 'Добавить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</MotionDialogContent>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.avatar-upload__menu {
|
||||||
|
min-width: 220px;
|
||||||
|
|
||||||
|
padding: 6px;
|
||||||
|
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
background: $color-media-bg;
|
||||||
|
border: 1px solid $color-media-border;
|
||||||
|
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload__menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
padding: 0 12px;
|
||||||
|
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-media-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-modal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
background: $color-media-overlay;
|
||||||
|
|
||||||
|
z-index: 9998;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-modal {
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 751px;
|
||||||
|
|
||||||
|
background: $color-media-bg;
|
||||||
|
|
||||||
|
border-radius: 24px;
|
||||||
|
|
||||||
|
//padding: 24px;
|
||||||
|
|
||||||
|
z-index: 9999;
|
||||||
|
.camera-modal__header{
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
button{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: $color-media-control-bg;
|
||||||
|
border: none;
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
padding: 8px;
|
||||||
|
height: 44px;
|
||||||
|
width: 44px;
|
||||||
|
color: $color-media-control-text-muted;
|
||||||
|
border-radius: 1000px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .2s ease;
|
||||||
|
&:hover{
|
||||||
|
color: $color-media-control-text;
|
||||||
|
background-color: $color-media-control-bg-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-modal__body {
|
||||||
|
margin-top: 0px;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-modal__not-found{
|
||||||
|
color: $color-media-control-text;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
//background-color: $blue;
|
||||||
|
border-radius: 24px;
|
||||||
|
svg{
|
||||||
|
width: 32px;
|
||||||
|
height: auto;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-modal__body video, .camera-modal__body img {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
border-radius: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-modal__footer {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
button{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba($black, 0.1);
|
||||||
|
border: none;
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
padding: 8px;
|
||||||
|
height: 44px;
|
||||||
|
width: 44px;
|
||||||
|
color: $color-media-control-text;
|
||||||
|
border-radius: 1000px;
|
||||||
|
box-shadow: inset 0 0 5px rgba($white, 0.4);
|
||||||
|
transition: .2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover{
|
||||||
|
box-shadow: inset 0 0 5px $blue20;
|
||||||
|
color: $blue20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Camera,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
|
||||||
|
|
||||||
|
import './AvatarCameraModal.scss'
|
||||||
|
|
||||||
|
type AvatarCameraModalProps = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onCapture: (file: File) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarCameraModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onCapture,
|
||||||
|
}: AvatarCameraModalProps) {
|
||||||
|
const videoRef =
|
||||||
|
useRef<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
|
const streamRef =
|
||||||
|
useRef<MediaStream | null>(null)
|
||||||
|
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
streamRef.current
|
||||||
|
?.getTracks()
|
||||||
|
.forEach((track) => track.stop())
|
||||||
|
|
||||||
|
streamRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
stopStream()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initCamera() {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const stream =
|
||||||
|
await navigator.mediaDevices.getUserMedia(
|
||||||
|
{
|
||||||
|
video: {
|
||||||
|
facingMode: 'user',
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
streamRef.current = stream
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(
|
||||||
|
'Не удалось получить доступ к камере',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initCamera()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopStream()
|
||||||
|
}
|
||||||
|
}, [open, stopStream])
|
||||||
|
|
||||||
|
async function handleCapture() {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
|
||||||
|
const video = videoRef.current
|
||||||
|
|
||||||
|
const canvas =
|
||||||
|
document.createElement('canvas')
|
||||||
|
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
|
||||||
|
const context =
|
||||||
|
canvas.getContext('2d')
|
||||||
|
|
||||||
|
if (!context) return
|
||||||
|
|
||||||
|
context.drawImage(
|
||||||
|
video,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob) return
|
||||||
|
|
||||||
|
const file = new File(
|
||||||
|
[blob],
|
||||||
|
`camera-${Date.now()}.jpg`,
|
||||||
|
{
|
||||||
|
type: 'image/jpeg',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onCapture(file)
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
0.95,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MotionDialogContent
|
||||||
|
open={open}
|
||||||
|
overlayClassName="camera-modal__overlay"
|
||||||
|
contentClassName="camera-modal"
|
||||||
|
>
|
||||||
|
<div className="camera-modal__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="camera-modal__body">
|
||||||
|
{error ? (
|
||||||
|
<div className='camera-modal__not-found'>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||||
|
<g fill="currentColor">
|
||||||
|
<path d="m7.762 7.5l.41-1.268A2.5 2.5 0 0 1 10.55 4.5h2.898a2.5 2.5 0 0 1 2.379 1.732l.41 1.268H17.5A2.5 2.5 0 0 1 20 10v6a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 16v-6a2.5 2.5 0 0 1 2.5-2.5z" opacity=".2"></path>
|
||||||
|
<path fillRule="evenodd" d="M6.172 5.232L5.762 6.5H4.5A2.5 2.5 0 0 0 2 9v6a2.5 2.5 0 0 0 2.5 2.5h11A2.5 2.5 0 0 0 18 15V9a2.5 2.5 0 0 0-2.5-2.5h-1.263l-.409-1.268a2.5 2.5 0 0 0-2.38-1.732H8.552a2.5 2.5 0 0 0-2.38 1.732M4.5 7.5h1.99l.633-1.96A1.5 1.5 0 0 1 8.551 4.5h2.898a1.5 1.5 0 0 1 1.427 1.04l.633 1.96H15.5A1.5 1.5 0 0 1 17 9v6a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 15V9a1.5 1.5 0 0 1 1.5-1.5" clipRule="evenodd"></path>
|
||||||
|
<path fillRule="evenodd" d="M7 11.5a3 3 0 1 0 6 0a3 3 0 0 0-6 0m5 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" clipRule="evenodd"></path>
|
||||||
|
<path d="M1.15 1.878a.514.514 0 0 1 .728-.727l16.971 16.971a.514.514 0 0 1-.727.727z"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="camera-modal__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="camera-modal__footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCapture}
|
||||||
|
disabled={Boolean(error)}
|
||||||
|
>
|
||||||
|
<Camera size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MotionDialogContent>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.avatar-crop-modal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 120;
|
||||||
|
|
||||||
|
background: $color-backdrop-strong;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 130;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
|
width: min(720px, calc(100vw - 28px));
|
||||||
|
height: min(680px, calc(100vh - 28px));
|
||||||
|
padding: 18px;
|
||||||
|
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
border-radius: 24px;
|
||||||
|
background: $color-surface;
|
||||||
|
color: $color-text;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
|
||||||
|
box-shadow: $shadow-modal-dark;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__header {
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: $font-size-18;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__close {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
background: $gray20;
|
||||||
|
color: $gray50;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__cropper {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 360px;
|
||||||
|
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
background: $color-bg
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__controls {
|
||||||
|
padding: 0 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
width: 82px;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__footer {
|
||||||
|
height: 44px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__cancel,
|
||||||
|
.avatar-crop-modal__submit {
|
||||||
|
height: 42px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0 18px;
|
||||||
|
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__cancel {
|
||||||
|
background: $gray20;
|
||||||
|
color: $gray50;
|
||||||
|
opacity: .8;
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-crop-modal__submit {
|
||||||
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import Cropper, { type Area } from 'react-easy-crop'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
|
||||||
|
|
||||||
|
import './AvatarCropModal.scss'
|
||||||
|
|
||||||
|
type AvatarCropModalProps = {
|
||||||
|
open: boolean
|
||||||
|
imageSrc: string
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: (file: File, previewUrl: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function createImage(src: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image()
|
||||||
|
|
||||||
|
image.onload = () => resolve(image)
|
||||||
|
image.onerror = reject
|
||||||
|
image.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCroppedAvatarFile(imageSrc: string, crop: Area) {
|
||||||
|
const image = await createImage(imageSrc)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const outputSize = 512
|
||||||
|
|
||||||
|
canvas.width = outputSize
|
||||||
|
canvas.height = outputSize
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Не удалось подготовить изображение')
|
||||||
|
}
|
||||||
|
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
crop.x,
|
||||||
|
crop.y,
|
||||||
|
crop.width,
|
||||||
|
crop.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
outputSize,
|
||||||
|
outputSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, 'image/png', 0.92)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error('Не удалось обрезать изображение')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new File([blob], 'avatar.png', {
|
||||||
|
type: 'image/png',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarCropModal({
|
||||||
|
open,
|
||||||
|
imageSrc,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: AvatarCropModalProps) {
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||||
|
const [zoom, setZoom] = useState(1)
|
||||||
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
setCrop({ x: 0, y: 0 })
|
||||||
|
setZoom(1)
|
||||||
|
setCroppedAreaPixels(null)
|
||||||
|
setIsProcessing(false)
|
||||||
|
}, [open, imageSrc])
|
||||||
|
|
||||||
|
const handleCropComplete = useCallback(
|
||||||
|
(_croppedArea: Area, nextCroppedAreaPixels: Area) => {
|
||||||
|
setCroppedAreaPixels(nextCroppedAreaPixels)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!croppedAreaPixels || isProcessing) return
|
||||||
|
|
||||||
|
setIsProcessing(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await createCroppedAvatarFile(imageSrc, croppedAreaPixels)
|
||||||
|
const previewUrl = URL.createObjectURL(file)
|
||||||
|
|
||||||
|
onConfirm(file, previewUrl)
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MotionDialogContent
|
||||||
|
open={open}
|
||||||
|
overlayClassName="avatar-crop-modal__overlay"
|
||||||
|
contentClassName="avatar-crop-modal"
|
||||||
|
>
|
||||||
|
<div className="avatar-crop-modal__header">
|
||||||
|
<Dialog.Title>Настройка фото</Dialog.Title>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="avatar-crop-modal__close"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="avatar-crop-modal__cropper">
|
||||||
|
<Cropper
|
||||||
|
image={imageSrc}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={1}
|
||||||
|
cropShape="round"
|
||||||
|
showGrid={false}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={handleCropComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="avatar-crop-modal__controls">
|
||||||
|
<label>
|
||||||
|
<span>Масштаб</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
step="0.05"
|
||||||
|
value={zoom}
|
||||||
|
onChange={(event) => setZoom(Number(event.target.value))}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="avatar-crop-modal__footer">
|
||||||
|
<button
|
||||||
|
className="avatar-crop-modal__cancel"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="avatar-crop-modal__submit"
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Подготовка...' : 'Установить фотографию'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MotionDialogContent>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.avatar-upload {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid $color-border-accent;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: $color-surface-muted;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload__preview {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
width: 148px;
|
||||||
|
height: 148px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 20%, rgba($white, 0.1), $transparent 28%),
|
||||||
|
linear-gradient(135deg, $blue, $color-primary-hover);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text-inverse;
|
||||||
|
font-size: $font-size-34;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
$shadow-avatar-preview;
|
||||||
|
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
&.transparent-backdrop{
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload__camera {
|
||||||
|
position: absolute;
|
||||||
|
right: 0px;
|
||||||
|
bottom: 0px;
|
||||||
|
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 2px solid $color-surface-muted;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
background: $blue;
|
||||||
|
//backdrop-filter: blur(16px);
|
||||||
|
color: $color-text-inverse;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
opacity 0.18s ease,
|
||||||
|
background-color 0.18s ease;
|
||||||
|
|
||||||
|
//box-shadow: 0 8px 18px rgba($color-shadow-rgb, 0.18);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
//opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload__text {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
b {
|
||||||
|
display: block;
|
||||||
|
color: $color-text-strong;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload__actions {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload__choose,
|
||||||
|
.avatar-upload__reset {
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 13px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 650;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload__choose {
|
||||||
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px $blue, 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload__reset {
|
||||||
|
width: 40px;
|
||||||
|
background: rgba($color-danger-rgb, 0.1);
|
||||||
|
color: $red;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($color-danger-rgb, 0.16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload__hint {
|
||||||
|
margin: -2px 0 0;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { AvatarCropModal } from '../AvatarCropModal/AvatarCropModal'
|
||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { Camera, ImagePlus, RotateCcw } from 'lucide-react'
|
||||||
|
import { AvatarCameraModal } from '../AvatarCameraModal/AvatarCameraModal'
|
||||||
|
import {
|
||||||
|
MODAL_EXIT_DURATION_MS,
|
||||||
|
useDeferredModalPayload,
|
||||||
|
} from '../../../../shared/lib/lazyMount'
|
||||||
|
|
||||||
|
import './AvatarUpload.scss'
|
||||||
|
|
||||||
|
type AvatarUploadProps = {
|
||||||
|
avatarUrl?: string
|
||||||
|
allowCamera?: boolean
|
||||||
|
initials: string
|
||||||
|
disabled?: boolean
|
||||||
|
hasSelectedFile?: boolean
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
chooseText?: string
|
||||||
|
replaceText?: string
|
||||||
|
alt?: string
|
||||||
|
onChange: (file: File, previewUrl: string) => void
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarUpload({
|
||||||
|
avatarUrl = '',
|
||||||
|
allowCamera = true,
|
||||||
|
initials,
|
||||||
|
disabled = false,
|
||||||
|
hasSelectedFile = false,
|
||||||
|
chooseText = 'Выбрать фото',
|
||||||
|
replaceText = 'Заменить фото',
|
||||||
|
alt = 'Изображение',
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
}: AvatarUploadProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const cropImageSrcRef = useRef('')
|
||||||
|
|
||||||
|
const [cropImageSrc, setCropImageSrc] = useState('')
|
||||||
|
const renderCropImageSrc = useDeferredModalPayload(cropImageSrc || null)
|
||||||
|
const [isCameraOpen, setIsCameraOpen] =
|
||||||
|
useState(false)
|
||||||
|
|
||||||
|
const [isMenuOpen, setIsMenuOpen] =
|
||||||
|
useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cropImageSrcRef.current = cropImageSrc
|
||||||
|
}, [cropImageSrc])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (cropImageSrcRef.current) {
|
||||||
|
URL.revokeObjectURL(cropImageSrcRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function revokeCropImageLater(src: string) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(src)
|
||||||
|
}, MODAL_EXIT_DURATION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCameraCapture(file: File) {
|
||||||
|
handleFileChange(file)
|
||||||
|
setIsCameraOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(file?: File) {
|
||||||
|
if (!file || disabled) return
|
||||||
|
|
||||||
|
if (cropImageSrc) {
|
||||||
|
URL.revokeObjectURL(cropImageSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCropImageSrc(URL.createObjectURL(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelCrop() {
|
||||||
|
if (cropImageSrc) {
|
||||||
|
revokeCropImageLater(cropImageSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCropImageSrc('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmCrop(file: File, previewUrl: string) {
|
||||||
|
if (cropImageSrc) {
|
||||||
|
revokeCropImageLater(cropImageSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCropImageSrc('')
|
||||||
|
onChange(file, previewUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="avatar-upload">
|
||||||
|
<div className={`avatar-upload__preview ${avatarUrl ? 'transparent-backdrop' : ''}`}>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img src={avatarUrl} alt={alt} />
|
||||||
|
) : (
|
||||||
|
<span>{initials}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenu.Root
|
||||||
|
open={isMenuOpen}
|
||||||
|
onOpenChange={setIsMenuOpen}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button
|
||||||
|
className="avatar-upload__camera"
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Выбрать фото"
|
||||||
|
>
|
||||||
|
<Camera size={18} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
className="avatar-upload__menu"
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
className="avatar-upload__menu-item"
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
setIsMenuOpen(false)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.click()
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ImagePlus size={16} />
|
||||||
|
<span>Выбрать изображение</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
|
{allowCamera && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
className="avatar-upload__menu-item"
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
setIsMenuOpen(false)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsCameraOpen(true)
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera size={16} />
|
||||||
|
<span>Сделать фото</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif"
|
||||||
|
hidden
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
handleFileChange(event.target.files?.[0])
|
||||||
|
event.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <div className="avatar-upload__text">
|
||||||
|
<span>PNG, JPG или GIF</span>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div className="avatar-upload__actions">
|
||||||
|
<button
|
||||||
|
className="avatar-upload__choose"
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{avatarUrl ? replaceText : chooseText}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasSelectedFile && (
|
||||||
|
<button
|
||||||
|
className="avatar-upload__reset"
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onReset}
|
||||||
|
aria-label="Сбросить выбранное фото"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* {hasSelectedFile && (
|
||||||
|
<p className="avatar-upload__hint">
|
||||||
|
Новое фото будет загружено после сохранения.
|
||||||
|
</p>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
<AvatarCropModal
|
||||||
|
open={Boolean(cropImageSrc)}
|
||||||
|
imageSrc={cropImageSrc || renderCropImageSrc || ''}
|
||||||
|
onCancel={handleCancelCrop}
|
||||||
|
onConfirm={handleConfirmCrop}
|
||||||
|
/>
|
||||||
|
<AvatarCameraModal
|
||||||
|
open={isCameraOpen}
|
||||||
|
onClose={() => setIsCameraOpen(false)}
|
||||||
|
onCapture={handleCameraCapture}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.employees-pagination {
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-pagination__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $gray20;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&[aria-label='Предыдущая страница'],
|
||||||
|
&[aria-label='Следующая страница'] {
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: $transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba($color-primary-rgb, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
box-shadow: var(--shadow-primary);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-pagination__ellipsis-button {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-pagination__ellipsis-dots,
|
||||||
|
.employees-pagination__ellipsis-arrow {
|
||||||
|
line-height: 0.5;
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-pagination__ellipsis-dots {
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-pagination__ellipsis-arrow {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(2px);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
|
||||||
|
&.prev {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-pagination__ellipsis-button:hover {
|
||||||
|
.employees-pagination__ellipsis-dots {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-pagination__ellipsis-arrow {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
.employees-pagination__controls button {
|
||||||
|
background: $color-surface-muted;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(.is-active) {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
color: $color-text-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: $color-primary;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet-down {
|
||||||
|
.employees-pagination {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
import './EmployeesPagination.scss'
|
||||||
|
|
||||||
|
export type PaginationItem = number | 'prev-ellipsis' | 'next-ellipsis'
|
||||||
|
|
||||||
|
type EmployeesPaginationProps = {
|
||||||
|
currentPage: number
|
||||||
|
loading: boolean
|
||||||
|
items: PaginationItem[]
|
||||||
|
hasPrevPage: boolean
|
||||||
|
hasNextPage: boolean
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => void
|
||||||
|
onJumpPrev: () => void
|
||||||
|
onJumpNext: () => void
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronLeftIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M15 18L9 12L15 6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronRightIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M9 6L15 12L9 18"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DoubleChevronLeftIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M11 17L6 12L11 7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18 17L13 12L18 7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DoubleChevronRightIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M13 7L18 12L13 17"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6 7L11 12L6 17"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmployeesPagination = memo(function EmployeesPagination({
|
||||||
|
currentPage,
|
||||||
|
loading,
|
||||||
|
items,
|
||||||
|
hasPrevPage,
|
||||||
|
hasNextPage,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onJumpPrev,
|
||||||
|
onJumpNext,
|
||||||
|
onPageChange,
|
||||||
|
}: EmployeesPaginationProps) {
|
||||||
|
return (
|
||||||
|
<div className="employees-pagination">
|
||||||
|
<div className="employees-pagination__controls">
|
||||||
|
<button
|
||||||
|
className="employees-pagination__button"
|
||||||
|
type="button"
|
||||||
|
disabled={!hasPrevPage || loading}
|
||||||
|
onClick={onPrev}
|
||||||
|
aria-label="Предыдущая страница"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{items.map((item, index) => {
|
||||||
|
if (item === 'prev-ellipsis' || item === 'next-ellipsis') {
|
||||||
|
const isPrev = item === 'prev-ellipsis'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="employees-pagination__button employees-pagination__ellipsis-button"
|
||||||
|
type="button"
|
||||||
|
key={`${item}-${index}`}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={isPrev ? onJumpPrev : onJumpNext}
|
||||||
|
aria-label={
|
||||||
|
isPrev
|
||||||
|
? 'Перейти на 5 страниц назад'
|
||||||
|
: 'Перейти на 5 страниц вперед'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="employees-pagination__ellipsis-dots">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="employees-pagination__ellipsis-arrow">
|
||||||
|
{isPrev ? (
|
||||||
|
<DoubleChevronLeftIcon />
|
||||||
|
) : (
|
||||||
|
<DoubleChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIndex = item - 1
|
||||||
|
const isActive = pageIndex === currentPage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`employees-pagination__button ${
|
||||||
|
isActive ? 'is-active' : ''
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
key={item}
|
||||||
|
disabled={loading || isActive}
|
||||||
|
onClick={() => onPageChange(pageIndex)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="employees-pagination__button"
|
||||||
|
type="button"
|
||||||
|
disabled={!hasNextPage || loading}
|
||||||
|
onClick={onNext}
|
||||||
|
aria-label="Следующая страница"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
PageTabs,
|
||||||
|
type PageTabItem,
|
||||||
|
} from '../../../../shared/ui/PageTabs/PageTabs'
|
||||||
|
|
||||||
|
export type EmployeesSection = 'users' | 'organisations'
|
||||||
|
|
||||||
|
type EmployeesTabsProps = {
|
||||||
|
activeSection: EmployeesSection
|
||||||
|
onChange: (section: EmployeesSection) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: Array<PageTabItem<EmployeesSection>> = [
|
||||||
|
{
|
||||||
|
value: 'users',
|
||||||
|
label: 'Пользователи',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'organisations',
|
||||||
|
label: 'Организации',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const EmployeesTabs = memo(function EmployeesTabs({
|
||||||
|
activeSection,
|
||||||
|
onChange,
|
||||||
|
}: EmployeesTabsProps) {
|
||||||
|
return (
|
||||||
|
<PageTabs
|
||||||
|
activeValue={activeSection}
|
||||||
|
items={tabs}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,70 @@
|
||||||
@use '../../../../shared/styles/variables' as *;
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.devices-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
background-color: $color-surface;
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: $shadow-toolbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-search {
|
||||||
|
width: 250px;
|
||||||
|
height: calc(100% - 20px);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
transition: .2s ease;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: $transparent;
|
||||||
|
color: $color-text-strong;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
box-shadow: inset 0 0 0 1.5px rgba($color-primary-rgb, .15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-toolbar__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: $color-bg;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: none;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-device {
|
||||||
|
background-color: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.add-device.add-employees{
|
.add-device.add-employees{
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -10,7 +75,7 @@
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
&:hover{
|
&:hover{
|
||||||
opacity: .85;
|
//box-shadow: 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,14 +84,14 @@
|
||||||
padding: 11px 14px 11px 20px !important;
|
padding: 11px 14px 11px 20px !important;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 550 !important;
|
font-weight: 550 !important;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -56,13 +121,85 @@
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employees-filter-button {
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 11px 12px 11px 16px !important;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: $color-surface;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-16 !important;
|
||||||
|
font-weight: 550 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
> span:not(.employees-filter-button__reset) {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 17px;
|
||||||
|
width: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $gray20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='open'],
|
||||||
|
&.is-active {
|
||||||
|
color: $blue;
|
||||||
|
background-color: $blue20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-filter-button--organisation {
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-filter-button__reset {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: -5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
|
||||||
|
color: $blue;
|
||||||
|
//background: rgba($gray30-rgb, 0.5);
|
||||||
|
transition:
|
||||||
|
background-color 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $red;
|
||||||
|
background: rgba($gray30-rgb, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.employees-sort-menu {
|
.employees-sort-menu {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
min-width: 210px;
|
min-width: 210px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
|
box-shadow: $shadow-popover;
|
||||||
animation: employeesSortMenuShow 0.16s ease;
|
animation: employeesSortMenuShow 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +214,7 @@
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: $font-size-14;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
@ -98,6 +235,118 @@
|
||||||
flex: 0 0 7px;
|
flex: 0 0 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employees-organisation-filter {
|
||||||
|
z-index: 100;
|
||||||
|
width: 300px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: $color-surface;
|
||||||
|
box-shadow: $shadow-popover;
|
||||||
|
animation: employeesSortMenuShow 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-organisation-filter__search {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: $transparent;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-organisation-filter__list {
|
||||||
|
max-height: 260px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: $gray50 $transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-organisation-filter__option {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $transparent;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected {
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-organisation-filter__state {
|
||||||
|
padding: 14px 10px;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes employeesSortMenuShow {
|
@keyframes employeesSortMenuShow {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -109,3 +358,61 @@
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include laptop-down {
|
||||||
|
.devices-toolbar {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-toolbar__right {
|
||||||
|
button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-filter-button--organisation {
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-sort {
|
||||||
|
padding: 10px 12px 10px 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet-down {
|
||||||
|
.devices-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-search {
|
||||||
|
order: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
min-height: 42px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.devices-toolbar__right {
|
||||||
|
order: 2;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.add-device {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-filter-button {
|
||||||
|
flex: 1 1 150px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,29 @@
|
||||||
|
import {
|
||||||
|
memo,
|
||||||
|
type ChangeEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
type PointerEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
import { Search } from 'lucide-react'
|
import * as Popover from '@radix-ui/react-popover'
|
||||||
|
import { Building2, ChevronDown, Search, ShieldCheck, X } from 'lucide-react'
|
||||||
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
GET_ORGANISATION_QUERY,
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
|
} from '../../../../entities/employee/api/employee.graphql'
|
||||||
import type {
|
import type {
|
||||||
OrganisationSortDirection,
|
EmployeeRole,
|
||||||
OrganisationSortField,
|
GetOrganisationData,
|
||||||
|
GetOrganisationVariables,
|
||||||
|
GetOrganisationsData,
|
||||||
|
GetOrganisationsVariables,
|
||||||
} from '../../../../entities/employee/model/types'
|
} from '../../../../entities/employee/model/types'
|
||||||
|
|
||||||
import './EmployeesToolbar.scss'
|
import './EmployeesToolbar.scss'
|
||||||
|
|
@ -11,11 +31,56 @@ import './EmployeesToolbar.scss'
|
||||||
export type EmployeesSortOption = {
|
export type EmployeesSortOption = {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
sortField: OrganisationSortField
|
sortField: 'ID' | 'Name' | 'Date'
|
||||||
sortDirection: OrganisationSortDirection
|
sortDirection: 'ASC' | 'DESC'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const employeesSortOptions: EmployeesSortOption[] = [
|
export const usersSortOptions: EmployeesSortOption[] = [
|
||||||
|
{
|
||||||
|
value: 'date-desc',
|
||||||
|
label: 'Сначала новые',
|
||||||
|
sortField: 'Date',
|
||||||
|
sortDirection: 'DESC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'id-asc',
|
||||||
|
label: 'ID по возрастанию',
|
||||||
|
sortField: 'ID',
|
||||||
|
sortDirection: 'ASC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'id-desc',
|
||||||
|
label: 'ID по убыванию',
|
||||||
|
sortField: 'ID',
|
||||||
|
sortDirection: 'DESC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'name-asc',
|
||||||
|
label: 'ФИО А–Я',
|
||||||
|
sortField: 'Name',
|
||||||
|
sortDirection: 'ASC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'name-desc',
|
||||||
|
label: 'ФИО Я–А',
|
||||||
|
sortField: 'Name',
|
||||||
|
sortDirection: 'DESC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'date-asc',
|
||||||
|
label: 'Сначала старые',
|
||||||
|
sortField: 'Date',
|
||||||
|
sortDirection: 'ASC',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const organisationsSortOptions: EmployeesSortOption[] = [
|
||||||
|
{
|
||||||
|
value: 'date-desc',
|
||||||
|
label: 'Сначала новые',
|
||||||
|
sortField: 'Date',
|
||||||
|
sortDirection: 'DESC',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'id-asc',
|
value: 'id-asc',
|
||||||
label: 'ID по возрастанию',
|
label: 'ID по возрастанию',
|
||||||
|
|
@ -40,12 +105,6 @@ export const employeesSortOptions: EmployeesSortOption[] = [
|
||||||
sortField: 'Name',
|
sortField: 'Name',
|
||||||
sortDirection: 'DESC',
|
sortDirection: 'DESC',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: 'date-desc',
|
|
||||||
label: 'Сначала новые',
|
|
||||||
sortField: 'Date',
|
|
||||||
sortDirection: 'DESC',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: 'date-asc',
|
value: 'date-asc',
|
||||||
label: 'Сначала старые',
|
label: 'Сначала старые',
|
||||||
|
|
@ -54,6 +113,22 @@ export const employeesSortOptions: EmployeesSortOption[] = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export type EmployeesRoleFilterOption = {
|
||||||
|
value: 'Admin' | 'User'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleFilterOptions: EmployeesRoleFilterOption[] = [
|
||||||
|
{
|
||||||
|
value: 'Admin',
|
||||||
|
label: 'Администратор',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'User',
|
||||||
|
label: 'Пользователь',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
type EmployeesToolbarProps = {
|
type EmployeesToolbarProps = {
|
||||||
addButtonText: string
|
addButtonText: string
|
||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
|
|
@ -61,21 +136,198 @@ type EmployeesToolbarProps = {
|
||||||
searchValue?: string
|
searchValue?: string
|
||||||
onSearchChange?: (value: string) => void
|
onSearchChange?: (value: string) => void
|
||||||
|
|
||||||
selectedSort?: EmployeesSortOption
|
sortOptions: EmployeesSortOption[]
|
||||||
onSortChange?: (option: EmployeesSortOption) => void
|
selectedSort: EmployeesSortOption
|
||||||
|
onSortChange: (option: EmployeesSortOption) => void
|
||||||
|
|
||||||
showSort?: boolean
|
showSort?: boolean
|
||||||
|
|
||||||
|
roleFilter?: EmployeeRole | null
|
||||||
|
onRoleFilterChange?: (role: EmployeeRole | null) => void
|
||||||
|
|
||||||
|
organisationFilterId?: string | null
|
||||||
|
onOrganisationFilterChange?: (
|
||||||
|
organisation: { id: string; name: string } | null,
|
||||||
|
) => void
|
||||||
|
|
||||||
|
showUserFilters?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmployeesToolbar({
|
export const EmployeesToolbar = memo(function EmployeesToolbar({
|
||||||
addButtonText,
|
addButtonText,
|
||||||
onAdd,
|
onAdd,
|
||||||
searchValue = '',
|
searchValue = '',
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
selectedSort = employeesSortOptions[0],
|
sortOptions,
|
||||||
|
selectedSort,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
showSort = true,
|
showSort = true,
|
||||||
|
roleFilter = null,
|
||||||
|
onRoleFilterChange,
|
||||||
|
organisationFilterId = null,
|
||||||
|
onOrganisationFilterChange,
|
||||||
|
showUserFilters = false,
|
||||||
}: EmployeesToolbarProps) {
|
}: EmployeesToolbarProps) {
|
||||||
|
const [isOrganisationOpen, setIsOrganisationOpen] = useState(false)
|
||||||
|
const [organisationSearch, setOrganisationSearch] = useState('')
|
||||||
|
const [debouncedOrganisationSearch, setDebouncedOrganisationSearch] =
|
||||||
|
useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setDebouncedOrganisationSearch(organisationSearch.trim())
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [organisationSearch])
|
||||||
|
|
||||||
|
const selectedRoleOption = useMemo(
|
||||||
|
() =>
|
||||||
|
roleFilterOptions.find((option) => option.value === roleFilter) ?? null,
|
||||||
|
[roleFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedOrganisationVariables = useMemo<GetOrganisationVariables>(
|
||||||
|
() => ({
|
||||||
|
id: organisationFilterId ?? '',
|
||||||
|
}),
|
||||||
|
[organisationFilterId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const organisationsVariables = useMemo<GetOrganisationsVariables>(
|
||||||
|
() => ({
|
||||||
|
page: 0,
|
||||||
|
query: debouncedOrganisationSearch || undefined,
|
||||||
|
sortDirection: 'ASC',
|
||||||
|
sortField: 'Name',
|
||||||
|
}),
|
||||||
|
[debouncedOrganisationSearch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: selectedOrganisationData,
|
||||||
|
} = useQuery<GetOrganisationData, GetOrganisationVariables>(
|
||||||
|
GET_ORGANISATION_QUERY,
|
||||||
|
{
|
||||||
|
variables: selectedOrganisationVariables,
|
||||||
|
skip: !showUserFilters || !organisationFilterId,
|
||||||
|
fetchPolicy: 'cache-first',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: organisationsData,
|
||||||
|
previousData: previousOrganisationsData,
|
||||||
|
loading: organisationsLoading,
|
||||||
|
error: organisationsError,
|
||||||
|
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
|
{
|
||||||
|
variables: organisationsVariables,
|
||||||
|
skip: !showUserFilters || !isOrganisationOpen,
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const organisationsResponse =
|
||||||
|
organisationsData?.getOrganisations ??
|
||||||
|
previousOrganisationsData?.getOrganisations
|
||||||
|
|
||||||
|
const organisations = useMemo(
|
||||||
|
() => organisationsResponse?.page ?? [],
|
||||||
|
[organisationsResponse?.page],
|
||||||
|
)
|
||||||
|
const selectedOrganisationName =
|
||||||
|
selectedOrganisationData?.getOrganisation?.name ??
|
||||||
|
(organisationFilterId ? `Организация #${organisationFilterId}` : null)
|
||||||
|
|
||||||
|
const organisationOptions = useMemo(() => {
|
||||||
|
const map = new Map<string, { id: string; name: string }>()
|
||||||
|
|
||||||
|
if (organisationFilterId && selectedOrganisationName) {
|
||||||
|
map.set(organisationFilterId, {
|
||||||
|
id: organisationFilterId,
|
||||||
|
name: selectedOrganisationName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
organisations.forEach((organisation) => {
|
||||||
|
map.set(String(organisation.id), {
|
||||||
|
id: String(organisation.id),
|
||||||
|
name: organisation.name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(map.values())
|
||||||
|
}, [organisationFilterId, selectedOrganisationName, organisations])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((
|
||||||
|
event: ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
onSearchChange?.(event.target.value)
|
||||||
|
}, [onSearchChange])
|
||||||
|
|
||||||
|
const handleOrganisationSearchChange = useCallback((
|
||||||
|
event: ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
setOrganisationSearch(event.target.value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleResetClick = useCallback((
|
||||||
|
event: MouseEvent<HTMLSpanElement>,
|
||||||
|
) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRoleResetPointerDown = useCallback((
|
||||||
|
event: PointerEvent<HTMLSpanElement>,
|
||||||
|
) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onRoleFilterChange?.(null)
|
||||||
|
}, [onRoleFilterChange])
|
||||||
|
|
||||||
|
const handleOrganisationResetPointerDown = useCallback((
|
||||||
|
event: PointerEvent<HTMLSpanElement>,
|
||||||
|
) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onOrganisationFilterChange?.(null)
|
||||||
|
}, [onOrganisationFilterChange])
|
||||||
|
|
||||||
|
const handleRoleResetKeyDown = useCallback((
|
||||||
|
event: KeyboardEvent<HTMLSpanElement>,
|
||||||
|
) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onRoleFilterChange?.(null)
|
||||||
|
}, [onRoleFilterChange])
|
||||||
|
|
||||||
|
const handleOrganisationResetKeyDown = useCallback((
|
||||||
|
event: KeyboardEvent<HTMLSpanElement>,
|
||||||
|
) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onOrganisationFilterChange?.(null)
|
||||||
|
}, [onOrganisationFilterChange])
|
||||||
|
|
||||||
|
const handleOrganisationSelect = useCallback((
|
||||||
|
organisation: { id: string; name: string },
|
||||||
|
) => {
|
||||||
|
onOrganisationFilterChange?.(organisation)
|
||||||
|
setIsOrganisationOpen(false)
|
||||||
|
setOrganisationSearch('')
|
||||||
|
setDebouncedOrganisationSearch('')
|
||||||
|
}, [onOrganisationFilterChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="devices-toolbar">
|
<div className="devices-toolbar">
|
||||||
<label className="devices-search">
|
<label className="devices-search">
|
||||||
|
|
@ -85,11 +337,196 @@ export function EmployeesToolbar({
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск"
|
placeholder="Поиск"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(event) => onSearchChange?.(event.target.value)}
|
onChange={handleSearchChange}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="devices-toolbar__right">
|
<div className="devices-toolbar__right">
|
||||||
|
{showUserFilters && (
|
||||||
|
<>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button
|
||||||
|
className={`employees-filter-button ${
|
||||||
|
selectedRoleOption ? 'is-active' : ''
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ShieldCheck size={17} />
|
||||||
|
<span>{selectedRoleOption?.label ?? 'Роль'}</span>
|
||||||
|
|
||||||
|
{selectedRoleOption ? (
|
||||||
|
<span
|
||||||
|
className="employees-filter-button__reset"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={handleRoleResetPointerDown}
|
||||||
|
onClick={handleResetClick}
|
||||||
|
onKeyDown={handleRoleResetKeyDown}
|
||||||
|
aria-label="Сбросить фильтр роли"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
className="employees-sort-menu"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
{roleFilterOptions.map((option) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={option.value}
|
||||||
|
className="employees-sort-menu__item"
|
||||||
|
onSelect={() => onRoleFilterChange?.(option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
|
||||||
|
{roleFilter === option.value && (
|
||||||
|
<span className="employees-sort-menu__check" />
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
<Popover.Root
|
||||||
|
open={isOrganisationOpen}
|
||||||
|
onOpenChange={setIsOrganisationOpen}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
className={`employees-filter-button employees-filter-button--organisation ${
|
||||||
|
organisationFilterId ? 'is-active' : ''
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Building2 size={17} />
|
||||||
|
<span>{selectedOrganisationName ?? 'Организация'}</span>
|
||||||
|
|
||||||
|
{organisationFilterId ? (
|
||||||
|
<span
|
||||||
|
className="employees-filter-button__reset"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={handleOrganisationResetPointerDown}
|
||||||
|
onClick={handleResetClick}
|
||||||
|
onKeyDown={handleOrganisationResetKeyDown}
|
||||||
|
aria-label="Сбросить фильтр организации"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
className="employees-organisation-filter"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={12}
|
||||||
|
>
|
||||||
|
<label className="employees-organisation-filter__search">
|
||||||
|
<Search size={16} />
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={organisationSearch}
|
||||||
|
onChange={handleOrganisationSearchChange}
|
||||||
|
placeholder="Название организации"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="employees-organisation-filter__list">
|
||||||
|
{organisationsLoading && organisationOptions.length === 0 && (
|
||||||
|
<div className="employees-organisation-filter__state">
|
||||||
|
Загрузка организаций...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{organisationsError && (
|
||||||
|
<div className="employees-organisation-filter__state is-error">
|
||||||
|
Не удалось загрузить организации
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!organisationsLoading &&
|
||||||
|
!organisationsError &&
|
||||||
|
organisationOptions.length === 0 && (
|
||||||
|
<div className="employees-organisation-filter__state">
|
||||||
|
Организации не найдены
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{organisationOptions.map((organisation) => {
|
||||||
|
const isSelected =
|
||||||
|
organisation.id === organisationFilterId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`employees-organisation-filter__option ${
|
||||||
|
isSelected ? 'is-selected' : ''
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
key={organisation.id}
|
||||||
|
onClick={() => handleOrganisationSelect(organisation)}
|
||||||
|
>
|
||||||
|
<span>{organisation.name}</span>
|
||||||
|
<small>ID: {organisation.id}</small>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSort && (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button className="employees-sort" type="button">
|
||||||
|
{selectedSort.label}
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
className="employees-sort-menu"
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={option.value}
|
||||||
|
className="employees-sort-menu__item"
|
||||||
|
onSelect={() => onSortChange?.(option)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
|
||||||
|
{selectedSort.value === option.value && (
|
||||||
|
<span className="employees-sort-menu__check" />
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="add-device add-employees"
|
className="add-device add-employees"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -139,43 +576,7 @@ export function EmployeesToolbar({
|
||||||
|
|
||||||
{addButtonText}
|
{addButtonText}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showSort && (
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger asChild>
|
|
||||||
<button className="employees-sort" type="button">
|
|
||||||
{selectedSort.label}
|
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
|
||||||
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
className="employees-sort-menu"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
{employeesSortOptions.map((option) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={option.value}
|
|
||||||
className="employees-sort-menu__item"
|
|
||||||
onSelect={() => onSortChange?.(option)}
|
|
||||||
>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
|
|
||||||
{selectedSort.value === option.value && (
|
|
||||||
<span className="employees-sort-menu__check" />
|
|
||||||
)}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.organisation-select {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-select__trigger {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid $color-border-strong;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
background: $color-surface;
|
||||||
|
color: $color-text;
|
||||||
|
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-placeholder {
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: $gray50;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba($color-primary-rgb, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-select.is-open {
|
||||||
|
.organisation-select__trigger {
|
||||||
|
border-color: rgba($color-primary-rgb, 0.7);
|
||||||
|
box-shadow: 0 0 0 3px rgba($color-primary-rgb, 0.12);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-select__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid $color-border-strong;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: $color-surface;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 18px 45px rgba($color-shadow-rgb, 0.12),
|
||||||
|
0 4px 12px rgba($color-shadow-rgb, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-select__search {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: $color-surface-muted;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100% !important;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
background: $transparent !important;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: $font-size-15;
|
||||||
|
height: 100% !important;
|
||||||
|
|
||||||
|
&:focus{
|
||||||
|
background-color: $transparent;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-select__list {
|
||||||
|
max-height: 250px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: $gray50 $transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-select__option {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
background: $transparent;
|
||||||
|
color: $color-text;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray20;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected {
|
||||||
|
background: $blue20;
|
||||||
|
span{
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
//color: rgba($white, 0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-select__state {
|
||||||
|
padding: 14px 10px;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-select__hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $color-surface-muted;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
import {
|
||||||
|
memo,
|
||||||
|
type ChangeEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
import { ChevronDown, Search } from 'lucide-react'
|
||||||
|
import { AnimatePresence, m } from 'framer-motion'
|
||||||
|
|
||||||
|
import { GET_ORGANISATIONS_QUERY } from '../../../../entities/employee/api/employee.graphql'
|
||||||
|
import type {
|
||||||
|
GetOrganisationsData,
|
||||||
|
GetOrganisationsVariables,
|
||||||
|
} from '../../../../entities/employee/model/types'
|
||||||
|
|
||||||
|
import './OrganisationSelect.scss'
|
||||||
|
import { popoverVariants, uiFadeTransition } from '../../../../shared/lib/motion'
|
||||||
|
|
||||||
|
type OrganisationSelectProps = {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
enabled: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
selectedLabel?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrganisationSelect = memo(function OrganisationSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
enabled,
|
||||||
|
disabled = false,
|
||||||
|
selectedLabel = null,
|
||||||
|
}: OrganisationSelectProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
|
|
||||||
|
const [selectedName, setSelectedName] = useState<string | null>(selectedLabel)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setSelectedName(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedLabel) {
|
||||||
|
setSelectedName(selectedLabel)
|
||||||
|
}
|
||||||
|
}, [selectedLabel, value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setDebouncedSearch(search.trim())
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setIsOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}
|
||||||
|
}, [enabled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
function handleDocumentMouseDown(event: MouseEvent) {
|
||||||
|
if (!rootRef.current?.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleDocumentMouseDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleDocumentMouseDown)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const queryVariables = useMemo<GetOrganisationsVariables>(() => ({
|
||||||
|
page: 0,
|
||||||
|
query: debouncedSearch || undefined,
|
||||||
|
sortDirection: 'ASC',
|
||||||
|
sortField: 'Name',
|
||||||
|
}), [debouncedSearch])
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
previousData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
|
{
|
||||||
|
variables: queryVariables,
|
||||||
|
skip: !enabled || !isOpen,
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const organisationsResponse =
|
||||||
|
data?.getOrganisations ?? previousData?.getOrganisations
|
||||||
|
|
||||||
|
const organisations = useMemo(
|
||||||
|
() => organisationsResponse?.page ?? [],
|
||||||
|
[organisationsResponse?.page],
|
||||||
|
)
|
||||||
|
const totalElements = organisationsResponse?.totalElements ?? 0
|
||||||
|
|
||||||
|
const selectedOrganisation = useMemo(
|
||||||
|
() => organisations.find(
|
||||||
|
(organisation) => String(organisation.id) === value,
|
||||||
|
),
|
||||||
|
[organisations, value],
|
||||||
|
)
|
||||||
|
|
||||||
|
const buttonText =
|
||||||
|
selectedOrganisation?.name ||
|
||||||
|
selectedName ||
|
||||||
|
selectedLabel ||
|
||||||
|
'Выберите организацию'
|
||||||
|
|
||||||
|
const handleToggleOpen = useCallback(() => {
|
||||||
|
setIsOpen((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((
|
||||||
|
event: ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
setSearch(event.target.value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelect = useCallback((organisation: { id: number; name: string }) => {
|
||||||
|
onChange(String(organisation.id))
|
||||||
|
setSelectedName(organisation.name)
|
||||||
|
setIsOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`organisation-select ${isOpen ? 'is-open' : ''}`}
|
||||||
|
ref={rootRef}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="organisation-select__trigger"
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleToggleOpen}
|
||||||
|
>
|
||||||
|
<span className={value ? '' : 'is-placeholder'}>{buttonText}</span>
|
||||||
|
<ChevronDown size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isOpen && (
|
||||||
|
<m.div
|
||||||
|
className="organisation-select__dropdown"
|
||||||
|
variants={popoverVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={uiFadeTransition}
|
||||||
|
>
|
||||||
|
<label className="organisation-select__search">
|
||||||
|
<Search size={16} />
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
placeholder="Поиск организации"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="organisation-select__list">
|
||||||
|
{loading && organisations.length === 0 && (
|
||||||
|
<div className="organisation-select__state">
|
||||||
|
Загрузка организаций...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="organisation-select__state is-error">
|
||||||
|
Не удалось загрузить организации
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && organisations.length === 0 && (
|
||||||
|
<div className="organisation-select__state">
|
||||||
|
Организации не найдены
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{organisations.map((organisation) => {
|
||||||
|
const organisationId = String(organisation.id)
|
||||||
|
const isSelected = organisationId === value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`organisation-select__option ${isSelected ? 'is-selected' : ''
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
key={organisation.id}
|
||||||
|
onClick={() => handleSelect(organisation)}
|
||||||
|
>
|
||||||
|
<span>{organisation.name}</span>
|
||||||
|
<small>ID: {organisation.id}</small>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalElements > organisations.length && (
|
||||||
|
<div className="organisation-select__hint">
|
||||||
|
Показаны первые {organisations.length} из {totalElements}.
|
||||||
|
Используйте поиск, чтобы найти нужную организацию.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</m.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,518 @@
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useSearchParams,
|
||||||
|
} from 'react-router-dom'
|
||||||
|
import { useMutation, useQuery } from '@apollo/client/react'
|
||||||
|
import { m } from 'framer-motion'
|
||||||
|
|
||||||
|
import { DELETE_ORGANISATION_MUTATION, GET_ORGANISATIONS_QUERY } from '../../../../entities/employee/api/employee.graphql'
|
||||||
|
import type {
|
||||||
|
GetOrganisationsData,
|
||||||
|
GetOrganisationsVariables,
|
||||||
|
Organisation,
|
||||||
|
DeleteOrganisationData,
|
||||||
|
DeleteOrganisationVariables,
|
||||||
|
} from '../../../../entities/employee/model/types'
|
||||||
|
import {
|
||||||
|
EmployeesToolbar,
|
||||||
|
organisationsSortOptions,
|
||||||
|
} from '../EmployeesToolbar/EmployeesToolbar'
|
||||||
|
import { EmployeesPagination } from '../EmployeesPagination/EmployeesPagination'
|
||||||
|
import { OrganisationsTable } from '../OrganisationsTable/OrganisationsTable'
|
||||||
|
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
||||||
|
import { getPaginationItems } from '../../lib/getPaginationItems'
|
||||||
|
import {
|
||||||
|
TableSkeleton,
|
||||||
|
type TableSkeletonColumn,
|
||||||
|
} from '../../../../shared/ui/TableSkeleton/TableSkeleton'
|
||||||
|
import { RevealContent } from '../../../../shared/ui/RevealContent/RevealContent'
|
||||||
|
import { pageSwapVariants, uiFadeTransition } from '../../../../shared/lib/motion'
|
||||||
|
import { EmptyState } from '../../../../shared/ui/EmptyState/EmptyState'
|
||||||
|
import {
|
||||||
|
useDeferredModalPayload,
|
||||||
|
} from '../../../../shared/lib/lazyMount'
|
||||||
|
import { AddOrganisationModal } from '../AddOrganisationModal/AddOrganisationModal'
|
||||||
|
|
||||||
|
import SimpleBar from "simplebar-react";
|
||||||
|
import "simplebar-react/dist/simplebar.min.css";
|
||||||
|
|
||||||
|
const ORGANISATIONS_SCROLL_KEY = 'employees.organisations.scrollTop'
|
||||||
|
|
||||||
|
const organisationsTableSkeletonColumns: TableSkeletonColumn[] = [
|
||||||
|
{ width: '50px', headerWidth: '34px' },
|
||||||
|
{ variant: 'person', width: '72%', headerWidth: '180px' },
|
||||||
|
{ variant: 'actions', headerWidth: '132px' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function OrganisationsSection() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const location = useLocation()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const tableCardRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const organisationsCurrentPage = Number(searchParams.get('oPage') ?? 0)
|
||||||
|
const organisationSearch = searchParams.get('oQuery') ?? ''
|
||||||
|
|
||||||
|
const organisationSort = useMemo(() => {
|
||||||
|
return organisationsSortOptions.find(
|
||||||
|
(option) => option.value === searchParams.get('oSort'),
|
||||||
|
) ?? organisationsSortOptions[0]
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const [debouncedOrganisationSearch, setDebouncedOrganisationSearch] =
|
||||||
|
useState('')
|
||||||
|
|
||||||
|
const [isAddOrganisationOpen, setIsAddOrganisationOpen] = useState(false)
|
||||||
|
const [editingOrganisation, setEditingOrganisation] =
|
||||||
|
useState<Organisation | null>(null)
|
||||||
|
const [deletingOrganisation, setDeletingOrganisation] =
|
||||||
|
useState<Organisation | null>(null)
|
||||||
|
const renderEditingOrganisation =
|
||||||
|
useDeferredModalPayload(editingOrganisation)
|
||||||
|
const renderDeletingOrganisation =
|
||||||
|
useDeferredModalPayload(deletingOrganisation)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setDebouncedOrganisationSearch(organisationSearch.trim())
|
||||||
|
}, 350)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [organisationSearch])
|
||||||
|
|
||||||
|
const organisationsQueryVariables = useMemo<GetOrganisationsVariables>(
|
||||||
|
() => ({
|
||||||
|
page: organisationsCurrentPage,
|
||||||
|
query: debouncedOrganisationSearch || undefined,
|
||||||
|
sortDirection: organisationSort.sortDirection,
|
||||||
|
sortField: organisationSort.sortField,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
debouncedOrganisationSearch,
|
||||||
|
organisationSort.sortDirection,
|
||||||
|
organisationSort.sortField,
|
||||||
|
organisationsCurrentPage,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: organisationsData,
|
||||||
|
previousData: previousOrganisationsData,
|
||||||
|
loading: organisationsLoading,
|
||||||
|
error: organisationsError,
|
||||||
|
refetch: refetchOrganisations,
|
||||||
|
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
|
{
|
||||||
|
variables: organisationsQueryVariables,
|
||||||
|
fetchPolicy: 'cache-first',
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const [deleteOrganisation, { loading: isDeletingOrganisation }] =
|
||||||
|
useMutation<DeleteOrganisationData, DeleteOrganisationVariables>(
|
||||||
|
DELETE_ORGANISATION_MUTATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
const organisationsResponse =
|
||||||
|
organisationsData?.getOrganisations ??
|
||||||
|
previousOrganisationsData?.getOrganisations
|
||||||
|
|
||||||
|
const organisations = organisationsResponse?.page ?? []
|
||||||
|
const organisationsTotalPages = organisationsResponse?.totalPages ?? 0
|
||||||
|
|
||||||
|
const isOrganisationsInitialLoading =
|
||||||
|
organisationsLoading && !organisationsResponse
|
||||||
|
|
||||||
|
const isOrganisationsUpdating =
|
||||||
|
organisationsLoading && Boolean(organisationsResponse)
|
||||||
|
const shouldShowOrganisationsError =
|
||||||
|
Boolean(organisationsError) && organisations.length === 0
|
||||||
|
const hasAppliedOrganisationFilters = Boolean(
|
||||||
|
debouncedOrganisationSearch,
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasPrevOrganisationsPage = organisationsCurrentPage > 0
|
||||||
|
const hasNextOrganisationsPage =
|
||||||
|
organisationsTotalPages > 0 &&
|
||||||
|
organisationsCurrentPage < organisationsTotalPages - 1
|
||||||
|
|
||||||
|
const organisationsPaginationItems = useMemo(
|
||||||
|
() => getPaginationItems(
|
||||||
|
organisationsCurrentPage,
|
||||||
|
organisationsTotalPages,
|
||||||
|
),
|
||||||
|
[organisationsCurrentPage, organisationsTotalPages],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOrganisationsInitialLoading) return
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const element = tableCardRef.current
|
||||||
|
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const savedScrollTop = Number(
|
||||||
|
sessionStorage.getItem(ORGANISATIONS_SCROLL_KEY) ?? 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
|
element.scrollTop = savedScrollTop
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId)
|
||||||
|
}
|
||||||
|
}, [isOrganisationsInitialLoading, organisationsCurrentPage])
|
||||||
|
|
||||||
|
const updateOrganisationsSearchParams = useCallback((
|
||||||
|
updates: Record<string, string | null>,
|
||||||
|
) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev)
|
||||||
|
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
if (value === null || value === '') {
|
||||||
|
next.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.set(key, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, [setSearchParams])
|
||||||
|
|
||||||
|
const resetOrganisationsScroll = useCallback(() => {
|
||||||
|
sessionStorage.setItem(ORGANISATIONS_SCROLL_KEY, '0')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOrganisationsScroll = useCallback(() => {
|
||||||
|
const element = tableCardRef.current
|
||||||
|
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
sessionStorage.setItem(
|
||||||
|
ORGANISATIONS_SCROLL_KEY,
|
||||||
|
String(element.scrollTop),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const tableScrollProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
ref: tableCardRef,
|
||||||
|
onScroll: handleOrganisationsScroll,
|
||||||
|
}),
|
||||||
|
[handleOrganisationsScroll],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setOrganisationsPage = useCallback((page: number) => {
|
||||||
|
resetOrganisationsScroll()
|
||||||
|
|
||||||
|
updateOrganisationsSearchParams({
|
||||||
|
oPage: page > 0 ? String(page) : null,
|
||||||
|
})
|
||||||
|
}, [resetOrganisationsScroll, updateOrganisationsSearchParams])
|
||||||
|
|
||||||
|
const handleNextOrganisationsPage = useCallback(() => {
|
||||||
|
if (!hasNextOrganisationsPage || organisationsLoading) return
|
||||||
|
|
||||||
|
setOrganisationsPage(organisationsCurrentPage + 1)
|
||||||
|
}, [
|
||||||
|
hasNextOrganisationsPage,
|
||||||
|
organisationsCurrentPage,
|
||||||
|
organisationsLoading,
|
||||||
|
setOrganisationsPage,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handlePrevOrganisationsPage = useCallback(() => {
|
||||||
|
if (!hasPrevOrganisationsPage || organisationsLoading) return
|
||||||
|
|
||||||
|
setOrganisationsPage(Math.max(0, organisationsCurrentPage - 1))
|
||||||
|
}, [
|
||||||
|
hasPrevOrganisationsPage,
|
||||||
|
organisationsCurrentPage,
|
||||||
|
organisationsLoading,
|
||||||
|
setOrganisationsPage,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleJumpPrevOrganisationsPage = useCallback(() => {
|
||||||
|
if (organisationsLoading) return
|
||||||
|
|
||||||
|
setOrganisationsPage(Math.max(0, organisationsCurrentPage - 5))
|
||||||
|
}, [organisationsCurrentPage, organisationsLoading, setOrganisationsPage])
|
||||||
|
|
||||||
|
const handleJumpNextOrganisationsPage = useCallback(() => {
|
||||||
|
if (organisationsLoading) return
|
||||||
|
|
||||||
|
setOrganisationsPage(
|
||||||
|
Math.min(organisationsTotalPages - 1, organisationsCurrentPage + 5),
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
organisationsCurrentPage,
|
||||||
|
organisationsLoading,
|
||||||
|
organisationsTotalPages,
|
||||||
|
setOrganisationsPage,
|
||||||
|
])
|
||||||
|
|
||||||
|
const refetchFirstOrganisationsPage = useCallback(async () => {
|
||||||
|
resetOrganisationsScroll()
|
||||||
|
|
||||||
|
updateOrganisationsSearchParams({
|
||||||
|
oPage: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
await refetchOrganisations({
|
||||||
|
...organisationsQueryVariables,
|
||||||
|
page: 0,
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
organisationsQueryVariables,
|
||||||
|
refetchOrganisations,
|
||||||
|
resetOrganisationsScroll,
|
||||||
|
updateOrganisationsSearchParams,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleOpenOrganisation = useCallback((organisation: Organisation) => {
|
||||||
|
navigate(`/employees/organisations/${organisation.id}`, {
|
||||||
|
state: {
|
||||||
|
fromEmployeesPath: `${location.pathname}${location.search}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [location.pathname, location.search, navigate])
|
||||||
|
|
||||||
|
const handleEditOrganisation = useCallback((organisation: Organisation) => {
|
||||||
|
setEditingOrganisation(organisation)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteOrganisation = useCallback((organisation: Organisation) => {
|
||||||
|
setDeletingOrganisation(organisation)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConfirmDeleteOrganisation = useCallback(async () => {
|
||||||
|
if (!deletingOrganisation) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteOrganisation({
|
||||||
|
variables: {
|
||||||
|
id: String(deletingOrganisation.id),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextPage =
|
||||||
|
organisations.length === 1 && organisationsCurrentPage > 0
|
||||||
|
? organisationsCurrentPage - 1
|
||||||
|
: organisationsCurrentPage
|
||||||
|
|
||||||
|
setDeletingOrganisation(null)
|
||||||
|
|
||||||
|
if (nextPage !== organisationsCurrentPage) {
|
||||||
|
updateOrganisationsSearchParams({
|
||||||
|
oPage: nextPage > 0 ? String(nextPage) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await refetchOrganisations({
|
||||||
|
page: nextPage,
|
||||||
|
query: debouncedOrganisationSearch || undefined,
|
||||||
|
sortDirection: organisationSort.sortDirection,
|
||||||
|
sortField: organisationSort.sortField,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Не удалось удалить организацию', error)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
debouncedOrganisationSearch,
|
||||||
|
deleteOrganisation,
|
||||||
|
deletingOrganisation,
|
||||||
|
organisationSort.sortDirection,
|
||||||
|
organisationSort.sortField,
|
||||||
|
organisations.length,
|
||||||
|
organisationsCurrentPage,
|
||||||
|
refetchOrganisations,
|
||||||
|
updateOrganisationsSearchParams,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleOrganisationSearchChange = useCallback((value: string) => {
|
||||||
|
resetOrganisationsScroll()
|
||||||
|
|
||||||
|
updateOrganisationsSearchParams({
|
||||||
|
oQuery: value || null,
|
||||||
|
oPage: null,
|
||||||
|
})
|
||||||
|
}, [resetOrganisationsScroll, updateOrganisationsSearchParams])
|
||||||
|
|
||||||
|
const handleOrganisationSortChange = useCallback((
|
||||||
|
option: typeof organisationsSortOptions[number],
|
||||||
|
) => {
|
||||||
|
resetOrganisationsScroll()
|
||||||
|
|
||||||
|
updateOrganisationsSearchParams({
|
||||||
|
oSort:
|
||||||
|
option.value === organisationsSortOptions[0].value
|
||||||
|
? null
|
||||||
|
: option.value,
|
||||||
|
oPage: null,
|
||||||
|
})
|
||||||
|
}, [resetOrganisationsScroll, updateOrganisationsSearchParams])
|
||||||
|
|
||||||
|
const handleOpenAddOrganisation = useCallback(() => {
|
||||||
|
setIsAddOrganisationOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleEditOrganisationOpenChange = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingOrganisation(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteOrganisationOpenChange = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setDeletingOrganisation(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EmployeesToolbar
|
||||||
|
addButtonText="Добавить организацию"
|
||||||
|
searchValue={organisationSearch}
|
||||||
|
onSearchChange={handleOrganisationSearchChange}
|
||||||
|
sortOptions={organisationsSortOptions}
|
||||||
|
selectedSort={organisationSort}
|
||||||
|
onSortChange={handleOrganisationSortChange}
|
||||||
|
showSort
|
||||||
|
onAdd={handleOpenAddOrganisation}
|
||||||
|
/>
|
||||||
|
<m.div
|
||||||
|
key="organisations-table"
|
||||||
|
className="employees-page__table-motion"
|
||||||
|
variants={pageSwapVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
transition={uiFadeTransition}
|
||||||
|
>
|
||||||
|
<div className="employees-table-container">
|
||||||
|
<div className="employees-table-shadow">
|
||||||
|
<SimpleBar
|
||||||
|
scrollableNodeProps={tableScrollProps}
|
||||||
|
className={`employees-table-card ${isOrganisationsUpdating ? 'is-updating' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RevealContent
|
||||||
|
loading={isOrganisationsInitialLoading}
|
||||||
|
fallback={
|
||||||
|
<TableSkeleton
|
||||||
|
className="employees-table organisations-table"
|
||||||
|
columns={organisationsTableSkeletonColumns}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shouldShowOrganisationsError && (
|
||||||
|
<div className="employees-state employees-state--error">
|
||||||
|
Не удалось загрузить организации
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!organisationsError && organisations.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
className="employees-state"
|
||||||
|
variant={
|
||||||
|
hasAppliedOrganisationFilters
|
||||||
|
? 'not-found'
|
||||||
|
: 'no-items'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
hasAppliedOrganisationFilters
|
||||||
|
? 'Не найдено результатов'
|
||||||
|
: 'Организаций пока нет'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
hasAppliedOrganisationFilters
|
||||||
|
? 'По заданным параметрам не найдено ни одной организации. Попробуйте изменить поиск.'
|
||||||
|
: 'Добавьте организацию, чтобы привязать сотрудников и устройства.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{organisations.length > 0 && (
|
||||||
|
<OrganisationsTable
|
||||||
|
organisations={organisations}
|
||||||
|
onOpen={handleOpenOrganisation}
|
||||||
|
onEdit={handleEditOrganisation}
|
||||||
|
onDelete={handleDeleteOrganisation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RevealContent>
|
||||||
|
</SimpleBar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{organisationsTotalPages > 1 && (
|
||||||
|
<EmployeesPagination
|
||||||
|
currentPage={organisationsCurrentPage}
|
||||||
|
loading={organisationsLoading}
|
||||||
|
items={organisationsPaginationItems}
|
||||||
|
hasPrevPage={hasPrevOrganisationsPage}
|
||||||
|
hasNextPage={hasNextOrganisationsPage}
|
||||||
|
onPrev={handlePrevOrganisationsPage}
|
||||||
|
onNext={handleNextOrganisationsPage}
|
||||||
|
onJumpPrev={handleJumpPrevOrganisationsPage}
|
||||||
|
onJumpNext={handleJumpNextOrganisationsPage}
|
||||||
|
onPageChange={setOrganisationsPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
|
<AddOrganisationModal
|
||||||
|
open={isAddOrganisationOpen}
|
||||||
|
mode="create"
|
||||||
|
onOpenChange={setIsAddOrganisationOpen}
|
||||||
|
onSuccess={refetchFirstOrganisationsPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddOrganisationModal
|
||||||
|
open={Boolean(editingOrganisation)}
|
||||||
|
mode="edit"
|
||||||
|
organisation={
|
||||||
|
editingOrganisation ?? renderEditingOrganisation
|
||||||
|
}
|
||||||
|
onOpenChange={handleEditOrganisationOpenChange}
|
||||||
|
onSuccess={refetchFirstOrganisationsPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
open={Boolean(deletingOrganisation)}
|
||||||
|
title="Удалить организацию?"
|
||||||
|
description={
|
||||||
|
renderDeletingOrganisation
|
||||||
|
? `Организация «${renderDeletingOrganisation.name}» будет удалена из системы. Это действие нельзя будет отменить.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmText="Удалить"
|
||||||
|
isLoading={isDeletingOrganisation}
|
||||||
|
onOpenChange={handleDeleteOrganisationOpenChange}
|
||||||
|
onConfirm={handleConfirmDeleteOrganisation}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { Pencil, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { Organisation } from '../../../../entities/employee/model/types'
|
||||||
|
|
||||||
|
function getOrganisationInitials(name: string) {
|
||||||
|
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
|
if (words.length === 0) return 'ОР'
|
||||||
|
|
||||||
|
return words
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCreationDate(timestamp?: number) {
|
||||||
|
if (!timestamp) return 'Дата создания не указана'
|
||||||
|
|
||||||
|
return `Создана ${new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(timestamp))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganisationsTableProps = {
|
||||||
|
organisations: Organisation[]
|
||||||
|
onOpen: (organisation: Organisation) => void
|
||||||
|
onEdit: (organisation: Organisation) => void
|
||||||
|
onDelete: (organisation: Organisation) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganisationTableRowProps = {
|
||||||
|
organisation: Organisation
|
||||||
|
onOpen: (organisation: Organisation) => void
|
||||||
|
onEdit: (organisation: Organisation) => void
|
||||||
|
onDelete: (organisation: Organisation) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrganisationTableRow = memo(function OrganisationTableRow({
|
||||||
|
organisation,
|
||||||
|
onOpen,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: OrganisationTableRowProps) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className="employees-table__row employees-table__row--clickable"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onOpen(organisation)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
onOpen(organisation)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td>{organisation.id}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div className="employee-person">
|
||||||
|
<div className={`employee-person__avatar ${organisation.logoUrl ? 'transparent-backdrop' : ''}`}>
|
||||||
|
{organisation.logoUrl ? (
|
||||||
|
<img src={organisation.logoUrl} alt={organisation.name} />
|
||||||
|
) : (
|
||||||
|
<span>{getOrganisationInitials(organisation.name)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="employee-person__info">
|
||||||
|
<span>{organisation.name}</span>
|
||||||
|
<small>{formatCreationDate(organisation.creationDate)}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div className="employees-table-actions">
|
||||||
|
<button
|
||||||
|
className="employees-action-btn employees-action-btn--edit"
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onEdit(organisation)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="employees-action-btn employees-action-btn--delete"
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onDelete(organisation)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const OrganisationsTable = memo(function OrganisationsTable({
|
||||||
|
organisations,
|
||||||
|
onOpen,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: OrganisationsTableProps) {
|
||||||
|
return (
|
||||||
|
<table className="employees-table organisations-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название организации</th>
|
||||||
|
<th>Управление</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{organisations.map((organisation) => (
|
||||||
|
<OrganisationTableRow
|
||||||
|
key={organisation.id}
|
||||||
|
organisation={organisation}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,443 @@
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
import { m } from 'framer-motion'
|
||||||
|
|
||||||
|
import { GET_USERS_QUERY } from '../../../../entities/employee/api/employee.graphql'
|
||||||
|
import type {
|
||||||
|
Employee,
|
||||||
|
EmployeeRole,
|
||||||
|
GetUsersData,
|
||||||
|
GetUsersVariables,
|
||||||
|
} from '../../../../entities/employee/model/types'
|
||||||
|
import {
|
||||||
|
EmployeesToolbar,
|
||||||
|
usersSortOptions,
|
||||||
|
} from '../EmployeesToolbar/EmployeesToolbar'
|
||||||
|
import { EmployeesPagination } from '../EmployeesPagination/EmployeesPagination'
|
||||||
|
import { UsersTable } from '../UsersTable/UsersTable'
|
||||||
|
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
||||||
|
import { getPaginationItems } from '../../lib/getPaginationItems'
|
||||||
|
import { getEmployeeFullName } from '../../lib/employeeFormatters'
|
||||||
|
import {
|
||||||
|
TableSkeleton,
|
||||||
|
type TableSkeletonColumn,
|
||||||
|
} from '../../../../shared/ui/TableSkeleton/TableSkeleton'
|
||||||
|
import { RevealContent } from '../../../../shared/ui/RevealContent/RevealContent'
|
||||||
|
import { pageSwapVariants, uiFadeTransition } from '../../../../shared/lib/motion'
|
||||||
|
import { EmptyState } from '../../../../shared/ui/EmptyState/EmptyState'
|
||||||
|
import {
|
||||||
|
useDeferredModalPayload,
|
||||||
|
} from '../../../../shared/lib/lazyMount'
|
||||||
|
import { AddEmployeeModal } from '../AddEmployeeModal/AddEmployeeModal'
|
||||||
|
|
||||||
|
import SimpleBar from "simplebar-react";
|
||||||
|
import "simplebar-react/dist/simplebar.min.css";
|
||||||
|
|
||||||
|
const USERS_SCROLL_KEY = 'employees.users.scrollTop'
|
||||||
|
|
||||||
|
const usersTableSkeletonColumns: TableSkeletonColumn[] = [
|
||||||
|
{ width: '42px', headerWidth: '34px' },
|
||||||
|
{ variant: 'person', width: '70%', headerWidth: '54px' },
|
||||||
|
{ width: '72%', headerWidth: '118px' },
|
||||||
|
{ variant: 'pill', headerWidth: '54px' },
|
||||||
|
{ variant: 'actions', headerWidth: '132px' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getRoleFilter(value: string | null): EmployeeRole | null {
|
||||||
|
if (value === 'Admin' || value === 'User') return value
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersSection() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const tableCardRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const usersCurrentPage = Number(searchParams.get('uPage') ?? 0)
|
||||||
|
const userSearch = searchParams.get('uQuery') ?? ''
|
||||||
|
const roleFilter = getRoleFilter(searchParams.get('uRole'))
|
||||||
|
const organisationFilterId = searchParams.get('uOrg')
|
||||||
|
|
||||||
|
const userSort = useMemo(() => {
|
||||||
|
return usersSortOptions.find(
|
||||||
|
(option) => option.value === searchParams.get('uSort'),
|
||||||
|
) ?? usersSortOptions[0]
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const [debouncedUserSearch, setDebouncedUserSearch] = useState('')
|
||||||
|
|
||||||
|
const [isAddEmployeeOpen, setIsAddEmployeeOpen] = useState(false)
|
||||||
|
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
||||||
|
const [deletingEmployee, setDeletingEmployee] = useState<Employee | null>(null)
|
||||||
|
const renderEditingEmployee = useDeferredModalPayload(editingEmployee)
|
||||||
|
const renderDeletingEmployee = useDeferredModalPayload(deletingEmployee)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setDebouncedUserSearch(userSearch.trim())
|
||||||
|
}, 350)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [userSearch])
|
||||||
|
|
||||||
|
const usersQueryVariables = useMemo<GetUsersVariables>(() => ({
|
||||||
|
page: usersCurrentPage,
|
||||||
|
query: debouncedUserSearch || undefined,
|
||||||
|
orgs: organisationFilterId ? [organisationFilterId] : undefined,
|
||||||
|
roles: roleFilter ? [roleFilter] : undefined,
|
||||||
|
sortDirection: userSort.sortDirection,
|
||||||
|
sortField: userSort.sortField,
|
||||||
|
}), [
|
||||||
|
debouncedUserSearch,
|
||||||
|
organisationFilterId,
|
||||||
|
roleFilter,
|
||||||
|
userSort.sortDirection,
|
||||||
|
userSort.sortField,
|
||||||
|
usersCurrentPage,
|
||||||
|
])
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: usersData,
|
||||||
|
previousData: previousUsersData,
|
||||||
|
loading: usersLoading,
|
||||||
|
error: usersError,
|
||||||
|
refetch: refetchUsers,
|
||||||
|
} = useQuery<GetUsersData, GetUsersVariables>(GET_USERS_QUERY, {
|
||||||
|
variables: usersQueryVariables,
|
||||||
|
fetchPolicy: 'cache-first',
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const usersResponse = usersData?.getUsers ?? previousUsersData?.getUsers
|
||||||
|
|
||||||
|
const employees = usersResponse?.page ?? []
|
||||||
|
const usersTotalPages = usersResponse?.totalPages ?? 0
|
||||||
|
|
||||||
|
const isUsersInitialLoading = usersLoading && !usersResponse
|
||||||
|
const isUsersUpdating = usersLoading && Boolean(usersResponse)
|
||||||
|
const shouldShowUsersError = Boolean(usersError) && employees.length === 0
|
||||||
|
const hasAppliedUsersFilters = Boolean(
|
||||||
|
debouncedUserSearch || roleFilter || organisationFilterId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasPrevUsersPage = usersCurrentPage > 0
|
||||||
|
const hasNextUsersPage =
|
||||||
|
usersTotalPages > 0 && usersCurrentPage < usersTotalPages - 1
|
||||||
|
|
||||||
|
const usersPaginationItems = useMemo(
|
||||||
|
() => getPaginationItems(usersCurrentPage, usersTotalPages),
|
||||||
|
[usersCurrentPage, usersTotalPages],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUsersInitialLoading) return
|
||||||
|
|
||||||
|
const element = tableCardRef.current
|
||||||
|
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const savedScrollTop = Number(
|
||||||
|
sessionStorage.getItem(USERS_SCROLL_KEY) ?? 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
|
element.scrollTop = savedScrollTop
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId)
|
||||||
|
}
|
||||||
|
}, [isUsersInitialLoading, usersCurrentPage])
|
||||||
|
|
||||||
|
const updateUsersSearchParams = useCallback((
|
||||||
|
updates: Record<string, string | null>,
|
||||||
|
) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev)
|
||||||
|
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
if (value === null || value === '') {
|
||||||
|
next.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.set(key, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, [setSearchParams])
|
||||||
|
|
||||||
|
const resetUsersScroll = useCallback(() => {
|
||||||
|
sessionStorage.setItem(USERS_SCROLL_KEY, '0')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleUsersScroll = useCallback(() => {
|
||||||
|
const element = tableCardRef.current
|
||||||
|
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
sessionStorage.setItem(USERS_SCROLL_KEY, String(element.scrollTop))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const tableScrollProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
ref: tableCardRef,
|
||||||
|
onScroll: handleUsersScroll,
|
||||||
|
}),
|
||||||
|
[handleUsersScroll],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setUsersPage = useCallback((page: number) => {
|
||||||
|
resetUsersScroll()
|
||||||
|
|
||||||
|
updateUsersSearchParams({
|
||||||
|
uPage: page > 0 ? String(page) : null,
|
||||||
|
})
|
||||||
|
}, [resetUsersScroll, updateUsersSearchParams])
|
||||||
|
|
||||||
|
const handleNextUsersPage = useCallback(() => {
|
||||||
|
if (!hasNextUsersPage || usersLoading) return
|
||||||
|
|
||||||
|
setUsersPage(usersCurrentPage + 1)
|
||||||
|
}, [hasNextUsersPage, setUsersPage, usersCurrentPage, usersLoading])
|
||||||
|
|
||||||
|
const handlePrevUsersPage = useCallback(() => {
|
||||||
|
if (!hasPrevUsersPage || usersLoading) return
|
||||||
|
|
||||||
|
setUsersPage(Math.max(0, usersCurrentPage - 1))
|
||||||
|
}, [hasPrevUsersPage, setUsersPage, usersCurrentPage, usersLoading])
|
||||||
|
|
||||||
|
const handleJumpPrevUsersPage = useCallback(() => {
|
||||||
|
if (usersLoading) return
|
||||||
|
|
||||||
|
setUsersPage(Math.max(0, usersCurrentPage - 5))
|
||||||
|
}, [setUsersPage, usersCurrentPage, usersLoading])
|
||||||
|
|
||||||
|
const handleJumpNextUsersPage = useCallback(() => {
|
||||||
|
if (usersLoading) return
|
||||||
|
|
||||||
|
setUsersPage(Math.min(usersTotalPages - 1, usersCurrentPage + 5))
|
||||||
|
}, [setUsersPage, usersCurrentPage, usersLoading, usersTotalPages])
|
||||||
|
|
||||||
|
const refetchFirstUsersPage = useCallback(async () => {
|
||||||
|
setUsersPage(0)
|
||||||
|
|
||||||
|
await refetchUsers({
|
||||||
|
...usersQueryVariables,
|
||||||
|
page: 0,
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
refetchUsers,
|
||||||
|
setUsersPage,
|
||||||
|
usersQueryVariables,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleEditEmployee = useCallback((employee: Employee) => {
|
||||||
|
setEditingEmployee(employee)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteEmployee = useCallback((employee: Employee) => {
|
||||||
|
setDeletingEmployee(employee)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConfirmDeleteEmployee = useCallback(() => {
|
||||||
|
if (!deletingEmployee) return
|
||||||
|
|
||||||
|
setDeletingEmployee(null)
|
||||||
|
}, [deletingEmployee])
|
||||||
|
|
||||||
|
const handleUserSearchChange = useCallback((value: string) => {
|
||||||
|
resetUsersScroll()
|
||||||
|
|
||||||
|
updateUsersSearchParams({
|
||||||
|
uQuery: value || null,
|
||||||
|
uPage: null,
|
||||||
|
})
|
||||||
|
}, [resetUsersScroll, updateUsersSearchParams])
|
||||||
|
|
||||||
|
const handleRoleFilterChange = useCallback((role: EmployeeRole | null) => {
|
||||||
|
resetUsersScroll()
|
||||||
|
|
||||||
|
updateUsersSearchParams({
|
||||||
|
uRole: role,
|
||||||
|
uPage: null,
|
||||||
|
})
|
||||||
|
}, [resetUsersScroll, updateUsersSearchParams])
|
||||||
|
|
||||||
|
const handleOrganisationFilterChange = useCallback((
|
||||||
|
organisation: { id: string; name: string } | null,
|
||||||
|
) => {
|
||||||
|
resetUsersScroll()
|
||||||
|
|
||||||
|
updateUsersSearchParams({
|
||||||
|
uOrg: organisation?.id ?? null,
|
||||||
|
uPage: null,
|
||||||
|
})
|
||||||
|
}, [resetUsersScroll, updateUsersSearchParams])
|
||||||
|
|
||||||
|
const handleUserSortChange = useCallback((
|
||||||
|
option: typeof usersSortOptions[number],
|
||||||
|
) => {
|
||||||
|
resetUsersScroll()
|
||||||
|
|
||||||
|
updateUsersSearchParams({
|
||||||
|
uSort: option.value === usersSortOptions[0].value ? null : option.value,
|
||||||
|
uPage: null,
|
||||||
|
})
|
||||||
|
}, [resetUsersScroll, updateUsersSearchParams])
|
||||||
|
|
||||||
|
const handleOpenAddEmployee = useCallback(() => {
|
||||||
|
setIsAddEmployeeOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleEditEmployeeOpenChange = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingEmployee(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteEmployeeOpenChange = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setDeletingEmployee(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EmployeesToolbar
|
||||||
|
addButtonText="Добавить сотрудника"
|
||||||
|
searchValue={userSearch}
|
||||||
|
onSearchChange={handleUserSearchChange}
|
||||||
|
sortOptions={usersSortOptions}
|
||||||
|
selectedSort={userSort}
|
||||||
|
roleFilter={roleFilter}
|
||||||
|
onRoleFilterChange={handleRoleFilterChange}
|
||||||
|
organisationFilterId={organisationFilterId}
|
||||||
|
onOrganisationFilterChange={handleOrganisationFilterChange}
|
||||||
|
onSortChange={handleUserSortChange}
|
||||||
|
showSort
|
||||||
|
showUserFilters
|
||||||
|
onAdd={handleOpenAddEmployee}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<m.div
|
||||||
|
key="users-table"
|
||||||
|
className="employees-page__table-motion"
|
||||||
|
variants={pageSwapVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
transition={uiFadeTransition}
|
||||||
|
>
|
||||||
|
<div className="employees-table-container">
|
||||||
|
<div className="employees-table-shadow">
|
||||||
|
<SimpleBar
|
||||||
|
scrollableNodeProps={tableScrollProps}
|
||||||
|
className={`employees-table-card ${isUsersUpdating ? 'is-updating' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RevealContent
|
||||||
|
loading={isUsersInitialLoading}
|
||||||
|
fallback={
|
||||||
|
<TableSkeleton
|
||||||
|
className="employees-table"
|
||||||
|
columns={usersTableSkeletonColumns}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shouldShowUsersError && (
|
||||||
|
<div className="employees-state employees-state--error">
|
||||||
|
Не удалось загрузить сотрудников
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!usersError && employees.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
className="employees-state"
|
||||||
|
variant={hasAppliedUsersFilters ? 'not-found' : 'no-items'}
|
||||||
|
title={
|
||||||
|
hasAppliedUsersFilters
|
||||||
|
? 'Не найдено результатов'
|
||||||
|
: 'Сотрудников пока нет'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
hasAppliedUsersFilters
|
||||||
|
? 'По заданным параметрам не найдено ни одного сотрудника. Попробуйте изменить поиск или фильтры.'
|
||||||
|
: 'Добавьте сотрудников, чтобы они появились в системе.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{employees.length > 0 && (
|
||||||
|
<UsersTable
|
||||||
|
employees={employees}
|
||||||
|
onEdit={handleEditEmployee}
|
||||||
|
onDelete={handleDeleteEmployee}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RevealContent>
|
||||||
|
</SimpleBar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usersTotalPages > 1 && (
|
||||||
|
<EmployeesPagination
|
||||||
|
currentPage={usersCurrentPage}
|
||||||
|
loading={usersLoading}
|
||||||
|
items={usersPaginationItems}
|
||||||
|
hasPrevPage={hasPrevUsersPage}
|
||||||
|
hasNextPage={hasNextUsersPage}
|
||||||
|
onPrev={handlePrevUsersPage}
|
||||||
|
onNext={handleNextUsersPage}
|
||||||
|
onJumpPrev={handleJumpPrevUsersPage}
|
||||||
|
onJumpNext={handleJumpNextUsersPage}
|
||||||
|
onPageChange={setUsersPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
|
<AddEmployeeModal
|
||||||
|
open={isAddEmployeeOpen}
|
||||||
|
mode="create"
|
||||||
|
onOpenChange={setIsAddEmployeeOpen}
|
||||||
|
onSuccess={refetchFirstUsersPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddEmployeeModal
|
||||||
|
open={Boolean(editingEmployee)}
|
||||||
|
mode="edit"
|
||||||
|
employee={editingEmployee ?? renderEditingEmployee}
|
||||||
|
onOpenChange={handleEditEmployeeOpenChange}
|
||||||
|
onSuccess={refetchFirstUsersPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
open={Boolean(deletingEmployee)}
|
||||||
|
title="Удалить сотрудника?"
|
||||||
|
description={
|
||||||
|
renderDeletingEmployee
|
||||||
|
? `Сотрудник «${getEmployeeFullName(
|
||||||
|
renderDeletingEmployee,
|
||||||
|
)}» будет удалён из системы. Это действие нельзя будет отменить.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmText="Удалить"
|
||||||
|
onOpenChange={handleDeleteEmployeeOpenChange}
|
||||||
|
onConfirm={handleConfirmDeleteEmployee}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { Pencil, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { Employee } from '../../../../entities/employee/model/types'
|
||||||
|
import {
|
||||||
|
getEmployeeFullName,
|
||||||
|
getEmployeeRoleLabel,
|
||||||
|
} from '../../lib/employeeFormatters'
|
||||||
|
|
||||||
|
function getUserInitials(employee: Employee) {
|
||||||
|
const first = employee.lastName?.trim()[0] ?? ''
|
||||||
|
const second = employee.firstName?.trim()[0] ?? ''
|
||||||
|
|
||||||
|
return `${first}${second}`.toUpperCase() || '??'
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsersTableProps = {
|
||||||
|
employees: Employee[]
|
||||||
|
onEdit: (employee: Employee) => void
|
||||||
|
onDelete: (employee: Employee) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserTableRowProps = {
|
||||||
|
employee: Employee
|
||||||
|
onEdit: (employee: Employee) => void
|
||||||
|
onDelete: (employee: Employee) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserTableRow = memo(function UserTableRow({
|
||||||
|
employee,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: UserTableRowProps) {
|
||||||
|
return (
|
||||||
|
<tr className="employees-table__row">
|
||||||
|
<td>{employee.id}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div className="employee-person">
|
||||||
|
<div className={`employee-person__avatar ${employee.avatarUrl ? 'transparent-backdrop' : ''}`}>
|
||||||
|
{employee.avatarUrl ? (
|
||||||
|
<img src={employee.avatarUrl} alt={getEmployeeFullName(employee)} />
|
||||||
|
) : (
|
||||||
|
<span>{getUserInitials(employee)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="employee-person__info">
|
||||||
|
<span>{getEmployeeFullName(employee) || 'ФИО не указано'}</span>
|
||||||
|
|
||||||
|
{employee.username && (
|
||||||
|
<small>@{employee.username}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span className="employee-organisation">
|
||||||
|
{employee.org?.name || 'Организация не указана'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
employee.role === 'Admin'
|
||||||
|
? 'employee-role employee-role--admin'
|
||||||
|
: 'employee-role'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getEmployeeRoleLabel(employee.role)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div className="employees-table-actions">
|
||||||
|
<button
|
||||||
|
className="employees-action-btn employees-action-btn--edit"
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onEdit(employee)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="employees-action-btn employees-action-btn--delete"
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onDelete(employee)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const UsersTable = memo(function UsersTable({
|
||||||
|
employees,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: UsersTableProps) {
|
||||||
|
return (
|
||||||
|
<table className="employees-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>ФИО</th>
|
||||||
|
<th>Организация</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Управление</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{employees.map((employee) => (
|
||||||
|
<UserTableRow
|
||||||
|
key={employee.id}
|
||||||
|
employee={employee}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Employee } from '../../../entities/employee/model/types'
|
||||||
|
|
||||||
|
export function getEmployeeFullName(employee: Employee) {
|
||||||
|
return [employee.lastName, employee.firstName, employee.middleName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEmployeeRoleLabel(role: string) {
|
||||||
|
if (role === 'Admin') return 'Администратор'
|
||||||
|
if (role === 'User') return 'Пользователь'
|
||||||
|
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { PaginationItem } from '../components/EmployeesPagination/EmployeesPagination'
|
||||||
|
|
||||||
|
export function getPaginationItems(
|
||||||
|
currentPage: number,
|
||||||
|
totalPages: number,
|
||||||
|
): PaginationItem[] {
|
||||||
|
if (totalPages <= 0) return []
|
||||||
|
|
||||||
|
const current = currentPage + 1
|
||||||
|
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
return Array.from({ length: totalPages }, (_, index) => index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = new Set<number>()
|
||||||
|
|
||||||
|
pages.add(1)
|
||||||
|
pages.add(totalPages)
|
||||||
|
pages.add(current)
|
||||||
|
pages.add(current - 1)
|
||||||
|
pages.add(current + 1)
|
||||||
|
|
||||||
|
if (current <= 4) {
|
||||||
|
pages.add(2)
|
||||||
|
pages.add(3)
|
||||||
|
pages.add(4)
|
||||||
|
pages.add(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current >= totalPages - 3) {
|
||||||
|
pages.add(totalPages - 1)
|
||||||
|
pages.add(totalPages - 2)
|
||||||
|
pages.add(totalPages - 3)
|
||||||
|
pages.add(totalPages - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedPages = Array.from(pages)
|
||||||
|
.filter((page) => page >= 1 && page <= totalPages)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
|
||||||
|
const result: PaginationItem[] = []
|
||||||
|
|
||||||
|
sortedPages.forEach((page, index) => {
|
||||||
|
const previousPage = sortedPages[index - 1]
|
||||||
|
|
||||||
|
if (previousPage && page - previousPage > 1) {
|
||||||
|
if (page - previousPage === 2) {
|
||||||
|
result.push(previousPage + 1)
|
||||||
|
} else {
|
||||||
|
result.push(page < current ? 'prev-ellipsis' : 'next-ellipsis')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
function resolveUploadUrl(uploadUrl: string) {
|
||||||
|
const trimmedUploadUrl = uploadUrl.trim()
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(trimmedUploadUrl)) {
|
||||||
|
const url = new URL(trimmedUploadUrl)
|
||||||
|
|
||||||
|
return `${url.pathname}${url.search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedUploadUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadMultipartFile(
|
||||||
|
uploadUrl: string,
|
||||||
|
fieldName: string,
|
||||||
|
file: File,
|
||||||
|
) {
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
formData.append(fieldName, file)
|
||||||
|
|
||||||
|
const response = await fetch(resolveUploadUrl(uploadUrl), {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '')
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Не удалось загрузить файл: ${response.status} ${text}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,223 @@
|
||||||
@use '../../shared/styles/variables' as *;
|
@use '../../shared/styles/variables' as *;
|
||||||
|
|
||||||
.login-page {
|
.login-page {
|
||||||
min-height: 100vh;
|
position: relative;
|
||||||
background: $color-bg;
|
isolation: isolate;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
padding: 24px;
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
95% 70% at 50% 118%,
|
||||||
|
rgba(3, 29, 154, 0.18) 0%,
|
||||||
|
rgba(66, 93, 232, 0.14) 32%,
|
||||||
|
rgba(47, 128, 237, 0.1) 52%,
|
||||||
|
rgba(239, 242, 247, 0.45) 72%,
|
||||||
|
transparent 86%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
70% 48% at 18% 100%,
|
||||||
|
rgba(24, 184, 155, 0.1) 0%,
|
||||||
|
transparent 68%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
#fbfcff 0%,
|
||||||
|
#f4f7fc 45%,
|
||||||
|
#eff2f7 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
95% 70% at 50% 125%,
|
||||||
|
rgba($color-primary-rgb, 0.34) 0%,
|
||||||
|
rgba(72, 96, 180, 0.22) 36%,
|
||||||
|
rgba($color-bg-rgb, 0.55) 62%,
|
||||||
|
transparent 82%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
#242a33 0%,
|
||||||
|
#242a33 12%,
|
||||||
|
#222832 18%,
|
||||||
|
#1f252e 30%,
|
||||||
|
#1d232c 55%,
|
||||||
|
#171b22 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch {
|
||||||
|
position: absolute;
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
width: 72px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__track {
|
||||||
|
position: relative;
|
||||||
|
width: 62px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
background: $color-surface-glass;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 1px 1px rgba($gray50-rgb, 0.24),
|
||||||
|
0 10px 28px rgba($color-shadow-rgb, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background 0.25s ease,
|
||||||
|
box-shadow 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__track::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 25% 35%, rgba($orange, 0.22), transparent 34%),
|
||||||
|
radial-gradient(circle at 78% 65%, rgba($blue, 0.2), transparent 38%);
|
||||||
|
|
||||||
|
opacity: 0.92;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__thumb {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
background: $white;
|
||||||
|
color: $orange;
|
||||||
|
box-shadow: 0 1px 8px -2px rgba($gray50-rgb, 0.8);
|
||||||
|
transform: translateX(0);
|
||||||
|
|
||||||
|
transition:
|
||||||
|
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
background 0.25s ease,
|
||||||
|
color 0.25s ease,
|
||||||
|
box-shadow 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__icon {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 50%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
|
||||||
|
transition:
|
||||||
|
opacity 0.25s ease,
|
||||||
|
color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__icon--sun {
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__icon--moon {
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch.is-light {
|
||||||
|
.login-theme-switch__icon--sun {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__icon--moon {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch.is-dark {
|
||||||
|
.login-theme-switch__track {
|
||||||
|
background: $color-surface-glass;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba($white, 0.08),
|
||||||
|
0 12px 30px rgba($black, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__track::before {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__thumb {
|
||||||
|
transform: translateX(36px);
|
||||||
|
background: $color-surface;
|
||||||
|
color: $blue;
|
||||||
|
box-shadow: 0 1px 6px -2px $gray50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__icon--sun {
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch__icon--moon {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch:active {
|
||||||
|
.login-theme-switch__thumb {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-dark .login-theme-switch__thumb {
|
||||||
|
transform: translateX(36px) scale(0.94);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
width: 100%;
|
position: relative;
|
||||||
max-width: 420px;
|
z-index: 1;
|
||||||
|
|
||||||
border-radius: 24px;
|
width: min(100%, 430px);
|
||||||
background: #ffffff;
|
padding: 30px;
|
||||||
padding: 32px;
|
border: 1px solid rgba($color-border-rgb, 0.74);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: $color-surface-glass;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: $shadow-card;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -25,69 +225,202 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card__header {
|
.login-card__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 8px 0 0;
|
margin: 6px 0 0;
|
||||||
color: $gray50;
|
color: $color-text-muted;
|
||||||
font-size: 16px;
|
font-size: $font-size-14;
|
||||||
font-weight: 450;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__logo {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-field {
|
.login-field {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-direction: column;
|
display: block;
|
||||||
gap: 8px;
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
.login-field__label {
|
||||||
color: #30394b;
|
position: absolute;
|
||||||
font-size: 14px;
|
z-index: 2;
|
||||||
font-weight: 500;
|
top: -8px;
|
||||||
}
|
left: 44px;
|
||||||
|
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-surface-glass;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-11;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 16px;
|
||||||
|
//text-transform: uppercase;
|
||||||
|
letter-spacing: 0;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
color 0.18s ease,
|
||||||
|
background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field__control {
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 12px 0 14px;
|
||||||
|
border: 1px solid $color-border-strong;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: $color-surface-muted;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
height: 44px;
|
min-width: 0;
|
||||||
border: 1px solid #dfe5ef;
|
flex: 1;
|
||||||
border-radius: 14px;
|
height: 100%;
|
||||||
padding: 0 14px;
|
border: none;
|
||||||
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 16px;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
&:focus {
|
color: $color-text-strong;
|
||||||
border-color: $blue;
|
font-size: $font-size-15;
|
||||||
background: #ffffff;
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-field:focus-within {
|
||||||
|
.login-field__label {
|
||||||
|
color: $blue;
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field__control {
|
||||||
|
border-color: $blue;
|
||||||
|
background: $color-surface;
|
||||||
|
color: $blue;
|
||||||
|
box-shadow: 0 0 0 3px rgba($color-primary-rgb, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field__password-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&[aria-pressed='true'] {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.login-error {
|
.login-error {
|
||||||
border-radius: 12px;
|
|
||||||
background: #ffecec;
|
|
||||||
color: $red;
|
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-weight: 450;
|
border-radius: 12px;
|
||||||
font-size: 14px;
|
background: $color-danger-bg;
|
||||||
|
|
||||||
|
color: $red;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-button {
|
.login-button {
|
||||||
height: 44px;
|
height: 54px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: $blue;
|
background: $blue;
|
||||||
|
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
font-size: 15px;
|
font-size: $font-size-16;
|
||||||
font-weight: 600;
|
font-weight: 550;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
opacity 0.18s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.login-page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-theme-switch {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,142 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { SubmitEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
import { useMutation } from '@apollo/client/react'
|
import { useMutation } from '@apollo/client/react'
|
||||||
import { SIGN_IN_MUTATION, CURRENT_USER_QUERY } from '../../features/auth/api/auth.graphql'
|
import {
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
LockKeyhole,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
UserRound,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CURRENT_USER_QUERY,
|
||||||
|
SIGN_IN_MUTATION,
|
||||||
|
} from '../../features/auth/api/auth.graphql'
|
||||||
|
import { useTheme } from '../../shared/theme/useTheme'
|
||||||
|
import LogoIcon from '../../assets/LogoIcon.svg'
|
||||||
|
import LogoIconWhite from '../../assets/LogoIconWhite.svg'
|
||||||
|
|
||||||
import './LoginPage.scss'
|
import './LoginPage.scss'
|
||||||
|
|
||||||
type LoginPageProps = {
|
type LoginPageProps = {
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginPage({ onSuccess }: LoginPageProps) {
|
export function LoginPage({ onSuccess }: LoginPageProps) {
|
||||||
const [username, setUsername] = useState('Admin1')
|
const [username, setUsername] = useState('Admin2')
|
||||||
const [password, setPassword] = useState('123456')
|
const [password, setPassword] = useState('123456')
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
|
||||||
|
const { isDark, toggleTheme } = useTheme()
|
||||||
|
const logo = isDark ? LogoIconWhite : LogoIcon
|
||||||
|
const canSubmit = Boolean(username.trim()) && Boolean(password.trim())
|
||||||
|
|
||||||
const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, {
|
const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, {
|
||||||
refetchQueries: [CURRENT_USER_QUERY],
|
refetchQueries: [CURRENT_USER_QUERY],
|
||||||
onCompleted: () => {
|
onCompleted: () => {
|
||||||
onSuccess()
|
onSuccess()
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!canSubmit || loading) return
|
||||||
|
|
||||||
|
signIn({
|
||||||
|
variables: {
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
|
return (
|
||||||
event.preventDefault()
|
<main className="login-page">
|
||||||
|
<button
|
||||||
|
className={`login-theme-switch ${isDark ? 'is-dark' : 'is-light'}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={isDark ? 'Включить светлую тему' : 'Включить тёмную тему'}
|
||||||
|
aria-pressed={isDark}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
>
|
||||||
|
<span className="login-theme-switch__track">
|
||||||
|
<span className="login-theme-switch__icon login-theme-switch__icon--sun">
|
||||||
|
<Sun size={14} />
|
||||||
|
</span>
|
||||||
|
|
||||||
signIn({
|
<span className="login-theme-switch__icon login-theme-switch__icon--moon">
|
||||||
variables: {
|
<Moon size={14} />
|
||||||
username,
|
</span>
|
||||||
password,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<span className="login-theme-switch__thumb">
|
||||||
<main className="login-page">
|
{isDark ? <Moon size={15} /> : <Sun size={15} />}
|
||||||
<form className="login-card" onSubmit={handleSubmit}>
|
</span>
|
||||||
<div className="login-card__header">
|
</span>
|
||||||
<img src='/favicon.svg' height={56} width={56} alt='logo'></img>
|
</button>
|
||||||
<p>Авторизуйтесь для доступа к панели управления устройствами</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="login-field">
|
<form className="login-card" onSubmit={handleSubmit}>
|
||||||
<span>Логин</span>
|
<div className="login-card__header">
|
||||||
<input
|
<span className="login-card__logo">
|
||||||
value={username}
|
<img src={logo} height={40} width={40} alt="АРМА" />
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
</span>
|
||||||
placeholder="Введите логин"
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="login-field">
|
<div>
|
||||||
<span>Пароль</span>
|
<p>Авторизуйтесь для доступа <br></br>к панели управления устройствами</p>
|
||||||
<input
|
</div>
|
||||||
value={password}
|
</div>
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
placeholder="Введите пароль"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error && (
|
<label className="login-field">
|
||||||
<div className="login-error">
|
<span className="login-field__label">Логин</span>
|
||||||
Не удалось войти. Проверьте логин и пароль.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button className="login-button" type="submit" disabled={loading}>
|
<span className="login-field__control">
|
||||||
{loading ? 'Вход...' : 'Войти'}
|
<UserRound size={18} />
|
||||||
</button>
|
|
||||||
</form>
|
<input
|
||||||
</main>
|
value={username}
|
||||||
)
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Введите логин"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="login-field">
|
||||||
|
<span className="login-field__label">Пароль</span>
|
||||||
|
|
||||||
|
<span className="login-field__control">
|
||||||
|
<LockKeyhole size={18} />
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
type={isPasswordVisible ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="login-field__password-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-label={isPasswordVisible ? 'Скрыть пароль' : 'Показать пароль'}
|
||||||
|
aria-pressed={isPasswordVisible}
|
||||||
|
onClick={() => setIsPasswordVisible((value) => !value)}
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="login-error">
|
||||||
|
Не удалось войти. Проверьте логин и пароль.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="login-button" type="submit" disabled={loading || !canSubmit}>
|
||||||
|
{loading ? 'Входим...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 20px 20px 36px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-page__sidebar {
|
.map-page__sidebar {
|
||||||
|
|
@ -20,7 +21,7 @@
|
||||||
.map-page__panel {
|
.map-page__panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -29,8 +30,8 @@
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 20px;
|
font-size: $font-size-20;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -38,7 +39,7 @@
|
||||||
.map-page__state {
|
.map-page__state {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-page__state--error {
|
.map-page__state--error {
|
||||||
|
|
@ -48,10 +49,10 @@
|
||||||
.map-page__count {
|
.map-page__count {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
|
|
||||||
b {
|
b {
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,18 +65,18 @@
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(3, 29, 154, 0.45) transparent;
|
scrollbar-color: rgba($color-primary-rgb, 0.45) $transparent;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
&::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: $transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: rgba(3, 29, 154, 0.35);
|
background: rgba($color-primary-rgb, 0.35);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,14 +94,14 @@
|
||||||
transition: 0.2s ease;
|
transition: 0.2s ease;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
small {
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -113,12 +114,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-page__map {
|
.map-page__map {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-page__map:fullscreen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
background: $color-surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-page__leaflet {
|
.map-page__leaflet {
|
||||||
|
|
@ -126,16 +135,141 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-page__load-state {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 600;
|
||||||
|
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-surface-glass;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 12px 28px rgba($color-shadow-rgb, 0.16);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 650;
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker {
|
||||||
|
background: $transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker__pin {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba($color-primary-rgb, 0.18);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker__inner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $blue;
|
||||||
|
border: 3px solid $color-surface;
|
||||||
|
box-shadow: 0 8px 18px rgba($color-primary-rgb, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker.is-online {
|
||||||
|
.device-map-marker__pin {
|
||||||
|
background: rgba($color-success-rgb, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker__inner {
|
||||||
|
background: $green;
|
||||||
|
box-shadow: 0 8px 18px rgba($color-success-rgb, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker.is-offline {
|
||||||
|
.device-map-marker__pin {
|
||||||
|
background: rgba($gray50-rgb, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker__inner {
|
||||||
|
background: $gray50;
|
||||||
|
box-shadow: 0 8px 18px rgba($gray50-rgb, 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker.is-lost {
|
||||||
|
.device-map-marker__pin {
|
||||||
|
background: rgba($color-danger-rgb, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-marker__inner {
|
||||||
|
background: $red;
|
||||||
|
box-shadow: 0 8px 18px rgba($color-danger-rgb, 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip.map-marker-label {
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $color-surface-glass;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.2;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 10px 24px rgba($color-shadow-rgb, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip-right.map-marker-label::before {
|
||||||
|
border-right-color: $color-surface-glass;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip-left.map-marker-label::before {
|
||||||
|
border-left-color: $color-surface-glass;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker-label.is-online {
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker-label.is-offline {
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker-label.is-lost {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
.map-popup {
|
.map-popup {
|
||||||
min-width: 210px;
|
min-width: 230px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-popup__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-popup__title {
|
.map-popup__title {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 8px;
|
|
||||||
|
|
||||||
color: $blue !important;
|
color: $blue !important;
|
||||||
font-size: 15px;
|
font-size: $font-size-15;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
|
|
@ -144,72 +278,247 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-popup__status {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-11;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-online {
|
||||||
|
background: rgba($color-success-rgb, 0.14);
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-offline {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-lost {
|
||||||
|
background: rgba($color-danger-rgb, 0.14);
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.map-popup__row {
|
.map-popup__row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 12px;
|
font-size: $font-size-12;
|
||||||
}
|
}
|
||||||
|
|
||||||
b {
|
b {
|
||||||
color: #111827;
|
color: $color-text-strong;
|
||||||
font-size: 13px;
|
font-size: $font-size-13;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-popup__coords {
|
.map-popup__coords {
|
||||||
margin-top: 8px;
|
padding-top: 2px;
|
||||||
color: #738098;
|
color: $color-text-muted;
|
||||||
font-size: 12px;
|
font-size: $font-size-12;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-touch .leaflet-bar {
|
.map-selection-panel {
|
||||||
border: none !important;
|
position: absolute;
|
||||||
background-clip: padding-box;
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 600;
|
||||||
|
|
||||||
|
width: min(340px, calc(100% - 24px));
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
align-items: stretch;
|
||||||
}
|
gap: 12px;
|
||||||
|
|
||||||
.leaflet-control-zoom-in,
|
pointer-events: none;
|
||||||
.leaflet-control-zoom-out {
|
|
||||||
--sans: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
;
|
|
||||||
font: 28px/100% var(--sans) !important;
|
|
||||||
width: 36px !important;
|
|
||||||
height: 36px !important;
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
text-indent: 0px !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
border: none !important;
|
|
||||||
transition: .2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
> * {
|
||||||
background-color: white !important;
|
pointer-events: auto;
|
||||||
color: $blue !important;
|
}
|
||||||
|
|
||||||
|
.map-track-filter {
|
||||||
|
position: static;
|
||||||
|
z-index: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-top.leaflet-left {
|
.device-map-info-card {
|
||||||
top: 50%;
|
position: absolute;
|
||||||
left: auto;
|
left: 12px;
|
||||||
right: 12px;
|
top: 70px;
|
||||||
transform: translateY(-50%);
|
z-index: 430;
|
||||||
|
|
||||||
|
width: min(180px, calc(100% - 24px));
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba($color-border-rgb, 0.72);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba($color-surface-rgb, 0.94);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-shadow: $shadow-popover;
|
||||||
|
&--map-page{
|
||||||
|
top: 120px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-left .leaflet-control {
|
|
||||||
margin-left: 0px !important;
|
.device-map-info-card__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $color-surface-muted;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text-muted;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-surface-hover;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-top .leaflet-control {
|
.device-map-info-card__header {
|
||||||
margin-top: 0px !important;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding-right: 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
b,
|
||||||
|
span,
|
||||||
|
.device-map-info-card__title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
b,
|
||||||
|
.device-map-info-card__title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-top: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: $font-size-12;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-info-card__status {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 22px;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-11;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-online {
|
||||||
|
background: rgba($color-success-rgb, 0.14);
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-offline {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-lost {
|
||||||
|
background: rgba($color-danger-rgb, 0.14);
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-track-filter {
|
.map-track-filter {
|
||||||
|
|
@ -221,7 +530,101 @@
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.16);
|
pointer-events: none;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-track-filter__reset {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-surface-glass;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 12px 28px rgba($color-shadow-rgb, 0.16);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-14;
|
||||||
|
font-weight: 650;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue;
|
||||||
|
box-shadow:
|
||||||
|
0 14px 32px rgba($color-shadow-rgb, 0.18),
|
||||||
|
inset 0 0 0 1px rgba($color-primary-rgb, 0.14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-track-filter__state {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-surface-glass;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 12px 28px rgba($color-shadow-rgb, 0.16);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
color: $color-text-heading;
|
||||||
|
font-size: $font-size-13;
|
||||||
|
font-weight: 650;
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include laptop-down {
|
||||||
|
.map-page__sidebar {
|
||||||
|
width: 280px;
|
||||||
|
flex-basis: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-page__panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-device-link {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet-down {
|
||||||
|
.map-page {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-page__sidebar {
|
||||||
|
width: 260px;
|
||||||
|
flex-basis: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-selection-panel,
|
||||||
|
.map-track-filter {
|
||||||
|
max-width: calc(100% - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-selection-panel > *,
|
||||||
|
.map-track-filter > * {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,29 +1,31 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useQuery } from '@apollo/client/react'
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
import { AnimatePresence, m } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
CircleMarker,
|
CircleMarker,
|
||||||
MapContainer,
|
MapContainer,
|
||||||
Marker,
|
Marker,
|
||||||
Polyline,
|
Polyline,
|
||||||
Popup,
|
|
||||||
TileLayer,
|
TileLayer,
|
||||||
useMap,
|
useMap,
|
||||||
useMapEvents,
|
useMapEvents,
|
||||||
} from 'react-leaflet'
|
} from 'react-leaflet'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
|
import { Battery, Clock3, Layers, MapPin, Thermometer, X } from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GET_PHONE_GPS_TRACK_QUERY,
|
GET_PHONE_GPS_TRACK_QUERY,
|
||||||
GET_PHONES_PAGE_QUERY,
|
GET_PHONES_QUERY,
|
||||||
} from '../../entities/device/api/device.graphql'
|
} from '../../entities/device/api/device.graphql'
|
||||||
import type {
|
import type {
|
||||||
Device,
|
Device,
|
||||||
DeviceGpsTrackPoint,
|
DeviceGpsTrackPoint,
|
||||||
|
DeviceNetworkStatus,
|
||||||
GetPhoneGpsTrackData,
|
GetPhoneGpsTrackData,
|
||||||
GetPhoneGpsTrackVariables,
|
GetPhoneGpsTrackVariables,
|
||||||
GetPhonesPageData,
|
GetPhonesData,
|
||||||
GetPhonesPageVariables,
|
GetPhonesVariables,
|
||||||
} from '../../entities/device/model/types'
|
} from '../../entities/device/model/types'
|
||||||
|
|
||||||
import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
|
import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
|
||||||
|
|
@ -31,22 +33,68 @@ import {
|
||||||
MapTrackPeriodControl,
|
MapTrackPeriodControl,
|
||||||
type MapTrackPeriodValue,
|
type MapTrackPeriodValue,
|
||||||
} from './components/MapTrackPeriodControl/MapTrackPeriodControl'
|
} from './components/MapTrackPeriodControl/MapTrackPeriodControl'
|
||||||
|
import { popoverVariants, uiFadeTransition } from '../../shared/lib/motion'
|
||||||
|
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
import './MapPage.scss'
|
import './MapPage.scss'
|
||||||
|
|
||||||
const markerIcon = L.divIcon({
|
const MAP_PHONES_SORT_DIRECTION = 'DESC'
|
||||||
className: 'device-map-marker',
|
const MAP_PHONES_SORT_FIELD = 'Date'
|
||||||
html: `
|
const routeColor = 'var(--color-primary)'
|
||||||
<div class="device-map-marker__pin">
|
|
||||||
<div class="device-map-marker__inner"></div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
iconSize: [34, 34],
|
|
||||||
iconAnchor: [17, 17],
|
|
||||||
popupAnchor: [0, -18],
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatLocationDate(timestamp: number) {
|
type MapInfoPoint = {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
date?: number
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNetworkStatusClass(status?: DeviceNetworkStatus) {
|
||||||
|
if (status === 'Online') return 'is-online'
|
||||||
|
if (status === 'Lost') return 'is-lost'
|
||||||
|
|
||||||
|
return 'is-offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNetworkStatusLabel(status?: DeviceNetworkStatus) {
|
||||||
|
if (status === 'Online') return 'В сети'
|
||||||
|
if (status === 'Lost') return 'Потерян'
|
||||||
|
|
||||||
|
return 'Не в сети'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMapInfoPointKey(lat: number, lng: number, date?: number) {
|
||||||
|
return `${date ?? 'no-date'}-${lat}-${lng}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerIconCache = new Map<string, L.DivIcon>()
|
||||||
|
|
||||||
|
function createMarkerIcon(status?: DeviceNetworkStatus) {
|
||||||
|
const iconKey = status ?? 'default'
|
||||||
|
const cachedIcon = markerIconCache.get(iconKey)
|
||||||
|
|
||||||
|
if (cachedIcon) return cachedIcon
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: `device-map-marker ${getNetworkStatusClass(status)}`,
|
||||||
|
html: `
|
||||||
|
<div class="device-map-marker__pin">
|
||||||
|
<div class="device-map-marker__inner"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [34, 34],
|
||||||
|
iconAnchor: [17, 17],
|
||||||
|
popupAnchor: [0, -18],
|
||||||
|
})
|
||||||
|
|
||||||
|
markerIconCache.set(iconKey, icon)
|
||||||
|
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocationDate(timestamp?: number) {
|
||||||
if (!timestamp) return 'Нет данных'
|
if (!timestamp) return 'Нет данных'
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
|
@ -58,6 +106,16 @@ function formatLocationDate(timestamp: number) {
|
||||||
}).format(new Date(timestamp))
|
}).format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number) {
|
||||||
|
return value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetric(value: number | null | undefined, suffix: string) {
|
||||||
|
if (typeof value !== 'number') return '-'
|
||||||
|
|
||||||
|
return `${value}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
|
function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
|
||||||
return [...track].sort((a, b) => a.date - b.date)
|
return [...track].sort((a, b) => a.date - b.date)
|
||||||
}
|
}
|
||||||
|
|
@ -85,18 +143,47 @@ function MapResetHandler({ onReset }: MapResetHandlerProps) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
type MapFitBoundsProps = {
|
function MapResizeWatcher() {
|
||||||
positions: [number, number][]
|
|
||||||
}
|
|
||||||
|
|
||||||
function MapFitBounds({ positions }: MapFitBoundsProps) {
|
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function invalidateMapSize() {
|
||||||
|
map.invalidateSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const animationFrameId = window.requestAnimationFrame(invalidateMapSize)
|
||||||
|
const timeoutId = window.setTimeout(invalidateMapSize, 220)
|
||||||
|
|
||||||
|
window.addEventListener('resize', invalidateMapSize)
|
||||||
|
document.addEventListener('fullscreenchange', invalidateMapSize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrameId)
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
window.removeEventListener('resize', invalidateMapSize)
|
||||||
|
document.removeEventListener('fullscreenchange', invalidateMapSize)
|
||||||
|
}
|
||||||
|
}, [map])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapFitBoundsProps = {
|
||||||
|
fitKey: string
|
||||||
|
positions: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapFitBounds({ fitKey, positions }: MapFitBoundsProps) {
|
||||||
|
const map = useMap()
|
||||||
|
const lastFitKeyRef = useRef<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!positions.length) return
|
if (!positions.length) return
|
||||||
|
if (lastFitKeyRef.current === fitKey) return
|
||||||
|
|
||||||
if (positions.length === 1) {
|
if (positions.length === 1) {
|
||||||
map.setView(positions[0], 14)
|
map.setView(positions[0], 14)
|
||||||
|
lastFitKeyRef.current = fitKey
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,22 +194,40 @@ function MapFitBounds({ positions }: MapFitBoundsProps) {
|
||||||
maxZoom: 15,
|
maxZoom: 15,
|
||||||
animate: true,
|
animate: true,
|
||||||
})
|
})
|
||||||
}, [map, positions])
|
|
||||||
|
lastFitKeyRef.current = fitKey
|
||||||
|
}, [fitKey, map, positions])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapPage() {
|
export function MapPage() {
|
||||||
|
const fullscreenTargetId = useId()
|
||||||
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
|
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
|
||||||
const [selectedPeriod, setSelectedPeriod] =
|
const [selectedPeriod, setSelectedPeriod] =
|
||||||
useState<MapTrackPeriodValue | null>(null)
|
useState<MapTrackPeriodValue | null>(null)
|
||||||
|
const [phonesPaginationError, setPhonesPaginationError] =
|
||||||
|
useState<Error | null>(null)
|
||||||
|
const [isFetchingNextPhonesPage, setIsFetchingNextPhonesPage] =
|
||||||
|
useState(false)
|
||||||
|
const [gpsFitDataKey, setGpsFitDataKey] = useState('no-period')
|
||||||
|
const [selectedPointKey, setSelectedPointKey] = useState<string | null>(null)
|
||||||
|
const [isInfoCardVisible, setIsInfoCardVisible] = useState(false)
|
||||||
|
const isFetchingPhonesPageRef = useRef(false)
|
||||||
|
const requestedPhonesPagesRef = useRef(new Set<number>([0]))
|
||||||
|
const fittedGpsPeriodRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const { data, loading, error } = useQuery<
|
const { data, loading, error, fetchMore } = useQuery<
|
||||||
GetPhonesPageData,
|
GetPhonesData,
|
||||||
GetPhonesPageVariables
|
GetPhonesVariables
|
||||||
>(GET_PHONES_PAGE_QUERY, {
|
>(GET_PHONES_QUERY, {
|
||||||
variables: {},
|
variables: {
|
||||||
|
page: 0,
|
||||||
|
sortDirection: MAP_PHONES_SORT_DIRECTION,
|
||||||
|
sortField: MAP_PHONES_SORT_FIELD,
|
||||||
|
},
|
||||||
fetchPolicy: 'network-only',
|
fetchPolicy: 'network-only',
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -142,24 +247,131 @@ export function MapPage() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const devices = data?.getPhonesPage.page ?? []
|
const phonesTotalPages = data?.getPhones.totalPages ?? 0
|
||||||
|
|
||||||
const devicesWithLocation = devices.filter((device) =>
|
useEffect(() => {
|
||||||
Boolean(device.lastLocation),
|
if (
|
||||||
)
|
phonesPaginationError ||
|
||||||
|
phonesTotalPages <= 1 ||
|
||||||
|
isFetchingPhonesPageRef.current
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPhonesPage = Array.from(
|
||||||
|
{ length: phonesTotalPages - 1 },
|
||||||
|
(_, index) => index + 1,
|
||||||
|
).find((page) => !requestedPhonesPagesRef.current.has(page))
|
||||||
|
|
||||||
|
if (nextPhonesPage === undefined) return
|
||||||
|
|
||||||
|
isFetchingPhonesPageRef.current = true
|
||||||
|
requestedPhonesPagesRef.current.add(nextPhonesPage)
|
||||||
|
setIsFetchingNextPhonesPage(true)
|
||||||
|
setPhonesPaginationError(null)
|
||||||
|
|
||||||
|
fetchMore({
|
||||||
|
variables: {
|
||||||
|
page: nextPhonesPage,
|
||||||
|
sortDirection: MAP_PHONES_SORT_DIRECTION,
|
||||||
|
sortField: MAP_PHONES_SORT_FIELD,
|
||||||
|
},
|
||||||
|
updateQuery: (previousData, { fetchMoreResult }) => {
|
||||||
|
const nextPageData = fetchMoreResult?.getPhones
|
||||||
|
|
||||||
|
if (!nextPageData) return previousData
|
||||||
|
|
||||||
|
const previousDevices = previousData.getPhones.page
|
||||||
|
const previousDeviceIds = new Set(
|
||||||
|
previousDevices.map((device) => device.id),
|
||||||
|
)
|
||||||
|
const nextDevices = nextPageData.page.filter((device) => {
|
||||||
|
return !previousDeviceIds.has(device.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPhones: {
|
||||||
|
...nextPageData,
|
||||||
|
page: [...previousDevices, ...nextDevices],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((requestError: unknown) => {
|
||||||
|
setPhonesPaginationError(
|
||||||
|
requestError instanceof Error
|
||||||
|
? requestError
|
||||||
|
: new Error(
|
||||||
|
'Не удалось загрузить следующую страницу устройств',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isFetchingPhonesPageRef.current = false
|
||||||
|
setIsFetchingNextPhonesPage(false)
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
fetchMore,
|
||||||
|
isFetchingNextPhonesPage,
|
||||||
|
phonesPaginationError,
|
||||||
|
phonesTotalPages,
|
||||||
|
])
|
||||||
|
|
||||||
|
const phonesError = error ?? phonesPaginationError
|
||||||
|
const isPhonesLoading = loading || isFetchingNextPhonesPage
|
||||||
|
const selectedPeriodKey = selectedPeriod
|
||||||
|
? `${selectedPeriod.periodStart.getTime()}-${selectedPeriod.periodEnd.getTime()}`
|
||||||
|
: 'no-period'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPeriod) {
|
||||||
|
fittedGpsPeriodRef.current = null
|
||||||
|
setGpsFitDataKey('no-period')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isGpsTrackLoading ||
|
||||||
|
!gpsTrackData ||
|
||||||
|
fittedGpsPeriodRef.current === selectedPeriodKey
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fittedGpsPeriodRef.current = selectedPeriodKey
|
||||||
|
setGpsFitDataKey(`ready:${selectedPeriodKey}`)
|
||||||
|
}, [gpsTrackData, isGpsTrackLoading, selectedPeriod, selectedPeriodKey])
|
||||||
|
|
||||||
|
const devices = useMemo(() => {
|
||||||
|
return data?.getPhones.page ?? []
|
||||||
|
}, [data?.getPhones.page])
|
||||||
|
|
||||||
|
const devicesWithLocation = useMemo(() => {
|
||||||
|
return devices.filter(
|
||||||
|
(device): device is Device & {
|
||||||
|
lastLocation: NonNullable<Device['lastLocation']>
|
||||||
|
} => Boolean(device.lastLocation),
|
||||||
|
)
|
||||||
|
}, [devices])
|
||||||
|
const shouldShowPhonesLoadState =
|
||||||
|
!selectedDevice &&
|
||||||
|
(Boolean(phonesError) ||
|
||||||
|
(isPhonesLoading && devicesWithLocation.length === 0))
|
||||||
|
|
||||||
const sortedGpsTrack = useMemo(() => {
|
const sortedGpsTrack = useMemo(() => {
|
||||||
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
||||||
}, [gpsTrackData])
|
}, [gpsTrackData])
|
||||||
|
|
||||||
const shouldShowCurrentLocation = Boolean(
|
const selectedLastLocation = selectedDevice?.lastLocation ?? null
|
||||||
selectedDevice?.lastLocation &&
|
|
||||||
isDateInPeriod(selectedDevice.lastLocation.date, selectedPeriod),
|
const shouldAddLastLocationToRoute = Boolean(
|
||||||
|
selectedLastLocation &&
|
||||||
|
isDateInPeriod(selectedLastLocation.date, selectedPeriod),
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedCurrentLocation =
|
const selectedCurrentLocation =
|
||||||
shouldShowCurrentLocation && selectedDevice?.lastLocation
|
shouldAddLastLocationToRoute && selectedLastLocation
|
||||||
? selectedDevice.lastLocation
|
? selectedLastLocation
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const selectedRoutePoints = useMemo(() => {
|
const selectedRoutePoints = useMemo(() => {
|
||||||
|
|
@ -187,21 +399,81 @@ export function MapPage() {
|
||||||
]
|
]
|
||||||
}, [selectedRoutePoints, selectedCurrentLocation])
|
}, [selectedRoutePoints, selectedCurrentLocation])
|
||||||
|
|
||||||
|
const selectedMapPositions = useMemo<[number, number][]>(() => {
|
||||||
|
const positions = [...selectedRoutePositions]
|
||||||
|
|
||||||
|
if (!selectedLastLocation) return positions
|
||||||
|
|
||||||
|
const hasLastLocation = positions.some(([lat, lng]) => {
|
||||||
|
return lat === selectedLastLocation.lat && lng === selectedLastLocation.lng
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasLastLocation) return positions
|
||||||
|
|
||||||
|
return [...positions, [selectedLastLocation.lat, selectedLastLocation.lng]]
|
||||||
|
}, [selectedLastLocation, selectedRoutePositions])
|
||||||
|
|
||||||
const isSelectedMode = Boolean(selectedDevice)
|
const isSelectedMode = Boolean(selectedDevice)
|
||||||
|
|
||||||
|
const routeMapPoints = useMemo<MapInfoPoint[]>(() => {
|
||||||
|
return selectedRoutePoints.map((point) => ({
|
||||||
|
key: createMapInfoPointKey(point.lat, point.lng, point.date),
|
||||||
|
title: 'Точка маршрута',
|
||||||
|
subtitle: 'Точка маршрута',
|
||||||
|
date: point.date,
|
||||||
|
lat: point.lat,
|
||||||
|
lng: point.lng,
|
||||||
|
}))
|
||||||
|
}, [selectedDevice, selectedRoutePoints])
|
||||||
|
|
||||||
|
const currentMapPoint = useMemo<MapInfoPoint | null>(() => {
|
||||||
|
if (!selectedDevice || !selectedLastLocation) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: createMapInfoPointKey(
|
||||||
|
selectedLastLocation.lat,
|
||||||
|
selectedLastLocation.lng,
|
||||||
|
selectedLastLocation.date,
|
||||||
|
),
|
||||||
|
title: selectedDevice.serial || `Устройство #${selectedDevice.id}`,
|
||||||
|
subtitle: selectedDevice.org?.name,
|
||||||
|
date: selectedLastLocation.date,
|
||||||
|
lat: selectedLastLocation.lat,
|
||||||
|
lng: selectedLastLocation.lng,
|
||||||
|
}
|
||||||
|
}, [selectedDevice, selectedLastLocation])
|
||||||
|
|
||||||
|
const selectedInfoPoint = useMemo(() => {
|
||||||
|
if (!selectedPointKey) return currentMapPoint
|
||||||
|
|
||||||
|
return (
|
||||||
|
routeMapPoints.find((point) => point.key === selectedPointKey) ??
|
||||||
|
currentMapPoint
|
||||||
|
)
|
||||||
|
}, [currentMapPoint, routeMapPoints, selectedPointKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPointKey && selectedInfoPoint?.key !== selectedPointKey) {
|
||||||
|
setSelectedPointKey(null)
|
||||||
|
}
|
||||||
|
}, [selectedInfoPoint, selectedPointKey])
|
||||||
|
|
||||||
const visibleMapPositions = useMemo<[number, number][]>(() => {
|
const visibleMapPositions = useMemo<[number, number][]>(() => {
|
||||||
if (isSelectedMode && selectedRoutePositions.length > 0) {
|
if (isSelectedMode) {
|
||||||
return selectedRoutePositions
|
return selectedMapPositions
|
||||||
}
|
}
|
||||||
|
|
||||||
return devicesWithLocation.map(
|
return devicesWithLocation.map(
|
||||||
(device) =>
|
(device) =>
|
||||||
[
|
[
|
||||||
device.lastLocation!.lat,
|
device.lastLocation.lat,
|
||||||
device.lastLocation!.lng,
|
device.lastLocation.lng,
|
||||||
] as [number, number],
|
] as [number, number],
|
||||||
)
|
)
|
||||||
}, [isSelectedMode, selectedRoutePositions, devicesWithLocation])
|
}, [isSelectedMode, selectedMapPositions, devicesWithLocation])
|
||||||
|
const visibleMapFitKey = isSelectedMode
|
||||||
|
? `selected:${selectedDevice?.id ?? 'none'}:${selectedPeriodKey}:${gpsFitDataKey}`
|
||||||
|
: 'all-devices'
|
||||||
|
|
||||||
const firstLocation = devicesWithLocation[0]?.lastLocation
|
const firstLocation = devicesWithLocation[0]?.lastLocation
|
||||||
|
|
||||||
|
|
@ -212,33 +484,160 @@ export function MapPage() {
|
||||||
function handleSelectDevice(device: Device) {
|
function handleSelectDevice(device: Device) {
|
||||||
setSelectedDevice(device)
|
setSelectedDevice(device)
|
||||||
setSelectedPeriod(null)
|
setSelectedPeriod(null)
|
||||||
|
setSelectedPointKey(null)
|
||||||
|
setIsInfoCardVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResetSelection() {
|
function handleResetSelection() {
|
||||||
setSelectedDevice(null)
|
setSelectedDevice(null)
|
||||||
setSelectedPeriod(null)
|
setSelectedPeriod(null)
|
||||||
|
setSelectedPointKey(null)
|
||||||
|
setIsInfoCardVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="map-page">
|
<section className="map-page">
|
||||||
<div className="map-page__map">
|
<div className="map-page__map" id={fullscreenTargetId}>
|
||||||
{selectedDevice && (
|
{shouldShowPhonesLoadState && (
|
||||||
<div className="map-track-filter">
|
<div
|
||||||
|
className={`map-page__load-state${
|
||||||
<MapTrackPeriodControl onChange={setSelectedPeriod} />
|
phonesError ? ' is-error' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{phonesError
|
||||||
|
? 'Не удалось загрузить устройства'
|
||||||
|
: 'Загружаем устройства'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{selectedDevice && selectedInfoPoint && isInfoCardVisible && (
|
||||||
|
<m.div
|
||||||
|
className="device-map-info-card device-map-info-card--map-page"
|
||||||
|
variants={popoverVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={uiFadeTransition}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="device-map-info-card__close"
|
||||||
|
type="button"
|
||||||
|
aria-label="Закрыть информацию о точке"
|
||||||
|
onClick={handleResetSelection}
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__header">
|
||||||
|
<Link
|
||||||
|
className="device-map-info-card__title"
|
||||||
|
to={`/devices/${selectedDevice.id}`}
|
||||||
|
>
|
||||||
|
{selectedInfoPoint.title}
|
||||||
|
</Link>
|
||||||
|
<span>
|
||||||
|
{selectedInfoPoint.subtitle ??
|
||||||
|
selectedDevice.org?.name ??
|
||||||
|
selectedDevice.serial}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__rows">
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<span
|
||||||
|
className={`device-map-info-card__status ${getNetworkStatusClass(
|
||||||
|
selectedDevice.networkStatus,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{getNetworkStatusLabel(selectedDevice.networkStatus)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<Clock3 size={15} />
|
||||||
|
<span>{formatLocationDate(selectedInfoPoint.date)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<Battery size={15} />
|
||||||
|
<span>
|
||||||
|
Заряд АКБ:{' '}
|
||||||
|
{formatMetric(selectedDevice.techState?.batteryLevel, '%')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<Thermometer size={15} />
|
||||||
|
<span>
|
||||||
|
Температура:{' '}
|
||||||
|
{formatMetric(selectedDevice.techState?.temperature, '°C')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-map-info-card__row">
|
||||||
|
<MapPin size={15} />
|
||||||
|
<span>
|
||||||
|
{formatCoordinate(selectedInfoPoint.lat)},{' '}
|
||||||
|
{formatCoordinate(selectedInfoPoint.lng)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{selectedDevice && (
|
||||||
|
<m.div
|
||||||
|
className="map-selection-panel"
|
||||||
|
variants={popoverVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={uiFadeTransition}
|
||||||
|
>
|
||||||
|
<div className="map-track-filter">
|
||||||
|
<MapTrackPeriodControl onChange={setSelectedPeriod} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="map-track-filter__reset"
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetSelection}
|
||||||
|
>
|
||||||
|
<Layers size={17} />
|
||||||
|
Все устройства
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(isGpsTrackLoading || gpsTrackError) && (
|
||||||
|
<div
|
||||||
|
className={`map-track-filter__state${
|
||||||
|
gpsTrackError ? ' is-error' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{gpsTrackError
|
||||||
|
? 'Не удалось загрузить маршрут'
|
||||||
|
: 'Загружаем маршрут'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={mapCenter}
|
center={mapCenter}
|
||||||
zoom={12}
|
zoom={12}
|
||||||
scrollWheelZoom
|
scrollWheelZoom
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
className="map-page__leaflet"
|
className="map-page__leaflet"
|
||||||
id="devices-map"
|
|
||||||
>
|
>
|
||||||
|
<MapResizeWatcher />
|
||||||
<MapResetHandler onReset={handleResetSelection} />
|
<MapResetHandler onReset={handleResetSelection} />
|
||||||
<MapFitBounds positions={visibleMapPositions} />
|
<MapFitBounds
|
||||||
|
fitKey={visibleMapFitKey}
|
||||||
|
positions={visibleMapPositions}
|
||||||
|
/>
|
||||||
|
|
||||||
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
|
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
|
||||||
|
|
||||||
|
|
@ -250,33 +649,14 @@ export function MapPage() {
|
||||||
<Marker
|
<Marker
|
||||||
key={device.id}
|
key={device.id}
|
||||||
position={[device.lastLocation.lat, device.lastLocation.lng]}
|
position={[device.lastLocation.lat, device.lastLocation.lng]}
|
||||||
icon={markerIcon}
|
icon={createMarkerIcon(device.networkStatus)}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: (event) => {
|
click: (event) => {
|
||||||
event.originalEvent.stopPropagation()
|
event.originalEvent.stopPropagation()
|
||||||
handleSelectDevice(device)
|
handleSelectDevice(device)
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Popup>
|
|
||||||
<div className="map-popup">
|
|
||||||
<Link
|
|
||||||
className="map-popup__title"
|
|
||||||
to={`/devices/${device.id}`}
|
|
||||||
>
|
|
||||||
{device.serial || `Устройство #${device.id}`}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="map-popup__row">
|
|
||||||
<b>{formatLocationDate(device.lastLocation.date)}</b>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="map-popup__coords">
|
|
||||||
{device.lastLocation.lat}, {device.lastLocation.lng}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
@ -286,81 +666,54 @@ export function MapPage() {
|
||||||
<Polyline
|
<Polyline
|
||||||
positions={selectedRoutePositions}
|
positions={selectedRoutePositions}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
color: '#031d9a',
|
color: routeColor,
|
||||||
weight: 4,
|
weight: 4,
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedRoutePoints.map((point, index) => (
|
{routeMapPoints.map((point) => (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
key={`${point.lat}-${point.lng}-${point.date}-${index}`}
|
key={point.key}
|
||||||
center={[point.lat, point.lng]}
|
center={[point.lat, point.lng]}
|
||||||
radius={4}
|
radius={4}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
color: '#031d9a',
|
color: routeColor,
|
||||||
fillColor: '#031d9a',
|
fillColor: routeColor,
|
||||||
fillOpacity: 1,
|
fillOpacity: 1,
|
||||||
weight: 2,
|
weight: 2,
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<Popup>
|
|
||||||
<div className="map-popup">
|
|
||||||
<div className="map-popup__row">
|
|
||||||
<b>{formatLocationDate(point.date)}</b>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="map-popup__coords">
|
|
||||||
{point.lat}, {point.lng}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
</CircleMarker>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{selectedCurrentLocation && (
|
|
||||||
<Marker
|
|
||||||
position={[
|
|
||||||
selectedCurrentLocation.lat,
|
|
||||||
selectedCurrentLocation.lng,
|
|
||||||
]}
|
|
||||||
icon={markerIcon}
|
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: (event) => {
|
click: (event) => {
|
||||||
event.originalEvent.stopPropagation()
|
event.originalEvent.stopPropagation()
|
||||||
|
setSelectedPointKey(point.key)
|
||||||
|
setIsInfoCardVisible(true)
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Popup>
|
))}
|
||||||
<div className="map-popup">
|
|
||||||
<Link
|
|
||||||
className="map-popup__title"
|
|
||||||
to={`/devices/${selectedDevice?.id}`}
|
|
||||||
>
|
|
||||||
{selectedDevice?.serial ||
|
|
||||||
`Устройство #${selectedDevice?.id}`}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="map-popup__row">
|
{selectedLastLocation && currentMapPoint && (
|
||||||
<b>
|
<Marker
|
||||||
Текущее местоположение:{' '}
|
position={[
|
||||||
{formatLocationDate(selectedCurrentLocation.date)}
|
selectedLastLocation.lat,
|
||||||
</b>
|
selectedLastLocation.lng,
|
||||||
</div>
|
]}
|
||||||
|
icon={createMarkerIcon(selectedDevice?.networkStatus)}
|
||||||
<div className="map-popup__coords">
|
eventHandlers={{
|
||||||
{selectedCurrentLocation.lat},{' '}
|
click: (event) => {
|
||||||
{selectedCurrentLocation.lng}
|
event.originalEvent.stopPropagation()
|
||||||
</div>
|
setSelectedPointKey(currentMapPoint.key)
|
||||||
</div>
|
setIsInfoCardVisible(true)
|
||||||
</Popup>
|
},
|
||||||
</Marker>
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FullscreenControl targetId="devices-map" />
|
<FullscreenControl targetId={fullscreenTargetId} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
.history-period-tabs {
|
.history-period-tabs {
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba($color-bg, .8);
|
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -39,10 +39,11 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
background: $gray30;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: #ffffff;
|
background: $color-bg;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,17 +55,18 @@
|
||||||
.history-date-picker__trigger {
|
.history-date-picker__trigger {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba($color-bg, .8);
|
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
color: #30394b;
|
color: var(--gray50);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
|
|
@ -78,9 +80,9 @@
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: rgba($color-bg, 1);
|
background: $color-bg;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
box-shadow: inset 0 0 0 1px rgba(3, 29, 154, 0.16);
|
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.16);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
@ -94,94 +96,99 @@
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
|
|
||||||
|
min-width: 100%;
|
||||||
|
width: max-content;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: #ffffff;
|
background: $color-surface;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
|
|
||||||
border: 1.5px solid $gray20;
|
border: 1.5px solid $gray20;
|
||||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
|
box-shadow: none;
|
||||||
|
|
||||||
button{
|
button{
|
||||||
transition: .18s ease;
|
transition: .18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-root {
|
.rdp-root {
|
||||||
--rdp-accent-color: $blue;
|
--rdp-accent-color: #{$blue};
|
||||||
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
|
--rdp-accent-background-color: #{$color-primary-soft};
|
||||||
--rdp-day_button-border-radius: 10px;
|
--rdp-day_button-border-radius: 12px;
|
||||||
|
--rdp-day_button-height: 34px;
|
||||||
|
--rdp-day_button-width: 34px;
|
||||||
|
--rdp-day-height: 36px;
|
||||||
|
--rdp-day-width: 36px;
|
||||||
--rdp-selected-border: 0;
|
--rdp-selected-border: 0;
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-months {
|
.rdp-months {
|
||||||
|
max-width: 100%;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rdp-month {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.rdp-month_caption {
|
.rdp-month_caption {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-caption_label {
|
.rdp-caption_label {
|
||||||
color: #30394b;
|
color: $color-text-strong;
|
||||||
font-size: 15px;
|
font-size: $font-size-14;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-nav {
|
.rdp-nav {
|
||||||
button {
|
gap: 4px;
|
||||||
width: 30px;
|
}
|
||||||
height: 30px;
|
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
color: $gray50;
|
.rdp-button_previous,
|
||||||
|
.rdp-button_next,
|
||||||
|
.rdp-nav button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
&:hover {
|
color: $blue;
|
||||||
background: $color-bg;
|
|
||||||
color: $blue;
|
&:hover {
|
||||||
}
|
background: $color-surface-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-weekday {
|
.rdp-weekday {
|
||||||
color: $gray50;
|
color: $color-text-muted;
|
||||||
font-size: 12px;
|
font-size: $font-size-12;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
|
||||||
|
|
||||||
.rdp-day {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
color: #30394b;
|
color: $color-text-heading;
|
||||||
font-size: 14px;
|
font-size: $font-size-12;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $color-bg;
|
background: $color-surface-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-selected {
|
.rdp-selected {
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
background: $blue;
|
background: $blue;
|
||||||
color: #ffffff;
|
color: $color-text-inverse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-range_middle {
|
.rdp-range_middle {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
background: #e8edff;
|
background: $color-primary-soft;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -189,19 +196,23 @@
|
||||||
|
|
||||||
.rdp-range_start {
|
.rdp-range_start {
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
border-radius: 10px;
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-range_end {
|
.rdp-range_end {
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
border-radius: 10px;
|
background: $blue;
|
||||||
|
color: $color-text-inverse;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-range_start.rdp-range_end {
|
.rdp-range_start.rdp-range_end {
|
||||||
.rdp-day_button {
|
.rdp-day_button {
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rdp-today {
|
.rdp-today {
|
||||||
|
|
@ -244,7 +255,7 @@
|
||||||
|
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(3, 29, 154, 0.16);
|
background: rgba($color-primary-rgb, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-range__active {
|
.history-range__active {
|
||||||
|
|
@ -295,52 +306,3 @@
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* react-day-picker */
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-root {
|
|
||||||
--rdp-accent-color: $blue;
|
|
||||||
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
|
|
||||||
--rdp-day_button-border-radius: 10px;
|
|
||||||
--rdp-selected-border: 0;
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-months {
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-caption_label {
|
|
||||||
color: #30394b;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-weekday {
|
|
||||||
color: $gray50;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-day_button {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
|
|
||||||
color: #30394b;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-selected .rdp-day_button {
|
|
||||||
background: $blue;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-range_middle .rdp-day_button {
|
|
||||||
background: #e8edff;
|
|
||||||
color: $blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-date-picker__dropdown .rdp-today .rdp-day_button {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,173 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.duration-picker {
|
||||||
|
--height: 44px;
|
||||||
|
--radius: 12px;
|
||||||
|
--gap-open: 7px;
|
||||||
|
--text: #{$duration-picker-text};
|
||||||
|
--muted: #{$duration-picker-muted};
|
||||||
|
@include desktop-down{
|
||||||
|
--height: 36px;
|
||||||
|
--gap-open: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
width: fit-content;
|
||||||
|
height: var(--height);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: visible;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
gap 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
|
||||||
|
opacity 180ms ease;
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker--open {
|
||||||
|
gap: var(--gap-open);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker__segment {
|
||||||
|
position: relative;
|
||||||
|
height: var(--height);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: $color-bg;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
width 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
|
||||||
|
gap 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
|
||||||
|
padding 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
|
||||||
|
border-radius 320ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
background-color 220ms ease,
|
||||||
|
box-shadow 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker__segment--days,
|
||||||
|
.duration-picker__segment--hours {
|
||||||
|
width: 44px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker__segment--days {
|
||||||
|
border-radius: var(--radius) 0 0 var(--radius);
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker__segment--hours,
|
||||||
|
.duration-picker__segment--minutes {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker__segment--minutes {
|
||||||
|
width: 64px;
|
||||||
|
padding: 0 3px;
|
||||||
|
padding-right: 8px;
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker--open .duration-picker__segment {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker--open .duration-picker__segment--days,
|
||||||
|
.duration-picker--open .duration-picker__segment--hours,
|
||||||
|
.duration-picker--open .duration-picker__segment--minutes {
|
||||||
|
width: 52px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker--open .duration-picker__segment--minutes {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker--open .duration-picker__segment--days:hover,
|
||||||
|
.duration-picker--open .duration-picker__segment--hours:hover,
|
||||||
|
.duration-picker--open .duration-picker__segment--minutes:hover {
|
||||||
|
background: $duration-picker-hover-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker--open .duration-picker__segment--days:focus-within,
|
||||||
|
.duration-picker--open .duration-picker__segment--hours:focus-within,
|
||||||
|
.duration-picker--open .duration-picker__segment--minutes:focus-within {
|
||||||
|
background: $color-surface;
|
||||||
|
box-shadow: $shadow-duration-focus;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker__input {
|
||||||
|
width: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: $transparent;
|
||||||
|
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: $font-size-16;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
width 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
|
||||||
|
transform 320ms cubic-bezier(0.34, 1.42, 0.64, 1);
|
||||||
|
|
||||||
|
&::-webkit-outer-spin-button,
|
||||||
|
&::-webkit-inner-spin-button {
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-text-fill-color: var(--text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker--open .duration-picker__input {
|
||||||
|
width: 28px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker__unit {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: $font-size-15;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
margin-left 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
|
||||||
|
color 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-picker--open .duration-picker__unit {
|
||||||
|
margin-left: 7px;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue