Обновить интерфейс MDM
This commit is contained in:
parent
820bdb924f
commit
ee339bf301
|
|
@ -18,5 +18,9 @@ export default defineConfig([
|
|||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@
|
|||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"framer-motion": "^12.38.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"graphql": "^16.13.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.9.0",
|
||||
|
|
@ -26,10 +27,13 @@
|
|||
"react": "^19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-hook-form": "^7.73.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"sass": "^1.99.0",
|
||||
"simplebar": "^6.3.3",
|
||||
"simplebar-react": "^3.3.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
|
|
@ -39,6 +43,7 @@
|
|||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.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": {
|
||||
"version": "1.2.8",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
|
|
@ -2986,13 +3041,13 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
||||
"version": "12.40.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
|
||||
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.38.0",
|
||||
"motion-utils": "^12.36.0",
|
||||
"motion-dom": "^12.40.0",
|
||||
"motion-utils": "^12.39.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
@ -3542,6 +3597,18 @@
|
|||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
|
@ -3578,18 +3645,18 @@
|
|||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
||||
"version": "12.40.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
|
||||
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.36.0"
|
||||
"motion-utils": "^12.39.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.36.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||
"version": "12.39.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
|
|
@ -3639,6 +3706,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz",
|
||||
|
|
@ -3842,6 +3915,20 @@
|
|||
"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": {
|
||||
"version": "7.73.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz",
|
||||
|
|
@ -4108,6 +4195,37 @@
|
|||
"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": {
|
||||
"version": "1.0.3",
|
||||
"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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"framer-motion": "^12.38.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"graphql": "^16.13.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.9.0",
|
||||
|
|
@ -28,10 +29,13 @@
|
|||
"react": "^19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-hook-form": "^7.73.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"sass": "^1.99.0",
|
||||
"simplebar": "^6.3.3",
|
||||
"simplebar-react": "^3.3.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
|
|
@ -41,6 +45,7 @@
|
|||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
@use './shared/styles/variables' as *;
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
font-size: $font-size-16;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
border: 2px solid $transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
|
|
@ -117,7 +119,7 @@
|
|||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
font-size: $font-size-16;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
|
|
@ -170,7 +172,7 @@
|
|||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
border: 5px solid $transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useState } from 'react'
|
||||
//import reactLogo from './assets/react.svg'
|
||||
//import viteLogo from './assets/vite.svg'
|
||||
//import heroImg from './assets/hero.png'
|
||||
|
|
|
|||
|
|
@ -1,13 +1,288 @@
|
|||
@use '../../shared/styles/variables' as *;
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
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 {
|
||||
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;
|
||||
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 * 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 './AppLayout.scss'
|
||||
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() {
|
||||
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 (
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<div className="app-layout__sidebar">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
<main className="app-layout__content">
|
||||
<Navbar />
|
||||
<Outlet />
|
||||
<SimpleBar ref={contentScrollRef} className="app-layout__scroll">
|
||||
<Navbar
|
||||
breadcrumbs={navbarBreadcrumbs}
|
||||
isScrolled={isContentScrolled}
|
||||
onOpenSidebar={() => setIsSidebarDrawerOpen(true)}
|
||||
/>
|
||||
<div className="app-layout__route-content">
|
||||
<Outlet context={{ setNavbarBreadcrumbs }} />
|
||||
</div>
|
||||
</SimpleBar>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,42 @@
|
|||
// src/app/router/router.tsx
|
||||
import { lazy, Suspense, type ReactNode } from 'react'
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
|
||||
import { AppLayout } from '../layouts/AppLayout'
|
||||
import { DevicesPage } from '../../pages/DevicesPage/DevicesPage'
|
||||
import { DevicePage } from '../../pages/DevicePage/DevicePage'
|
||||
import { MapPage } from '../../pages/MapPage/MapPage'
|
||||
import { EmployeesPage } from '../../pages/EmployeesPage/EmployeesPage'
|
||||
import { OrganisationPage } from '../../pages/OrganisationPage/OrganisationPage'
|
||||
|
||||
const DevicesPage = lazy(() =>
|
||||
import('../../pages/DevicesPage/DevicesPage').then((module) => ({
|
||||
default: module.DevicesPage,
|
||||
})),
|
||||
)
|
||||
|
||||
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([
|
||||
{
|
||||
|
|
@ -19,23 +49,23 @@ export const router = createBrowserRouter([
|
|||
},
|
||||
{
|
||||
path: 'devices',
|
||||
element: <DevicesPage />,
|
||||
element: lazyRoute(<DevicesPage />),
|
||||
},
|
||||
{
|
||||
path: 'devices/:deviceId',
|
||||
element: <DevicePage />,
|
||||
element: lazyRoute(<DevicePage />),
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
element: <MapPage />,
|
||||
element: lazyRoute(<MapPage />),
|
||||
},
|
||||
{
|
||||
path: 'employees',
|
||||
element: <EmployeesPage />,
|
||||
element: lazyRoute(<EmployeesPage />),
|
||||
},
|
||||
{
|
||||
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'
|
||||
|
||||
export const GET_PHONES_PAGE_QUERY = gql`
|
||||
query GetPhonesPage($key: String) {
|
||||
getPhonesPage(key: $key) {
|
||||
export const GET_PHONES_QUERY = gql`
|
||||
query GetPhones(
|
||||
$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 {
|
||||
id
|
||||
imei
|
||||
imei2
|
||||
serial
|
||||
orgId
|
||||
registerDate
|
||||
|
||||
org {
|
||||
id
|
||||
name
|
||||
creationDate
|
||||
policy {
|
||||
canUseBluetooth
|
||||
canUseGPS
|
||||
canUseCamera
|
||||
canUseSim
|
||||
}
|
||||
}
|
||||
|
||||
policy {
|
||||
canUseBluetooth
|
||||
canUseCamera
|
||||
canUseGPS
|
||||
canUseSim
|
||||
}
|
||||
|
||||
techState {
|
||||
needMaintenance
|
||||
}
|
||||
networkStatus
|
||||
|
||||
lastLocation {
|
||||
alt
|
||||
|
|
@ -40,9 +34,102 @@ export const GET_PHONES_PAGE_QUERY = gql`
|
|||
lat
|
||||
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
|
||||
imei
|
||||
imei2
|
||||
networkStatus
|
||||
|
||||
lastLocation {
|
||||
alt
|
||||
|
|
@ -66,20 +154,26 @@ export const GET_PHONE_QUERY = gql`
|
|||
id
|
||||
name
|
||||
policy {
|
||||
canUseBluetooth
|
||||
canUseCamera
|
||||
canUseGPS
|
||||
canUseSim
|
||||
bluetooth
|
||||
bluetoothEditable
|
||||
camera
|
||||
cameraEditable
|
||||
GPS
|
||||
gpsEditable
|
||||
sim
|
||||
simEditable
|
||||
offlineTimeThreshold
|
||||
}
|
||||
}
|
||||
|
||||
orgId
|
||||
|
||||
policy {
|
||||
canUseBluetooth
|
||||
canUseCamera
|
||||
canUseGPS
|
||||
canUseSim
|
||||
bluetooth
|
||||
camera
|
||||
GPS
|
||||
sim
|
||||
locked
|
||||
}
|
||||
|
||||
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`
|
||||
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) {
|
||||
batteryCapacity
|
||||
batteryCycles
|
||||
batteryLevel
|
||||
chargeType
|
||||
date
|
||||
screenOn
|
||||
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
|
||||
imei
|
||||
imei2
|
||||
networkStatus
|
||||
|
||||
lastLocation {
|
||||
alt
|
||||
|
|
@ -147,33 +289,40 @@ export const GET_DEVICE_PAGE_QUERY = gql`
|
|||
id
|
||||
name
|
||||
policy {
|
||||
canUseBluetooth
|
||||
canUseCamera
|
||||
canUseGPS
|
||||
canUseSim
|
||||
bluetooth
|
||||
bluetoothEditable
|
||||
camera
|
||||
cameraEditable
|
||||
GPS
|
||||
gpsEditable
|
||||
sim
|
||||
simEditable
|
||||
offlineTimeThreshold
|
||||
}
|
||||
}
|
||||
|
||||
orgId
|
||||
|
||||
policy {
|
||||
canUseBluetooth
|
||||
canUseCamera
|
||||
canUseGPS
|
||||
canUseSim
|
||||
bluetooth
|
||||
camera
|
||||
GPS
|
||||
sim
|
||||
locked
|
||||
}
|
||||
|
||||
registerDate
|
||||
serial
|
||||
|
||||
techState {
|
||||
batteryCycles
|
||||
batteryLevel
|
||||
batteryRemainingCapacity
|
||||
hits
|
||||
needMaintenance
|
||||
overheats
|
||||
worktime
|
||||
batteryCycles
|
||||
batteryLevel
|
||||
batteryRemainingCapacity
|
||||
hits
|
||||
needMaintenance
|
||||
overheats
|
||||
temperature
|
||||
worktime
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -182,10 +331,10 @@ export const GET_DEVICE_PAGE_QUERY = gql`
|
|||
startDate: $telemetryStartDate
|
||||
endDate: $telemetryEndDate
|
||||
) {
|
||||
batteryCapacity
|
||||
batteryCycles
|
||||
batteryLevel
|
||||
chargeType
|
||||
date
|
||||
screenOn
|
||||
temperature
|
||||
}
|
||||
|
||||
|
|
@ -215,19 +364,27 @@ export const CREATE_PHONE_REGISTRATION_TOKEN_MUTATION = gql`
|
|||
export const CHANGE_PHONE_POLICY_MUTATION = gql`
|
||||
mutation ChangePhonePolicy(
|
||||
$id: ID!
|
||||
$canUseBluetooth: Boolean!
|
||||
$canUseCamera: Boolean!
|
||||
$canUseGPS: Boolean!
|
||||
$canUseSim: Boolean!
|
||||
$bluetooth: ServiceUseOption!
|
||||
$camera: ComponentUseOption!
|
||||
$GPS: ServiceUseOption!
|
||||
$sim: ServiceUseOption!
|
||||
$locked: Boolean!
|
||||
) {
|
||||
changePhonePolicy(
|
||||
id: $id
|
||||
policy: {
|
||||
canUseBluetooth: $canUseBluetooth
|
||||
canUseCamera: $canUseCamera
|
||||
canUseGPS: $canUseGPS
|
||||
canUseSim: $canUseSim
|
||||
bluetooth: $bluetooth
|
||||
camera: $camera
|
||||
GPS: $GPS
|
||||
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
|
||||
}
|
||||
|
||||
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 = {
|
||||
canUseBluetooth: boolean
|
||||
canUseCamera: boolean
|
||||
canUseGPS: boolean
|
||||
canUseSim: boolean
|
||||
bluetooth?: ServiceUseOption | LegacyPhoneUsePolicyOption
|
||||
camera?: ComponentUseOption | LegacyPhoneUsePolicyOption
|
||||
GPS?: ServiceUseOption | LegacyPhoneUsePolicyOption
|
||||
sim?: ServiceUseOption | LegacyPhoneUsePolicyOption
|
||||
canUseBluetooth?: boolean
|
||||
canUseCamera?: boolean
|
||||
canUseGPS?: boolean
|
||||
canUseSim?: boolean
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export type DeviceTechState = {
|
||||
|
|
@ -19,14 +53,18 @@ export type DeviceTechState = {
|
|||
hits: number | null
|
||||
needMaintenance: boolean
|
||||
overheats: number | null
|
||||
temperature?: number | null
|
||||
worktime: number | null
|
||||
}
|
||||
|
||||
export type DeviceNetworkStatus = 'Online' | 'Offline' | 'Lost'
|
||||
|
||||
export type DeviceOrganisation = {
|
||||
creationDate?: number
|
||||
id: number
|
||||
logoUrl?: string | null
|
||||
name: string
|
||||
policy?: DevicePolicy | null
|
||||
policy?: GroupUsePolicy | null
|
||||
}
|
||||
|
||||
export type Device = {
|
||||
|
|
@ -34,6 +72,7 @@ export type Device = {
|
|||
imei: string
|
||||
imei2: string
|
||||
serial: string
|
||||
networkStatus?: DeviceNetworkStatus
|
||||
orgId: number
|
||||
registerDate: number
|
||||
org: DeviceOrganisation | null
|
||||
|
|
@ -42,15 +81,40 @@ export type Device = {
|
|||
lastLocation: DeviceLocation | null
|
||||
}
|
||||
|
||||
export type GetPhonesPageData = {
|
||||
getPhonesPage: {
|
||||
export type GetPhonesData = {
|
||||
getPhones: {
|
||||
page: Device[]
|
||||
nextKey: string | null
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export type GetPhonesPageVariables = {
|
||||
key?: string
|
||||
export type GetPhonesVariables = {
|
||||
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 = {
|
||||
|
|
@ -61,27 +125,89 @@ export type GetPhoneVariables = {
|
|||
id: number
|
||||
}
|
||||
|
||||
export type DeviceChargeType =
|
||||
| 'USB'
|
||||
| 'Wireless'
|
||||
| 'AC'
|
||||
| 'Disconnected'
|
||||
export type DeviceTelemetryItem = {
|
||||
batteryCapacity: number | null
|
||||
batteryCycles: number | null
|
||||
batteryLevel: number | null
|
||||
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
|
||||
|
||||
export type DeviceTelemetryItem = {
|
||||
batteryLevel: number
|
||||
chargeType: DeviceChargeType
|
||||
export type DevicePackageUseEvent = {
|
||||
date: number
|
||||
screenOn: boolean
|
||||
temperature: number
|
||||
packageName: string
|
||||
type: DevicePackageUseEventType
|
||||
}
|
||||
|
||||
export type DevicePackageUseEventsGroup = {
|
||||
events: DevicePackageUseEvent[]
|
||||
phonePackage: DevicePackage | null
|
||||
}
|
||||
|
||||
export type GetTelemetryData = {
|
||||
getTelemetry: DeviceTelemetryItem[]
|
||||
getPhoneStateEvents: DevicePhoneStateEvent[]
|
||||
getPhonePackagesUseEvents: DevicePackageUseEventsGroup[]
|
||||
}
|
||||
|
||||
export type GetTelemetryVariables = {
|
||||
phoneId: number
|
||||
packagesPhoneId: string
|
||||
startDate: number
|
||||
endDate?: number
|
||||
}
|
||||
|
|
@ -119,15 +245,16 @@ export type GetDevicePageVariables = {
|
|||
}
|
||||
|
||||
export type ChangePhonePolicyData = {
|
||||
changePhonePolicy: boolean
|
||||
changePhonePolicy: DevicePolicy
|
||||
}
|
||||
|
||||
export type ChangePhonePolicyVariables = {
|
||||
id: string
|
||||
canUseBluetooth: boolean
|
||||
canUseCamera: boolean
|
||||
canUseGPS: boolean
|
||||
canUseSim: boolean
|
||||
bluetooth: ServiceUseOption
|
||||
camera: ComponentUseOption
|
||||
GPS: ServiceUseOption
|
||||
sim: ServiceUseOption
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export type CreatePhoneRegistrationTokenData = {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,40 @@
|
|||
import { gql } from '@apollo/client'
|
||||
|
||||
export const GET_USERS_PAGE_QUERY = gql`
|
||||
query GetUsersPage($key: String) {
|
||||
getUsersPage(key: $key) {
|
||||
export const GET_USERS_QUERY = gql`
|
||||
query GetUsers(
|
||||
$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 {
|
||||
id
|
||||
avatarUrl
|
||||
firstName
|
||||
id
|
||||
lastName
|
||||
middleName
|
||||
org {
|
||||
creationDate
|
||||
id
|
||||
logoUrl
|
||||
name
|
||||
}
|
||||
orgId
|
||||
role
|
||||
username
|
||||
}
|
||||
nextKey
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -26,16 +45,28 @@ export const GET_ORGANISATION_QUERY = gql`
|
|||
creationDate
|
||||
id
|
||||
name
|
||||
logoUrl
|
||||
policy {
|
||||
canUseBluetooth
|
||||
canUseCamera
|
||||
canUseGPS
|
||||
canUseSim
|
||||
bluetooth
|
||||
bluetoothEditable
|
||||
camera
|
||||
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`
|
||||
query GetOrganisations(
|
||||
$page: Int!
|
||||
|
|
@ -55,13 +86,7 @@ export const GET_ORGANISATIONS_QUERY = gql`
|
|||
creationDate
|
||||
id
|
||||
name
|
||||
policy {
|
||||
canUseBluetooth
|
||||
canUseCamera
|
||||
canUseGPS
|
||||
canUseSim
|
||||
date
|
||||
}
|
||||
logoUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,18 +95,28 @@ export const GET_ORGANISATIONS_QUERY = gql`
|
|||
export const CREATE_ORGANISATION_MUTATION = gql`
|
||||
mutation CreateOrganisation(
|
||||
$name: String!
|
||||
$canUseBluetooth: Boolean!
|
||||
$canUseCamera: Boolean!
|
||||
$canUseGPS: Boolean!
|
||||
$canUseSim: Boolean!
|
||||
$bluetooth: ServiceUseOption!
|
||||
$bluetoothEditable: Boolean!
|
||||
$camera: ComponentUseOption!
|
||||
$cameraEditable: Boolean!
|
||||
$GPS: ServiceUseOption!
|
||||
$gpsEditable: Boolean!
|
||||
$sim: ServiceUseOption!
|
||||
$simEditable: Boolean!
|
||||
$offlineTimeThreshold: Float!
|
||||
) {
|
||||
createOrganisation(
|
||||
name: $name
|
||||
policy: {
|
||||
canUseBluetooth: $canUseBluetooth
|
||||
canUseCamera: $canUseCamera
|
||||
canUseGPS: $canUseGPS
|
||||
canUseSim: $canUseSim
|
||||
bluetooth: $bluetooth
|
||||
bluetoothEditable: $bluetoothEditable
|
||||
camera: $camera
|
||||
cameraEditable: $cameraEditable
|
||||
GPS: $GPS
|
||||
gpsEditable: $gpsEditable
|
||||
sim: $sim
|
||||
simEditable: $simEditable
|
||||
offlineTimeThreshold: $offlineTimeThreshold
|
||||
}
|
||||
) {
|
||||
id
|
||||
|
|
@ -94,19 +129,29 @@ export const CHANGE_ORGANISATION_MUTATION = gql`
|
|||
mutation ChangeOrganisation(
|
||||
$id: ID!
|
||||
$name: String!
|
||||
$canUseBluetooth: Boolean!
|
||||
$canUseCamera: Boolean!
|
||||
$canUseGPS: Boolean!
|
||||
$canUseSim: Boolean!
|
||||
$bluetooth: ServiceUseOption!
|
||||
$bluetoothEditable: Boolean!
|
||||
$camera: ComponentUseOption!
|
||||
$cameraEditable: Boolean!
|
||||
$GPS: ServiceUseOption!
|
||||
$gpsEditable: Boolean!
|
||||
$sim: ServiceUseOption!
|
||||
$simEditable: Boolean!
|
||||
$offlineTimeThreshold: Float!
|
||||
) {
|
||||
changeOrganisation(
|
||||
id: $id
|
||||
name: $name
|
||||
policy: {
|
||||
canUseBluetooth: $canUseBluetooth
|
||||
canUseCamera: $canUseCamera
|
||||
canUseGPS: $canUseGPS
|
||||
canUseSim: $canUseSim
|
||||
bluetooth: $bluetooth
|
||||
bluetoothEditable: $bluetoothEditable
|
||||
camera: $camera
|
||||
cameraEditable: $cameraEditable
|
||||
GPS: $GPS
|
||||
gpsEditable: $gpsEditable
|
||||
sim: $sim
|
||||
simEditable: $simEditable
|
||||
offlineTimeThreshold: $offlineTimeThreshold
|
||||
}
|
||||
) {
|
||||
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(
|
||||
$orgId: ID!
|
||||
$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 Employee = {
|
||||
avatarUrl?: string | null
|
||||
id: number
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName: string
|
||||
username?: string
|
||||
orgId: number
|
||||
role: EmployeeRole
|
||||
org: {
|
||||
creationDate?: number
|
||||
id: number
|
||||
logoUrl?: string | null
|
||||
name: string
|
||||
} | 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 = {
|
||||
canUseBluetooth: boolean
|
||||
canUseCamera: boolean
|
||||
canUseGPS: boolean
|
||||
canUseSim: boolean
|
||||
bluetooth?: ServiceUseOption | GroupUsePolicyOption
|
||||
bluetoothEditable?: boolean
|
||||
camera?: ComponentUseOption | GroupUsePolicyOption
|
||||
cameraEditable?: boolean
|
||||
GPS?: ServiceUseOption | GroupUsePolicyOption
|
||||
gpsEditable?: boolean
|
||||
sim?: ServiceUseOption | GroupUsePolicyOption
|
||||
simEditable?: boolean
|
||||
canUseBluetooth?: boolean
|
||||
canUseCamera?: boolean
|
||||
canUseGPS?: boolean
|
||||
canUseSim?: boolean
|
||||
offlineTimeThreshold: number
|
||||
date?: number
|
||||
}
|
||||
|
||||
|
|
@ -26,6 +74,7 @@ export type Organisation = {
|
|||
id: number
|
||||
name: string
|
||||
policy?: OrganisationPolicy | null
|
||||
logoUrl?: string | null
|
||||
}
|
||||
|
||||
export type GetOrganisationData = {
|
||||
|
|
@ -46,10 +95,29 @@ export type ChangeOrganisationData = {
|
|||
export type ChangeOrganisationVariables = {
|
||||
id: string
|
||||
name: string
|
||||
canUseBluetooth: boolean
|
||||
canUseCamera: boolean
|
||||
canUseGPS: boolean
|
||||
canUseSim: boolean
|
||||
bluetooth: ServiceUseOption
|
||||
bluetoothEditable: boolean
|
||||
camera: ComponentUseOption
|
||||
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 = {
|
||||
|
|
@ -78,19 +146,44 @@ export type GetOrganisationsVariables = {
|
|||
sortField: OrganisationSortField
|
||||
}
|
||||
|
||||
export type SignUpData = {
|
||||
signUp: {
|
||||
id: number
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName: string
|
||||
orgId: number
|
||||
role: EmployeeRole
|
||||
org: {
|
||||
id: number
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
export type DeleteOrganisationData = {
|
||||
deleteOrganisation: boolean
|
||||
}
|
||||
|
||||
export type DeleteOrganisationVariables = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type CreateUserData = {
|
||||
createUser: Employee
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
|
@ -112,12 +205,33 @@ export type CreateOrganisationData = {
|
|||
|
||||
export type CreateOrganisationVariables = {
|
||||
name: string
|
||||
canUseBluetooth: boolean
|
||||
canUseCamera: boolean
|
||||
canUseGPS: boolean
|
||||
canUseSim: boolean
|
||||
bluetooth: ServiceUseOption
|
||||
bluetoothEditable: boolean
|
||||
camera: ComponentUseOption
|
||||
cameraEditable: boolean
|
||||
GPS: ServiceUseOption
|
||||
gpsEditable: boolean
|
||||
sim: ServiceUseOption
|
||||
simEditable: boolean
|
||||
offlineTimeThreshold: number
|
||||
}
|
||||
|
||||
export type OrganisationSortDirection = 'ASC' | 'DESC'
|
||||
|
||||
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`
|
||||
query CurrentUser {
|
||||
currentUser {
|
||||
avatarUrl
|
||||
id
|
||||
role
|
||||
firstName
|
||||
middleName
|
||||
lastName
|
||||
username
|
||||
org {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -11,6 +11,15 @@ import { LoginPage } from '../../../pages/LoginPage/LoginPage'
|
|||
type CurrentUser = {
|
||||
id: string
|
||||
role: string
|
||||
avatarUrl: string
|
||||
firstName: string
|
||||
middleName: string
|
||||
lastName: string
|
||||
username: string
|
||||
org: {
|
||||
id: number
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
type CurrentUserQueryData = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
@use './shared/styles/variables' as *;
|
||||
@use './shared/styles/themes';
|
||||
@use './shared/styles/table-surfaces';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
|
|
@ -9,25 +11,12 @@
|
|||
}
|
||||
|
||||
: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;;
|
||||
--heading: '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;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: $color-bg;
|
||||
font-synthesis: none;
|
||||
|
|
@ -36,27 +25,7 @@
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
font-size: $font-size-16;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +43,12 @@
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
background: $color-bg;
|
||||
}
|
||||
html{
|
||||
background: $color-bg;
|
||||
}
|
||||
|
||||
button{
|
||||
font-family: inherit;
|
||||
}
|
||||
|
|
@ -82,28 +56,32 @@ a{
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
.simplebar-content{
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: black;
|
||||
color: $color-text-strong;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-size: $font-size-32;
|
||||
margin: 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
font-size: $font-size-36;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-size: $font-size-24;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
font-size: $font-size-20;
|
||||
}
|
||||
}
|
||||
p {
|
||||
|
|
@ -119,7 +97,7 @@ code,
|
|||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
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 '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 { initTheme } from './shared/theme/theme'
|
||||
|
||||
const isAuthEnabled = import.meta.env.VITE_AUTH_ENABLED === 'true'
|
||||
|
||||
initTheme()
|
||||
|
||||
const app = isAuthEnabled ? (
|
||||
<AuthGate>
|
||||
<RouterProvider router={router} />
|
||||
|
|
@ -22,7 +26,9 @@ const app = isAuthEnabled ? (
|
|||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
{app}
|
||||
<LazyMotion features={domAnimation}>
|
||||
{app}
|
||||
</LazyMotion>
|
||||
</ApolloProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,56 +2,57 @@
|
|||
|
||||
.device-page {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: calc(80% - 40px);
|
||||
max-height: calc(100% - 40px);
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
overflow: visible;
|
||||
padding: 20px 20px 36px 20px;
|
||||
}
|
||||
|
||||
.device-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 420px 260px minmax(360px, 1fr);
|
||||
grid-template-columns: 340px 340px minmax(360px, 1fr);
|
||||
gap: 20px;
|
||||
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 {
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
background: rgba($color-surface-rgb, .5);
|
||||
backdrop-filter: blur(22px);
|
||||
padding: 20px;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.device-card--main {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -60,10 +61,10 @@
|
|||
|
||||
.device-main__image {
|
||||
height: calc(50% - 40px);
|
||||
width: 30%;
|
||||
width: 27.1%;
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
background: #f1f4f8;
|
||||
background: $color-surface-subtle;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -88,8 +89,8 @@
|
|||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
color: black;
|
||||
font-size: 30px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-30;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
|
@ -97,7 +98,7 @@
|
|||
p {
|
||||
margin: 0;
|
||||
color: $gray50;
|
||||
font-size: 17px;
|
||||
font-size: $font-size-17;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
|
@ -120,8 +121,8 @@
|
|||
align-items: center;
|
||||
gap: 9px;
|
||||
|
||||
color: black;
|
||||
font-size: 18px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 500;
|
||||
|
||||
svg {
|
||||
|
|
@ -134,12 +135,12 @@
|
|||
|
||||
&.is-success svg {
|
||||
color: $green;
|
||||
background: #eaf8ee;
|
||||
background: $color-success-bg;
|
||||
}
|
||||
|
||||
&.is-danger svg {
|
||||
color: $red;
|
||||
background: #ffe8e8;
|
||||
background: $color-danger-bg;
|
||||
}
|
||||
|
||||
&.is-muted svg {
|
||||
|
|
@ -159,17 +160,46 @@
|
|||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
color: black;
|
||||
font-size: 18px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 500;
|
||||
|
||||
.device-registered-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
height: inherit;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
|
|
@ -185,6 +215,7 @@
|
|||
color: $gray50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.device-card__actions {
|
||||
|
|
@ -202,10 +233,10 @@
|
|||
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #dfe6ff;
|
||||
background: $color-primary-soft;
|
||||
|
||||
color: $blue;
|
||||
font-size: 18px;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: .2s ease;
|
||||
|
|
@ -213,7 +244,8 @@
|
|||
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
color: white;
|
||||
color: $color-text-inverse;
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +254,7 @@
|
|||
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #ffdede;
|
||||
background: $color-danger-bg-strong;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -238,7 +270,8 @@
|
|||
|
||||
&:hover {
|
||||
background-color: $red;
|
||||
color: white;
|
||||
color: $color-text-inverse;
|
||||
box-shadow: var(--shadow-danger);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -252,8 +285,8 @@
|
|||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #738098;
|
||||
font-size: 15px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
|
@ -265,8 +298,8 @@
|
|||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #30394b;
|
||||
font-size: 18px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -275,8 +308,8 @@
|
|||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 9px;
|
||||
background: #eef1f6;
|
||||
color: #30394b;
|
||||
background: $color-surface-hover;
|
||||
color: $color-text-heading;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,66 +321,13 @@
|
|||
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 {
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
height: calc(100% - 20px);
|
||||
}
|
||||
|
||||
|
|
@ -357,8 +337,8 @@
|
|||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 18px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 500;
|
||||
|
||||
&:nth-child(1) {
|
||||
|
|
@ -373,7 +353,7 @@
|
|||
height: 24px;
|
||||
border-radius: 8px;
|
||||
color: $blue;
|
||||
background: #eef1f6;
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,6 +374,11 @@
|
|||
}
|
||||
|
||||
.device-battery {
|
||||
--device-battery-progress-empty: #cfd5df;
|
||||
--device-battery-progress-danger: #e00000;
|
||||
--device-battery-progress-warning: #ff8a34;
|
||||
--device-battery-progress-strong: #031d9a;
|
||||
|
||||
h3 {
|
||||
text-align: left;
|
||||
line-height: 1;
|
||||
|
|
@ -401,8 +386,8 @@
|
|||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid $gray20;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 18px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -420,7 +405,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
svg{
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 4px;
|
||||
|
|
@ -447,9 +432,9 @@
|
|||
.device-battery__circle {
|
||||
position: relative;
|
||||
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
flex: 0 0 110px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
flex: 0 0 120px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -466,15 +451,15 @@
|
|||
justify-content: center;
|
||||
|
||||
color: $color-text;
|
||||
font-size: 26px;
|
||||
font-size: $font-size-26;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.device-battery__circle-svg {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
|
@ -506,8 +491,8 @@
|
|||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 16px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
|
@ -516,12 +501,12 @@
|
|||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
color: #31b24a;
|
||||
background: #eaf8ee;
|
||||
color: $color-success-strong;
|
||||
background: $color-success-bg;
|
||||
}
|
||||
|
||||
b {
|
||||
color: #30394b;
|
||||
color: $color-text-heading;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
|
@ -532,11 +517,11 @@
|
|||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 18px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 500;
|
||||
|
||||
.device-stats-text{
|
||||
.device-stats-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
|
|
@ -551,7 +536,7 @@
|
|||
height: 24px;
|
||||
border-radius: 9px;
|
||||
color: $green;
|
||||
background: #eaf8ee;
|
||||
background: $color-success-bg;
|
||||
}
|
||||
|
||||
b {
|
||||
|
|
@ -562,7 +547,7 @@
|
|||
.device-page__empty {
|
||||
min-height: 260px;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -580,7 +565,278 @@
|
|||
border: none;
|
||||
border-radius: 12px;
|
||||
background: $blue;
|
||||
color: #ffffff;
|
||||
color: $color-text-inverse;
|
||||
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 { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
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 { DeviceHistoryModal } from './components/DeviceHistoryModal/DeviceHistoryModal'
|
||||
|
||||
import type { Device as PageDevice } from './types'
|
||||
|
||||
import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
||||
import type {
|
||||
Device as ApiDevice,
|
||||
DeviceNetworkStatus,
|
||||
GetPhoneGpsTrackData,
|
||||
GetTelemetryData,
|
||||
GetDevicePageData,
|
||||
|
|
@ -19,8 +32,27 @@ import { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard'
|
|||
import { DeviceActionsCard } from './components/DeviceActionsCard/DeviceActionsCard'
|
||||
import { DevicePermissionsCard } from './components/DevicePermissionsCard/DevicePermissionsCard'
|
||||
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 { 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) {
|
||||
if (!timestamp) return 'Нет данных'
|
||||
|
|
@ -48,15 +80,39 @@ function getSortedGpsTrack(track: GetPhoneGpsTrackData['getPhoneGpsTrack']) {
|
|||
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(
|
||||
device: ApiDevice,
|
||||
batteryLevel?: number,
|
||||
gpsTrack: GetPhoneGpsTrackData['getPhoneGpsTrack'] = [],
|
||||
telemetry: GetTelemetryData['getTelemetry'] = [],
|
||||
): PageDevice {
|
||||
const sortedGpsTrack = getSortedGpsTrack(gpsTrack)
|
||||
const policy = device.policy
|
||||
const techState = device.techState
|
||||
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 =
|
||||
sortedGpsTrack.length > 0
|
||||
|
|
@ -97,10 +153,13 @@ function mapApiDeviceToPageDevice(
|
|||
|
||||
employee: device.org?.name ?? null,
|
||||
organisation: device.org?.name,
|
||||
organisationId: device.org?.id ?? device.orgId,
|
||||
policy,
|
||||
organisationPolicy: device.org?.policy ?? null,
|
||||
|
||||
condition: needMaintenance ? 'inspection' : 'ok',
|
||||
connection: location ? 'online' : 'offline',
|
||||
connectionText: location ? 'В сети' : 'Не в сети',
|
||||
connection: getDeviceNetworkConnection(device.networkStatus),
|
||||
connectionText: getDeviceNetworkLabel(device.networkStatus),
|
||||
|
||||
workTime: null,
|
||||
registeredAt: device.registerDate
|
||||
|
|
@ -121,26 +180,28 @@ function mapApiDeviceToPageDevice(
|
|||
|
||||
permissions: {
|
||||
wifi: false,
|
||||
bluetooth: policy?.canUseBluetooth ?? false,
|
||||
gps: policy?.canUseGPS ?? false,
|
||||
camera: policy?.canUseCamera ?? false,
|
||||
sim: policy?.canUseSim ?? false,
|
||||
bluetooth: bluetoothEnabled,
|
||||
gps: gpsEnabled,
|
||||
camera: cameraEnabled,
|
||||
sim: simEnabled,
|
||||
speaker: false,
|
||||
locked: isLocked,
|
||||
},
|
||||
|
||||
statusIcons: {
|
||||
gps: policy?.canUseGPS ?? false,
|
||||
gps: gpsEnabled,
|
||||
wifi: false,
|
||||
bluetooth: policy?.canUseBluetooth ?? false,
|
||||
lock: false,
|
||||
camera: policy?.canUseCamera ?? false,
|
||||
sim: policy?.canUseSim ?? false,
|
||||
bluetooth: bluetoothEnabled,
|
||||
lock: isLocked,
|
||||
camera: cameraEnabled,
|
||||
sim: simEnabled,
|
||||
sound: false,
|
||||
kiosk: false,
|
||||
},
|
||||
|
||||
battery: techState?.batteryLevel ?? batteryLevel,
|
||||
batteryMaxCapacity: techState?.batteryRemainingCapacity ?? undefined,
|
||||
telemetry,
|
||||
chargeCycles: formatTechValue(techState?.batteryCycles),
|
||||
totalWorkTime: techState?.worktime ?? null,
|
||||
mediumImpacts: formatTechValue(techState?.hits),
|
||||
|
|
@ -151,103 +212,227 @@ function mapApiDeviceToPageDevice(
|
|||
export function DevicePage() {
|
||||
const { deviceId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { setNavbarBreadcrumbs } =
|
||||
useOutletContext<AppLayoutOutletContext>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||
const shouldMountHistoryModal = useLazyMount(isHistoryOpen)
|
||||
|
||||
const numericDeviceId = Number(deviceId)
|
||||
const isAppsView = searchParams.get('view') === 'apps'
|
||||
|
||||
const { data, loading, error } = useQuery<
|
||||
GetDevicePageData,
|
||||
GetDevicePageVariables
|
||||
>(GET_DEVICE_PAGE_QUERY, {
|
||||
variables: {
|
||||
id: numericDeviceId,
|
||||
phoneId: String(numericDeviceId),
|
||||
telemetryStartDate: 0,
|
||||
gpsStartDate: 0,
|
||||
},
|
||||
skip: !numericDeviceId,
|
||||
fetchPolicy: 'network-only',
|
||||
pollInterval: 15000,
|
||||
})
|
||||
useEffect(() => {
|
||||
return preloadOnIdle([loadDeviceHistoryModal])
|
||||
}, [])
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
} = useQuery<
|
||||
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(() => {
|
||||
return getLatestTelemetryItem(data?.getTelemetry ?? [])
|
||||
}, [data])
|
||||
return getLatestTelemetryItem(data?.getTelemetry ?? [])
|
||||
}, [data])
|
||||
|
||||
const device = useMemo(() => {
|
||||
if (!data?.getPhone) return null
|
||||
const device = useMemo(() => {
|
||||
if (!data?.getPhone) return null
|
||||
|
||||
return mapApiDeviceToPageDevice(
|
||||
data.getPhone,
|
||||
latestTelemetryItem?.batteryLevel,
|
||||
data.getPhoneGpsTrack ?? [],
|
||||
)
|
||||
}, [data, latestTelemetryItem])
|
||||
return mapApiDeviceToPageDevice(
|
||||
data.getPhone,
|
||||
latestTelemetryItem?.batteryLevel ?? undefined,
|
||||
data.getPhoneGpsTrack ?? [],
|
||||
data.getTelemetry ?? [],
|
||||
)
|
||||
}, [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) {
|
||||
return (
|
||||
<section className="device-page">
|
||||
<div className="device-page__empty">
|
||||
<h2>Некорректный ID устройства</h2>
|
||||
|
||||
<button type="button" onClick={() => navigate('/devices')}>
|
||||
<EmptyState
|
||||
className="device-page__empty"
|
||||
variant="not-found"
|
||||
title="Некорректный ID устройства"
|
||||
description="Проверьте ссылку или вернитесь к списку устройств."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/devices')}
|
||||
>
|
||||
Вернуться к списку
|
||||
</button>
|
||||
</div>
|
||||
</EmptyState>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && !device) {
|
||||
return (
|
||||
<section className="device-page">
|
||||
<div className="device-page__empty">
|
||||
<h2>Загрузка устройства...</h2>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
return <DevicePageSkeleton />
|
||||
}
|
||||
|
||||
if (error || !device) {
|
||||
return (
|
||||
<section className="device-page">
|
||||
<div className="device-page__empty">
|
||||
<h2>Устройство не найдено</h2>
|
||||
|
||||
<button type="button" onClick={() => navigate('/devices')}>
|
||||
<EmptyState
|
||||
className="device-page__empty"
|
||||
variant="not-found"
|
||||
title="Устройство не найдено"
|
||||
description="Возможно, устройство было удалено или ссылка устарела."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/devices')}
|
||||
>
|
||||
Вернуться к списку
|
||||
</button>
|
||||
</div>
|
||||
</EmptyState>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="device-page">
|
||||
<div className="device-breadcrumbs">
|
||||
<Link to="/devices">
|
||||
Все устройства
|
||||
</Link>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isAppsView ? (
|
||||
<m.div
|
||||
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>
|
||||
<span>{device.factoryNumber}</span>
|
||||
</div>
|
||||
|
||||
<div className="device-page__grid">
|
||||
<DeviceMainCard
|
||||
device={device}
|
||||
onOpenHistory={() => setIsHistoryOpen(true)}
|
||||
/>
|
||||
<DeviceMapCard device={device} />
|
||||
<DeviceActionsCard />
|
||||
<DevicePermissionsCard device={device} />
|
||||
<DeviceStatsCards device={device} />
|
||||
</div>
|
||||
<DeviceHistoryModal
|
||||
open={isHistoryOpen}
|
||||
onOpenChange={setIsHistoryOpen}
|
||||
device={device}
|
||||
/>
|
||||
<div className="device-page__details-row">
|
||||
<DeviceStatsCards device={device} />
|
||||
<DevicePermissionsCard
|
||||
device={device}
|
||||
onEditingChange={handlePermissionsEditingChange}
|
||||
onRefetchPolicy={handleRefetchPolicy}
|
||||
/>
|
||||
<DeviceActionsCard
|
||||
device={device}
|
||||
onOpenApps={handleOpenApps}
|
||||
onLockStateChange={handleLockStateChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{shouldMountHistoryModal && (
|
||||
<Suspense fallback={null}>
|
||||
<DeviceHistoryModal
|
||||
open={isHistoryOpen}
|
||||
onOpenChange={setIsHistoryOpen}
|
||||
device={device}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
@use '../../../../shared/styles/variables' as *;
|
||||
|
||||
.battery-circle {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
|
|
@ -18,7 +20,7 @@
|
|||
}
|
||||
|
||||
.battery-circle__track {
|
||||
stroke: #cfd5df;
|
||||
stroke: $color-border-accent;
|
||||
}
|
||||
|
||||
.battery-circle__progress {
|
||||
|
|
@ -35,8 +37,8 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 44px;
|
||||
font-size: $font-size-44;
|
||||
font-weight: 500;
|
||||
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 { KioskIcon } from '../../../../assets/icons/Kiosk'
|
||||
import { RebootIcon } from '../../../../assets/icons/Reboot'
|
||||
import { MessageIcon } from '../../../../assets/icons/Message'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { AnimatePresence, m, useReducedMotion } from 'framer-motion'
|
||||
import { useMutation } from '@apollo/client/react'
|
||||
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 (
|
||||
<div className="device-card device-actions">
|
||||
<h3>Действия</h3>
|
||||
<h3>Управление</h3>
|
||||
|
||||
<div className="actions-button-container">
|
||||
<button type="button" disabled>
|
||||
<BlockIcon />
|
||||
Заблокировать
|
||||
<button
|
||||
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 type="button" disabled>
|
||||
<KioskIcon />
|
||||
Включить режим киоска
|
||||
<button
|
||||
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 type="button" disabled>
|
||||
<RebootIcon />
|
||||
Перезагрузить
|
||||
</button>
|
||||
<div className="future-label">
|
||||
<CircleAlert />
|
||||
Дополнительные функции будут доступны позже
|
||||
</div>
|
||||
|
||||
<button type="button" disabled>
|
||||
<MessageIcon />
|
||||
Отправить сообщение
|
||||
</button>
|
||||
{lockError && (
|
||||
<div className="device-actions__error">
|
||||
Не удалось изменить блокировку
|
||||
</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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 1000px;
|
||||
margin-top: 40px;
|
||||
min-height: 0;
|
||||
margin-top: 200px;
|
||||
}
|
||||
|
||||
.device-history-chart .echarts-for-react {
|
||||
width: 100%;
|
||||
height: 1000px !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.device-history-chart__events-title {
|
||||
|
|
@ -21,15 +21,15 @@
|
|||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #30394b;
|
||||
font-size: 18px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #738098;
|
||||
font-size: 14px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 450;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
|
@ -49,16 +49,16 @@
|
|||
|
||||
.device-history-events-legend__item {
|
||||
border: none;
|
||||
background: transparent;
|
||||
background: $transparent;
|
||||
padding: 0;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: #30394b;
|
||||
color: $color-text-heading;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
|
||||
cursor: pointer;
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
border: 1.5px solid #c9d2e3;
|
||||
border: 1.5px solid $color-border-accent;
|
||||
border-radius: 5px;
|
||||
|
||||
display: inline-flex;
|
||||
|
|
@ -96,13 +96,12 @@
|
|||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
//border-radius: 2px;
|
||||
//background: #ffffff;
|
||||
color: white;
|
||||
color: $color-text-inverse;
|
||||
}
|
||||
|
||||
.device-history-chart__battery-title,
|
||||
.device-history-chart__temperature-title {
|
||||
.device-history-chart__temperature-title,
|
||||
.device-history-chart__apps-title {
|
||||
position: absolute;
|
||||
left: 90px;
|
||||
z-index: 2;
|
||||
|
|
@ -110,15 +109,15 @@
|
|||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #30394b;
|
||||
font-size: 18px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #738098;
|
||||
font-size: 14px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 450;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
|
@ -140,14 +139,290 @@
|
|||
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: #f1f4f8;
|
||||
background: $color-surface-subtle;
|
||||
|
||||
color: #738098;
|
||||
font-size: 13px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-13;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.device-history-chart__state--error {
|
||||
background: rgba(224, 0, 0, 0.08);
|
||||
background: $color-danger-tint;
|
||||
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;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
animation: historyOverlayShow 0.2s ease;
|
||||
background: $color-backdrop-soft;
|
||||
}
|
||||
|
||||
.device-history-modal {
|
||||
position: fixed;
|
||||
z-index: 600;
|
||||
inset: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 48px);
|
||||
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
padding: 26px 28px;
|
||||
background: $color-surface;
|
||||
padding: 20px;
|
||||
|
||||
overflow: auto;
|
||||
//overflow: hidden;
|
||||
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22);
|
||||
animation: historyModalShow 0.2s ease;
|
||||
box-shadow: 0 24px 80px rgba($color-shadow-rgb, 0.22);
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $gray50 transparent;
|
||||
.device-history-modal__scroll {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
.simplebar-content {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.simplebar-track.simplebar-vertical {
|
||||
top: 14px;
|
||||
right: -18px;
|
||||
bottom: 14px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
.simplebar-scrollbar::before {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
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 {
|
||||
margin: 0;
|
||||
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-20;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
|
||||
span {
|
||||
margin-left: 6px;
|
||||
color: $gray50;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
}
|
||||
|
|
@ -73,17 +133,21 @@
|
|||
gap: 14px;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
|
||||
.is-green {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
color: #30394b;
|
||||
|
||||
padding-left: 20px;
|
||||
padding-right: 8px;
|
||||
border-radius: 8px;
|
||||
color: $green;
|
||||
font-weight: 500;
|
||||
font-size: $font-size-16;
|
||||
background-color: rgba($color-success-rgb, .1);
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
|
||||
width: 6px;
|
||||
|
|
@ -94,6 +158,53 @@
|
|||
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 {
|
||||
|
|
@ -101,13 +212,13 @@
|
|||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
background: $transparent;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
color: #111827;
|
||||
color: $color-text-strong;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
|
|
@ -139,17 +250,17 @@
|
|||
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
background: $transparent;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-active {
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
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;
|
||||
gap: 8px;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 14px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-14;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
|
|
@ -185,7 +296,7 @@
|
|||
margin-bottom: 8px;
|
||||
|
||||
color: $blue;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
}
|
||||
|
||||
.device-history-range__track {
|
||||
|
|
@ -210,7 +321,7 @@
|
|||
margin: 0;
|
||||
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
background: $transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
|
@ -244,8 +355,8 @@
|
|||
|
||||
h3 {
|
||||
margin: 0 0 8px 100px;
|
||||
color: #30394b;
|
||||
font-size: 16px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
|
@ -262,7 +373,7 @@
|
|||
grid-template-columns: repeat(5, 1fr);
|
||||
|
||||
color: $gray50;
|
||||
font-size: 13px;
|
||||
font-size: $font-size-13;
|
||||
|
||||
span {
|
||||
transform: translateX(-50%);
|
||||
|
|
@ -289,12 +400,12 @@
|
|||
text-align: right;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
}
|
||||
|
||||
.history-row__line {
|
||||
position: relative;
|
||||
border-top: 1px solid #dbe2ee;
|
||||
border-top: 1px solid $color-border-muted;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
|
|
@ -303,7 +414,7 @@
|
|||
top: -1px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: #dbe2ee;
|
||||
background: $color-border-muted;
|
||||
}
|
||||
|
||||
&::before {
|
||||
|
|
@ -327,15 +438,15 @@
|
|||
}
|
||||
|
||||
.history-segment--purple {
|
||||
background: #7a3fe8;
|
||||
background: $accent-purple;
|
||||
}
|
||||
|
||||
.history-segment--green {
|
||||
background: #18b89b;
|
||||
background: $accent-teal;
|
||||
}
|
||||
|
||||
.history-segment--light-blue {
|
||||
background: #2f80ed;
|
||||
background: $accent-sky;
|
||||
}
|
||||
|
||||
.history-point {
|
||||
|
|
@ -354,20 +465,20 @@
|
|||
}
|
||||
|
||||
.history-point--orange {
|
||||
background: #ff7a1a;
|
||||
background: $color-warning-strong;
|
||||
}
|
||||
|
||||
.battery-chart {
|
||||
position: relative;
|
||||
height: 110px;
|
||||
margin-left: 100px;
|
||||
border-bottom: 1px solid #dbe2ee;
|
||||
border-bottom: 1px solid $color-border-muted;
|
||||
|
||||
background:
|
||||
linear-gradient(to bottom, transparent 19%, #dbe2ee 20%, transparent 21%),
|
||||
linear-gradient(to bottom, transparent 39%, #dbe2ee 40%, transparent 41%),
|
||||
linear-gradient(to bottom, transparent 59%, #dbe2ee 60%, transparent 61%),
|
||||
linear-gradient(to bottom, transparent 79%, #dbe2ee 80%, transparent 81%);
|
||||
linear-gradient(to bottom, $transparent 19%, $color-border-muted 20%, $transparent 21%),
|
||||
linear-gradient(to bottom, $transparent 39%, $color-border-muted 40%, $transparent 41%),
|
||||
linear-gradient(to bottom, $transparent 59%, $color-border-muted 60%, $transparent 61%),
|
||||
linear-gradient(to bottom, $transparent 79%, $color-border-muted 80%, $transparent 81%);
|
||||
}
|
||||
|
||||
.battery-chart__area {
|
||||
|
|
@ -376,10 +487,10 @@
|
|||
|
||||
background: linear-gradient(
|
||||
168deg,
|
||||
rgba(49, 178, 74, 0.38) 0%,
|
||||
rgba(49, 178, 74, 0.28) 45%,
|
||||
rgba(49, 178, 74, 0.05) 46%,
|
||||
rgba(49, 178, 74, 0.05) 100%
|
||||
rgba($color-success-strong-rgb, 0.38) 0%,
|
||||
rgba($color-success-strong-rgb, 0.28) 45%,
|
||||
rgba($color-success-strong-rgb, 0.05) 46%,
|
||||
rgba($color-success-strong-rgb, 0.05) 100%
|
||||
);
|
||||
|
||||
clip-path: polygon(0 10%, 100% 90%, 100% 100%, 0 100%);
|
||||
|
|
@ -396,7 +507,7 @@
|
|||
justify-content: space-between;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 13px;
|
||||
font-size: $font-size-13;
|
||||
}
|
||||
|
||||
.history-summary {
|
||||
|
|
@ -412,10 +523,10 @@
|
|||
background: $color-bg;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 13px;
|
||||
font-size: $font-size-13;
|
||||
|
||||
b {
|
||||
color: #111827;
|
||||
color: $color-text-strong;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
|
@ -429,17 +540,17 @@
|
|||
h3 {
|
||||
margin: 0 0 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #dbe2ee;
|
||||
border-bottom: 1px solid $color-border-muted;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 16px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.device-apps-list {
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -449,8 +560,8 @@
|
|||
padding: 0 16px;
|
||||
|
||||
border: none;
|
||||
border-bottom: 1px solid #e4e8f0;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid $color-border-muted;
|
||||
background: $color-surface;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 28px 220px 1fr auto 20px;
|
||||
|
|
@ -465,7 +576,7 @@
|
|||
}
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
background: $color-surface-muted;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -477,56 +588,58 @@
|
|||
}
|
||||
|
||||
.device-app-row__name {
|
||||
color: #30394b;
|
||||
font-size: 15px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.device-app-row__time {
|
||||
color: $gray50;
|
||||
font-size: 13px;
|
||||
font-size: $font-size-13;
|
||||
}
|
||||
|
||||
.device-app-row__percent {
|
||||
color: $gray50;
|
||||
font-size: 13px;
|
||||
font-size: $font-size-13;
|
||||
}
|
||||
|
||||
.device-app-row__arrow {
|
||||
color: $gray50;
|
||||
font-size: 22px;
|
||||
font-size: $font-size-22;
|
||||
}
|
||||
|
||||
.device-apps-card__more {
|
||||
margin-top: 12px;
|
||||
|
||||
border: none;
|
||||
background: transparent;
|
||||
background: $transparent;
|
||||
|
||||
color: $blue;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes historyOverlayShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
[data-theme='dark'] {
|
||||
.device-history-modal {
|
||||
background: $color-surface;
|
||||
box-shadow: 0 26px 80px rgba($color-shadow-rgb, 0.62);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes historyModalShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.99);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
.device-history-chart__zoom {
|
||||
background: rgba($color-surface-rgb, 0.76);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow:
|
||||
0 18px 44px rgba($color-shadow-rgb, 0.38),
|
||||
inset 0 0 0 1px rgba($gray50-rgb, 0.08);
|
||||
|
||||
.sticky-line {
|
||||
background-color: rgba($gray50-rgb, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.device-history-modal__close:hover {
|
||||
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 { X } from 'lucide-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 { conditionText, connectionText } from '../../types'
|
||||
import {
|
||||
GET_TELEMETRY_QUERY,
|
||||
} from '../../../../entities/device/api/device.graphql'
|
||||
import type {
|
||||
GetTelemetryData,
|
||||
GetTelemetryVariables,
|
||||
DeviceHistoryChartItem,
|
||||
DevicePackageUseEventsGroup,
|
||||
DevicePhoneStateEventType
|
||||
} from '../../../../entities/device/model/types'
|
||||
|
||||
import {
|
||||
DeviceHistoryPeriodControl,
|
||||
type DeviceHistoryPeriodValue,
|
||||
} from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl'
|
||||
import { DeviceHistoryChart } from '../DeviceHistoryChart/DeviceHistoryChart'
|
||||
import { TimelineZoom } from '../TimelineZoom/TimelineZoom'
|
||||
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
|
||||
|
||||
import './DeviceHistoryModal.scss'
|
||||
|
||||
|
|
@ -26,6 +41,50 @@ type DeviceHistoryModalProps = {
|
|||
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({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -34,59 +93,183 @@ export function DeviceHistoryModal({
|
|||
const [selectedPeriod, setSelectedPeriod] =
|
||||
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 {
|
||||
data: telemetryData,
|
||||
loading: telemetryLoading,
|
||||
error: telemetryError,
|
||||
} = useQuery<GetTelemetryData, GetTelemetryVariables>(GET_TELEMETRY_QUERY, {
|
||||
variables: {
|
||||
phoneId: Number(device.id),
|
||||
startDate: selectedPeriod?.periodStart.getTime() ?? 0,
|
||||
endDate: selectedPeriod?.periodEnd.getTime(),
|
||||
},
|
||||
skip: !open || !selectedPeriod,
|
||||
variables: telemetryVariables,
|
||||
skip: !shouldRenderChart,
|
||||
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 (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="device-history-modal__overlay" />
|
||||
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<MotionDialogContent
|
||||
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__header">
|
||||
<div>
|
||||
<Dialog.Title className="device-history-modal__title">
|
||||
{device.factoryNumber}
|
||||
<span>ID: {device.id}</span>
|
||||
</Dialog.Title>
|
||||
<div className="device-history-modal__meta">
|
||||
<span>{device.imei} · {device.imei2}</span>
|
||||
<span className={getHistoryConditionClass(device)}>
|
||||
{conditionText[device.condition]}
|
||||
</span>
|
||||
<span className={getHistoryConnectionClass(device)}>
|
||||
{device.connectionText || connectionText[device.connection]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="device-history-modal__meta">
|
||||
<span>{device.imei}</span>
|
||||
<span>Версия приложения: 244</span>
|
||||
<span className="is-green">Исправно</span>
|
||||
<span className="is-green">
|
||||
{device.connectionText || 'В сети'}
|
||||
</span>
|
||||
<Dialog.Close className="device-history-modal__close" type="button">
|
||||
<X size={22} />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Close className="device-history-modal__close" type="button">
|
||||
<X size={22} />
|
||||
</Dialog.Close>
|
||||
<DeviceHistoryPeriodControl onChange={setSelectedPeriod} />
|
||||
{selectedPeriod && zoomRange && (
|
||||
<TimelineZoom
|
||||
min={selectedPeriod.periodStart.getTime()}
|
||||
max={selectedPeriod.periodEnd.getTime()}
|
||||
start={zoomRange.start}
|
||||
end={zoomRange.end}
|
||||
onChange={setZoomRange}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<DeviceHistoryPeriodControl onChange={setSelectedPeriod} />
|
||||
|
||||
<DeviceHistoryChart
|
||||
period={selectedPeriod}
|
||||
telemetry={telemetryData?.getTelemetry ?? []}
|
||||
isLoading={telemetryLoading}
|
||||
isError={Boolean(telemetryError)}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
<SimpleBar className="device-history-modal__scroll">
|
||||
{shouldRenderChart && selectedPeriod && zoomRange ? (
|
||||
<Suspense fallback={<DeviceHistoryChartPlaceholder />}>
|
||||
<DeviceHistoryChart
|
||||
period={selectedPeriod}
|
||||
zoomRange={zoomRange}
|
||||
telemetry={telemetryWithEvents}
|
||||
packageUseEvents={packageUseEvents}
|
||||
isLoading={telemetryLoading}
|
||||
isError={Boolean(telemetryError)}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<DeviceHistoryChartPlaceholder />
|
||||
)}
|
||||
</SimpleBar>
|
||||
</MotionDialogContent>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
.history-period-tabs {
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
background: $color-bg;
|
||||
//background: $color-bg;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -39,11 +39,17 @@
|
|||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
background: rgba($color-surface-rgb, .6) !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: #ffffff;
|
||||
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-radius: 999px;
|
||||
background: $color-bg;
|
||||
//background: $color-bg;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: #30394b;
|
||||
//color: #30394b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
|
|
@ -77,7 +83,7 @@
|
|||
&:hover,
|
||||
&.is-active {
|
||||
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 {
|
||||
color: $blue;
|
||||
|
|
@ -91,94 +97,102 @@
|
|||
top: calc(100% + 8px);
|
||||
z-index: 120;
|
||||
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
background: $color-surface;
|
||||
padding: 12px;
|
||||
|
||||
border: 1.5px solid $gray20;
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
|
||||
box-shadow: none;
|
||||
|
||||
button{
|
||||
transition: .18s ease;
|
||||
}
|
||||
|
||||
.rdp-root {
|
||||
--rdp-accent-color: $blue;
|
||||
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
|
||||
--rdp-day_button-border-radius: 10px;
|
||||
--rdp-accent-color: #{$blue};
|
||||
--rdp-accent-background-color: #{$color-primary-soft};
|
||||
--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;
|
||||
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.rdp-months {
|
||||
max-width: 100%;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.rdp-month {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdp-month_caption {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rdp-caption_label {
|
||||
color: #30394b;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rdp-nav {
|
||||
button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
color: $gray50;
|
||||
.rdp-button_previous,
|
||||
.rdp-button_next,
|
||||
.rdp-nav button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg;
|
||||
color: $blue;
|
||||
}
|
||||
color: $blue;
|
||||
|
||||
&:hover {
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-weekday {
|
||||
color: $gray50;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rdp-day {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-12;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rdp-day_button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-12;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg;
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-selected {
|
||||
.rdp-day_button {
|
||||
background: $blue;
|
||||
color: #ffffff;
|
||||
color: $color-text-inverse;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-range_middle {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
.rdp-day_button {
|
||||
background: #e8edff;
|
||||
background: $color-primary-soft;
|
||||
color: $blue;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
|
@ -186,19 +200,23 @@
|
|||
|
||||
.rdp-range_start {
|
||||
.rdp-day_button {
|
||||
border-radius: 10px;
|
||||
background: $blue;
|
||||
color: $color-text-inverse;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-range_end {
|
||||
.rdp-day_button {
|
||||
border-radius: 10px;
|
||||
background: $blue;
|
||||
color: $color-text-inverse;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-range_start.rdp-range_end {
|
||||
.rdp-day_button {
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.rdp-today {
|
||||
|
|
@ -241,7 +259,7 @@
|
|||
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(3, 29, 154, 0.16);
|
||||
background: rgba($color-primary-rgb, 0.16);
|
||||
}
|
||||
|
||||
.history-range__active {
|
||||
|
|
@ -293,51 +311,3 @@
|
|||
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 { ShieldCheck, Signal, Smartphone, Trash2 } from 'lucide-react'
|
||||
import { memo, useState } from '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 { conditionText, connectionText, getStatusClass } from '../../types'
|
||||
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
||||
import { useDeferredModalPayload } from '../../../../shared/lib/lazyMount'
|
||||
|
||||
type DeviceMainCardProps = {
|
||||
device: Device
|
||||
onOpenHistory: () => void
|
||||
}
|
||||
|
||||
function getDeviceFullName(employee: Device) {
|
||||
return [employee.serialNumber]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
function getDeviceFullName(device: Device) {
|
||||
return [device.serialNumber].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 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() {
|
||||
if (!deletingDevice) return
|
||||
|
||||
console.log('Удаление устройства пока без мутации', deletingDevice)
|
||||
|
||||
setDeletingDevice(null)
|
||||
}
|
||||
|
||||
function handleDeleteDevice(device: Device) {
|
||||
setDeletingDevice(device)
|
||||
}
|
||||
setDeletingDevice(device)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="device-card device-card--main">
|
||||
|
|
@ -39,7 +112,7 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
|
|||
{device.image ? (
|
||||
<img src={device.image} alt={device.model ?? 'Устройство'} />
|
||||
) : (
|
||||
<img src='/devices/армафон3.3+.webp'></img>
|
||||
<img src="/devices/армафон3.3+.webp" alt="АРМАФОН" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -49,33 +122,60 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
|
|||
<p>{device.factoryNumber}</p>
|
||||
<p>
|
||||
{device.imei}
|
||||
{device.serialNumber ? ` · ${device.serialNumber}` : ''}
|
||||
{device.imei2 ? ` · ${device.imei2}` : ''}
|
||||
</p>
|
||||
|
||||
<div className="device-main__divider" />
|
||||
|
||||
<div className="device-main__statuses">
|
||||
<div className={`device-status ${getStatusClass(device.condition)}`}>
|
||||
<ShieldCheck size={17} />
|
||||
{conditionText[device.condition]}
|
||||
</div>
|
||||
|
||||
<div className={`device-status ${getStatusClass(device.connection)}`}>
|
||||
<Signal size={17} />
|
||||
{device.connectionText ?? connectionText[device.connection]}
|
||||
</div>
|
||||
|
||||
{device.workTime && (
|
||||
<div className="device-status is-muted">
|
||||
<Smartphone size={17} />
|
||||
В работе: {device.workTime}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{statusItems.map((status, index) => (
|
||||
<m.div
|
||||
key={status.key}
|
||||
layout={!shouldReduceMotion}
|
||||
className={`device-status ${status.className}`}
|
||||
initial={{ opacity: 0, x: shouldReduceMotion ? 0 : -8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
...statusTransition,
|
||||
delay: shouldReduceMotion ? 0 : index * 0.04,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: shouldReduceMotion ? 0 : -8,
|
||||
transition: statusTransition,
|
||||
}}
|
||||
>
|
||||
{status.icon}
|
||||
{status.content}
|
||||
</m.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Smartphone size={17} />
|
||||
<div className="device-registered-info">
|
||||
|
|
@ -85,21 +185,31 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
|
|||
</div>
|
||||
|
||||
<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 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} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDangerDialog
|
||||
open={Boolean(deletingDevice)}
|
||||
title="Удалить сотрудника?"
|
||||
title="Удалить смартфон?"
|
||||
description={
|
||||
deletingDevice
|
||||
? `Устройство ${getDeviceFullName(deletingDevice)} будет удалено. Это действие нельзя будет отменить.`
|
||||
renderDeletingDevice
|
||||
? `Устройство ${getDeviceFullName(renderDeletingDevice)} будет удалено. Это действие нельзя будет отменить.`
|
||||
: ''
|
||||
}
|
||||
confirmText="Удалить"
|
||||
|
|
@ -112,4 +222,4 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
|
|||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, areDeviceMainCardPropsEqual)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: visible;
|
||||
background: #eef1f6;
|
||||
background: $color-media-bg;
|
||||
}
|
||||
|
||||
.device-map__leaflet {
|
||||
|
|
@ -27,17 +27,17 @@
|
|||
padding: 0 10px;
|
||||
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
background: rgba($color-surface-rgb, 0.92);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
color: #30394b;
|
||||
color: $color-text-heading;
|
||||
font-size: 12px;
|
||||
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 {
|
||||
color: $blue;
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: rgba(3, 29, 154, 0.18);
|
||||
background: rgba($color-primary-rgb, 0.18);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -65,8 +65,41 @@
|
|||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: $blue;
|
||||
border: 3px solid #ffffff;
|
||||
box-shadow: 0 8px 18px rgba(3, 29, 154, 0.35);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.device-map-popup {
|
||||
|
|
@ -75,13 +108,13 @@
|
|||
gap: 3px;
|
||||
|
||||
b {
|
||||
color: #151a24;
|
||||
color: $color-text-heading;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #738098;
|
||||
color: $color-text-muted;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
@ -95,6 +128,179 @@
|
|||
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 {
|
||||
padding: 0 !important;
|
||||
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 {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Marker,
|
||||
Polyline,
|
||||
Popup,
|
||||
TileLayer,
|
||||
useMap,
|
||||
} from 'react-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 {
|
||||
GET_PHONE_GPS_TRACK_QUERY,
|
||||
} from '../../../../entities/device/api/device.graphql'
|
||||
import { GET_PHONE_GPS_TRACK_QUERY } from '../../../../entities/device/api/device.graphql'
|
||||
import type {
|
||||
DeviceGpsTrackPoint,
|
||||
DeviceTelemetryItem,
|
||||
GetPhoneGpsTrackData,
|
||||
GetPhoneGpsTrackVariables,
|
||||
} from '../../../../entities/device/model/types'
|
||||
|
|
@ -34,24 +32,63 @@ type DeviceMapCardProps = {
|
|||
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 = {
|
||||
lat: 54.7558,
|
||||
lng: 87.1099,
|
||||
}
|
||||
|
||||
const deviceMarkerIcon = 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],
|
||||
})
|
||||
const markerIconCache = new Map<string, L.DivIcon>()
|
||||
|
||||
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 'Нет данных'
|
||||
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
|
|
@ -63,11 +100,79 @@ function formatLocationDate(timestamp: number) {
|
|||
}).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[]) {
|
||||
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
|
||||
|
||||
return (
|
||||
|
|
@ -90,15 +195,14 @@ function MapResizeWatcher() {
|
|||
return null
|
||||
}
|
||||
|
||||
type MapFitBoundsProps = {
|
||||
positions: [number, number][]
|
||||
}
|
||||
|
||||
function MapFitBounds({ positions }: MapFitBoundsProps) {
|
||||
function MapFitBounds({ positions, fitKey }: MapFitBoundsProps) {
|
||||
const map = useMap()
|
||||
const lastFitKeyRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!positions.length) return
|
||||
if (!positions.length || lastFitKeyRef.current === fitKey) return
|
||||
|
||||
lastFitKeyRef.current = fitKey
|
||||
|
||||
if (positions.length === 1) {
|
||||
map.setView(positions[0], 14)
|
||||
|
|
@ -112,14 +216,23 @@ function MapFitBounds({ positions }: MapFitBoundsProps) {
|
|||
maxZoom: 15,
|
||||
animate: true,
|
||||
})
|
||||
}, [map, positions])
|
||||
}, [fitKey, map, positions])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps) {
|
||||
export function DeviceMapCard({
|
||||
device,
|
||||
variant = 'card',
|
||||
}: DeviceMapCardProps) {
|
||||
const [selectedPeriod, setSelectedPeriod] =
|
||||
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<
|
||||
GetPhoneGpsTrackData,
|
||||
|
|
@ -139,6 +252,19 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
|||
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
||||
}, [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(
|
||||
device.lastLocationDate,
|
||||
selectedPeriod,
|
||||
|
|
@ -147,10 +273,10 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
|||
const selectedCurrentLocation =
|
||||
shouldShowCurrentLocation && device.location
|
||||
? {
|
||||
lat: device.location.lat,
|
||||
lng: device.location.lng,
|
||||
time: device.lastLocationAt,
|
||||
}
|
||||
lat: device.location.lat,
|
||||
lng: device.location.lng,
|
||||
date: device.lastLocationDate,
|
||||
}
|
||||
: null
|
||||
|
||||
const selectedRoutePoints = useMemo(() => {
|
||||
|
|
@ -187,7 +313,81 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
|||
return [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 (
|
||||
<div
|
||||
|
|
@ -199,7 +399,7 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
|||
>
|
||||
<div className="device-card__header">
|
||||
<div className="device-card__title">
|
||||
<Map size={18} />
|
||||
<MapIcon size={18} />
|
||||
<h3>Устройство на карте</h3>
|
||||
</div>
|
||||
|
||||
|
|
@ -214,6 +414,63 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
|||
<MapTrackPeriodControl onChange={setSelectedPeriod} />
|
||||
</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
|
||||
center={currentPosition}
|
||||
zoom={14}
|
||||
|
|
@ -223,7 +480,7 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
|||
id="device-map"
|
||||
>
|
||||
<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}" />
|
||||
|
||||
|
|
@ -231,45 +488,44 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
|
|||
<Polyline
|
||||
positions={routePositions}
|
||||
pathOptions={{
|
||||
color: '#031d9a',
|
||||
color: routeColor,
|
||||
weight: 4,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedRoutePoints.map((point, index) => (
|
||||
{routeMapPoints.map((point) => (
|
||||
<CircleMarker
|
||||
key={`${point.lat}-${point.lng}-${point.date}-${index}`}
|
||||
key={point.key}
|
||||
center={[point.lat, point.lng]}
|
||||
radius={4}
|
||||
pathOptions={{
|
||||
color: '#031d9a',
|
||||
fillColor: '#031d9a',
|
||||
color: routeColor,
|
||||
fillColor: routeColor,
|
||||
fillOpacity: 1,
|
||||
weight: 2,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="device-map-popup">
|
||||
<b>Точка маршрута</b>
|
||||
<span>{formatLocationDate(point.date)}</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
eventHandlers={{
|
||||
click: (event) => {
|
||||
event.originalEvent.stopPropagation()
|
||||
setSelectedPointKey(point.key)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selectedCurrentLocation && (
|
||||
<Marker position={currentPosition} icon={deviceMarkerIcon}>
|
||||
<Popup>
|
||||
<div className="device-map-popup">
|
||||
<b>{device.model ?? 'Устройство'}</b>
|
||||
<span>{device.factoryNumber}</span>
|
||||
<span>{device.imei}</span>
|
||||
<span>{device.lastLocationAt ?? 'Время не указано'}</span>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
{currentMapPoint && (
|
||||
<Marker
|
||||
position={currentPosition}
|
||||
icon={deviceMarkerIcon}
|
||||
eventHandlers={{
|
||||
click: (event) => {
|
||||
event.originalEvent.stopPropagation()
|
||||
setSelectedPointKey(currentMapPoint.key)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 *;
|
||||
|
||||
.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 {
|
||||
position: relative;
|
||||
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
flex: 0 0 48px;
|
||||
padding: 0;
|
||||
width: 118px;
|
||||
height: 28px;
|
||||
flex: 0 0 118px;
|
||||
padding: 3px;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
|
||||
border-radius: 999px;
|
||||
background: $gray20;
|
||||
box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
|
||||
background: $color-bg;
|
||||
box-shadow: inset 0 0 0 1px rgba($gray50-rgb, 0.08);
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
|
@ -25,19 +362,29 @@
|
|||
box-shadow 0.28s 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;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
|
||||
width: 26px;
|
||||
height: 18px;
|
||||
width: calc((100% - 6px) / 3);
|
||||
height: calc(100% - 6px);
|
||||
border-radius: 100px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
|
||||
box-shadow:
|
||||
0 2px 5px rgba(15, 23, 42, 0.18),
|
||||
0 1px 1px rgba(15, 23, 42, 0.08);
|
||||
0 2px 5px rgba($color-shadow-rgb, 0.18),
|
||||
0 1px 1px rgba($color-shadow-rgb, 0.08);
|
||||
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
|
|
@ -45,40 +392,124 @@
|
|||
width 0.18s ease;
|
||||
}
|
||||
|
||||
&.is-enabled {
|
||||
background: $blue;
|
||||
box-shadow: inset 0 0 0 1px rgba($blue, 0.12);
|
||||
.device-permission__switch-option {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
> span {
|
||||
transform: translateX(16px);
|
||||
min-width: 0;
|
||||
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:
|
||||
0 3px 7px rgba(3, 29, 154, 0.24),
|
||||
0 1px 1px rgba(15, 23, 42, 0.08);
|
||||
0 3px 7px -4px rgba($color-primary-rgb),
|
||||
0 1px 1px rgba($color-shadow-rgb, 0.08);
|
||||
}
|
||||
|
||||
.device-permission__switch-option.is-active .device-permission__switch-label {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba($gray50, 0.12),
|
||||
0 4px 10px rgba(15, 23, 42, 0.06);
|
||||
&.is-enabled,
|
||||
&.is-allowed {
|
||||
background: rgba($color-primary-rgb, 0.1);
|
||||
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);
|
||||
|
||||
> span {
|
||||
width: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-enabled:active > span {
|
||||
transform: translateX(17px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:focus-within {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba($blue, 0.18),
|
||||
inset 0 0 0 1px rgba($blue, 0.18);
|
||||
//0 0 0 3px rgba($color-primary-rgb, 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,
|
||||
filter 0.2s ease;
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.45;
|
||||
&.is-policy-locked {
|
||||
cursor: not-allowed;
|
||||
|
||||
svg,
|
||||
span,
|
||||
button {
|
||||
pointer-events: none;
|
||||
>svg,
|
||||
.device-permission__label>span:first-child,
|
||||
.device-permission__switch {
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.device-permission__switch {
|
||||
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 {
|
||||
Check,
|
||||
CircleHelp,
|
||||
Lock,
|
||||
Pencil,
|
||||
Power,
|
||||
PowerOff,
|
||||
Smartphone,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { Device } from '../../types'
|
||||
import {
|
||||
|
|
@ -8,186 +18,573 @@ import {
|
|||
import type {
|
||||
ChangePhonePolicyData,
|
||||
ChangePhonePolicyVariables,
|
||||
ComponentUseOption,
|
||||
DevicePolicy,
|
||||
GroupUsePolicy,
|
||||
ServiceUseOption,
|
||||
} 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 { GpsIcon } from '../../../../assets/icons/Gps'
|
||||
import { CameraIcon } from '../../../../assets/icons/Camera'
|
||||
import { GpsIcon } from '../../../../assets/icons/Gps'
|
||||
import { SimIcon } from '../../../../assets/icons/Sim'
|
||||
import { VolumeIcon } from '../../../../assets/icons/Volume'
|
||||
import {
|
||||
Notification,
|
||||
type NotificationVariant,
|
||||
} from '../../../../widgets/Notification/Notification'
|
||||
|
||||
import './DevicePermissionsCard.scss'
|
||||
|
||||
type DevicePermissionsCardProps = {
|
||||
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 = {
|
||||
key: DevicePolicyKey
|
||||
editableKey: OrganisationPolicyEditableKey
|
||||
kind: PolicyKind
|
||||
icon: ReactNode
|
||||
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[] = [
|
||||
{
|
||||
key: 'wifi',
|
||||
icon: <WifiIcon />,
|
||||
label: 'Wi-Fi',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'bluetooth',
|
||||
editableKey: 'bluetoothEditable',
|
||||
kind: 'service',
|
||||
icon: <BluetoothIcon />,
|
||||
label: 'Bluetooth',
|
||||
},
|
||||
{
|
||||
key: 'gps',
|
||||
editableKey: 'gpsEditable',
|
||||
kind: 'service',
|
||||
icon: <GpsIcon />,
|
||||
label: 'GPS',
|
||||
},
|
||||
{
|
||||
key: 'camera',
|
||||
editableKey: 'cameraEditable',
|
||||
kind: 'component',
|
||||
icon: <CameraIcon />,
|
||||
label: 'Камера',
|
||||
},
|
||||
{
|
||||
key: 'sim',
|
||||
editableKey: 'simEditable',
|
||||
kind: 'service',
|
||||
icon: <SimIcon />,
|
||||
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 {
|
||||
const policy = device.policy
|
||||
const permissions = device.permissions
|
||||
|
||||
return {
|
||||
wifi: device.permissions?.wifi ?? false,
|
||||
bluetooth: device.permissions?.bluetooth ?? false,
|
||||
gps: device.permissions?.gps ?? false,
|
||||
camera: device.permissions?.camera ?? false,
|
||||
sim: device.permissions?.sim ?? false,
|
||||
speaker: device.permissions?.speaker ?? false,
|
||||
bluetooth: normalizeServiceUseOption(
|
||||
policy?.bluetooth ?? policy?.canUseBluetooth,
|
||||
permissions?.bluetooth,
|
||||
),
|
||||
gps: normalizeServiceUseOption(
|
||||
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 {
|
||||
canUseBluetooth: policy.bluetooth,
|
||||
canUseCamera: policy.camera,
|
||||
canUseGPS: policy.gps,
|
||||
canUseSim: policy.sim,
|
||||
bluetooth: normalizeServiceUseOption(
|
||||
policy?.bluetooth ?? policy?.canUseBluetooth ?? fallback.bluetooth,
|
||||
),
|
||||
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(() => {
|
||||
return mapDevicePermissionsToState(device)
|
||||
}, [device])
|
||||
|
||||
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 [notification, setNotification] =
|
||||
useState<PolicyNotification>(null)
|
||||
|
||||
const [changePhonePolicy] = useMutation<
|
||||
ChangePhonePolicyData,
|
||||
ChangePhonePolicyVariables
|
||||
>(CHANGE_PHONE_POLICY_MUTATION)
|
||||
|
||||
const hasChanges = isPolicyChanged(policyState, savedPolicyState)
|
||||
const hasHeaderActionsOpen = isEditing || hasChanges
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing || isSaving) return
|
||||
|
||||
setPolicyState(initialPolicy)
|
||||
}, [initialPolicy])
|
||||
setSavedPolicyState(initialPolicy)
|
||||
setSaveError(false)
|
||||
setIsEditing(false)
|
||||
}, [initialPolicy, isEditing, isSaving])
|
||||
|
||||
async function handleToggle(key: DevicePolicyKey, disabled?: boolean) {
|
||||
if (disabled || savingKey) return
|
||||
useEffect(() => {
|
||||
onEditingChange?.(isEditing)
|
||||
|
||||
const previousPolicy = policyState
|
||||
const nextPolicy = {
|
||||
...policyState,
|
||||
[key]: !policyState[key],
|
||||
return () => {
|
||||
if (isEditing) {
|
||||
onEditingChange?.(false)
|
||||
}
|
||||
}
|
||||
}, [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)
|
||||
setSavingKey(key)
|
||||
await handleSave()
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!hasChanges || isSaving) return
|
||||
|
||||
const requestedPolicy = policyState
|
||||
|
||||
setIsSaving(true)
|
||||
setSaveError(false)
|
||||
|
||||
try {
|
||||
await changePhonePolicy({
|
||||
const result = await changePhonePolicy({
|
||||
variables: {
|
||||
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 {
|
||||
setPolicyState(previousPolicy)
|
||||
setSaveError(true)
|
||||
showNotification(
|
||||
'error',
|
||||
'Не удалось сохранить политику устройства',
|
||||
'Изменения остались в черновике. Попробуйте сохранить еще раз.',
|
||||
)
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="device-card device-permissions">
|
||||
{permissionItems.map((item) => {
|
||||
const enabled = policyState[item.key]
|
||||
const isSaving = savingKey === item.key
|
||||
const isDisabled = item.disabled || Boolean(savingKey)
|
||||
<div
|
||||
className={`device-card device-permissions ${
|
||||
hasHeaderActionsOpen ? 'is-editing' : ''
|
||||
} ${isEditing ? 'is-policy-editing' : ''}`}
|
||||
>
|
||||
<div className="device-permissions__header">
|
||||
<div className="device-permissions__title">
|
||||
<h3>Опции</h3>
|
||||
|
||||
return (
|
||||
<PermissionItem
|
||||
key={item.key}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
enabled={enabled}
|
||||
disabled={isDisabled}
|
||||
isUnavailable={item.disabled}
|
||||
isSaving={isSaving}
|
||||
onToggle={() => handleToggle(item.key, item.disabled)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<span
|
||||
className="device-permissions__help"
|
||||
tabIndex={0}
|
||||
aria-label="Пояснение значений опций"
|
||||
>
|
||||
<CircleHelp size={16} />
|
||||
|
||||
<span className="device-permissions__help-tooltip" role="tooltip">
|
||||
<span className="device-permissions__help-row">
|
||||
{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 && (
|
||||
<div className="device-permissions__error">
|
||||
Не удалось сохранить политику устройства
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notification && (
|
||||
<Notification
|
||||
key={notification.id}
|
||||
variant={notification.variant}
|
||||
title={notification.title}
|
||||
description={notification.description}
|
||||
onClose={() => setNotification(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, areDevicePermissionsCardPropsEqual)
|
||||
|
||||
type PermissionItemProps = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
enabled: boolean
|
||||
kind: PolicyKind
|
||||
value: PolicyValue
|
||||
options: PolicyValue[]
|
||||
disabled?: boolean
|
||||
isUnavailable?: boolean
|
||||
isSaving?: boolean
|
||||
onToggle: () => void
|
||||
isPolicyLocked?: boolean
|
||||
onChange: (value: PolicyValue) => void
|
||||
}
|
||||
|
||||
function PermissionItem({
|
||||
icon,
|
||||
label,
|
||||
enabled,
|
||||
kind,
|
||||
value,
|
||||
options,
|
||||
disabled = false,
|
||||
isUnavailable = false,
|
||||
isSaving = false,
|
||||
onToggle,
|
||||
isPolicyLocked = false,
|
||||
onChange,
|
||||
}: PermissionItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={`device-permission ${isUnavailable ? 'is-disabled' : ''} ${
|
||||
isSaving ? 'is-saving' : ''
|
||||
className={`device-permission ${
|
||||
isPolicyLocked ? 'is-policy-locked' : ''
|
||||
}`}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
|
|
@ -196,16 +593,66 @@ function PermissionItem({
|
|||
<div className="device-permission__label">
|
||||
<span>{label}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`device-permission__switch ${enabled ? 'is-enabled' : ''}`}
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
aria-pressed={enabled}
|
||||
aria-label={`${enabled ? 'Выключить' : 'Включить'} ${label}`}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
<span className="device-permission__controls">
|
||||
{isPolicyLocked && (
|
||||
<span
|
||||
className="device-permission__policy-lock"
|
||||
tabIndex={0}
|
||||
aria-label="Опция зафиксирована политикой организации"
|
||||
>
|
||||
<Lock size={14} aria-hidden="true" />
|
||||
|
||||
<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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
type DeviceStatsCardsProps = {
|
||||
|
|
@ -23,7 +24,22 @@ function formatWorkTimeHours(worktime?: number | null) {
|
|||
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 =
|
||||
typeof device.battery === 'number'
|
||||
|
|
@ -31,18 +47,18 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
|||
: null
|
||||
|
||||
const batterySize = 110
|
||||
const batteryStroke = 10
|
||||
const batteryStroke = 12
|
||||
const batteryRadius = (batterySize - batteryStroke) / 2
|
||||
const batteryLength = 2 * Math.PI * batteryRadius
|
||||
const batteryProgress = batteryValue !== null ? batteryValue / 100 : 0
|
||||
const batteryDashOffset = batteryLength * (1 - batteryProgress)
|
||||
|
||||
function getBatteryColor(value: number | null) {
|
||||
if (value === null) return '#cfd5df'
|
||||
if (value <= 20) return '#e00000'
|
||||
if (value <= 50) return '#ff8a34'
|
||||
if (value === null) return 'var(--device-battery-progress-empty)'
|
||||
if (value <= 20) return 'var(--device-battery-progress-danger)'
|
||||
if (value <= 50) return 'var(--device-battery-progress-warning)'
|
||||
|
||||
return '#031d9a'
|
||||
return 'var(--device-battery-progress-strong)'
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -124,4 +140,4 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
|||
</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 DeviceConnection = 'online' | 'offline' | 'offlineDanger'
|
||||
|
||||
|
|
@ -9,6 +15,9 @@ export type Device = {
|
|||
imei2: string
|
||||
serialNumber?: string
|
||||
organisation?: string
|
||||
organisationId?: number
|
||||
policy?: DevicePolicy | null
|
||||
organisationPolicy?: GroupUsePolicy | null
|
||||
workTime: string | null
|
||||
employee: string | null
|
||||
condition: DeviceCondition
|
||||
|
|
@ -19,6 +28,7 @@ export type Device = {
|
|||
lastLocationDate?: number
|
||||
battery?: number
|
||||
batteryMaxCapacity?: number
|
||||
telemetry?: DeviceTelemetryItem[]
|
||||
chargeCycles?: string
|
||||
totalWorkTime?: number | null
|
||||
mediumImpacts?: string
|
||||
|
|
@ -40,6 +50,7 @@ export type Device = {
|
|||
camera: boolean
|
||||
sim: boolean
|
||||
speaker: boolean
|
||||
locked?: boolean
|
||||
}
|
||||
statusIcons: {
|
||||
gps: boolean
|
||||
|
|
@ -61,7 +72,7 @@ export const conditionText: Record<DeviceCondition, string> = {
|
|||
export const connectionText: Record<DeviceConnection, string> = {
|
||||
online: 'В сети',
|
||||
offline: 'Не в сети',
|
||||
offlineDanger: 'Долго не в сети',
|
||||
offlineDanger: 'Потерян',
|
||||
}
|
||||
|
||||
export function getStatusClass(status: DeviceCondition | DeviceConnection) {
|
||||
|
|
|
|||
|
|
@ -5,51 +5,252 @@
|
|||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 20px 20px 36px;
|
||||
//overflow: hidden;
|
||||
}
|
||||
|
||||
.devices-table-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
//overflow: hidden;
|
||||
//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-filters-width: 292px;
|
||||
--devices-filters-gap: 20px;
|
||||
--devices-filters-duration: 360ms;
|
||||
--devices-filters-easing: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
//overflow: hidden;
|
||||
}
|
||||
|
||||
.devices-filters-slot {
|
||||
flex: 0 0 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
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 {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
//overflow: hidden;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $gray50 transparent;
|
||||
background: $color-surface;
|
||||
min-height: 0px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
.simplebar-wrapper,
|
||||
.simplebar-mask,
|
||||
.simplebar-offset,
|
||||
.simplebar-content-wrapper,
|
||||
.simplebar-content {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.simplebar-content-wrapper{
|
||||
border-radius: 20px;
|
||||
}
|
||||
.simplebar-track.simplebar-vertical {
|
||||
top: 48px;
|
||||
bottom: 14px;
|
||||
width: 14px;
|
||||
right: -7px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $gray30;
|
||||
border-radius: 999px;
|
||||
.simplebar-scrollbar::before {
|
||||
background: color-mix(in srgb, var(--gray50) 85%, transparent);
|
||||
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 {
|
||||
background: $gray50;
|
||||
.simplebar-track.simplebar-horizontal {
|
||||
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 {
|
||||
|
|
@ -62,8 +263,8 @@
|
|||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($blue, 0.025);
|
||||
box-shadow: inset 3px 0 0 rgba($blue, 0.8);
|
||||
background-color: $color-primary-tint-soft;
|
||||
box-shadow: inset 3px 0 0 rgba($color-primary-rgb, 0.8);
|
||||
|
||||
.device-info__number {
|
||||
color: $blue;
|
||||
|
|
@ -75,10 +276,10 @@
|
|||
//height: 36px;
|
||||
padding: 14px 20px;
|
||||
line-height: 1;
|
||||
border-bottom: 1px solid #e3e8f0;
|
||||
border-bottom: 1px solid $color-border;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 16px;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 450;
|
||||
text-align: left;
|
||||
}
|
||||
|
|
@ -90,8 +291,8 @@
|
|||
vertical-align: middle;
|
||||
text-align: left;
|
||||
|
||||
color: #151a24;
|
||||
font-size: 18px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +328,7 @@
|
|||
}
|
||||
|
||||
.devices-table__id {
|
||||
color: #1d2533;
|
||||
color: $color-text-strong;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
|
@ -138,16 +339,16 @@
|
|||
}
|
||||
|
||||
.device-info__number {
|
||||
color: #111827;
|
||||
font-size: 18px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 600;
|
||||
transition: .2s ease;
|
||||
}
|
||||
|
||||
.device-info__imei,
|
||||
.device-info__employee {
|
||||
color: #738098;
|
||||
font-size: 18px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
|
@ -156,8 +357,8 @@
|
|||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
color: #738098;
|
||||
font-size: 18px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
|
@ -166,14 +367,14 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
text-transform: uppercase;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-14;
|
||||
padding: 0px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 550;
|
||||
|
||||
&--green {
|
||||
background-color: hsla(128, 56%, 45%, 0.15);
|
||||
background-color: $color-success-tint;
|
||||
color: $green;
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +384,7 @@
|
|||
}
|
||||
|
||||
&--red {
|
||||
background-color: hsla(0, 100%, 43%, 0.15);
|
||||
background-color: $color-danger-status-bg;
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
|
@ -209,25 +410,30 @@
|
|||
|
||||
.device-icons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 16px);
|
||||
grid-template-columns: repeat(4, 20px);
|
||||
gap: 4px 8px;
|
||||
align-items: center;
|
||||
|
||||
color: $gray50;
|
||||
|
||||
svg {
|
||||
background-color: $color-bg;
|
||||
width: 20px;
|
||||
height: auto;
|
||||
color: $gray50;
|
||||
stroke-width: 2;
|
||||
padding: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.is-active {
|
||||
color: $blue;
|
||||
background-color: $blue20;
|
||||
}
|
||||
|
||||
.is-danger {
|
||||
color: $red;
|
||||
background-color: $color-danger-bg;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,8 +447,8 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: #151a24;
|
||||
font-size: 18px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: .2s ease;
|
||||
|
|
@ -259,14 +465,16 @@
|
|||
}
|
||||
|
||||
.devices-pagination {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
padding: 12px 14px 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
color: #738098;
|
||||
font-size: 16px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-16;
|
||||
}
|
||||
|
||||
.devices-pagination__controls {
|
||||
|
|
@ -281,15 +489,15 @@
|
|||
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #e9edf5;
|
||||
background: $color-surface-active;
|
||||
|
||||
color: #738098;
|
||||
font-size: 17px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-17;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-active {
|
||||
background: #031d9a;
|
||||
color: #ffffff;
|
||||
background: $color-primary;
|
||||
color: $color-text-inverse;
|
||||
}
|
||||
|
||||
&: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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
z-index: 110;
|
||||
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
background: $color-backdrop;
|
||||
backdrop-filter: blur(2px);
|
||||
|
||||
animation: addDeviceOverlayShow 0.18s ease;
|
||||
}
|
||||
|
||||
.add-device-modal {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
z-index: 120;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
|
||||
|
|
@ -21,11 +20,10 @@
|
|||
padding: 24px;
|
||||
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
|
||||
background: $color-surface;
|
||||
box-shadow: $shadow-modal;
|
||||
|
||||
transform: translate(-50%, -50%);
|
||||
animation: addDeviceModalShow 0.2s ease;
|
||||
}
|
||||
|
||||
.add-device-modal__header {
|
||||
|
|
@ -40,8 +38,8 @@
|
|||
.add-device-modal__title {
|
||||
margin: 0;
|
||||
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-24;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +47,7 @@
|
|||
margin: 6px 0 0;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
|
@ -85,7 +83,7 @@
|
|||
margin: 0;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -94,7 +92,7 @@
|
|||
.add-device-qr__box {
|
||||
min-height: 260px;
|
||||
border-radius: 22px;
|
||||
border: 1px dashed rgba(3, 29, 154, 0.32);
|
||||
border: 1px dashed rgba($color-primary-rgb, 0.32);
|
||||
background: $color-bg;
|
||||
|
||||
display: flex;
|
||||
|
|
@ -106,8 +104,8 @@
|
|||
color: $blue;
|
||||
|
||||
span {
|
||||
color: #111827;
|
||||
font-size: 18px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 600;
|
||||
}
|
||||
.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 {
|
||||
margin-top: 22px;
|
||||
|
||||
|
|
@ -134,7 +199,7 @@
|
|||
background: $color-bg;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
|
|
@ -142,28 +207,6 @@
|
|||
|
||||
&:hover {
|
||||
background: $gray20;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
color: $color-text-strong;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
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 { CREATE_PHONE_REGISTRATION_TOKEN_MUTATION } from '../../../../entities/device/api/device.graphql'
|
||||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
CreatePhoneRegistrationTokenData,
|
||||
CreatePhoneRegistrationTokenVariables,
|
||||
} from '../../../../entities/device/model/types'
|
||||
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
|
||||
|
||||
import './AddDeviceModal.scss'
|
||||
|
||||
|
|
@ -17,24 +18,40 @@ type AddDeviceModalProps = {
|
|||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function formatExpiresIn(expiresIn: number) {
|
||||
if (!expiresIn) return '—'
|
||||
const SECOND_MS = 1000
|
||||
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()
|
||||
const expiresAt = expiresIn > 10_000_000_000
|
||||
? expiresIn
|
||||
: now + expiresIn * 1000
|
||||
function getExpiresAt(expiresIn: number, receivedAt: number) {
|
||||
if (!Number.isFinite(expiresIn) || expiresIn <= 0) return 0
|
||||
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(expiresAt))
|
||||
if (expiresIn >= UNIX_MILLISECONDS_THRESHOLD) {
|
||||
return expiresIn
|
||||
}
|
||||
|
||||
if (expiresIn >= UNIX_SECONDS_THRESHOLD) {
|
||||
return expiresIn * SECOND_MS
|
||||
}
|
||||
|
||||
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) {
|
||||
const [tokenReceivedAt, setTokenReceivedAt] = useState(0)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const [createToken, { data, loading, error }] = useMutation<
|
||||
CreatePhoneRegistrationTokenData,
|
||||
|
|
@ -44,6 +61,11 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
|||
})
|
||||
|
||||
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(() => {
|
||||
if (!registrationData) return ''
|
||||
|
|
@ -58,18 +80,51 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
|||
if (!open) return
|
||||
|
||||
createToken()
|
||||
.then(() => {
|
||||
const receivedAt = Date.now()
|
||||
|
||||
setTokenReceivedAt(receivedAt)
|
||||
setNow(receivedAt)
|
||||
})
|
||||
.catch(() => {
|
||||
setTokenReceivedAt(0)
|
||||
})
|
||||
}, [open, createToken])
|
||||
|
||||
function handleRefreshToken() {
|
||||
createToken()
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="add-device-modal__overlay" />
|
||||
|
||||
<Dialog.Content className="add-device-modal">
|
||||
<MotionDialogContent
|
||||
open={open}
|
||||
overlayClassName="add-device-modal__overlay"
|
||||
contentClassName="add-device-modal"
|
||||
motionPreset="fade"
|
||||
>
|
||||
<div className="add-device-modal__header">
|
||||
<div>
|
||||
<Dialog.Title className="add-device-modal__title">
|
||||
|
|
@ -109,13 +164,39 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
|||
size={220}
|
||||
level="M"
|
||||
bgColor='transparent'
|
||||
fgColor="#031D9A"
|
||||
fgColor="currentColor"
|
||||
includeMargin
|
||||
/>
|
||||
</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>
|
||||
После сканирования устройство появится в списке и будет доступно
|
||||
для назначения сотруднику.
|
||||
|
|
@ -127,8 +208,7 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
|||
Закрыть
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</MotionDialogContent>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
inset: 0;
|
||||
z-index: 90;
|
||||
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
background: $color-backdrop-soft;
|
||||
backdrop-filter: blur(2px);
|
||||
|
||||
animation: deviceMapModalOverlayShow 0.18s ease;
|
||||
|
|
@ -17,14 +17,15 @@
|
|||
inset: 24px;
|
||||
|
||||
padding: 24px;
|
||||
border: 1px solid $color-border-muted;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.24);
|
||||
box-shadow: $shadow-modal-strong;
|
||||
animation: deviceMapModalShow 0.2s ease;
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +41,7 @@
|
|||
.device-map-modal__title {
|
||||
margin: 0;
|
||||
|
||||
color: $color-text;
|
||||
color: $color-text-heading;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
|
@ -48,7 +49,7 @@
|
|||
.device-map-modal__description {
|
||||
margin: 4px 0 0;
|
||||
|
||||
color: $gray50;
|
||||
color: $color-text-muted;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
@ -59,19 +60,19 @@
|
|||
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: $color-bg;
|
||||
background: $color-surface-muted;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
color: $gray50;
|
||||
color: $color-text-muted;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
background: $gray20;
|
||||
color: $color-primary;
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +83,7 @@
|
|||
.device-map {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.device-map__container {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { X } from 'lucide-react'
|
|||
|
||||
import type { Device } from '../../../DevicePage/types'
|
||||
import { DeviceMapCard } from '../../../DevicePage/components/DeviceMapCard/DeviceMapCard'
|
||||
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
|
||||
|
||||
import './DeviceMapModal.scss'
|
||||
|
||||
|
|
@ -19,33 +20,34 @@ export function DeviceMapModal({
|
|||
}: DeviceMapModalProps) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="device-map-modal__overlay" />
|
||||
<MotionDialogContent
|
||||
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">
|
||||
<div className="device-map-modal__header">
|
||||
<div>
|
||||
<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>
|
||||
<Dialog.Description className="device-map-modal__description">
|
||||
{device?.factoryNumber || 'Выбранное устройство'}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
{device && (
|
||||
<div className="device-map-modal__body">
|
||||
<DeviceMapCard device={device} variant="modal" />
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
<Dialog.Close className="device-map-modal__close" type="button">
|
||||
<X size={22} />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{device && (
|
||||
<div className="device-map-modal__body">
|
||||
<DeviceMapCard device={device} variant="modal" />
|
||||
</div>
|
||||
)}
|
||||
</MotionDialogContent>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,14 +12,14 @@
|
|||
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #eef1f6;
|
||||
background: $color-surface-hover;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: #4f5b73;
|
||||
font-size: 14px;
|
||||
color: $color-text-soft;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
}
|
||||
|
||||
&.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;
|
||||
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
border: 1.5px solid $gray20;
|
||||
}
|
||||
|
||||
.devices-date-range__calendar {
|
||||
--rdp-accent-color: $blue;
|
||||
--rdp-accent-background-color: #e8edff;
|
||||
--rdp-accent-color: #{$blue};
|
||||
--rdp-accent-background-color: #{$color-primary-soft};
|
||||
--rdp-day_button-border-radius: 12px;
|
||||
--rdp-day_button-height: 34px;
|
||||
--rdp-day_button-width: 34px;
|
||||
|
|
@ -78,9 +78,9 @@
|
|||
}
|
||||
|
||||
.rdp-caption_label {
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 700;
|
||||
color: #151a24;
|
||||
color: $color-text-strong;
|
||||
}
|
||||
|
||||
.rdp-nav {
|
||||
|
|
@ -95,33 +95,33 @@
|
|||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background: #eef1f6;
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-weekday {
|
||||
color: #8c96aa;
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-12;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rdp-day_button {
|
||||
color: #30394b;
|
||||
font-size: 12px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-12;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rdp-day_button:hover {
|
||||
background: #eef1f6;
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
|
||||
.rdp-selected .rdp-day_button {
|
||||
background: $blue;
|
||||
color: #ffffff;
|
||||
color: $color-text-inverse;
|
||||
}
|
||||
|
||||
.rdp-range_middle .rdp-day_button {
|
||||
background: #e8edff;
|
||||
background: $color-primary-soft;
|
||||
color: $blue;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
.rdp-range_start .rdp-day_button,
|
||||
.rdp-range_end .rdp-day_button {
|
||||
background: $blue;
|
||||
color: #ffffff;
|
||||
color: $color-text-inverse;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
|
|
@ -151,8 +151,8 @@
|
|||
gap: 5px;
|
||||
|
||||
span {
|
||||
color: #738098;
|
||||
font-size: 11px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-11;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -163,10 +163,10 @@
|
|||
border: none;
|
||||
outline: none;
|
||||
border-radius: 10px;
|
||||
background: #eef1f6;
|
||||
background: $color-surface-hover;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 13px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-13;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
|
@ -185,15 +185,15 @@
|
|||
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #eef1f6;
|
||||
background: $color-surface-hover;
|
||||
|
||||
color: #4f5b73;
|
||||
font-size: 12px;
|
||||
color: $color-text-soft;
|
||||
font-size: $font-size-12;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #e8edff;
|
||||
background: $color-primary-soft;
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
|
@ -202,7 +202,7 @@
|
|||
.devices-date-range__footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e3e8f0;
|
||||
border-top: 1px solid $color-border;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -210,8 +210,8 @@
|
|||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #738098;
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-12;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
|
@ -230,17 +230,17 @@
|
|||
border: none;
|
||||
border-radius: 10px;
|
||||
|
||||
font-size: 12px;
|
||||
font-size: $font-size-12;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.devices-date-range__reset {
|
||||
background: #eef1f6;
|
||||
color: #4f5b73;
|
||||
background: $color-surface-hover;
|
||||
color: $color-text-soft;
|
||||
}
|
||||
|
||||
.devices-date-range__apply {
|
||||
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 { format } from 'date-fns'
|
||||
import { CalendarDays, ChevronDown, X } from 'lucide-react'
|
||||
import { AnimatePresence, m } from 'framer-motion'
|
||||
|
||||
import 'react-day-picker/style.css'
|
||||
import './DevicesDateRangePicker.scss'
|
||||
import { popoverVariants, uiFadeTransition } from '../../../../shared/lib/motion'
|
||||
|
||||
export type DevicesDateRangePickerValue = {
|
||||
from: Date | null
|
||||
|
|
@ -138,8 +140,16 @@ export function DevicesDateRangePicker({
|
|||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="devices-date-range__popover">
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<m.div
|
||||
className="devices-date-range__popover"
|
||||
variants={popoverVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={uiFadeTransition}
|
||||
>
|
||||
<DayPicker
|
||||
mode="range"
|
||||
selected={selectedRange}
|
||||
|
|
@ -216,8 +226,9 @@ export function DevicesDateRangePicker({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,20 +1,22 @@
|
|||
@use '../../../../shared/styles/variables' as *;
|
||||
|
||||
.react-datepicker {
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.15);
|
||||
box-shadow: $shadow-popover-soft;
|
||||
font-family: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.react-datepicker__header {
|
||||
background: #f3f6fa;
|
||||
border-bottom: 1px solid #e3e8f0;
|
||||
background: $color-surface-muted;
|
||||
border-bottom: 1px solid $color-border;
|
||||
}
|
||||
|
||||
.react-datepicker__current-month,
|
||||
.react-datepicker-time__header,
|
||||
.react-datepicker-year-header {
|
||||
color: #30394b;
|
||||
color: $color-text-heading;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -25,19 +27,19 @@
|
|||
.react-datepicker__time-box
|
||||
ul.react-datepicker__time-list
|
||||
li.react-datepicker__time-list-item--selected {
|
||||
background: #031d9a;
|
||||
color: #ffffff;
|
||||
background: $color-primary;
|
||||
color: $color-text-inverse;
|
||||
}
|
||||
|
||||
.react-datepicker__day--in-range,
|
||||
.react-datepicker__day--in-selecting-range {
|
||||
background: #e8edff;
|
||||
color: #031d9a;
|
||||
background: $color-primary-soft;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.react-datepicker__day:hover,
|
||||
.react-datepicker__month-text:hover,
|
||||
.react-datepicker__quarter-text:hover,
|
||||
.react-datepicker__year-text:hover {
|
||||
background: #eef1f6;
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
|
|
@ -1,32 +1,43 @@
|
|||
@use '../../../../shared/styles/variables' as *;
|
||||
|
||||
.devices-filters {
|
||||
width: 0;
|
||||
flex: 0 0 0;
|
||||
width: var(--devices-filters-width, 292px);
|
||||
min-width: var(--devices-filters-width, 292px);
|
||||
margin-left: var(--devices-filters-gap, 20px);
|
||||
box-sizing: border-box;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
|
||||
transform: translateX(16px);
|
||||
transform: translateX(6px);
|
||||
will-change: opacity, transform;
|
||||
|
||||
transition:
|
||||
width 0.25s ease,
|
||||
flex-basis 0.25s 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 {
|
||||
width: 292px;
|
||||
flex-basis: 292px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
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 {
|
||||
width: 292px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
|
@ -34,8 +45,9 @@
|
|||
|
||||
.devices-filter-item {
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
overflow: hidden;
|
||||
box-shadow: 2px 2px 30px -10px rgba($gray50-rgb, .2);
|
||||
&:nth-child(1)[data-state=open]{
|
||||
overflow: visible;
|
||||
}
|
||||
|
|
@ -51,16 +63,16 @@
|
|||
margin: 0 16px;
|
||||
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid $transparent;
|
||||
background: $transparent;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
color: black;
|
||||
font-size: 18px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
|
|
@ -116,10 +128,10 @@
|
|||
border: none;
|
||||
outline: none;
|
||||
border-radius: 999px;
|
||||
background: #eef1f6;
|
||||
background: $color-surface-hover;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 16px;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -131,10 +143,10 @@
|
|||
display: block;
|
||||
|
||||
border: none;
|
||||
background: transparent;
|
||||
background: $transparent;
|
||||
|
||||
color: $blue;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 400;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
|
|
@ -149,7 +161,7 @@
|
|||
min-height: 28px;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 13px;
|
||||
font-size: $font-size-13;
|
||||
|
||||
& + & {
|
||||
margin-top: 12px;
|
||||
|
|
@ -158,7 +170,7 @@
|
|||
|
||||
.devices-filter-row__label {
|
||||
color: $gray50;
|
||||
font-size: 16px;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
|
@ -174,8 +186,8 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 16px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
|
@ -189,15 +201,15 @@
|
|||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #dfe5ef;
|
||||
background: #f3f6fa;
|
||||
border: 2px solid $color-border-strong;
|
||||
background: $color-surface-muted;
|
||||
position: relative;
|
||||
flex: 0 0 18px;
|
||||
}
|
||||
|
||||
.devices-radio input:checked + .devices-radio__control {
|
||||
border-color: #031d9a;
|
||||
background: #031d9a;
|
||||
border-color: $color-primary;
|
||||
background: $color-primary;
|
||||
}
|
||||
|
||||
.devices-radio input:checked + .devices-radio__control::after {
|
||||
|
|
@ -205,7 +217,7 @@
|
|||
position: absolute;
|
||||
inset: 5px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
}
|
||||
|
||||
.devices-radio__label {
|
||||
|
|
@ -224,15 +236,17 @@
|
|||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
border: 2px solid #dfe5ef;
|
||||
background: #f3f6fa;
|
||||
border: 2px solid $color-border-strong;
|
||||
background: $color-surface-muted;
|
||||
position: relative;
|
||||
flex: 0 0 18px;
|
||||
transition: .2s ease;
|
||||
}
|
||||
|
||||
.devices-checkbox input:checked + .devices-checkbox__control {
|
||||
border-color: #031d9a;
|
||||
background: #031d9a;
|
||||
border-color: $color-primary;
|
||||
background: $color-primary;
|
||||
box-shadow: 0 2px 8px -2px rgba(var(--color-primary-rgb), 0.8);
|
||||
}
|
||||
|
||||
.devices-checkbox input:checked + .devices-checkbox__control::after {
|
||||
|
|
@ -242,7 +256,7 @@
|
|||
top: 2px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid #ffffff;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
|
@ -266,3 +280,14 @@
|
|||
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 { useSearchParams } from 'react-router-dom'
|
||||
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'
|
||||
|
||||
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 = {
|
||||
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>({
|
||||
from: new Date(2026, 3, 20),
|
||||
to: new Date(2026, 3, 22),
|
||||
fromTime: '07:00',
|
||||
toTime: '16:00',
|
||||
const selectedOrganisationIds = useMemo(
|
||||
() => searchParams.get('dOrgs')?.split(',').filter(Boolean) ?? [],
|
||||
[searchParams],
|
||||
)
|
||||
|
||||
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 (
|
||||
<aside className={`devices-filters ${isOpen ? 'devices-filters--open' : ''}`}>
|
||||
<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>
|
||||
setSearchParams(params)
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
<Accordion.Content className="devices-filter-item__content">
|
||||
<div className="devices-filter-item__inner">
|
||||
<div className="devices-period">
|
||||
<DevicesDateRangePicker value={workPeriod} onChange={setWorkPeriod} />
|
||||
</div>
|
||||
const handleNetworkStatusChange = useCallback((
|
||||
status: DeviceNetworkStatus,
|
||||
checked: boolean,
|
||||
) => {
|
||||
const currentStatuses = new Set(selectedNetworkStatuses)
|
||||
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
if (checked) {
|
||||
currentStatuses.add(status)
|
||||
} else {
|
||||
currentStatuses.delete(status)
|
||||
}
|
||||
|
||||
{/* <Accordion.Item className="devices-filter-item" value="statuses">
|
||||
<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>
|
||||
const nextStatuses = networkStatusOptions
|
||||
.map((option) => option.value)
|
||||
.filter((optionStatus) => currentStatuses.has(optionStatus))
|
||||
|
||||
<Accordion.Content className="devices-filter-item__content">
|
||||
<div className="devices-filter-item__inner">
|
||||
<div className="devices-filter-row">
|
||||
<span className="devices-filter-row__label">GPS</span>
|
||||
updateSearchParams({
|
||||
dNetwork:
|
||||
nextStatuses.length === networkStatusOptions.length
|
||||
? null
|
||||
: nextStatuses.length === 0
|
||||
? 'none'
|
||||
: nextStatuses.join(','),
|
||||
dPage: '0',
|
||||
})
|
||||
}, [selectedNetworkStatuses, updateSearchParams])
|
||||
|
||||
<label className="devices-radio">
|
||||
<input type="radio" name="gps" />
|
||||
<span className="devices-radio__control" />
|
||||
<span className="devices-radio__label">Вкл</span>
|
||||
</label>
|
||||
const handleMaintenanceChange = useCallback((value: boolean, checked: boolean) => {
|
||||
const currentStates = new Set(selectedMaintenanceStates)
|
||||
|
||||
<label className="devices-radio">
|
||||
<input type="radio" name="gps" defaultChecked />
|
||||
<span className="devices-radio__control" />
|
||||
<span className="devices-radio__label">Выкл</span>
|
||||
</label>
|
||||
</div>
|
||||
if (checked) {
|
||||
currentStates.add(value)
|
||||
} else {
|
||||
currentStates.delete(value)
|
||||
}
|
||||
|
||||
<div className="devices-filter-row">
|
||||
<span className="devices-filter-row__label">Wi-Fi</span>
|
||||
if (currentStates.size === 0) return
|
||||
|
||||
<label className="devices-radio">
|
||||
<input type="radio" name="wifi" defaultChecked />
|
||||
<span className="devices-radio__control" />
|
||||
<span className="devices-radio__label">Вкл</span>
|
||||
</label>
|
||||
const nextStates = maintenanceOptions
|
||||
.map((option) => option.value)
|
||||
.filter((optionValue) => currentStates.has(optionValue))
|
||||
|
||||
<label className="devices-radio">
|
||||
<input type="radio" name="wifi" />
|
||||
<span className="devices-radio__control" />
|
||||
<span className="devices-radio__label">Выкл</span>
|
||||
</label>
|
||||
</div>
|
||||
updateSearchParams({
|
||||
dNeedMaintenance:
|
||||
nextStates.length === maintenanceOptions.length
|
||||
? null
|
||||
: String(nextStates[0]),
|
||||
dPage: '0',
|
||||
})
|
||||
}, [selectedMaintenanceStates, updateSearchParams])
|
||||
|
||||
<div className="devices-filter-row">
|
||||
<span className="devices-filter-row__label">Камера</span>
|
||||
const handleOrganisationsChange = useCallback((ids: string[]) => {
|
||||
updateSearchParams({
|
||||
dOrgs: ids.length > 0 ? ids.join(',') : null,
|
||||
dPage: '0',
|
||||
})
|
||||
}, [updateSearchParams])
|
||||
|
||||
<label className="devices-radio">
|
||||
<input type="radio" name="camera" />
|
||||
<span className="devices-radio__control" />
|
||||
<span className="devices-radio__label">Вкл</span>
|
||||
</label>
|
||||
return (
|
||||
<aside
|
||||
className={`devices-filters ${isOpen ? 'devices-filters--open' : ''}`}
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
<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">
|
||||
<input type="radio" name="camera" />
|
||||
<span className="devices-radio__control" />
|
||||
<span className="devices-radio__label">Выкл</span>
|
||||
</label>
|
||||
</div>
|
||||
<Accordion.Content className="devices-filter-item__content">
|
||||
<div className="devices-filter-item__inner">
|
||||
<div className="devices-period">
|
||||
<DevicesDateRangePicker
|
||||
value={workPeriod}
|
||||
onChange={setWorkPeriod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<button className="devices-filter-reset" type="button">
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item> */}
|
||||
<Accordion.Item
|
||||
className="devices-filter-item"
|
||||
value="organisations"
|
||||
>
|
||||
<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.Item className="devices-filter-item" value="network">
|
||||
<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">
|
||||
<div className="devices-filter-item__inner">
|
||||
<DevicesOrganisationFilter
|
||||
selectedOrganisationIds={selectedOrganisationIds}
|
||||
onChange={handleOrganisationsChange}
|
||||
/>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Content className="devices-filter-item__content">
|
||||
<div className="devices-filter-item__inner">
|
||||
<div className='devices-checkbox-list'>
|
||||
<label className="devices-checkbox">
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span className="devices-checkbox__control" />
|
||||
<span>В сети</span>
|
||||
</label>
|
||||
<Accordion.Item className="devices-filter-item" value="network">
|
||||
<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-checkbox">
|
||||
<input type="checkbox" />
|
||||
<span className="devices-checkbox__control" />
|
||||
<span>Не в сети</span>
|
||||
</label>
|
||||
<Accordion.Content className="devices-filter-item__content">
|
||||
<div className="devices-filter-item__inner">
|
||||
<div className="devices-checkbox-list">
|
||||
{networkStatusOptions.map((option) => (
|
||||
<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">
|
||||
<input type="checkbox" />
|
||||
<span className="devices-checkbox__control" />
|
||||
<span>Долго не в сети</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item className="devices-filter-item" value="condition">
|
||||
<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.Item className="devices-filter-item" value="condition">
|
||||
<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">
|
||||
<div className="devices-filter-item__inner">
|
||||
<div className='devices-checkbox-list'>
|
||||
<label className="devices-checkbox">
|
||||
<input type="checkbox" defaultChecked />
|
||||
<span className="devices-checkbox__control" />
|
||||
<span>Исправно</span>
|
||||
</label>
|
||||
|
||||
<label className="devices-checkbox">
|
||||
<input type="checkbox" />
|
||||
<span className="devices-checkbox__control" />
|
||||
<span>Требует осмотра</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
<Accordion.Content className="devices-filter-item__content">
|
||||
<div className="devices-filter-item__inner">
|
||||
<div className="devices-checkbox-list">
|
||||
{maintenanceOptions.map((option) => (
|
||||
<label className="devices-checkbox" key={String(option.value)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaintenanceStates.includes(option.value)}
|
||||
onChange={(event) => {
|
||||
handleMaintenanceChange(
|
||||
option.value,
|
||||
event.target.checked,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<span className="devices-checkbox__control" />
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</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() {
|
||||
return (
|
||||
<div className="devices-tabs">
|
||||
<button className="devices-tabs__item devices-tabs__item--active" type="button">
|
||||
В работе: <b>15 из 77</b>
|
||||
</button>
|
||||
import {
|
||||
GET_PHONES_TABS_STATS_QUERY,
|
||||
} from '../../../../entities/device/api/device.graphql'
|
||||
import type {
|
||||
GetPhonesTabsStatsData,
|
||||
GetPhonesTabsStatsVariables,
|
||||
} from '../../../../entities/device/model/types'
|
||||
import {
|
||||
PageTabs,
|
||||
type PageTabItem,
|
||||
} from '../../../../shared/ui/PageTabs/PageTabs'
|
||||
|
||||
<button className="devices-tabs__item" type="button">
|
||||
Долгое время не в сети: <b>8</b>
|
||||
<span className="devices-tabs__alert" />
|
||||
</button>
|
||||
type DevicesTab = 'working' | 'lost' | 'locked' | 'maintenance'
|
||||
type DevicesTabsValue = DevicesTab | 'none'
|
||||
|
||||
<button className="devices-tabs__item" type="button">
|
||||
Требуют обслуживания: <b>9</b>
|
||||
<span className="devices-tabs__alert" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
function getActiveTab(searchParams: URLSearchParams): DevicesTabsValue {
|
||||
const locked = searchParams.get('dLocked')
|
||||
const needMaintenance = searchParams.get('dNeedMaintenance')
|
||||
const networkStatus = searchParams.get('dNetwork')
|
||||
|
||||
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;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
background-color: white;
|
||||
background-color: $color-surface;
|
||||
border-radius: 100px;
|
||||
padding: 4px;
|
||||
box-shadow: $shadow-toolbar;
|
||||
}
|
||||
|
||||
.devices-search {
|
||||
|
|
@ -32,9 +33,9 @@
|
|||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: black;
|
||||
font-size: 16px;
|
||||
background: $transparent;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-16;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray50;
|
||||
|
|
@ -42,7 +43,7 @@
|
|||
}
|
||||
|
||||
&: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 {
|
||||
padding: 12px 20px;
|
||||
font-size: 16px;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 500;
|
||||
background-color: $color-bg;
|
||||
border-radius: 20px;
|
||||
|
|
@ -62,8 +63,12 @@
|
|||
|
||||
.add-device {
|
||||
background-color: $blue;
|
||||
color: white;
|
||||
color: $color-text-inverse;
|
||||
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-radius: 20px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
|
||||
cursor: pointer;
|
||||
|
|
@ -108,9 +113,9 @@
|
|||
padding: 6px;
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -125,15 +130,15 @@
|
|||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 14px;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&[data-highlighted] {
|
||||
background: #f1f4f8;
|
||||
background: $color-surface-subtle;
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
|
@ -196,3 +201,76 @@
|
|||
border-radius: 50%;
|
||||
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 { ChevronDown, Search, SlidersHorizontal } from 'lucide-react'
|
||||
import { Search, SlidersHorizontal } from 'lucide-react'
|
||||
|
||||
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 = {
|
||||
isFiltersOpen: boolean
|
||||
onToggleFilters: () => void
|
||||
showFilters?: boolean
|
||||
onAddDevice: () => void
|
||||
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
|
||||
sortField: DevicesSortField
|
||||
sortDirection: DevicesSortDirection
|
||||
onSortChange: (option: DevicesSortOption) => void
|
||||
}
|
||||
|
||||
const sortOptions = [
|
||||
export const devicesSortOptions: DevicesSortOption[] = [
|
||||
{
|
||||
value: 'default',
|
||||
label: 'По умолчанию',
|
||||
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: 'serial-asc',
|
||||
label: 'Заводской номер А–Я',
|
||||
sortField: 'Serial',
|
||||
sortDirection: 'ASC',
|
||||
},
|
||||
{
|
||||
value: 'serial-desc',
|
||||
label: 'Заводской номер Я–А',
|
||||
}
|
||||
sortField: 'Serial',
|
||||
sortDirection: 'DESC',
|
||||
},
|
||||
{
|
||||
value: 'date-asc',
|
||||
label: 'Сначала старые',
|
||||
sortField: 'Date',
|
||||
sortDirection: 'ASC',
|
||||
},
|
||||
]
|
||||
|
||||
export function DevicesToolbar({
|
||||
export const DevicesToolbar = memo(function DevicesToolbar({
|
||||
isFiltersOpen,
|
||||
onToggleFilters,
|
||||
showFilters = true,
|
||||
onAddDevice,
|
||||
|
||||
search,
|
||||
onSearchChange,
|
||||
|
||||
sortField,
|
||||
sortDirection,
|
||||
onSortChange,
|
||||
}: DevicesToolbarProps) {
|
||||
const [selectedSort, setSelectedSort] = useState(sortOptions[0])
|
||||
|
||||
const selectedSort =
|
||||
devicesSortOptions.find(
|
||||
(option) =>
|
||||
option.sortField === sortField &&
|
||||
option.sortDirection === sortDirection,
|
||||
) ?? devicesSortOptions[0]
|
||||
|
||||
return (
|
||||
<div className="devices-toolbar">
|
||||
<label className="devices-search">
|
||||
<Search size={16} />
|
||||
<input type="text" placeholder="Поиск" />
|
||||
<input
|
||||
placeholder="Поиск"
|
||||
value={search}
|
||||
onChange={(e) =>
|
||||
onSearchChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="devices-toolbar__right">
|
||||
<button className="add-device" type="button" onClick={onAddDevice}>
|
||||
Добавить устройство
|
||||
</button>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button className="devices-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" 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>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
|
@ -68,11 +119,11 @@ export function DevicesToolbar({
|
|||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
{devicesSortOptions.map((option) => (
|
||||
<DropdownMenu.Item
|
||||
key={option.value}
|
||||
className="devices-sort-menu__item"
|
||||
onSelect={() => setSelectedSort(option)}
|
||||
onSelect={() => onSortChange(option)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
|
||||
|
|
@ -84,17 +135,22 @@ export function DevicesToolbar({
|
|||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<button
|
||||
className={`devices-filter ${isFiltersOpen ? 'devices-filter--active' : ''}`}
|
||||
type="button"
|
||||
aria-label="Фильтр"
|
||||
onClick={onToggleFilters}
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
<span className="devices-filter__alert" />
|
||||
<button className="add-device" type="button" onClick={onAddDevice}>
|
||||
Добавить устройство
|
||||
</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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,37 +8,25 @@
|
|||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
max-height: calc(100% - 56px);
|
||||
overflow: visible;
|
||||
padding: 20px 20px 36px;
|
||||
}
|
||||
|
||||
.employees-sections {
|
||||
width: fit-content;
|
||||
.employees-page__section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.employees-sections__item {
|
||||
padding: 10px 18px;
|
||||
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
|
||||
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-page__table-motion {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.employees-toolbar--organisations {
|
||||
|
|
@ -51,37 +39,47 @@
|
|||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
//overflow: hidden;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.employees-table-card {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
max-height: 100%;
|
||||
//overflow: hidden;
|
||||
//box-shadow: $shadow-card;
|
||||
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $gray50 transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
.simplebar-content-wrapper {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
|
||||
.simplebar-track.simplebar-vertical {
|
||||
top: 48px;
|
||||
bottom: 14px;
|
||||
width: 14px;
|
||||
right: -7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $gray50;
|
||||
border-radius: 999px;
|
||||
.simplebar-scrollbar::before {
|
||||
background: color-mix(in srgb, var(--gray50) 85%, transparent);
|
||||
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 {
|
||||
background: $gray50;
|
||||
.simplebar-track.simplebar-horizontal {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,12 +88,14 @@
|
|||
min-width: 1180px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
}
|
||||
|
||||
th {
|
||||
|
|
@ -104,10 +104,10 @@
|
|||
border-bottom: 1px solid $gray20;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 16px;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 450;
|
||||
text-align: left;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
}
|
||||
|
||||
td {
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
text-align: left;
|
||||
|
||||
color: $color-text;
|
||||
font-size: 17px;
|
||||
font-size: $font-size-17;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
|
@ -169,23 +169,74 @@
|
|||
|
||||
.employee-person {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
|
||||
span {
|
||||
color: $color-text;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
.employee-person__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 48px;
|
||||
|
||||
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-name {
|
||||
color: $gray50;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.employee-organisation-name {
|
||||
|
|
@ -204,13 +255,13 @@
|
|||
background: $blue20;
|
||||
color: $blue;
|
||||
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.employee-role--admin {
|
||||
background: $orange;
|
||||
color: #ffffff;
|
||||
background: $color-warning-tint !important;
|
||||
color: $orange !important;
|
||||
}
|
||||
|
||||
.employees-table-actions {
|
||||
|
|
@ -230,7 +281,7 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
|
|
@ -252,12 +303,12 @@
|
|||
color: $blue;
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 1px $blue;
|
||||
box-shadow: inset 0 0 0 1px $blue, var(--shadow-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.employees-action-btn--delete {
|
||||
background: rgba(224, 0, 0, 0.12);
|
||||
background: $color-danger-tint-strong;
|
||||
color: $red;
|
||||
|
||||
&:hover {
|
||||
|
|
@ -268,12 +319,19 @@
|
|||
.employees-table__row {
|
||||
position: relative;
|
||||
transition: 0.2s ease;
|
||||
box-shadow: inset 0px 0 0 transparent;
|
||||
box-shadow: inset 0px 0 0 $transparent;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($blue, 0.025);
|
||||
box-shadow: inset 3px 0 0 rgba($blue, 0.8);
|
||||
background-color: $color-primary-tint-soft;
|
||||
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;
|
||||
border-radius: 999px;
|
||||
|
||||
background: rgba(3, 29, 154, 0.08);
|
||||
background: $color-primary-tint;
|
||||
color: $blue;
|
||||
|
||||
font-size: 16px;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
|
@ -309,13 +367,13 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
opacity: 0.88;
|
||||
|
||||
box-shadow: inset 0px 0 0 transparent;
|
||||
box-shadow: inset 0px 0 0 $transparent;
|
||||
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
|
|
@ -329,105 +387,24 @@
|
|||
}
|
||||
|
||||
.employees-action-btn--edit {
|
||||
background: $blue20;
|
||||
background-color: $blue20;
|
||||
color: $blue;
|
||||
|
||||
&: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 {
|
||||
background: rgba(224, 0, 0, 0.12);
|
||||
background-color: $color-danger-tint-strong;
|
||||
color: $red;
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 1px $red;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
background-color: $red;
|
||||
color: $white;
|
||||
box-shadow: inset 0 0 0 1px $red, 0 8px 20px -8px rgba(var(--color-danger-rgb), 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -439,11 +416,155 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
color: #738098;
|
||||
font-size: 15px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.employees-state--error {
|
||||
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 { useNavigate } from 'react-router-dom'
|
||||
import { useApolloClient, useQuery } from '@apollo/client/react'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import {
|
||||
GET_ORGANISATIONS_QUERY,
|
||||
GET_USERS_PAGE_QUERY,
|
||||
} from '../../entities/employee/api/employee.graphql'
|
||||
import type {
|
||||
Employee,
|
||||
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'
|
||||
EmployeesTabs,
|
||||
type EmployeesSection,
|
||||
} from './components/EmployeesTabs/EmployeesTabs'
|
||||
import { UsersSection } from './components/UsersSection/UsersSection'
|
||||
import { OrganisationsSection } from './components/OrganisationsSection/OrganisationsSection'
|
||||
|
||||
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() {
|
||||
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)
|
||||
const [usersLoadedPages, setUsersLoadedPages] = useState<Employee[][]>([])
|
||||
const [usersLoadedNextKeys, setUsersLoadedNextKeys] = useState<Array<string | null>>([])
|
||||
const [isUsersPageLoading, setIsUsersPageLoading] = useState(false)
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('section', section)
|
||||
|
||||
const [organisationsCurrentPage, setOrganisationsCurrentPage] = useState(0)
|
||||
const [organisationSearch, setOrganisationSearch] = useState('')
|
||||
const [debouncedOrganisationSearch, setDebouncedOrganisationSearch] =
|
||||
useState('')
|
||||
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,
|
||||
return next
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
},
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}, [activeSection, setSearchParams])
|
||||
|
||||
return (
|
||||
<section className="employees-page">
|
||||
<div className="employees-sections">
|
||||
<button
|
||||
className={
|
||||
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)
|
||||
}}
|
||||
<EmployeesTabs
|
||||
activeSection={activeSection}
|
||||
onChange={handleSectionChange}
|
||||
/>
|
||||
|
||||
<div className="employees-table-container">
|
||||
<div
|
||||
className={`employees-table-card ${activeSection === 'organisations' && isOrganisationsUpdating
|
||||
? 'is-updating'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{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 className="employees-page__section">
|
||||
{activeSection === 'users' ? (
|
||||
<UsersSection />
|
||||
) : (
|
||||
<OrganisationsSection />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,31 +3,31 @@
|
|||
.add-employee-modal__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
z-index: 110;
|
||||
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
background: $color-backdrop;
|
||||
backdrop-filter: blur(2px);
|
||||
|
||||
animation: addEmployeeOverlayShow 0.18s ease;
|
||||
}
|
||||
|
||||
.add-employee-modal {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
z-index: 120;
|
||||
top: 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%);
|
||||
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
border-radius: 26px;
|
||||
background: $color-surface;
|
||||
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 {
|
||||
|
|
@ -42,8 +42,8 @@
|
|||
.add-employee-modal__title {
|
||||
margin: 0;
|
||||
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-24;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
margin: 6px 0 0;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
|
@ -76,17 +76,83 @@
|
|||
color: $blue;
|
||||
background: $gray20;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.add-employee-form {
|
||||
display: flex;
|
||||
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;
|
||||
&__grid {
|
||||
}
|
||||
|
||||
.add-employee-fields__section {
|
||||
padding: 16px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.add-employee-form__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.add-employee-form__grid--credentials {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.add-employee-field__spacer {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.add-employee-field {
|
||||
|
|
@ -94,50 +160,286 @@
|
|||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
color: #30394b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
> span {
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input, select {
|
||||
input,
|
||||
select {
|
||||
height: 44px;
|
||||
border: 1px solid #dfe5ef;
|
||||
border: 1px solid $color-border-strong;
|
||||
border-radius: 14px;
|
||||
padding: 0 14px;
|
||||
|
||||
background: #f8fafc;
|
||||
background: $color-surface;
|
||||
outline: none;
|
||||
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 400;
|
||||
|
||||
transition: 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
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 {
|
||||
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 {
|
||||
border-radius: 14px;
|
||||
background: rgba(224, 0, 0, 0.08);
|
||||
background: $color-danger-tint;
|
||||
padding: 12px 14px;
|
||||
|
||||
color: $red;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.add-employee-modal__footer {
|
||||
margin-top: 8px;
|
||||
padding-top: 4px;
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
@ -152,7 +454,7 @@
|
|||
border: none;
|
||||
border-radius: 14px;
|
||||
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
|
|
@ -165,16 +467,7 @@
|
|||
|
||||
&:hover {
|
||||
background: $gray20;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.add-employee-submit {
|
||||
background: $blue;
|
||||
color: #ffffff;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
color: $color-text-strong;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
|
@ -183,24 +476,37 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes addEmployeeOverlayShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
.add-employee-submit {
|
||||
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);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
&:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
&:disabled:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes addEmployeeModalShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.98);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
@media (max-width: 820px) {
|
||||
.add-employee-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.add-employee-modal {
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
z-index: 110;
|
||||
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
background: $color-backdrop;
|
||||
backdrop-filter: blur(2px);
|
||||
|
||||
animation: addOrganisationOverlayShow 0.18s ease;
|
||||
}
|
||||
|
||||
.add-organisation-modal {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
z-index: 120;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 40px);
|
||||
padding: 24px;
|
||||
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
|
||||
background: $color-surface;
|
||||
box-shadow: $shadow-modal;
|
||||
|
||||
transform: translate(-50%, -50%);
|
||||
animation: addOrganisationModalShow 0.2s ease;
|
||||
}
|
||||
|
||||
.add-organisation-modal--with-policy {
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.add-organisation-modal__header {
|
||||
|
|
@ -41,7 +43,7 @@
|
|||
margin: 0;
|
||||
|
||||
color: $color-text;
|
||||
font-size: 24px;
|
||||
font-size: $font-size-24;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +51,7 @@
|
|||
margin: 6px 0 0;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
|
@ -82,6 +84,44 @@
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -89,28 +129,28 @@
|
|||
|
||||
span {
|
||||
color: $color-text;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 44px;
|
||||
border: 1px solid #dfe5ef;
|
||||
border: 1px solid $color-border-strong;
|
||||
border-radius: 14px;
|
||||
padding: 0 14px;
|
||||
|
||||
background: #f8fafc;
|
||||
background: $color-surface-muted;
|
||||
outline: none;
|
||||
|
||||
color: $color-text;
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 400;
|
||||
|
||||
transition: 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: $blue;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
|
|
@ -121,11 +161,11 @@
|
|||
|
||||
.add-organisation-error {
|
||||
border-radius: 14px;
|
||||
background: rgba(224, 0, 0, 0.08);
|
||||
background: $color-danger-tint;
|
||||
padding: 12px 14px;
|
||||
|
||||
color: $red;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +185,7 @@
|
|||
border: none;
|
||||
border-radius: 14px;
|
||||
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
|
|
@ -169,198 +209,23 @@
|
|||
|
||||
.add-organisation-submit {
|
||||
background: $blue;
|
||||
color: #ffffff;
|
||||
color: $color-text-inverse;
|
||||
|
||||
&: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 {
|
||||
margin-top: 2px;
|
||||
padding: 14px;
|
||||
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;
|
||||
@media (max-width: 900px) {
|
||||
.add-organisation-modal--with-policy {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: $gray50;
|
||||
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);
|
||||
.add-organisation-form--with-policy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { X } from 'lucide-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 {
|
||||
CHANGE_ORGANISATION_MUTATION,
|
||||
CREATE_ORGANISATION_MUTATION,
|
||||
CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION,
|
||||
GET_ORGANISATION_QUERY,
|
||||
GET_ORGANISATIONS_QUERY,
|
||||
} from '../../../../entities/employee/api/employee.graphql'
|
||||
import type {
|
||||
ChangeOrganisationData,
|
||||
ChangeOrganisationVariables,
|
||||
CreateOrganisationData,
|
||||
CreateOrganisationVariables,
|
||||
CreateUploadOrganisationLogoUrlData,
|
||||
CreateUploadOrganisationLogoUrlVariables,
|
||||
GetOrganisationData,
|
||||
GetOrganisationVariables,
|
||||
Organisation,
|
||||
OrganisationPolicy,
|
||||
} 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'
|
||||
|
||||
|
|
@ -36,82 +51,16 @@ type AddOrganisationModalProps = {
|
|||
onSuccess?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
type ModalPolicyKey =
|
||||
| keyof OrganisationPolicy
|
||||
| 'wifi'
|
||||
| 'speaker'
|
||||
type OrganisationPolicyVariables = Omit<
|
||||
CreateOrganisationVariables,
|
||||
'name'
|
||||
>
|
||||
|
||||
type ModalPolicyItem = {
|
||||
key: ModalPolicyKey
|
||||
label: string
|
||||
icon: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
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'
|
||||
function getPolicyVariables(
|
||||
policy?: OrganisationPolicy | OrganisationPolicyDraft | null,
|
||||
): OrganisationPolicyVariables {
|
||||
return mapOrganisationPolicyDraftToVariables(
|
||||
mapOrganisationPolicyToDraft(policy),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -125,10 +74,19 @@ export function AddOrganisationModal({
|
|||
const client = useApolloClient()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [policy, setPolicy] = useState<OrganisationPolicy>(
|
||||
defaultOrganisationPolicy,
|
||||
const [policy, setPolicy] = useState<OrganisationPolicyDraft>(
|
||||
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 [createOrganisation, { loading: isCreating, error: createError }] =
|
||||
|
|
@ -140,37 +98,121 @@ export function AddOrganisationModal({
|
|||
useMutation<ChangeOrganisationData, ChangeOrganisationVariables>(
|
||||
CHANGE_ORGANISATION_MUTATION,
|
||||
)
|
||||
|
||||
const isLoading = isCreating || isChanging
|
||||
const [isLogoUploading, setIsLogoUploading] = useState(false)
|
||||
const isLoading = isCreating || isChanging || isLogoUploading
|
||||
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(() => {
|
||||
if (!open) return
|
||||
|
||||
resetLogoDraft()
|
||||
|
||||
if (isEditMode && organisation) {
|
||||
setName(organisation.name ?? '')
|
||||
return
|
||||
}
|
||||
|
||||
setName('')
|
||||
setPolicy(defaultOrganisationPolicy)
|
||||
setPolicy(defaultOrganisationPolicyDraft)
|
||||
}, [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>) {
|
||||
event.preventDefault()
|
||||
|
||||
|
|
@ -200,170 +242,201 @@ export function AddOrganisationModal({
|
|||
|
||||
const policyVariables = getPolicyVariables(currentPolicy)
|
||||
|
||||
await changeOrganisation({
|
||||
variables: {
|
||||
id: String(organisation.id),
|
||||
name: trimmedName,
|
||||
...policyVariables,
|
||||
},
|
||||
refetchQueries: [
|
||||
{
|
||||
query: GET_ORGANISATION_QUERY,
|
||||
variables: {
|
||||
id: String(organisation.id),
|
||||
try {
|
||||
await changeOrganisation({
|
||||
variables: {
|
||||
id: String(organisation.id),
|
||||
name: trimmedName,
|
||||
...policyVariables,
|
||||
},
|
||||
refetchQueries: [
|
||||
{
|
||||
query: GET_ORGANISATION_QUERY,
|
||||
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
|
||||
}
|
||||
|
||||
console.log('createOrganisation variables', {
|
||||
name: trimmedName,
|
||||
...policy,
|
||||
})
|
||||
const policyVariables = getPolicyVariables(policy)
|
||||
|
||||
await createOrganisation({
|
||||
variables: {
|
||||
name: trimmedName,
|
||||
...policy,
|
||||
},
|
||||
})
|
||||
try {
|
||||
const result = await createOrganisation({
|
||||
variables: {
|
||||
name: trimmedName,
|
||||
...policyVariables,
|
||||
},
|
||||
})
|
||||
|
||||
await onSuccess?.()
|
||||
const createdOrganisationId = result.data?.createOrganisation.id
|
||||
|
||||
setName('')
|
||||
setPolicy(defaultOrganisationPolicy)
|
||||
onOpenChange(false)
|
||||
if (!createdOrganisationId) {
|
||||
throw new Error('Организация создана, но id не вернулся')
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="add-organisation-modal__overlay" />
|
||||
<MotionDialogContent
|
||||
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">
|
||||
<div className="add-organisation-modal__header">
|
||||
<div>
|
||||
<Dialog.Title className="add-organisation-modal__title">
|
||||
{isEditMode ? 'Редактировать организацию' : 'Добавить организацию'}
|
||||
</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>
|
||||
<Dialog.Description className="add-organisation-modal__description">
|
||||
{isEditMode
|
||||
? 'Изменение данных организации'
|
||||
: 'Создание организации для привязки пользователей и устройств'}
|
||||
</Dialog.Description>
|
||||
</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">
|
||||
<span>Название организации</span>
|
||||
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Например: Organisation 1"
|
||||
onChange={handleNameChange}
|
||||
placeholder="Название организации"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!isEditMode && (
|
||||
<div className="add-organisation-policy">
|
||||
<div className="add-organisation-policy__header">
|
||||
<h3>Начальная политика</h3>
|
||||
<p>Будет применяться к сотрудникам этой организации</p>
|
||||
</div>
|
||||
{!isEditMode && (
|
||||
<OrganisationPolicyCard
|
||||
variant="form"
|
||||
title="Начальная политика"
|
||||
description="Будет применяться к устройствам этой организации"
|
||||
value={policy}
|
||||
disabled={isLoading}
|
||||
onChange={setPolicy}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="add-organisation-policy__list">
|
||||
{policyItems.map((item) => {
|
||||
const enabled = getPolicyValue(item.key)
|
||||
|
||||
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>
|
||||
{mutationError && (
|
||||
<div className="add-organisation-error">
|
||||
{isEditMode
|
||||
? 'Не удалось сохранить организацию. Проверьте данные.'
|
||||
: 'Не удалось добавить организацию. Проверьте данные.'}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 *;
|
||||
|
||||
.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{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -10,7 +75,7 @@
|
|||
width: auto;
|
||||
}
|
||||
&: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;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 550 !important;
|
||||
|
||||
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 {
|
||||
z-index: 100;
|
||||
min-width: 210px;
|
||||
padding: 6px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
|
||||
background: $color-surface;
|
||||
box-shadow: $shadow-popover;
|
||||
animation: employeesSortMenuShow 0.16s ease;
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +214,7 @@
|
|||
gap: 12px;
|
||||
|
||||
color: $gray50;
|
||||
font-size: 14px;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
|
||||
outline: none;
|
||||
|
|
@ -98,6 +235,118 @@
|
|||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
@ -109,3 +358,61 @@
|
|||
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 { 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 {
|
||||
OrganisationSortDirection,
|
||||
OrganisationSortField,
|
||||
EmployeeRole,
|
||||
GetOrganisationData,
|
||||
GetOrganisationVariables,
|
||||
GetOrganisationsData,
|
||||
GetOrganisationsVariables,
|
||||
} from '../../../../entities/employee/model/types'
|
||||
|
||||
import './EmployeesToolbar.scss'
|
||||
|
|
@ -11,11 +31,56 @@ import './EmployeesToolbar.scss'
|
|||
export type EmployeesSortOption = {
|
||||
value: string
|
||||
label: string
|
||||
sortField: OrganisationSortField
|
||||
sortDirection: OrganisationSortDirection
|
||||
sortField: 'ID' | 'Name' | 'Date'
|
||||
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',
|
||||
label: 'ID по возрастанию',
|
||||
|
|
@ -40,12 +105,6 @@ export const employeesSortOptions: EmployeesSortOption[] = [
|
|||
sortField: 'Name',
|
||||
sortDirection: 'DESC',
|
||||
},
|
||||
{
|
||||
value: 'date-desc',
|
||||
label: 'Сначала новые',
|
||||
sortField: 'Date',
|
||||
sortDirection: 'DESC',
|
||||
},
|
||||
{
|
||||
value: 'date-asc',
|
||||
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 = {
|
||||
addButtonText: string
|
||||
onAdd: () => void
|
||||
|
|
@ -61,21 +136,198 @@ type EmployeesToolbarProps = {
|
|||
searchValue?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
|
||||
selectedSort?: EmployeesSortOption
|
||||
onSortChange?: (option: EmployeesSortOption) => void
|
||||
sortOptions: EmployeesSortOption[]
|
||||
selectedSort: EmployeesSortOption
|
||||
onSortChange: (option: EmployeesSortOption) => void
|
||||
|
||||
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,
|
||||
onAdd,
|
||||
searchValue = '',
|
||||
onSearchChange,
|
||||
selectedSort = employeesSortOptions[0],
|
||||
sortOptions,
|
||||
selectedSort,
|
||||
onSortChange,
|
||||
showSort = true,
|
||||
roleFilter = null,
|
||||
onRoleFilterChange,
|
||||
organisationFilterId = null,
|
||||
onOrganisationFilterChange,
|
||||
showUserFilters = false,
|
||||
}: 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 (
|
||||
<div className="devices-toolbar">
|
||||
<label className="devices-search">
|
||||
|
|
@ -85,11 +337,196 @@ export function EmployeesToolbar({
|
|||
type="text"
|
||||
placeholder="Поиск"
|
||||
value={searchValue}
|
||||
onChange={(event) => onSearchChange?.(event.target.value)}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<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
|
||||
className="add-device add-employees"
|
||||
type="button"
|
||||
|
|
@ -139,43 +576,7 @@ export function EmployeesToolbar({
|
|||
|
||||
{addButtonText}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 *;
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
align-items: 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 {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
padding: 32px;
|
||||
width: min(100%, 430px);
|
||||
padding: 30px;
|
||||
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;
|
||||
flex-direction: column;
|
||||
|
|
@ -25,69 +225,202 @@
|
|||
}
|
||||
|
||||
.login-card__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
color: $gray50;
|
||||
font-size: 16px;
|
||||
font-weight: 450;
|
||||
line-height: 1.4;
|
||||
margin: 6px 0 0;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 500;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #30394b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.login-field__label {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
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 {
|
||||
height: 44px;
|
||||
border: 1px solid #dfe5ef;
|
||||
border-radius: 14px;
|
||||
padding: 0 14px;
|
||||
|
||||
background: #f8fafc;
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
border-color: $blue;
|
||||
background: #ffffff;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-15;
|
||||
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 {
|
||||
border-radius: 12px;
|
||||
background: #ffecec;
|
||||
color: $red;
|
||||
padding: 10px 12px;
|
||||
font-weight: 450;
|
||||
font-size: 14px;
|
||||
border-radius: 12px;
|
||||
background: $color-danger-bg;
|
||||
|
||||
color: $red;
|
||||
font-size: $font-size-13;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 44px;
|
||||
height: 54px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: $blue;
|
||||
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $color-text-inverse;
|
||||
font-size: $font-size-16;
|
||||
font-weight: 550;
|
||||
|
||||
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 {
|
||||
opacity: 0.65;
|
||||
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 type { SubmitEvent } from 'react'
|
||||
import type { FormEvent } from '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'
|
||||
|
||||
type LoginPageProps = {
|
||||
onSuccess: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function LoginPage({ onSuccess }: LoginPageProps) {
|
||||
const [username, setUsername] = useState('Admin1')
|
||||
const [password, setPassword] = useState('123456')
|
||||
const [username, setUsername] = useState('Admin2')
|
||||
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, {
|
||||
refetchQueries: [CURRENT_USER_QUERY],
|
||||
onCompleted: () => {
|
||||
onSuccess()
|
||||
},
|
||||
const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, {
|
||||
refetchQueries: [CURRENT_USER_QUERY],
|
||||
onCompleted: () => {
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!canSubmit || loading) return
|
||||
|
||||
signIn({
|
||||
variables: {
|
||||
username: username.trim(),
|
||||
password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
return (
|
||||
<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({
|
||||
variables: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
}
|
||||
<span className="login-theme-switch__icon login-theme-switch__icon--moon">
|
||||
<Moon size={14} />
|
||||
</span>
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-card__header">
|
||||
<img src='/favicon.svg' height={56} width={56} alt='logo'></img>
|
||||
<p>Авторизуйтесь для доступа к панели управления устройствами</p>
|
||||
</div>
|
||||
<span className="login-theme-switch__thumb">
|
||||
{isDark ? <Moon size={15} /> : <Sun size={15} />}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<label className="login-field">
|
||||
<span>Логин</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Введите логин"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-card__header">
|
||||
<span className="login-card__logo">
|
||||
<img src={logo} height={40} width={40} alt="АРМА" />
|
||||
</span>
|
||||
|
||||
<label className="login-field">
|
||||
<span>Пароль</span>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Введите пароль"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<p>Авторизуйтесь для доступа <br></br>к панели управления устройствами</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
Не удалось войти. Проверьте логин и пароль.
|
||||
</div>
|
||||
)}
|
||||
<label className="login-field">
|
||||
<span className="login-field__label">Логин</span>
|
||||
|
||||
<button className="login-button" type="submit" disabled={loading}>
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
<span className="login-field__control">
|
||||
<UserRound size={18} />
|
||||
|
||||
<input
|
||||
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%;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
padding: 20px 20px 36px 20px;
|
||||
}
|
||||
|
||||
.map-page__sidebar {
|
||||
|
|
@ -20,7 +21,7 @@
|
|||
.map-page__panel {
|
||||
height: 100%;
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
padding: 18px;
|
||||
|
||||
display: flex;
|
||||
|
|
@ -29,8 +30,8 @@
|
|||
|
||||
h2 {
|
||||
margin: 0 0 14px;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-20;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +39,7 @@
|
|||
.map-page__state {
|
||||
margin: 0;
|
||||
color: $gray50;
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
}
|
||||
|
||||
.map-page__state--error {
|
||||
|
|
@ -48,10 +49,10 @@
|
|||
.map-page__count {
|
||||
margin: 0 0 14px;
|
||||
color: $gray50;
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
|
||||
b {
|
||||
color: #111827;
|
||||
color: $color-text-strong;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,18 +65,18 @@
|
|||
min-height: 0;
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(3, 29, 154, 0.45) transparent;
|
||||
scrollbar-color: rgba($color-primary-rgb, 0.45) $transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: $transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(3, 29, 154, 0.35);
|
||||
background: rgba($color-primary-rgb, 0.35);
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
|
|
@ -93,14 +94,14 @@
|
|||
transition: 0.2s ease;
|
||||
|
||||
span {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
small {
|
||||
color: $gray50;
|
||||
font-size: 13px;
|
||||
font-size: $font-size-13;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
|
@ -113,12 +114,20 @@
|
|||
}
|
||||
|
||||
.map-page__map {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
background: $color-surface;
|
||||
}
|
||||
|
||||
.map-page__map:fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
background: $color-surface;
|
||||
}
|
||||
|
||||
.map-page__leaflet {
|
||||
|
|
@ -126,16 +135,141 @@
|
|||
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 {
|
||||
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 {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
|
||||
color: $blue !important;
|
||||
font-size: 15px;
|
||||
font-size: $font-size-15;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
color: #738098;
|
||||
font-size: 12px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-12;
|
||||
}
|
||||
|
||||
b {
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-13;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.map-popup__coords {
|
||||
margin-top: 8px;
|
||||
color: #738098;
|
||||
font-size: 12px;
|
||||
padding-top: 2px;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-12;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: none !important;
|
||||
background-clip: padding-box;
|
||||
.map-selection-panel {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 600;
|
||||
|
||||
width: min(340px, calc(100% - 24px));
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.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;
|
||||
pointer-events: none;
|
||||
|
||||
&:hover {
|
||||
background-color: white !important;
|
||||
color: $blue !important;
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.map-track-filter {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-top.leaflet-left {
|
||||
top: 50%;
|
||||
left: auto;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
.device-map-info-card {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 70px;
|
||||
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 {
|
||||
margin-top: 0px !important;
|
||||
.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,
|
||||
.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 {
|
||||
|
|
@ -221,7 +530,101 @@
|
|||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
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 { useQuery } from '@apollo/client/react'
|
||||
import { AnimatePresence, m } from 'framer-motion'
|
||||
import {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Marker,
|
||||
Polyline,
|
||||
Popup,
|
||||
TileLayer,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
} from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { Battery, Clock3, Layers, MapPin, Thermometer, X } from 'lucide-react'
|
||||
|
||||
import {
|
||||
GET_PHONE_GPS_TRACK_QUERY,
|
||||
GET_PHONES_PAGE_QUERY,
|
||||
GET_PHONES_QUERY,
|
||||
} from '../../entities/device/api/device.graphql'
|
||||
import type {
|
||||
Device,
|
||||
DeviceGpsTrackPoint,
|
||||
DeviceNetworkStatus,
|
||||
GetPhoneGpsTrackData,
|
||||
GetPhoneGpsTrackVariables,
|
||||
GetPhonesPageData,
|
||||
GetPhonesPageVariables,
|
||||
GetPhonesData,
|
||||
GetPhonesVariables,
|
||||
} from '../../entities/device/model/types'
|
||||
|
||||
import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
|
||||
|
|
@ -31,22 +33,68 @@ import {
|
|||
MapTrackPeriodControl,
|
||||
type MapTrackPeriodValue,
|
||||
} from './components/MapTrackPeriodControl/MapTrackPeriodControl'
|
||||
import { popoverVariants, uiFadeTransition } from '../../shared/lib/motion'
|
||||
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './MapPage.scss'
|
||||
|
||||
const markerIcon = 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],
|
||||
})
|
||||
const MAP_PHONES_SORT_DIRECTION = 'DESC'
|
||||
const MAP_PHONES_SORT_FIELD = 'Date'
|
||||
const routeColor = 'var(--color-primary)'
|
||||
|
||||
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 'Нет данных'
|
||||
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
|
|
@ -58,6 +106,16 @@ function formatLocationDate(timestamp: number) {
|
|||
}).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[]) {
|
||||
return [...track].sort((a, b) => a.date - b.date)
|
||||
}
|
||||
|
|
@ -85,18 +143,47 @@ function MapResetHandler({ onReset }: MapResetHandlerProps) {
|
|||
return null
|
||||
}
|
||||
|
||||
type MapFitBoundsProps = {
|
||||
positions: [number, number][]
|
||||
}
|
||||
|
||||
function MapFitBounds({ positions }: MapFitBoundsProps) {
|
||||
function MapResizeWatcher() {
|
||||
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(() => {
|
||||
if (!positions.length) return
|
||||
if (lastFitKeyRef.current === fitKey) return
|
||||
|
||||
if (positions.length === 1) {
|
||||
map.setView(positions[0], 14)
|
||||
lastFitKeyRef.current = fitKey
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -107,22 +194,40 @@ function MapFitBounds({ positions }: MapFitBoundsProps) {
|
|||
maxZoom: 15,
|
||||
animate: true,
|
||||
})
|
||||
}, [map, positions])
|
||||
|
||||
lastFitKeyRef.current = fitKey
|
||||
}, [fitKey, map, positions])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function MapPage() {
|
||||
const fullscreenTargetId = useId()
|
||||
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
|
||||
const [selectedPeriod, setSelectedPeriod] =
|
||||
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<
|
||||
GetPhonesPageData,
|
||||
GetPhonesPageVariables
|
||||
>(GET_PHONES_PAGE_QUERY, {
|
||||
variables: {},
|
||||
const { data, loading, error, fetchMore } = useQuery<
|
||||
GetPhonesData,
|
||||
GetPhonesVariables
|
||||
>(GET_PHONES_QUERY, {
|
||||
variables: {
|
||||
page: 0,
|
||||
sortDirection: MAP_PHONES_SORT_DIRECTION,
|
||||
sortField: MAP_PHONES_SORT_FIELD,
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
notifyOnNetworkStatusChange: true,
|
||||
})
|
||||
|
||||
const {
|
||||
|
|
@ -142,24 +247,131 @@ export function MapPage() {
|
|||
},
|
||||
)
|
||||
|
||||
const devices = data?.getPhonesPage.page ?? []
|
||||
const phonesTotalPages = data?.getPhones.totalPages ?? 0
|
||||
|
||||
const devicesWithLocation = devices.filter((device) =>
|
||||
Boolean(device.lastLocation),
|
||||
)
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
||||
}, [gpsTrackData])
|
||||
|
||||
const shouldShowCurrentLocation = Boolean(
|
||||
selectedDevice?.lastLocation &&
|
||||
isDateInPeriod(selectedDevice.lastLocation.date, selectedPeriod),
|
||||
const selectedLastLocation = selectedDevice?.lastLocation ?? null
|
||||
|
||||
const shouldAddLastLocationToRoute = Boolean(
|
||||
selectedLastLocation &&
|
||||
isDateInPeriod(selectedLastLocation.date, selectedPeriod),
|
||||
)
|
||||
|
||||
const selectedCurrentLocation =
|
||||
shouldShowCurrentLocation && selectedDevice?.lastLocation
|
||||
? selectedDevice.lastLocation
|
||||
shouldAddLastLocationToRoute && selectedLastLocation
|
||||
? selectedLastLocation
|
||||
: null
|
||||
|
||||
const selectedRoutePoints = useMemo(() => {
|
||||
|
|
@ -187,21 +399,81 @@ export function MapPage() {
|
|||
]
|
||||
}, [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 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][]>(() => {
|
||||
if (isSelectedMode && selectedRoutePositions.length > 0) {
|
||||
return selectedRoutePositions
|
||||
if (isSelectedMode) {
|
||||
return selectedMapPositions
|
||||
}
|
||||
|
||||
return devicesWithLocation.map(
|
||||
(device) =>
|
||||
[
|
||||
device.lastLocation!.lat,
|
||||
device.lastLocation!.lng,
|
||||
device.lastLocation.lat,
|
||||
device.lastLocation.lng,
|
||||
] 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
|
||||
|
||||
|
|
@ -212,33 +484,160 @@ export function MapPage() {
|
|||
function handleSelectDevice(device: Device) {
|
||||
setSelectedDevice(device)
|
||||
setSelectedPeriod(null)
|
||||
setSelectedPointKey(null)
|
||||
setIsInfoCardVisible(true)
|
||||
}
|
||||
|
||||
function handleResetSelection() {
|
||||
setSelectedDevice(null)
|
||||
setSelectedPeriod(null)
|
||||
setSelectedPointKey(null)
|
||||
setIsInfoCardVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="map-page">
|
||||
<div className="map-page__map">
|
||||
{selectedDevice && (
|
||||
<div className="map-track-filter">
|
||||
|
||||
<MapTrackPeriodControl onChange={setSelectedPeriod} />
|
||||
<div className="map-page__map" id={fullscreenTargetId}>
|
||||
{shouldShowPhonesLoadState && (
|
||||
<div
|
||||
className={`map-page__load-state${
|
||||
phonesError ? ' is-error' : ''
|
||||
}`}
|
||||
>
|
||||
{phonesError
|
||||
? 'Не удалось загрузить устройства'
|
||||
: 'Загружаем устройства'}
|
||||
</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
|
||||
center={mapCenter}
|
||||
zoom={12}
|
||||
scrollWheelZoom
|
||||
attributionControl={false}
|
||||
className="map-page__leaflet"
|
||||
id="devices-map"
|
||||
>
|
||||
<MapResizeWatcher />
|
||||
<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}" />
|
||||
|
||||
|
|
@ -250,33 +649,14 @@ export function MapPage() {
|
|||
<Marker
|
||||
key={device.id}
|
||||
position={[device.lastLocation.lat, device.lastLocation.lng]}
|
||||
icon={markerIcon}
|
||||
icon={createMarkerIcon(device.networkStatus)}
|
||||
eventHandlers={{
|
||||
click: (event) => {
|
||||
event.originalEvent.stopPropagation()
|
||||
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
|
||||
positions={selectedRoutePositions}
|
||||
pathOptions={{
|
||||
color: '#031d9a',
|
||||
color: routeColor,
|
||||
weight: 4,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedRoutePoints.map((point, index) => (
|
||||
{routeMapPoints.map((point) => (
|
||||
<CircleMarker
|
||||
key={`${point.lat}-${point.lng}-${point.date}-${index}`}
|
||||
key={point.key}
|
||||
center={[point.lat, point.lng]}
|
||||
radius={4}
|
||||
pathOptions={{
|
||||
color: '#031d9a',
|
||||
fillColor: '#031d9a',
|
||||
color: routeColor,
|
||||
fillColor: routeColor,
|
||||
fillOpacity: 1,
|
||||
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={{
|
||||
click: (event) => {
|
||||
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">
|
||||
<b>
|
||||
Текущее местоположение:{' '}
|
||||
{formatLocationDate(selectedCurrentLocation.date)}
|
||||
</b>
|
||||
</div>
|
||||
|
||||
<div className="map-popup__coords">
|
||||
{selectedCurrentLocation.lat},{' '}
|
||||
{selectedCurrentLocation.lng}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
{selectedLastLocation && currentMapPoint && (
|
||||
<Marker
|
||||
position={[
|
||||
selectedLastLocation.lat,
|
||||
selectedLastLocation.lng,
|
||||
]}
|
||||
icon={createMarkerIcon(selectedDevice?.networkStatus)}
|
||||
eventHandlers={{
|
||||
click: (event) => {
|
||||
event.originalEvent.stopPropagation()
|
||||
setSelectedPointKey(currentMapPoint.key)
|
||||
setIsInfoCardVisible(true)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<FullscreenControl targetId="devices-map" />
|
||||
<FullscreenControl targetId={fullscreenTargetId} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
.history-period-tabs {
|
||||
padding: 2px;
|
||||
border-radius: 999px;
|
||||
background: rgba($color-bg, .8);
|
||||
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -39,10 +39,11 @@
|
|||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
background: $gray30;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: #ffffff;
|
||||
background: $color-bg;
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
|
@ -54,17 +55,18 @@
|
|||
.history-date-picker__trigger {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
text-wrap: nowrap;
|
||||
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba($color-bg, .8);
|
||||
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: #30394b;
|
||||
color: var(--gray50);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
|
|
@ -78,9 +80,9 @@
|
|||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: rgba($color-bg, 1);
|
||||
background: $color-bg;
|
||||
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 {
|
||||
color: $blue;
|
||||
|
|
@ -94,94 +96,99 @@
|
|||
top: calc(100% + 8px);
|
||||
z-index: 120;
|
||||
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
background: $color-surface;
|
||||
padding: 12px;
|
||||
|
||||
border: 1.5px solid $gray20;
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
|
||||
box-shadow: none;
|
||||
|
||||
button{
|
||||
transition: .18s ease;
|
||||
}
|
||||
|
||||
.rdp-root {
|
||||
--rdp-accent-color: $blue;
|
||||
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
|
||||
--rdp-day_button-border-radius: 10px;
|
||||
--rdp-accent-color: #{$blue};
|
||||
--rdp-accent-background-color: #{$color-primary-soft};
|
||||
--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;
|
||||
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.rdp-months {
|
||||
max-width: 100%;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.rdp-month {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdp-month_caption {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rdp-caption_label {
|
||||
color: #30394b;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rdp-nav {
|
||||
button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
color: $gray50;
|
||||
.rdp-button_previous,
|
||||
.rdp-button_next,
|
||||
.rdp-nav button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg;
|
||||
color: $blue;
|
||||
}
|
||||
color: $blue;
|
||||
|
||||
&:hover {
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-weekday {
|
||||
color: $gray50;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rdp-day {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-12;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rdp-day_button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
|
||||
color: #30394b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $color-text-heading;
|
||||
font-size: $font-size-12;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg;
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-selected {
|
||||
.rdp-day_button {
|
||||
background: $blue;
|
||||
color: #ffffff;
|
||||
color: $color-text-inverse;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-range_middle {
|
||||
background-color: transparent;
|
||||
.rdp-day_button {
|
||||
background: #e8edff;
|
||||
background: $color-primary-soft;
|
||||
color: $blue;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
|
@ -189,19 +196,23 @@
|
|||
|
||||
.rdp-range_start {
|
||||
.rdp-day_button {
|
||||
border-radius: 10px;
|
||||
background: $blue;
|
||||
color: $color-text-inverse;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-range_end {
|
||||
.rdp-day_button {
|
||||
border-radius: 10px;
|
||||
background: $blue;
|
||||
color: $color-text-inverse;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-range_start.rdp-range_end {
|
||||
.rdp-day_button {
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.rdp-today {
|
||||
|
|
@ -244,7 +255,7 @@
|
|||
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(3, 29, 154, 0.16);
|
||||
background: rgba($color-primary-rgb, 0.16);
|
||||
}
|
||||
|
||||
.history-range__active {
|
||||
|
|
@ -295,52 +306,3 @@
|
|||
|
||||
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