Промежуточный этап 1
This commit is contained in:
parent
9064a99373
commit
820bdb924f
|
|
@ -1,2 +1,3 @@
|
||||||
VITE_GRAPHQL_API_URL=/graphql
|
VITE_GRAPHQL_API_URL=/graphql
|
||||||
VITE_MAPTILER_KEY=IorSzMRqcNUCYzcXZhi6
|
VITE_MAPTILER_KEY=IorSzMRqcNUCYzcXZhi6
|
||||||
|
VITE_AUTH_ENABLED=true
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"graphql": "^16.13.2",
|
"graphql": "^16.13.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.9.0",
|
"lucide-react": "^1.9.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
|
@ -3789,6 +3790,15 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.5",
|
"version": "19.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 192.168.1.181",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"graphql": "^16.13.2",
|
"graphql": "^16.13.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.9.0",
|
"lucide-react": "^1.9.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { DevicesPage } from '../../pages/DevicesPage/DevicesPage'
|
||||||
import { DevicePage } from '../../pages/DevicePage/DevicePage'
|
import { DevicePage } from '../../pages/DevicePage/DevicePage'
|
||||||
import { MapPage } from '../../pages/MapPage/MapPage'
|
import { MapPage } from '../../pages/MapPage/MapPage'
|
||||||
import { EmployeesPage } from '../../pages/EmployeesPage/EmployeesPage'
|
import { EmployeesPage } from '../../pages/EmployeesPage/EmployeesPage'
|
||||||
|
import { OrganisationPage } from '../../pages/OrganisationPage/OrganisationPage'
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -32,6 +33,10 @@ export const router = createBrowserRouter([
|
||||||
path: 'employees',
|
path: 'employees',
|
||||||
element: <EmployeesPage />,
|
element: <EmployeesPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'employees/organisations/:organisationId',
|
||||||
|
element: <OrganisationPage />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export function BlockIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function BlockIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path d="M16.6666 9.99984C16.6666 9.08067 15.9191 8.33317 14.9999 8.33317H14.1666V5.83317C14.1666 3.53567 12.2974 1.6665 9.99992 1.6665C7.70242 1.6665 5.83325 3.53567 5.83325 5.83317V8.33317H4.99992C4.08075 8.33317 3.33325 9.08067 3.33325 9.99984V16.6665C3.33325 17.5857 4.08075 18.3332 4.99992 18.3332H14.9999C15.9191 18.3332 16.6666 17.5857 16.6666 16.6665V9.99984ZM7.49992 5.83317C7.49992 4.45484 8.62158 3.33317 9.99992 3.33317C11.3783 3.33317 12.4999 4.45484 12.4999 5.83317V8.33317H7.49992V5.83317Z" fill="currentColor" />
|
<path d="M16.6666 9.99984C16.6666 9.08067 15.9191 8.33317 14.9999 8.33317H14.1666V5.83317C14.1666 3.53567 12.2974 1.6665 9.99992 1.6665C7.70242 1.6665 5.83325 3.53567 5.83325 5.83317V8.33317H4.99992C4.08075 8.33317 3.33325 9.08067 3.33325 9.99984V16.6665C3.33325 17.5857 4.08075 18.3332 4.99992 18.3332H14.9999C15.9191 18.3332 16.6666 17.5857 16.6666 16.6665V9.99984ZM7.49992 5.83317C7.49992 4.45484 8.62158 3.33317 9.99992 3.33317C11.3783 3.33317 12.4999 4.45484 12.4999 5.83317V8.33317H7.49992V5.83317Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export function BluetoothIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function BluetoothIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path d="M9.99992 10.0002L14.1666 6.66683L9.99992 3.3335V10.0002ZM9.99992 10.0002L14.1666 13.3335L9.99992 16.6668V10.0002ZM9.99992 10.0002L5.83325 6.66683M9.99992 10.0002L5.83325 13.3335" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M9.99992 10.0002L14.1666 6.66683L9.99992 3.3335V10.0002ZM9.99992 10.0002L14.1666 13.3335L9.99992 16.6668V10.0002ZM9.99992 10.0002L5.83325 6.66683M9.99992 10.0002L5.83325 13.3335" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export function CameraIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function CameraIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path d="M2.78884 17.9814H17.2106C18.9517 17.9814 19.8392 17.1107 19.8392 15.3864V6.62248C19.8392 4.8982 18.9517 4.03605 17.2106 4.03605H15.2521C14.5992 4.03605 14.3985 3.90213 14.0217 3.48355L13.3435 2.72998C12.9335 2.26998 12.5067 2.01855 11.636 2.01855H8.30455C7.44241 2.01855 7.01527 2.26998 6.59705 2.72998L5.91884 3.48355C5.55063 3.89355 5.34134 4.03605 4.68848 4.03605H2.78848C1.04741 4.03605 0.159912 4.8982 0.159912 6.62248V15.3864C0.159912 17.1107 1.04777 17.9814 2.78884 17.9814ZM2.81348 16.6336C1.98491 16.6336 1.50777 16.19 1.50777 15.3193V6.69784C1.50777 5.82713 1.98491 5.38356 2.81348 5.38356H5.02313C5.7767 5.38356 6.17848 5.24141 6.59705 4.77284L7.25813 4.03605C7.73527 3.50034 7.97813 3.36641 8.72313 3.36641H11.2174C11.9624 3.36641 12.2053 3.50034 12.6824 4.03605L13.3435 4.77248C13.7621 5.24141 14.1638 5.38356 14.9174 5.38356H17.1856C18.0142 5.38356 18.4917 5.82713 18.4917 6.69784V15.3193C18.4917 16.19 18.0146 16.6336 17.1856 16.6336H2.81348ZM9.99563 15.3028C10.597 15.3045 11.1927 15.1872 11.7485 14.9576C12.3043 14.728 12.8091 14.3907 13.2339 13.9651C13.6588 13.5395 13.9951 13.034 14.2237 12.4778C14.4522 11.9216 14.5685 11.3256 14.5656 10.7243C14.5656 8.17927 12.5399 6.1457 9.99527 6.1457C7.46741 6.1457 5.43348 8.17927 5.43348 10.7243C5.43348 13.2686 7.46741 15.3028 9.99527 15.3028M16.0135 8.68141C16.5828 8.68141 17.0513 8.22141 17.0513 7.65213C17.0513 7.37687 16.942 7.11289 16.7474 6.91825C16.5527 6.72361 16.2887 6.61427 16.0135 6.61427C15.7382 6.61427 15.4742 6.72361 15.2796 6.91825C15.085 7.11289 14.9756 7.37687 14.9756 7.65213C14.9756 8.22141 15.4442 8.68141 16.0135 8.68141ZM9.99563 14.0307C8.1792 14.0307 6.69777 12.5575 6.69777 10.7243C6.69777 8.89105 8.17098 7.4182 9.99563 7.4182C10.4299 7.41768 10.8601 7.50284 11.2615 7.6688C11.6628 7.83476 12.0275 8.07825 12.3347 8.38535C12.6418 8.69244 12.8853 9.05711 13.0513 9.45845C13.2173 9.8598 13.3025 10.2899 13.3021 10.7243C13.3026 11.1586 13.2174 11.5888 13.0514 11.9902C12.8854 12.3916 12.6419 12.7563 12.3348 13.0634C12.0276 13.3706 11.6629 13.6141 11.2616 13.7801C10.8602 13.946 10.43 14.0312 9.99563 14.0307Z" fill="currentColor" />
|
<path d="M2.78884 17.9814H17.2106C18.9517 17.9814 19.8392 17.1107 19.8392 15.3864V6.62248C19.8392 4.8982 18.9517 4.03605 17.2106 4.03605H15.2521C14.5992 4.03605 14.3985 3.90213 14.0217 3.48355L13.3435 2.72998C12.9335 2.26998 12.5067 2.01855 11.636 2.01855H8.30455C7.44241 2.01855 7.01527 2.26998 6.59705 2.72998L5.91884 3.48355C5.55063 3.89355 5.34134 4.03605 4.68848 4.03605H2.78848C1.04741 4.03605 0.159912 4.8982 0.159912 6.62248V15.3864C0.159912 17.1107 1.04777 17.9814 2.78884 17.9814ZM2.81348 16.6336C1.98491 16.6336 1.50777 16.19 1.50777 15.3193V6.69784C1.50777 5.82713 1.98491 5.38356 2.81348 5.38356H5.02313C5.7767 5.38356 6.17848 5.24141 6.59705 4.77284L7.25813 4.03605C7.73527 3.50034 7.97813 3.36641 8.72313 3.36641H11.2174C11.9624 3.36641 12.2053 3.50034 12.6824 4.03605L13.3435 4.77248C13.7621 5.24141 14.1638 5.38356 14.9174 5.38356H17.1856C18.0142 5.38356 18.4917 5.82713 18.4917 6.69784V15.3193C18.4917 16.19 18.0146 16.6336 17.1856 16.6336H2.81348ZM9.99563 15.3028C10.597 15.3045 11.1927 15.1872 11.7485 14.9576C12.3043 14.728 12.8091 14.3907 13.2339 13.9651C13.6588 13.5395 13.9951 13.034 14.2237 12.4778C14.4522 11.9216 14.5685 11.3256 14.5656 10.7243C14.5656 8.17927 12.5399 6.1457 9.99527 6.1457C7.46741 6.1457 5.43348 8.17927 5.43348 10.7243C5.43348 13.2686 7.46741 15.3028 9.99527 15.3028M16.0135 8.68141C16.5828 8.68141 17.0513 8.22141 17.0513 7.65213C17.0513 7.37687 16.942 7.11289 16.7474 6.91825C16.5527 6.72361 16.2887 6.61427 16.0135 6.61427C15.7382 6.61427 15.4742 6.72361 15.2796 6.91825C15.085 7.11289 14.9756 7.37687 14.9756 7.65213C14.9756 8.22141 15.4442 8.68141 16.0135 8.68141ZM9.99563 14.0307C8.1792 14.0307 6.69777 12.5575 6.69777 10.7243C6.69777 8.89105 8.17098 7.4182 9.99563 7.4182C10.4299 7.41768 10.8601 7.50284 11.2615 7.6688C11.6628 7.83476 12.0275 8.07825 12.3347 8.38535C12.6418 8.69244 12.8853 9.05711 13.0513 9.45845C13.2173 9.8598 13.3025 10.2899 13.3021 10.7243C13.3026 11.1586 13.2174 11.5888 13.0514 11.9902C12.8854 12.3916 12.6419 12.7563 12.3348 13.0634C12.0276 13.3706 11.6629 13.6141 11.2616 13.7801C10.8602 13.946 10.43 14.0312 9.99563 14.0307Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export function GpsIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function GpsIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path d="M5.83325 15C4.30909 15.3433 3.33325 15.87 3.33325 16.4617C3.33325 17.495 6.31825 18.3333 9.99992 18.3333C13.6816 18.3333 16.6666 17.495 16.6666 16.4617C16.6666 15.87 15.6908 15.3433 14.1666 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<path d="M5.83325 15C4.30909 15.3433 3.33325 15.87 3.33325 16.4617C3.33325 17.495 6.31825 18.3333 9.99992 18.3333C13.6816 18.3333 16.6666 17.495 16.6666 16.4617C16.6666 15.87 15.6908 15.3433 14.1666 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
<path d="M12.0832 7.49984C12.0832 8.05237 11.8637 8.58228 11.473 8.97298C11.0823 9.36368 10.5524 9.58317 9.99984 9.58317C9.4473 9.58317 8.9174 9.36368 8.5267 8.97298C8.136 8.58228 7.9165 8.05237 7.9165 7.49984C7.9165 6.9473 8.136 6.4174 8.5267 6.0267C8.9174 5.636 9.4473 5.4165 9.99984 5.4165C10.5524 5.4165 11.0823 5.636 11.473 6.0267C11.8637 6.4174 12.0832 6.9473 12.0832 7.49984Z" stroke="currentColor" stroke-width="1.5" />
|
<path d="M12.0832 7.49984C12.0832 8.05237 11.8637 8.58228 11.473 8.97298C11.0823 9.36368 10.5524 9.58317 9.99984 9.58317C9.4473 9.58317 8.9174 9.36368 8.5267 8.97298C8.136 8.58228 7.9165 8.05237 7.9165 7.49984C7.9165 6.9473 8.136 6.4174 8.5267 6.0267C8.9174 5.636 9.4473 5.4165 9.99984 5.4165C10.5524 5.4165 11.0823 5.636 11.473 6.0267C11.8637 6.4174 12.0832 6.9473 12.0832 7.49984Z" stroke="currentColor" stroke-width="1.5" />
|
||||||
<path d="M11.0475 14.5782C10.766 14.849 10.3906 15.0003 9.99999 15.0003C9.60939 15.0003 9.23397 14.849 8.95249 14.5782C6.37833 12.084 2.92916 9.29817 4.61083 5.25317C5.52166 3.06567 7.70499 1.6665 9.99999 1.6665C12.295 1.6665 14.4792 3.0665 15.3892 5.25317C17.0692 9.29234 13.6283 12.0923 11.0475 14.5782Z" stroke="currentColor" stroke-width="1.5" />
|
<path d="M11.0475 14.5782C10.766 14.849 10.3906 15.0003 9.99999 15.0003C9.60939 15.0003 9.23397 14.849 8.95249 14.5782C6.37833 12.084 2.92916 9.29817 4.61083 5.25317C5.52166 3.06567 7.70499 1.6665 9.99999 1.6665C12.295 1.6665 14.4792 3.0665 15.3892 5.25317C17.0692 9.29234 13.6283 12.0923 11.0475 14.5782Z" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export function KioskIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function KioskIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path d="M5.62511 1.25C5.54289 1.25002 5.46149 1.26626 5.38555 1.29779C5.30962 1.32932 5.24066 1.37552 5.18261 1.43375L1.43261 5.18375C1.34541 5.27123 1.28608 5.38258 1.26212 5.50376C1.23816 5.62493 1.25064 5.75048 1.29798 5.86457C1.34533 5.97865 1.42542 6.07615 1.52813 6.14475C1.63085 6.21335 1.75159 6.24998 1.87511 6.25H16.8751C16.9989 6.25022 17.1199 6.21369 17.2229 6.14503C17.3259 6.07637 17.4062 5.97867 17.4536 5.86433C17.501 5.74999 17.5134 5.62415 17.4892 5.50276C17.465 5.38138 17.4052 5.26991 17.3176 5.1825L13.5676 1.4325C13.5095 1.3745 13.4405 1.32853 13.3645 1.29721C13.2886 1.2659 13.2072 1.24985 13.1251 1.25H11.3839L13.7939 5H10.8476L10.0439 1.25H8.70636L7.90261 5H4.95636L7.36636 1.25H5.62511ZM2.50011 7.5V17.5C2.50011 18.1925 3.05761 18.75 3.75011 18.75H15.0001C15.6926 18.75 16.2501 18.1925 16.2501 17.5V7.5H2.50011ZM4.68761 10H6.56261C7.08136 10 7.50011 10.4188 7.50011 10.9375V16.5625C7.50011 17.0813 7.08136 17.5 6.56261 17.5H4.68761C4.43897 17.5 4.20052 17.4012 4.0247 17.2254C3.84888 17.0496 3.75011 16.8111 3.75011 16.5625V10.9375C3.75011 10.4188 4.16761 10 4.68761 10ZM11.2501 10H13.7501C14.4426 10 15.0001 10.5575 15.0001 11.25V13.75C15.0001 14.4425 14.4426 15 13.7501 15H11.2501C10.5576 15 10.0001 14.4425 10.0001 13.75V11.25C10.0001 10.5575 10.5576 10 11.2501 10Z" fill="currentColor" />
|
<path d="M5.62511 1.25C5.54289 1.25002 5.46149 1.26626 5.38555 1.29779C5.30962 1.32932 5.24066 1.37552 5.18261 1.43375L1.43261 5.18375C1.34541 5.27123 1.28608 5.38258 1.26212 5.50376C1.23816 5.62493 1.25064 5.75048 1.29798 5.86457C1.34533 5.97865 1.42542 6.07615 1.52813 6.14475C1.63085 6.21335 1.75159 6.24998 1.87511 6.25H16.8751C16.9989 6.25022 17.1199 6.21369 17.2229 6.14503C17.3259 6.07637 17.4062 5.97867 17.4536 5.86433C17.501 5.74999 17.5134 5.62415 17.4892 5.50276C17.465 5.38138 17.4052 5.26991 17.3176 5.1825L13.5676 1.4325C13.5095 1.3745 13.4405 1.32853 13.3645 1.29721C13.2886 1.2659 13.2072 1.24985 13.1251 1.25H11.3839L13.7939 5H10.8476L10.0439 1.25H8.70636L7.90261 5H4.95636L7.36636 1.25H5.62511ZM2.50011 7.5V17.5C2.50011 18.1925 3.05761 18.75 3.75011 18.75H15.0001C15.6926 18.75 16.2501 18.1925 16.2501 17.5V7.5H2.50011ZM4.68761 10H6.56261C7.08136 10 7.50011 10.4188 7.50011 10.9375V16.5625C7.50011 17.0813 7.08136 17.5 6.56261 17.5H4.68761C4.43897 17.5 4.20052 17.4012 4.0247 17.2254C3.84888 17.0496 3.75011 16.8111 3.75011 16.5625V10.9375C3.75011 10.4188 4.16761 10 4.68761 10ZM11.2501 10H13.7501C14.4426 10 15.0001 10.5575 15.0001 11.25V13.75C15.0001 14.4425 14.4426 15 13.7501 15H11.2501C10.5576 15 10.0001 14.4425 10.0001 13.75V11.25C10.0001 10.5575 10.5576 10 11.2501 10Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export function MessageIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function MessageIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}>
|
||||||
<path fill="currentColor" d="M5.821 4.91c3.898-2.765 9.469-2.539 13.073.536c3.667 3.127 4.168 8.238 1.152 11.897c-2.842 3.447-7.965 4.583-12.231 2.805l-.232-.101l-4.375.931l-.075.013l-.11.009l-.113-.004l-.044-.005l-.11-.02l-.105-.034l-.1-.044l-.076-.042l-.108-.077l-.081-.074l-.073-.083l-.053-.075l-.065-.115l-.042-.106l-.031-.113l-.013-.075l-.009-.11l.004-.113l.005-.044l.02-.11l.022-.072l1.15-3.451l-.022-.036C.969 12.45 1.97 7.805 5.59 5.079l.23-.168z" />
|
<path fill="currentColor" d="M5.821 4.91c3.898-2.765 9.469-2.539 13.073.536c3.667 3.127 4.168 8.238 1.152 11.897c-2.842 3.447-7.965 4.583-12.231 2.805l-.232-.101l-4.375.931l-.075.013l-.11.009l-.113-.004l-.044-.005l-.11-.02l-.105-.034l-.1-.044l-.076-.042l-.108-.077l-.081-.074l-.073-.083l-.053-.075l-.065-.115l-.042-.106l-.031-.113l-.013-.075l-.009-.11l.004-.113l.005-.044l.02-.11l.022-.072l1.15-3.451l-.022-.036C.969 12.45 1.97 7.805 5.59 5.079l.23-.168z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export function RebootIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function RebootIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5 10.3467V12.3032C11.9886 12.7611 11.6667 13.4263 11.6667 14.1667C11.6667 15.5474 12.7859 16.6667 14.1667 16.6667C15.5474 16.6667 16.6667 15.5474 16.6667 14.1667C16.6667 13.4263 16.3448 12.7611 15.8334 12.3033V10.3467C17.305 10.9897 18.3333 12.4581 18.3333 14.1666C18.3333 16.4679 16.4679 18.3333 14.1667 18.3333C11.8655 18.3333 10 16.4679 10 14.1667C10 12.4581 11.0284 10.9897 12.5 10.3467ZM8.33332 2.5V8.33332H6.66668L6.66656 5.2123C5.15539 6.26645 4.16668 8.01777 4.16668 10C4.16668 13.0334 6.48207 15.5261 9.44191 15.8069C9.65926 16.4331 9.99758 17.0027 10.429 17.4879C10.287 17.496 10.144 17.5 10 17.5C5.85785 17.5 2.5 14.1421 2.5 10C2.5 7.64391 3.58645 5.54156 5.28566 4.1666L2.5 4.16668V2.5H8.33332ZM15 9.16668V14.1667H13.3333V9.16668H15ZM10.9304 2.55715C14.6335 3.01535 17.5 6.17289 17.5 10C17.5 10.144 17.4959 10.287 17.4877 10.4289C17.0027 9.99758 16.433 9.65926 15.807 9.44195C15.5488 6.72117 13.4217 4.54492 10.7236 4.21109L10.9304 2.55715Z" fill="currentColor" />
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5 10.3467V12.3032C11.9886 12.7611 11.6667 13.4263 11.6667 14.1667C11.6667 15.5474 12.7859 16.6667 14.1667 16.6667C15.5474 16.6667 16.6667 15.5474 16.6667 14.1667C16.6667 13.4263 16.3448 12.7611 15.8334 12.3033V10.3467C17.305 10.9897 18.3333 12.4581 18.3333 14.1666C18.3333 16.4679 16.4679 18.3333 14.1667 18.3333C11.8655 18.3333 10 16.4679 10 14.1667C10 12.4581 11.0284 10.9897 12.5 10.3467ZM8.33332 2.5V8.33332H6.66668L6.66656 5.2123C5.15539 6.26645 4.16668 8.01777 4.16668 10C4.16668 13.0334 6.48207 15.5261 9.44191 15.8069C9.65926 16.4331 9.99758 17.0027 10.429 17.4879C10.287 17.496 10.144 17.5 10 17.5C5.85785 17.5 2.5 14.1421 2.5 10C2.5 7.64391 3.58645 5.54156 5.28566 4.1666L2.5 4.16668V2.5H8.33332ZM15 9.16668V14.1667H13.3333V9.16668H15ZM10.9304 2.55715C14.6335 3.01535 17.5 6.17289 17.5 10C17.5 10.144 17.4959 10.287 17.4877 10.4289C17.0027 9.99758 16.433 9.65926 15.807 9.44195C15.5488 6.72117 13.4217 4.54492 10.7236 4.21109L10.9304 2.55715Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
export function SimIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function SimIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path d="M17.0367 8.82183V14.3985C17.0369 14.8606 16.9461 15.3182 16.7693 15.7452C16.5926 16.1721 16.3335 16.5601 16.0067 16.8868C15.68 17.2136 15.292 17.4727 14.865 17.6495C14.4381 17.8262 13.9805 17.9171 13.5184 17.9168H6.48171C6.01962 17.9171 5.56201 17.8262 5.13505 17.6495C4.70808 17.4727 4.32014 17.2136 3.99339 16.8868C3.66664 16.5601 3.40749 16.1721 3.23075 15.7452C3.05402 15.3182 2.96316 14.8606 2.96338 14.3985V5.60183C2.96316 5.13974 3.05402 4.68213 3.23075 4.25516C3.40749 3.8282 3.66664 3.44026 3.99339 3.11351C4.32014 2.78675 4.70808 2.5276 5.13505 2.35087C5.56201 2.17413 6.01962 2.08328 6.48171 2.0835H10.2984C10.7607 2.08295 11.2186 2.17356 11.6459 2.35016C12.0731 2.52676 12.4614 2.78587 12.7884 3.11267L16.0075 6.33183C16.3343 6.65885 16.5935 7.0471 16.7701 7.47436C16.9466 7.90162 17.0373 8.35951 17.0367 8.82183Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M17.0367 8.82183V14.3985C17.0369 14.8606 16.9461 15.3182 16.7693 15.7452C16.5926 16.1721 16.3335 16.5601 16.0067 16.8868C15.68 17.2136 15.292 17.4727 14.865 17.6495C14.4381 17.8262 13.9805 17.9171 13.5184 17.9168H6.48171C6.01962 17.9171 5.56201 17.8262 5.13505 17.6495C4.70808 17.4727 4.32014 17.2136 3.99339 16.8868C3.66664 16.5601 3.40749 16.1721 3.23075 15.7452C3.05402 15.3182 2.96316 14.8606 2.96338 14.3985V5.60183C2.96316 5.13974 3.05402 4.68213 3.23075 4.25516C3.40749 3.8282 3.66664 3.44026 3.99339 3.11351C4.32014 2.78675 4.70808 2.5276 5.13505 2.35087C5.56201 2.17413 6.01962 2.08328 6.48171 2.0835H10.2984C10.7607 2.08295 11.2186 2.17356 11.6459 2.35016C12.0731 2.52676 12.4614 2.78587 12.7884 3.11267L16.0075 6.33183C16.3343 6.65885 16.5935 7.0471 16.7701 7.47436C16.9466 7.90162 17.0373 8.35951 17.0367 8.82183Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<path d="M10.0001 8.24121V15.2779M5.60181 11.7595H14.3985M12.6393 8.24121H7.36097C7.13 8.24121 6.90128 8.28672 6.6879 8.37514C6.47451 8.46355 6.28064 8.59315 6.11735 8.75651C5.95406 8.91987 5.82456 9.11381 5.73625 9.32724C5.64793 9.54066 5.60253 9.7694 5.60264 10.0004V13.5187C5.60253 13.7497 5.64793 13.9784 5.73625 14.1919C5.82456 14.4053 5.95406 14.5992 6.11735 14.7626C6.28064 14.9259 6.47451 15.0555 6.6879 15.144C6.90128 15.2324 7.13 15.2779 7.36097 15.2779H12.6393C13.1056 15.2779 13.5529 15.0926 13.8826 14.7629C14.2124 14.4331 14.3976 13.9859 14.3976 13.5195V10.0004C14.3978 9.7694 14.3523 9.54066 14.264 9.32724C14.1757 9.11381 14.0462 8.91987 13.8829 8.75651C13.7196 8.59315 13.5258 8.46355 13.3124 8.37514C13.099 8.28672 12.8703 8.24121 12.6393 8.24121Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M10.0001 8.24121V15.2779M5.60181 11.7595H14.3985M12.6393 8.24121H7.36097C7.13 8.24121 6.90128 8.28672 6.6879 8.37514C6.47451 8.46355 6.28064 8.59315 6.11735 8.75651C5.95406 8.91987 5.82456 9.11381 5.73625 9.32724C5.64793 9.54066 5.60253 9.7694 5.60264 10.0004V13.5187C5.60253 13.7497 5.64793 13.9784 5.73625 14.1919C5.82456 14.4053 5.95406 14.5992 6.11735 14.7626C6.28064 14.9259 6.47451 15.0555 6.6879 15.144C6.90128 15.2324 7.13 15.2779 7.36097 15.2779H12.6393C13.1056 15.2779 13.5529 15.0926 13.8826 14.7629C14.2124 14.4331 14.3976 13.9859 14.3976 13.5195V10.0004C14.3978 9.7694 14.3523 9.54066 14.264 9.32724C14.1757 9.11381 14.0462 8.91987 13.8829 8.75651C13.7196 8.59315 13.5258 8.46355 13.3124 8.37514C13.099 8.28672 12.8703 8.24121 12.6393 8.24121Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export function VolumeIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function VolumeIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<g clip-path="url(#clip0_1465_884)">
|
<g clip-path="url(#clip0_1465_884)">
|
||||||
<path d="M4.28578 7.14308H2.14293C1.76405 7.14308 1.40068 7.29359 1.13277 7.5615C0.864865 7.82941 0.714355 8.19277 0.714355 8.57165V11.4288C0.714355 11.8077 0.864865 12.171 1.13277 12.4389C1.40068 12.7069 1.76405 12.8574 2.14293 12.8574H4.28578M4.28578 7.14308V12.8574M4.28578 7.14308L10.0001 3.12879C10.207 2.9856 10.4481 2.89962 10.699 2.87955C10.9498 2.85948 11.2016 2.90604 11.4286 3.01451C11.6509 3.14274 11.8346 3.32848 11.9602 3.55221C12.0859 3.77594 12.149 4.02939 12.1429 4.28593V15.7145C12.1369 15.9844 12.0546 16.2471 11.9054 16.4721C11.7562 16.6972 11.5463 16.8753 11.3001 16.9859C11.073 17.0944 10.8213 17.141 10.5704 17.1209C10.3196 17.1008 10.0785 17.0148 9.8715 16.8717L4.28578 12.8574M15.0001 7.85736C15.4967 8.45778 15.7514 9.22191 15.7144 10.0002C15.7514 10.7785 15.4967 11.5427 15.0001 12.1431" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M4.28578 7.14308H2.14293C1.76405 7.14308 1.40068 7.29359 1.13277 7.5615C0.864865 7.82941 0.714355 8.19277 0.714355 8.57165V11.4288C0.714355 11.8077 0.864865 12.171 1.13277 12.4389C1.40068 12.7069 1.76405 12.8574 2.14293 12.8574H4.28578M4.28578 7.14308V12.8574M4.28578 7.14308L10.0001 3.12879C10.207 2.9856 10.4481 2.89962 10.699 2.87955C10.9498 2.85948 11.2016 2.90604 11.4286 3.01451C11.6509 3.14274 11.8346 3.32848 11.9602 3.55221C12.0859 3.77594 12.149 4.02939 12.1429 4.28593V15.7145C12.1369 15.9844 12.0546 16.2471 11.9054 16.4721C11.7562 16.6972 11.5463 16.8753 11.3001 16.9859C11.073 17.0944 10.8213 17.141 10.5704 17.1209C10.3196 17.1008 10.0785 17.0148 9.8715 16.8717L4.28578 12.8574M15.0001 7.85736C15.4967 8.45778 15.7514 9.22191 15.7144 10.0002C15.7514 10.7785 15.4967 11.5427 15.0001 12.1431" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</g>
|
</g>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
export function WifiIcon() {
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function WifiIcon({ className, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path d="M9.99992 5.8335C8.7777 5.8335 7.58659 6.02461 6.42659 6.40683C5.26659 6.78905 4.20075 7.35461 3.22909 8.1035C2.95131 8.32572 2.63547 8.4335 2.28159 8.42683C1.9277 8.42016 1.62547 8.29183 1.37492 8.04183C1.13881 7.80572 1.02075 7.51405 1.02075 7.16683C1.02075 6.81961 1.15964 6.54183 1.43742 6.3335C2.65964 5.36127 4.00353 4.61822 5.46909 4.10433C6.93464 3.59044 8.44492 3.3335 9.99992 3.3335C11.5694 3.3335 13.0833 3.59044 14.5416 4.10433C15.9999 4.61822 17.3402 5.36127 18.5624 6.3335C18.8402 6.54183 18.9791 6.81961 18.9791 7.16683C18.9791 7.51405 18.861 7.80572 18.6249 8.04183C18.3749 8.29183 18.0727 8.42044 17.7183 8.42766C17.3638 8.43489 17.048 8.32711 16.7708 8.10433C15.7985 7.35433 14.736 6.7885 13.5833 6.40683C12.4305 6.02516 11.236 5.83405 9.99992 5.8335ZM9.99992 10.8335C9.43047 10.8335 8.87853 10.9066 8.34408 11.0527C7.80964 11.1988 7.29909 11.4174 6.81242 11.7085C6.50686 11.9029 6.17714 11.9932 5.82325 11.9793C5.46936 11.9654 5.16714 11.8335 4.91659 11.5835C4.68047 11.3474 4.56575 11.0593 4.57242 10.7193C4.57909 10.3793 4.72159 10.1118 4.99992 9.91683C5.73603 9.40294 6.5277 9.01072 7.37492 8.74016C8.22214 8.46961 9.09714 8.33405 9.99992 8.3335C10.9027 8.33294 11.7777 8.4685 12.6249 8.74016C13.4721 9.01183 14.2638 9.40405 14.9999 9.91683C15.2777 10.1113 15.4202 10.3788 15.4274 10.7193C15.4346 11.0599 15.3199 11.3479 15.0833 11.5835C14.8333 11.8335 14.531 11.9654 14.1766 11.9793C13.8221 11.9932 13.4924 11.9029 13.1874 11.7085C12.7013 11.4168 12.191 11.1979 11.6566 11.0518C11.1221 10.9057 10.5699 10.8329 9.99992 10.8335ZM8.82325 16.1777C8.49658 15.851 8.33325 15.4585 8.33325 15.0002C8.33325 14.5418 8.49658 14.1496 8.82325 13.8235C9.14992 13.4974 9.54214 13.3341 9.99992 13.3335C10.4577 13.3329 10.8502 13.4963 11.1774 13.8235C11.5046 14.1507 11.6677 14.5429 11.6666 15.0002C11.6655 15.4574 11.5024 15.8499 11.1774 16.1777C10.8524 16.5054 10.4599 16.6685 9.99992 16.6668C9.53992 16.6652 9.1477 16.5021 8.82325 16.1777Z" fill="currentColor" />
|
<path d="M9.99992 5.8335C8.7777 5.8335 7.58659 6.02461 6.42659 6.40683C5.26659 6.78905 4.20075 7.35461 3.22909 8.1035C2.95131 8.32572 2.63547 8.4335 2.28159 8.42683C1.9277 8.42016 1.62547 8.29183 1.37492 8.04183C1.13881 7.80572 1.02075 7.51405 1.02075 7.16683C1.02075 6.81961 1.15964 6.54183 1.43742 6.3335C2.65964 5.36127 4.00353 4.61822 5.46909 4.10433C6.93464 3.59044 8.44492 3.3335 9.99992 3.3335C11.5694 3.3335 13.0833 3.59044 14.5416 4.10433C15.9999 4.61822 17.3402 5.36127 18.5624 6.3335C18.8402 6.54183 18.9791 6.81961 18.9791 7.16683C18.9791 7.51405 18.861 7.80572 18.6249 8.04183C18.3749 8.29183 18.0727 8.42044 17.7183 8.42766C17.3638 8.43489 17.048 8.32711 16.7708 8.10433C15.7985 7.35433 14.736 6.7885 13.5833 6.40683C12.4305 6.02516 11.236 5.83405 9.99992 5.8335ZM9.99992 10.8335C9.43047 10.8335 8.87853 10.9066 8.34408 11.0527C7.80964 11.1988 7.29909 11.4174 6.81242 11.7085C6.50686 11.9029 6.17714 11.9932 5.82325 11.9793C5.46936 11.9654 5.16714 11.8335 4.91659 11.5835C4.68047 11.3474 4.56575 11.0593 4.57242 10.7193C4.57909 10.3793 4.72159 10.1118 4.99992 9.91683C5.73603 9.40294 6.5277 9.01072 7.37492 8.74016C8.22214 8.46961 9.09714 8.33405 9.99992 8.3335C10.9027 8.33294 11.7777 8.4685 12.6249 8.74016C13.4721 9.01183 14.2638 9.40405 14.9999 9.91683C15.2777 10.1113 15.4202 10.3788 15.4274 10.7193C15.4346 11.0599 15.3199 11.3479 15.0833 11.5835C14.8333 11.8335 14.531 11.9654 14.1766 11.9793C13.8221 11.9932 13.4924 11.9029 13.1874 11.7085C12.7013 11.4168 12.191 11.1979 11.6566 11.0518C11.1221 10.9057 10.5699 10.8329 9.99992 10.8335ZM8.82325 16.1777C8.49658 15.851 8.33325 15.4585 8.33325 15.0002C8.33325 14.5418 8.49658 14.1496 8.82325 13.8235C9.14992 13.4974 9.54214 13.3341 9.99992 13.3335C10.4577 13.3329 10.8502 13.4963 11.1774 13.8235C11.5046 14.1507 11.6677 14.5429 11.6666 15.0002C11.6655 15.4574 11.5024 15.8499 11.1774 16.1777C10.8524 16.5054 10.4599 16.6685 9.99992 16.6668C9.53992 16.6652 9.1477 16.5021 8.82325 16.1777Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,32 @@ export const GET_PHONES_PAGE_QUERY = gql`
|
||||||
imei
|
imei
|
||||||
imei2
|
imei2
|
||||||
serial
|
serial
|
||||||
|
orgId
|
||||||
|
registerDate
|
||||||
|
|
||||||
|
org {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
creationDate
|
||||||
|
policy {
|
||||||
|
canUseBluetooth
|
||||||
|
canUseGPS
|
||||||
|
canUseCamera
|
||||||
|
canUseSim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
policy {
|
||||||
|
canUseBluetooth
|
||||||
|
canUseCamera
|
||||||
|
canUseGPS
|
||||||
|
canUseSim
|
||||||
|
}
|
||||||
|
|
||||||
|
techState {
|
||||||
|
needMaintenance
|
||||||
|
}
|
||||||
|
|
||||||
lastLocation {
|
lastLocation {
|
||||||
alt
|
alt
|
||||||
date
|
date
|
||||||
|
|
@ -15,6 +41,7 @@ export const GET_PHONES_PAGE_QUERY = gql`
|
||||||
lng
|
lng
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextKey
|
nextKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -26,13 +53,181 @@ export const GET_PHONE_QUERY = gql`
|
||||||
id
|
id
|
||||||
imei
|
imei
|
||||||
imei2
|
imei2
|
||||||
serial
|
|
||||||
lastLocation {
|
lastLocation {
|
||||||
alt
|
alt
|
||||||
date
|
date
|
||||||
lat
|
lat
|
||||||
lng
|
lng
|
||||||
}
|
}
|
||||||
|
|
||||||
|
org {
|
||||||
|
creationDate
|
||||||
|
id
|
||||||
|
name
|
||||||
|
policy {
|
||||||
|
canUseBluetooth
|
||||||
|
canUseCamera
|
||||||
|
canUseGPS
|
||||||
|
canUseSim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId
|
||||||
|
|
||||||
|
policy {
|
||||||
|
canUseBluetooth
|
||||||
|
canUseCamera
|
||||||
|
canUseGPS
|
||||||
|
canUseSim
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDate
|
||||||
|
serial
|
||||||
|
|
||||||
|
techState {
|
||||||
|
batteryCycles
|
||||||
|
batteryLevel
|
||||||
|
batteryRemainingCapacity
|
||||||
|
hits
|
||||||
|
needMaintenance
|
||||||
|
overheats
|
||||||
|
worktime
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const GET_PHONE_GPS_TRACK_QUERY = gql`
|
||||||
|
query GetPhoneGpsTrack($phoneId: ID!, $startDate: Float!, $endDate: Float) {
|
||||||
|
getPhoneGpsTrack(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
|
||||||
|
alt
|
||||||
|
date
|
||||||
|
lat
|
||||||
|
lng
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_TELEMETRY_QUERY = gql`
|
||||||
|
query GetTelemetry($phoneId: Int!, $startDate: Float!, $endDate: Float) {
|
||||||
|
getTelemetry(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
|
||||||
|
batteryLevel
|
||||||
|
chargeType
|
||||||
|
date
|
||||||
|
screenOn
|
||||||
|
temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_DEVICE_PAGE_QUERY = gql`
|
||||||
|
query GetDevicePage(
|
||||||
|
$id: Int!
|
||||||
|
$phoneId: ID!
|
||||||
|
$telemetryStartDate: Float!
|
||||||
|
$telemetryEndDate: Float
|
||||||
|
$gpsStartDate: Float!
|
||||||
|
$gpsEndDate: Float
|
||||||
|
) {
|
||||||
|
getPhone(id: $id) {
|
||||||
|
id
|
||||||
|
imei
|
||||||
|
imei2
|
||||||
|
|
||||||
|
lastLocation {
|
||||||
|
alt
|
||||||
|
date
|
||||||
|
lat
|
||||||
|
lng
|
||||||
|
}
|
||||||
|
|
||||||
|
org {
|
||||||
|
creationDate
|
||||||
|
id
|
||||||
|
name
|
||||||
|
policy {
|
||||||
|
canUseBluetooth
|
||||||
|
canUseCamera
|
||||||
|
canUseGPS
|
||||||
|
canUseSim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orgId
|
||||||
|
|
||||||
|
policy {
|
||||||
|
canUseBluetooth
|
||||||
|
canUseCamera
|
||||||
|
canUseGPS
|
||||||
|
canUseSim
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDate
|
||||||
|
serial
|
||||||
|
|
||||||
|
techState {
|
||||||
|
batteryCycles
|
||||||
|
batteryLevel
|
||||||
|
batteryRemainingCapacity
|
||||||
|
hits
|
||||||
|
needMaintenance
|
||||||
|
overheats
|
||||||
|
worktime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTelemetry(
|
||||||
|
phoneId: $id
|
||||||
|
startDate: $telemetryStartDate
|
||||||
|
endDate: $telemetryEndDate
|
||||||
|
) {
|
||||||
|
batteryLevel
|
||||||
|
chargeType
|
||||||
|
date
|
||||||
|
screenOn
|
||||||
|
temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
getPhoneGpsTrack(
|
||||||
|
phoneId: $phoneId
|
||||||
|
startDate: $gpsStartDate
|
||||||
|
endDate: $gpsEndDate
|
||||||
|
) {
|
||||||
|
alt
|
||||||
|
date
|
||||||
|
lat
|
||||||
|
lng
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CREATE_PHONE_REGISTRATION_TOKEN_MUTATION = gql`
|
||||||
|
mutation CreatePhoneRegistrationToken {
|
||||||
|
createPhoneRegistrationToken {
|
||||||
|
address
|
||||||
|
expiresIn
|
||||||
|
token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CHANGE_PHONE_POLICY_MUTATION = gql`
|
||||||
|
mutation ChangePhonePolicy(
|
||||||
|
$id: ID!
|
||||||
|
$canUseBluetooth: Boolean!
|
||||||
|
$canUseCamera: Boolean!
|
||||||
|
$canUseGPS: Boolean!
|
||||||
|
$canUseSim: Boolean!
|
||||||
|
) {
|
||||||
|
changePhonePolicy(
|
||||||
|
id: $id
|
||||||
|
policy: {
|
||||||
|
canUseBluetooth: $canUseBluetooth
|
||||||
|
canUseCamera: $canUseCamera
|
||||||
|
canUseGPS: $canUseGPS
|
||||||
|
canUseSim: $canUseSim
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
@ -5,11 +5,40 @@ export type DeviceLocation = {
|
||||||
lng: number
|
lng: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DevicePolicy = {
|
||||||
|
canUseBluetooth: boolean
|
||||||
|
canUseCamera: boolean
|
||||||
|
canUseGPS: boolean
|
||||||
|
canUseSim: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceTechState = {
|
||||||
|
batteryCycles: number | null
|
||||||
|
batteryLevel: number | null
|
||||||
|
batteryRemainingCapacity: number | null
|
||||||
|
hits: number | null
|
||||||
|
needMaintenance: boolean
|
||||||
|
overheats: number | null
|
||||||
|
worktime: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceOrganisation = {
|
||||||
|
creationDate?: number
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
policy?: DevicePolicy | null
|
||||||
|
}
|
||||||
|
|
||||||
export type Device = {
|
export type Device = {
|
||||||
id: number
|
id: number
|
||||||
imei: string
|
imei: string
|
||||||
imei2: string
|
imei2: string
|
||||||
serial: string
|
serial: string
|
||||||
|
orgId: number
|
||||||
|
registerDate: number
|
||||||
|
org: DeviceOrganisation | null
|
||||||
|
policy: DevicePolicy | null
|
||||||
|
techState: DeviceTechState | null
|
||||||
lastLocation: DeviceLocation | null
|
lastLocation: DeviceLocation | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,3 +60,82 @@ export type GetPhoneData = {
|
||||||
export type GetPhoneVariables = {
|
export type GetPhoneVariables = {
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeviceChargeType =
|
||||||
|
| 'USB'
|
||||||
|
| 'Wireless'
|
||||||
|
| 'AC'
|
||||||
|
| 'Disconnected'
|
||||||
|
| string
|
||||||
|
|
||||||
|
export type DeviceTelemetryItem = {
|
||||||
|
batteryLevel: number
|
||||||
|
chargeType: DeviceChargeType
|
||||||
|
date: number
|
||||||
|
screenOn: boolean
|
||||||
|
temperature: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetTelemetryData = {
|
||||||
|
getTelemetry: DeviceTelemetryItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetTelemetryVariables = {
|
||||||
|
phoneId: number
|
||||||
|
startDate: number
|
||||||
|
endDate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceGpsTrackPoint = {
|
||||||
|
alt: number
|
||||||
|
date: number
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhoneGpsTrackData = {
|
||||||
|
getPhoneGpsTrack: DeviceGpsTrackPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhoneGpsTrackVariables = {
|
||||||
|
phoneId: string
|
||||||
|
startDate: number
|
||||||
|
endDate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetDevicePageData = {
|
||||||
|
getPhone: Device | null
|
||||||
|
getTelemetry: DeviceTelemetryItem[]
|
||||||
|
getPhoneGpsTrack: DeviceGpsTrackPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetDevicePageVariables = {
|
||||||
|
id: number
|
||||||
|
phoneId: string
|
||||||
|
telemetryStartDate: number
|
||||||
|
telemetryEndDate?: number
|
||||||
|
gpsStartDate: number
|
||||||
|
gpsEndDate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangePhonePolicyData = {
|
||||||
|
changePhonePolicy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangePhonePolicyVariables = {
|
||||||
|
id: string
|
||||||
|
canUseBluetooth: boolean
|
||||||
|
canUseCamera: boolean
|
||||||
|
canUseGPS: boolean
|
||||||
|
canUseSim: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreatePhoneRegistrationTokenData = {
|
||||||
|
createPhoneRegistrationToken: {
|
||||||
|
address: string
|
||||||
|
expiresIn: number
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreatePhoneRegistrationTokenVariables = Record<string, never>
|
||||||
|
|
@ -5,6 +5,14 @@ export const GET_USERS_PAGE_QUERY = gql`
|
||||||
getUsersPage(key: $key) {
|
getUsersPage(key: $key) {
|
||||||
page {
|
page {
|
||||||
id
|
id
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
middleName
|
||||||
|
org {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
orgId
|
||||||
role
|
role
|
||||||
}
|
}
|
||||||
nextKey
|
nextKey
|
||||||
|
|
@ -12,23 +20,133 @@ export const GET_USERS_PAGE_QUERY = gql`
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export const SIGN_UP_MUTATION = gql`
|
export const GET_ORGANISATION_QUERY = gql`
|
||||||
mutation SignUp(
|
query GetOrganisation($id: ID!) {
|
||||||
$organisationId: ID!
|
getOrganisation(id: $id) {
|
||||||
$username: String!
|
creationDate
|
||||||
$password: String!
|
|
||||||
$groupId: ID
|
|
||||||
) {
|
|
||||||
signUp(
|
|
||||||
payload: {
|
|
||||||
organisationId: $organisationId
|
|
||||||
username: $username
|
|
||||||
password: $password
|
|
||||||
groupId: $groupId
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
id
|
id
|
||||||
role
|
name
|
||||||
|
policy {
|
||||||
|
canUseBluetooth
|
||||||
|
canUseCamera
|
||||||
|
canUseGPS
|
||||||
|
canUseSim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_ORGANISATIONS_QUERY = gql`
|
||||||
|
query GetOrganisations(
|
||||||
|
$page: Int!
|
||||||
|
$query: String
|
||||||
|
$sortDirection: SortDirection!
|
||||||
|
$sortField: OrganisationSortField!
|
||||||
|
) {
|
||||||
|
getOrganisations(
|
||||||
|
page: $page
|
||||||
|
query: $query
|
||||||
|
sortDirection: $sortDirection
|
||||||
|
sortField: $sortField
|
||||||
|
) {
|
||||||
|
totalPages
|
||||||
|
totalElements
|
||||||
|
page {
|
||||||
|
creationDate
|
||||||
|
id
|
||||||
|
name
|
||||||
|
policy {
|
||||||
|
canUseBluetooth
|
||||||
|
canUseCamera
|
||||||
|
canUseGPS
|
||||||
|
canUseSim
|
||||||
|
date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CREATE_ORGANISATION_MUTATION = gql`
|
||||||
|
mutation CreateOrganisation(
|
||||||
|
$name: String!
|
||||||
|
$canUseBluetooth: Boolean!
|
||||||
|
$canUseCamera: Boolean!
|
||||||
|
$canUseGPS: Boolean!
|
||||||
|
$canUseSim: Boolean!
|
||||||
|
) {
|
||||||
|
createOrganisation(
|
||||||
|
name: $name
|
||||||
|
policy: {
|
||||||
|
canUseBluetooth: $canUseBluetooth
|
||||||
|
canUseCamera: $canUseCamera
|
||||||
|
canUseGPS: $canUseGPS
|
||||||
|
canUseSim: $canUseSim
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CHANGE_ORGANISATION_MUTATION = gql`
|
||||||
|
mutation ChangeOrganisation(
|
||||||
|
$id: ID!
|
||||||
|
$name: String!
|
||||||
|
$canUseBluetooth: Boolean!
|
||||||
|
$canUseCamera: Boolean!
|
||||||
|
$canUseGPS: Boolean!
|
||||||
|
$canUseSim: Boolean!
|
||||||
|
) {
|
||||||
|
changeOrganisation(
|
||||||
|
id: $id
|
||||||
|
name: $name
|
||||||
|
policy: {
|
||||||
|
canUseBluetooth: $canUseBluetooth
|
||||||
|
canUseCamera: $canUseCamera
|
||||||
|
canUseGPS: $canUseGPS
|
||||||
|
canUseSim: $canUseSim
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
creationDate
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SIGN_UP_MUTATION = gql`
|
||||||
|
mutation createUser(
|
||||||
|
$orgId: ID!
|
||||||
|
$firstName: String!
|
||||||
|
$lastName: String!
|
||||||
|
$middleName: String!
|
||||||
|
$username: String!
|
||||||
|
$password: String!
|
||||||
|
$role: Role!
|
||||||
|
) {
|
||||||
|
createUser(
|
||||||
|
payload: {
|
||||||
|
orgId: $orgId
|
||||||
|
firstName: $firstName
|
||||||
|
lastName: $lastName
|
||||||
|
middleName: $middleName
|
||||||
|
username: $username
|
||||||
|
password: $password
|
||||||
|
role: $role
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
middleName
|
||||||
|
orgId
|
||||||
|
role
|
||||||
|
org {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
@ -1,6 +1,55 @@
|
||||||
|
export type EmployeeRole = 'User' | 'Admin' | string
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
id: number
|
id: number
|
||||||
role: string
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
middleName: string
|
||||||
|
orgId: number
|
||||||
|
role: EmployeeRole
|
||||||
|
org: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrganisationPolicy = {
|
||||||
|
canUseBluetooth: boolean
|
||||||
|
canUseCamera: boolean
|
||||||
|
canUseGPS: boolean
|
||||||
|
canUseSim: boolean
|
||||||
|
date?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Organisation = {
|
||||||
|
creationDate?: number
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
policy?: OrganisationPolicy | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetOrganisationData = {
|
||||||
|
getOrganisation: Organisation | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetOrganisationVariables = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangeOrganisationData = {
|
||||||
|
changeOrganisation: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangeOrganisationVariables = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
canUseBluetooth: boolean
|
||||||
|
canUseCamera: boolean
|
||||||
|
canUseGPS: boolean
|
||||||
|
canUseSim: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetUsersPageData = {
|
export type GetUsersPageData = {
|
||||||
|
|
@ -14,16 +63,61 @@ export type GetUsersPageVariables = {
|
||||||
key?: string
|
key?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GetOrganisationsData = {
|
||||||
|
getOrganisations: {
|
||||||
|
totalPages: number
|
||||||
|
totalElements: number
|
||||||
|
page: Organisation[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetOrganisationsVariables = {
|
||||||
|
page: number
|
||||||
|
query?: string
|
||||||
|
sortDirection: OrganisationSortDirection
|
||||||
|
sortField: OrganisationSortField
|
||||||
|
}
|
||||||
|
|
||||||
export type SignUpData = {
|
export type SignUpData = {
|
||||||
signUp: {
|
signUp: {
|
||||||
id: number
|
id: number
|
||||||
role: string
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
middleName: string
|
||||||
|
orgId: number
|
||||||
|
role: EmployeeRole
|
||||||
|
org: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SignUpVariables = {
|
export type SignUpVariables = {
|
||||||
organisationId: string
|
orgId: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
middleName: string
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
groupId?: string | null
|
role: EmployeeRole
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateOrganisationData = {
|
||||||
|
createOrganisation: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateOrganisationVariables = {
|
||||||
|
name: string
|
||||||
|
canUseBluetooth: boolean
|
||||||
|
canUseCamera: boolean
|
||||||
|
canUseGPS: boolean
|
||||||
|
canUseSim: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrganisationSortDirection = 'ASC' | 'DESC'
|
||||||
|
|
||||||
|
export type OrganisationSortField = 'ID' | 'Name' | 'Date'
|
||||||
|
|
@ -13,7 +13,15 @@ export const REFRESH_SESSION_MUTATION = gql`
|
||||||
mutation RefreshSession {
|
mutation RefreshSession {
|
||||||
refreshSession {
|
refreshSession {
|
||||||
id
|
id
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
middleName
|
||||||
|
orgId
|
||||||
role
|
role
|
||||||
|
org {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useQuery } from '@apollo/client/react'
|
import { useMutation, useQuery } from '@apollo/client/react'
|
||||||
|
|
||||||
import { CURRENT_USER_QUERY } from '../api/auth.graphql'
|
import {
|
||||||
|
CURRENT_USER_QUERY,
|
||||||
|
REFRESH_SESSION_MUTATION,
|
||||||
|
} from '../api/auth.graphql'
|
||||||
import { LoginPage } from '../../../pages/LoginPage/LoginPage'
|
import { LoginPage } from '../../../pages/LoginPage/LoginPage'
|
||||||
|
|
||||||
type CurrentUser = {
|
type CurrentUser = {
|
||||||
|
|
@ -14,12 +17,17 @@ type CurrentUserQueryData = {
|
||||||
currentUser: CurrentUser | null
|
currentUser: CurrentUser | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RefreshSessionData = {
|
||||||
|
refreshSession: CurrentUser | null
|
||||||
|
}
|
||||||
|
|
||||||
type AuthGateProps = {
|
type AuthGateProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthGate({ children }: AuthGateProps) {
|
export function AuthGate({ children }: AuthGateProps) {
|
||||||
const [isForcedLogout, setIsForcedLogout] = useState(false)
|
const [isForcedLogout, setIsForcedLogout] = useState(false)
|
||||||
|
const [isRefreshFailed, setIsRefreshFailed] = useState(false)
|
||||||
|
|
||||||
const { data, loading, error, refetch } = useQuery<CurrentUserQueryData>(
|
const { data, loading, error, refetch } = useQuery<CurrentUserQueryData>(
|
||||||
CURRENT_USER_QUERY,
|
CURRENT_USER_QUERY,
|
||||||
|
|
@ -29,9 +37,26 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [refreshSession, { loading: isRefreshing }] =
|
||||||
|
useMutation<RefreshSessionData>(REFRESH_SESSION_MUTATION, {
|
||||||
|
onCompleted: async (result) => {
|
||||||
|
if (!result.refreshSession) {
|
||||||
|
setIsRefreshFailed(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRefreshFailed(false)
|
||||||
|
await refetch()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setIsRefreshFailed(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
setIsForcedLogout(true)
|
setIsForcedLogout(true)
|
||||||
|
setIsRefreshFailed(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('auth:logout', handleLogout)
|
window.addEventListener('auth:logout', handleLogout)
|
||||||
|
|
@ -41,30 +66,36 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (isForcedLogout) {
|
useEffect(() => {
|
||||||
|
if (isForcedLogout) return
|
||||||
|
if (!error) return
|
||||||
|
if (isRefreshFailed) return
|
||||||
|
|
||||||
|
refreshSession()
|
||||||
|
}, [error, isForcedLogout, isRefreshFailed, refreshSession])
|
||||||
|
|
||||||
|
if (isForcedLogout || isRefreshFailed) {
|
||||||
return (
|
return (
|
||||||
<LoginPage
|
<LoginPage
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setIsForcedLogout(false)
|
setIsForcedLogout(false)
|
||||||
|
setIsRefreshFailed(false)
|
||||||
refetch()
|
refetch()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading || isRefreshing) {
|
||||||
return (
|
return <div style={{ padding: 24 }}>Проверка авторизации...</div>
|
||||||
<div style={{ padding: 24 }}>
|
|
||||||
Проверка авторизации...
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data?.currentUser) {
|
if (!data?.currentUser) {
|
||||||
return (
|
return (
|
||||||
<LoginPage
|
<LoginPage
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setIsForcedLogout(false)
|
setIsForcedLogout(false)
|
||||||
|
setIsRefreshFailed(false)
|
||||||
refetch()
|
refetch()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,21 @@ import { router } from './app/router/router.tsx'
|
||||||
import 'react-day-picker/style.css'
|
import 'react-day-picker/style.css'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { AuthGate } from './features/auth/ui/AuthGate'
|
import { AuthGate } from './features/auth/ui/AuthGate'
|
||||||
//import 'leaflet.fullscreen/Control.FullScreen.css'
|
|
||||||
//import 'leaflet.fullscreen'
|
const isAuthEnabled = import.meta.env.VITE_AUTH_ENABLED === 'true'
|
||||||
|
|
||||||
|
const app = isAuthEnabled ? (
|
||||||
|
<AuthGate>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthGate>
|
||||||
|
) : (
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
)
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ApolloProvider client={apolloClient}>
|
<ApolloProvider client={apolloClient}>
|
||||||
<AuthGate>
|
{app}
|
||||||
<RouterProvider router={router} />
|
|
||||||
</AuthGate>
|
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -271,9 +271,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
padding: 5px;
|
padding: 4px;
|
||||||
width: 30px;
|
width: 24px;
|
||||||
height: 30px;
|
height: 24px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: #eef1f6;
|
background: #eef1f6;
|
||||||
color: #30394b;
|
color: #30394b;
|
||||||
|
|
@ -387,33 +387,6 @@
|
||||||
border-top: 1px solid $gray20;
|
border-top: 1px solid $gray20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-permission__switch {
|
|
||||||
width: 36px;
|
|
||||||
height: 20px;
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #d6dce8;
|
|
||||||
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
span {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ffffff;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-enabled {
|
|
||||||
background: $blue;
|
|
||||||
|
|
||||||
span {
|
|
||||||
transform: translateX(16px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-card-stats {
|
.device-card-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -472,24 +445,56 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-battery__circle {
|
.device-battery__circle {
|
||||||
width: 96px;
|
position: relative;
|
||||||
height: 96px;
|
|
||||||
border-radius: 50%;
|
width: 110px;
|
||||||
background:
|
height: 110px;
|
||||||
radial-gradient(circle at center, #ffffff 58%, transparent 60%),
|
flex: 0 0 110px;
|
||||||
conic-gradient($blue 0 65%, #d6dce8 65% 100%);
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: #111827;
|
position: absolute;
|
||||||
font-size: 24px;
|
inset: 0;
|
||||||
font-weight: 600;
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-battery__circle-svg {
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-battery__circle-track,
|
||||||
|
.device-battery__circle-progress {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-battery__circle-track {
|
||||||
|
stroke: $gray20;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-battery__circle-progress {
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition:
|
||||||
|
stroke-dashoffset 0.35s ease,
|
||||||
|
stroke 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.device-stats {
|
.device-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useQuery } from '@apollo/client/react'
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
import { DeviceHistoryModal } from './components/DeviceHistoryModal/DeviceHistoryModal'
|
||||||
|
|
||||||
import type { Device as PageDevice } from './types'
|
import type { Device as PageDevice } from './types'
|
||||||
|
|
||||||
import { GET_PHONE_QUERY } from '../../entities/device/api/device.graphql'
|
import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
||||||
import type {
|
import type {
|
||||||
GetPhoneData,
|
|
||||||
GetPhoneVariables,
|
|
||||||
Device as ApiDevice,
|
Device as ApiDevice,
|
||||||
|
GetPhoneGpsTrackData,
|
||||||
|
GetTelemetryData,
|
||||||
|
GetDevicePageData,
|
||||||
|
GetDevicePageVariables,
|
||||||
} from '../../entities/device/model/types'
|
} from '../../entities/device/model/types'
|
||||||
|
|
||||||
import { DeviceMainCard } from './components/DeviceMainCard/DeviceMainCard'
|
import { DeviceMainCard } from './components/DeviceMainCard/DeviceMainCard'
|
||||||
|
|
@ -19,7 +22,7 @@ import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards
|
||||||
|
|
||||||
import './DevicePage.scss'
|
import './DevicePage.scss'
|
||||||
|
|
||||||
function formatLocationDate(timestamp?: number) {
|
function formatLocationDate(timestamp: number) {
|
||||||
if (!timestamp) return 'Нет данных'
|
if (!timestamp) return 'Нет данных'
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
|
@ -28,17 +31,61 @@ function formatLocationDate(timestamp?: number) {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
}).format(new Date(timestamp * 1000))
|
}).format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapApiDeviceToPageDevice(device: ApiDevice): PageDevice {
|
function formatTechValue(value?: number | null) {
|
||||||
const location = device.lastLocation
|
return typeof value === 'number' ? String(value) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestTelemetryItem(telemetry: GetTelemetryData['getTelemetry']) {
|
||||||
|
if (!telemetry.length) return null
|
||||||
|
|
||||||
|
return [...telemetry].sort((a, b) => b.date - a.date)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedGpsTrack(track: GetPhoneGpsTrackData['getPhoneGpsTrack']) {
|
||||||
|
return [...track].sort((a, b) => a.date - b.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiDeviceToPageDevice(
|
||||||
|
device: ApiDevice,
|
||||||
|
batteryLevel?: number,
|
||||||
|
gpsTrack: GetPhoneGpsTrackData['getPhoneGpsTrack'] = [],
|
||||||
|
): PageDevice {
|
||||||
|
const sortedGpsTrack = getSortedGpsTrack(gpsTrack)
|
||||||
|
const policy = device.policy
|
||||||
|
const techState = device.techState
|
||||||
|
const needMaintenance = techState?.needMaintenance ?? false
|
||||||
|
|
||||||
|
const currentGpsPoint =
|
||||||
|
sortedGpsTrack.length > 0
|
||||||
|
? sortedGpsTrack[sortedGpsTrack.length - 1]
|
||||||
|
: null
|
||||||
|
|
||||||
|
const routeGpsPoints =
|
||||||
|
sortedGpsTrack.length > 1
|
||||||
|
? sortedGpsTrack.slice(0, -1)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const location = currentGpsPoint
|
||||||
|
? {
|
||||||
|
lat: currentGpsPoint.lat,
|
||||||
|
lng: currentGpsPoint.lng,
|
||||||
|
}
|
||||||
|
: device.lastLocation
|
||||||
? {
|
? {
|
||||||
lat: device.lastLocation.lat,
|
lat: device.lastLocation.lat,
|
||||||
lng: device.lastLocation.lng,
|
lng: device.lastLocation.lng,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const lastLocationAt = currentGpsPoint
|
||||||
|
? formatLocationDate(currentGpsPoint.date)
|
||||||
|
: device.lastLocation
|
||||||
|
? formatLocationDate(device.lastLocation.date)
|
||||||
|
: 'Нет данных'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: device.id,
|
id: device.id,
|
||||||
|
|
||||||
|
|
@ -48,84 +95,96 @@ function mapApiDeviceToPageDevice(device: ApiDevice): PageDevice {
|
||||||
imei2: device.imei2 || 'IMEI 2 не указан',
|
imei2: device.imei2 || 'IMEI 2 не указан',
|
||||||
serialNumber: device.serial || undefined,
|
serialNumber: device.serial || undefined,
|
||||||
|
|
||||||
employee: null,
|
employee: device.org?.name ?? null,
|
||||||
|
organisation: device.org?.name,
|
||||||
|
|
||||||
condition: 'ok',
|
condition: needMaintenance ? 'inspection' : 'ok',
|
||||||
connection: device.lastLocation ? 'online' : 'offline',
|
connection: location ? 'online' : 'offline',
|
||||||
connectionText: device.lastLocation ? 'В сети' : 'Нет геопозиции',
|
connectionText: location ? 'В сети' : 'Не в сети',
|
||||||
|
|
||||||
workTime: null,
|
workTime: null,
|
||||||
registeredAt: undefined,
|
registeredAt: device.registerDate
|
||||||
|
? formatLocationDate(device.registerDate)
|
||||||
|
: undefined,
|
||||||
|
|
||||||
image: undefined,
|
image: undefined,
|
||||||
|
|
||||||
location,
|
location,
|
||||||
lastLocationAt: device.lastLocation
|
lastLocationAt,
|
||||||
? formatLocationDate(device.lastLocation.date)
|
lastLocationDate: device.lastLocation?.date,
|
||||||
: 'Нет данных',
|
|
||||||
|
|
||||||
route: device.lastLocation
|
route: routeGpsPoints.map((point) => ({
|
||||||
? [
|
lat: point.lat,
|
||||||
{
|
lng: point.lng,
|
||||||
lat: device.lastLocation.lat,
|
time: formatLocationDate(point.date),
|
||||||
lng: device.lastLocation.lng,
|
})),
|
||||||
time: formatLocationDate(device.lastLocation.date),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
|
|
||||||
permissions: {
|
permissions: {
|
||||||
wifi: true,
|
wifi: false,
|
||||||
bluetooth: true,
|
bluetooth: policy?.canUseBluetooth ?? false,
|
||||||
gps: Boolean(device.lastLocation),
|
gps: policy?.canUseGPS ?? false,
|
||||||
camera: true,
|
camera: policy?.canUseCamera ?? false,
|
||||||
sim: true,
|
sim: policy?.canUseSim ?? false,
|
||||||
speaker: true,
|
speaker: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
statusIcons: {
|
statusIcons: {
|
||||||
gps: Boolean(device.lastLocation),
|
gps: policy?.canUseGPS ?? false,
|
||||||
wifi: true,
|
wifi: false,
|
||||||
bluetooth: true,
|
bluetooth: policy?.canUseBluetooth ?? false,
|
||||||
lock: false,
|
lock: false,
|
||||||
camera: true,
|
camera: policy?.canUseCamera ?? false,
|
||||||
sim: true,
|
sim: policy?.canUseSim ?? false,
|
||||||
sound: true,
|
sound: false,
|
||||||
kiosk: false,
|
kiosk: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
battery: undefined,
|
battery: techState?.batteryLevel ?? batteryLevel,
|
||||||
batteryMaxCapacity: undefined,
|
batteryMaxCapacity: techState?.batteryRemainingCapacity ?? undefined,
|
||||||
chargeCycles: undefined,
|
chargeCycles: formatTechValue(techState?.batteryCycles),
|
||||||
totalWorkTime: undefined,
|
totalWorkTime: techState?.worktime ?? null,
|
||||||
mediumImpacts: undefined,
|
mediumImpacts: formatTechValue(techState?.hits),
|
||||||
}
|
overheats: formatTechValue(techState?.overheats),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevicePage() {
|
export function DevicePage() {
|
||||||
const { deviceId } = useParams()
|
const { deviceId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||||
|
|
||||||
const phoneId = Number(deviceId)
|
const numericDeviceId = Number(deviceId)
|
||||||
|
|
||||||
const { data, loading, error } = useQuery<GetPhoneData, GetPhoneVariables>(
|
const { data, loading, error } = useQuery<
|
||||||
GET_PHONE_QUERY,
|
GetDevicePageData,
|
||||||
{
|
GetDevicePageVariables
|
||||||
|
>(GET_DEVICE_PAGE_QUERY, {
|
||||||
variables: {
|
variables: {
|
||||||
id: phoneId,
|
id: numericDeviceId,
|
||||||
|
phoneId: String(numericDeviceId),
|
||||||
|
telemetryStartDate: 0,
|
||||||
|
gpsStartDate: 0,
|
||||||
},
|
},
|
||||||
skip: !phoneId,
|
skip: !numericDeviceId,
|
||||||
fetchPolicy: 'cache-and-network',
|
fetchPolicy: 'network-only',
|
||||||
},
|
pollInterval: 15000,
|
||||||
)
|
})
|
||||||
|
|
||||||
const device = useMemo(() => {
|
const latestTelemetryItem = useMemo(() => {
|
||||||
|
return getLatestTelemetryItem(data?.getTelemetry ?? [])
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const device = useMemo(() => {
|
||||||
if (!data?.getPhone) return null
|
if (!data?.getPhone) return null
|
||||||
|
|
||||||
return mapApiDeviceToPageDevice(data.getPhone)
|
return mapApiDeviceToPageDevice(
|
||||||
}, [data])
|
data.getPhone,
|
||||||
|
latestTelemetryItem?.batteryLevel,
|
||||||
|
data.getPhoneGpsTrack ?? [],
|
||||||
|
)
|
||||||
|
}, [data, latestTelemetryItem])
|
||||||
|
|
||||||
if (!phoneId) {
|
if (!numericDeviceId) {
|
||||||
return (
|
return (
|
||||||
<section className="device-page">
|
<section className="device-page">
|
||||||
<div className="device-page__empty">
|
<div className="device-page__empty">
|
||||||
|
|
@ -175,12 +234,20 @@ export function DevicePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="device-page__grid">
|
<div className="device-page__grid">
|
||||||
<DeviceMainCard device={device} />
|
<DeviceMainCard
|
||||||
|
device={device}
|
||||||
|
onOpenHistory={() => setIsHistoryOpen(true)}
|
||||||
|
/>
|
||||||
<DeviceMapCard device={device} />
|
<DeviceMapCard device={device} />
|
||||||
<DeviceActionsCard />
|
<DeviceActionsCard />
|
||||||
<DevicePermissionsCard device={device} />
|
<DevicePermissionsCard device={device} />
|
||||||
<DeviceStatsCards device={device} />
|
<DeviceStatsCards device={device} />
|
||||||
</div>
|
</div>
|
||||||
|
<DeviceHistoryModal
|
||||||
|
open={isHistoryOpen}
|
||||||
|
onOpenChange={setIsHistoryOpen}
|
||||||
|
device={device}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
.battery-circle {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-circle__svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-circle__track,
|
||||||
|
.battery-circle__progress {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-circle__track {
|
||||||
|
stroke: #cfd5df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-circle__progress {
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition:
|
||||||
|
stroke-dashoffset 0.35s ease,
|
||||||
|
stroke 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-circle__value {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
font-size: 44px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
type BatteryCircleProps = {
|
||||||
|
value?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBatteryColor(value: number) {
|
||||||
|
if (value <= 20) return '#E53935'
|
||||||
|
if (value <= 50) return '#F59E0B'
|
||||||
|
return '#0B28B8'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatteryCircle({ value }: BatteryCircleProps) {
|
||||||
|
const battery = typeof value === 'number'
|
||||||
|
? Math.max(0, Math.min(100, value))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const size = 180
|
||||||
|
const strokeWidth = 14
|
||||||
|
const radius = (size - strokeWidth) / 2
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
|
||||||
|
const dashOffset = circumference * (1 - battery / 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="battery-circle">
|
||||||
|
<svg
|
||||||
|
className="battery-circle__svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
>
|
||||||
|
<g transform={`rotate(45 ${size / 2} ${size / 2})`}>
|
||||||
|
<circle
|
||||||
|
className="battery-circle__track"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="battery-circle__progress"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
stroke={getBatteryColor(battery)}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span className="battery-circle__value">{battery}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.device-history-chart {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 1000px;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-chart .echarts-for-react {
|
||||||
|
width: 100%;
|
||||||
|
height: 1000px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-chart__events-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 90px;
|
||||||
|
top: 60px;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #738098;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 450;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-events-legend {
|
||||||
|
position: absolute;
|
||||||
|
left: 90px;
|
||||||
|
right: 0;
|
||||||
|
top: 340px;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-events-legend__item {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: #30394b;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.55;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-events-legend__checkbox {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
border: 1.5px solid #c9d2e3;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-events-legend__check {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
|
||||||
|
//border-radius: 2px;
|
||||||
|
//background: #ffffff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-chart__battery-title,
|
||||||
|
.device-history-chart__temperature-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 90px;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #738098;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 450;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-chart__battery-title {
|
||||||
|
top: 680px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-chart__temperature-title {
|
||||||
|
top: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-chart__state {
|
||||||
|
position: absolute;
|
||||||
|
top: 52px;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f4f8;
|
||||||
|
|
||||||
|
color: #738098;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-chart__state--error {
|
||||||
|
background: rgba(224, 0, 0, 0.08);
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,885 @@
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
|
||||||
|
import type { DeviceTelemetryItem } from '../../../../entities/device/model/types'
|
||||||
|
import type { DeviceHistoryPeriodValue } from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl'
|
||||||
|
|
||||||
|
import './DeviceHistoryChart.scss'
|
||||||
|
|
||||||
|
type DeviceHistoryChartProps = {
|
||||||
|
period: DeviceHistoryPeriodValue | null
|
||||||
|
telemetry: DeviceTelemetryItem[]
|
||||||
|
isLoading?: boolean
|
||||||
|
isError?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventCategoryKey =
|
||||||
|
| 'session'
|
||||||
|
| 'charging'
|
||||||
|
| 'screen'
|
||||||
|
| 'gps'
|
||||||
|
| 'network'
|
||||||
|
|
||||||
|
type HistoryEvent = {
|
||||||
|
category: EventCategoryKey
|
||||||
|
name: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_FAMILY = 'Montserrat Variable, Montserrat, sans-serif'
|
||||||
|
|
||||||
|
const EVENT_CATEGORIES: Array<{
|
||||||
|
key: EventCategoryKey
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'session',
|
||||||
|
label: 'Сессия',
|
||||||
|
color: '#031d9a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'charging',
|
||||||
|
label: 'Зарядка',
|
||||||
|
color: '#7a3fe8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'screen',
|
||||||
|
label: 'Экран вкл',
|
||||||
|
color: '#2f80ed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gps',
|
||||||
|
label: 'GPS вкл',
|
||||||
|
color: '#18b89b',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'network',
|
||||||
|
label: 'Потеря сети',
|
||||||
|
color: '#ff8a34',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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 getLineValueAtTime(data: number[][], hoverTime: number) {
|
||||||
|
if (!data.length) return null
|
||||||
|
|
||||||
|
const firstPoint = data[0]
|
||||||
|
const lastPoint = data[data.length - 1]
|
||||||
|
|
||||||
|
if (hoverTime <= firstPoint[0]) return firstPoint[1]
|
||||||
|
if (hoverTime >= lastPoint[0]) return lastPoint[1]
|
||||||
|
|
||||||
|
for (let index = 0; index < data.length - 1; index += 1) {
|
||||||
|
const currentPoint = data[index]
|
||||||
|
const nextPoint = data[index + 1]
|
||||||
|
|
||||||
|
const currentTime = currentPoint[0]
|
||||||
|
const currentValue = currentPoint[1]
|
||||||
|
const nextTime = nextPoint[0]
|
||||||
|
const nextValue = nextPoint[1]
|
||||||
|
|
||||||
|
if (hoverTime >= currentTime && hoverTime <= nextTime) {
|
||||||
|
const progress = (hoverTime - currentTime) / (nextTime - currentTime)
|
||||||
|
const interpolatedValue =
|
||||||
|
currentValue + (nextValue - currentValue) * progress
|
||||||
|
|
||||||
|
return Math.round(interpolatedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTooltipRange(start: number, end: number) {
|
||||||
|
return `${formatTooltipDate(start)} — ${formatTooltipDate(end)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChargeTypeLabel(chargeType: string) {
|
||||||
|
if (chargeType === 'USB') return 'USB-зарядка'
|
||||||
|
if (chargeType === 'Wireless') return 'Беспроводная зарядка'
|
||||||
|
if (chargeType === 'AC') return 'Зарядка от сети'
|
||||||
|
if (chargeType === 'Disconnected') return 'Не заряжается'
|
||||||
|
|
||||||
|
return chargeType
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTelemetry(telemetry: DeviceTelemetryItem[]) {
|
||||||
|
return [...telemetry].sort((a, b) => a.date - b.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTelemetryLines(telemetry: DeviceTelemetryItem[]) {
|
||||||
|
const sortedTelemetry = sortTelemetry(telemetry)
|
||||||
|
|
||||||
|
return {
|
||||||
|
battery: sortedTelemetry
|
||||||
|
.filter((item) => typeof item.batteryLevel === 'number')
|
||||||
|
.map((item) => [item.date, item.batteryLevel]),
|
||||||
|
|
||||||
|
temperature: sortedTelemetry
|
||||||
|
.filter((item) => typeof item.temperature === 'number')
|
||||||
|
.map((item) => [item.date, item.temperature]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTelemetryEvents(telemetry: DeviceTelemetryItem[]): HistoryEvent[] {
|
||||||
|
const sortedTelemetry = sortTelemetry(telemetry)
|
||||||
|
const events: HistoryEvent[] = []
|
||||||
|
|
||||||
|
let screenStart: number | null = null
|
||||||
|
let chargingStart: number | null = null
|
||||||
|
let chargingType: string | null = null
|
||||||
|
|
||||||
|
sortedTelemetry.forEach((item, index) => {
|
||||||
|
const nextItem = sortedTelemetry[index + 1]
|
||||||
|
const currentDate = item.date
|
||||||
|
const nextDate = nextItem?.date ?? item.date
|
||||||
|
|
||||||
|
if (item.screenOn && screenStart === null) {
|
||||||
|
screenStart = currentDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.screenOn && screenStart !== null) {
|
||||||
|
events.push({
|
||||||
|
category: 'screen',
|
||||||
|
name: 'Экран включен',
|
||||||
|
start: screenStart,
|
||||||
|
end: currentDate,
|
||||||
|
color: '#2f80ed',
|
||||||
|
})
|
||||||
|
|
||||||
|
screenStart = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCharging = item.chargeType && item.chargeType !== 'Disconnected'
|
||||||
|
|
||||||
|
if (isCharging && chargingStart === null) {
|
||||||
|
chargingStart = currentDate
|
||||||
|
chargingType = item.chargeType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isCharging &&
|
||||||
|
chargingStart !== null &&
|
||||||
|
chargingType !== null &&
|
||||||
|
item.chargeType !== chargingType
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
category: 'charging',
|
||||||
|
name: getChargeTypeLabel(chargingType),
|
||||||
|
start: chargingStart,
|
||||||
|
end: currentDate,
|
||||||
|
color: '#7a3fe8',
|
||||||
|
})
|
||||||
|
|
||||||
|
chargingStart = currentDate
|
||||||
|
chargingType = item.chargeType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCharging && chargingStart !== null && chargingType !== null) {
|
||||||
|
events.push({
|
||||||
|
category: 'charging',
|
||||||
|
name: getChargeTypeLabel(chargingType),
|
||||||
|
start: chargingStart,
|
||||||
|
end: currentDate,
|
||||||
|
color: '#7a3fe8',
|
||||||
|
})
|
||||||
|
|
||||||
|
chargingStart = null
|
||||||
|
chargingType = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === sortedTelemetry.length - 1) {
|
||||||
|
if (screenStart !== null) {
|
||||||
|
events.push({
|
||||||
|
category: 'screen',
|
||||||
|
name: 'Экран включен',
|
||||||
|
start: screenStart,
|
||||||
|
end: nextDate,
|
||||||
|
color: '#2f80ed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chargingStart !== null && chargingType !== null) {
|
||||||
|
events.push({
|
||||||
|
category: 'charging',
|
||||||
|
name: getChargeTypeLabel(chargingType),
|
||||||
|
start: chargingStart,
|
||||||
|
end: nextDate,
|
||||||
|
color: '#7a3fe8',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return events.filter((event) => event.end > event.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipRectByRect(shape: any, rect: any) {
|
||||||
|
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 renderEventItem(params: any, api: any) {
|
||||||
|
const categoryIndex = api.value(0)
|
||||||
|
const start = api.coord([api.value(1), categoryIndex])
|
||||||
|
const end = api.coord([api.value(2), categoryIndex])
|
||||||
|
|
||||||
|
const height = 10
|
||||||
|
|
||||||
|
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',
|
||||||
|
transition: ['shape', 'style'],
|
||||||
|
shape: {
|
||||||
|
...rectShape,
|
||||||
|
r: 999,
|
||||||
|
},
|
||||||
|
style: api.style(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceHistoryChart({
|
||||||
|
period,
|
||||||
|
telemetry,
|
||||||
|
isLoading = false,
|
||||||
|
isError = false,
|
||||||
|
}: DeviceHistoryChartProps) {
|
||||||
|
const [enabledCategories, setEnabledCategories] = useState<EventCategoryKey[]>(
|
||||||
|
EVENT_CATEGORIES.map((category) => category.key),
|
||||||
|
)
|
||||||
|
|
||||||
|
const periodStart =
|
||||||
|
period?.periodStart.getTime() ?? Date.now() - 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const periodEnd = period?.periodEnd.getTime() ?? Date.now()
|
||||||
|
|
||||||
|
const allEvents = useMemo(
|
||||||
|
() => createTelemetryEvents(telemetry),
|
||||||
|
[telemetry],
|
||||||
|
)
|
||||||
|
|
||||||
|
const { battery, temperature } = useMemo(
|
||||||
|
() => createTelemetryLines(telemetry),
|
||||||
|
[telemetry],
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleCategories = EVENT_CATEGORIES.filter((category) =>
|
||||||
|
enabledCategories.includes(category.key),
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleEvents = allEvents.filter((event) =>
|
||||||
|
enabledCategories.includes(event.category),
|
||||||
|
)
|
||||||
|
|
||||||
|
const eventSeriesData = visibleEvents.map((event) => {
|
||||||
|
const categoryIndex = visibleCategories.findIndex(
|
||||||
|
(category) => category.key === event.category,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: event.name,
|
||||||
|
value: [categoryIndex, event.start, event.end],
|
||||||
|
itemStyle: {
|
||||||
|
color: event.color,
|
||||||
|
},
|
||||||
|
meta: event,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleCategory(categoryKey: EventCategoryKey) {
|
||||||
|
setEnabledCategories((prev) => {
|
||||||
|
if (prev.includes(categoryKey)) {
|
||||||
|
return prev.filter((item) => item !== categoryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, categoryKey]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: 250,
|
||||||
|
animationDurationUpdate: 250,
|
||||||
|
animationEasingUpdate: 'cubicOut',
|
||||||
|
|
||||||
|
textStyle: {
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'line',
|
||||||
|
axis: 'x',
|
||||||
|
lineStyle: {
|
||||||
|
color: '#cfd7e6',
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
backgroundColor: '#20263a',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
formatter: (params: any) => {
|
||||||
|
return formatTooltipDate(Number(params.value))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderWidth: 0,
|
||||||
|
backgroundColor: '#20263a',
|
||||||
|
padding: 14,
|
||||||
|
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: any[]) => {
|
||||||
|
if (!params?.length) return ''
|
||||||
|
|
||||||
|
const hoverTime = Number(params[0].axisValue)
|
||||||
|
|
||||||
|
if (!Number.isFinite(hoverTime)) return ''
|
||||||
|
|
||||||
|
const eventsAtTime = visibleEvents.filter((event) => {
|
||||||
|
return event.start <= hoverTime && event.end >= hoverTime
|
||||||
|
})
|
||||||
|
const batteryValue = getLineValueAtTime(battery, hoverTime)
|
||||||
|
const temperatureValue = getLineValueAtTime(temperature, hoverTime)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!eventsAtTime.length &&
|
||||||
|
temperatureValue === null &&
|
||||||
|
batteryValue === null
|
||||||
|
) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = eventsAtTime
|
||||||
|
.map((event) => {
|
||||||
|
return `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:7px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0;">
|
||||||
|
<span style="width:10px;height:10px;border-radius:999px;background:${event.color};display:inline-block;flex:0 0 auto;"></span>
|
||||||
|
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px;">
|
||||||
|
${event.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style="color:#ffffff;font-weight:600;white-space:nowrap;">
|
||||||
|
${formatTooltipRange(event.start, event.end)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const temperatureRow =
|
||||||
|
temperatureValue !== null
|
||||||
|
? `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:7px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0;">
|
||||||
|
<span style="width:10px;height:10px;border-radius:999px;background:#ff8a34;display:inline-block;flex:0 0 auto;"></span>
|
||||||
|
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px;">
|
||||||
|
Температура
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style="color:#ffffff;font-weight:600;white-space:nowrap;">
|
||||||
|
${temperatureValue}°C
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const batteryRow =
|
||||||
|
batteryValue !== null
|
||||||
|
? `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:7px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0;">
|
||||||
|
<span style="width:10px;height:10px;border-radius:999px;background:#31b24a;display:inline-block;flex:0 0 auto;"></span>
|
||||||
|
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px;">
|
||||||
|
Аккумулятор
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style="color:#ffffff;font-weight:600;white-space:nowrap;">
|
||||||
|
${batteryValue}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="min-width:360px;">
|
||||||
|
<div style="color:#aeb8cc;font-size:12px;margin-bottom:8px;">
|
||||||
|
${formatTooltipDate(hoverTime)}
|
||||||
|
</div>
|
||||||
|
${rows}
|
||||||
|
${temperatureRow}
|
||||||
|
${batteryRow}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
axisPointer: {
|
||||||
|
link: [
|
||||||
|
{
|
||||||
|
xAxisIndex: [0, 1, 2],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
dataZoom: [
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
xAxisIndex: [0, 1, 2],
|
||||||
|
|
||||||
|
top: 0,
|
||||||
|
height: 28,
|
||||||
|
start: 0,
|
||||||
|
end: 100,
|
||||||
|
|
||||||
|
borderColor: 'transparent',
|
||||||
|
backgroundColor: '#eff2f7',
|
||||||
|
fillerColor: 'rgba(3, 28, 154, 0.15)',
|
||||||
|
|
||||||
|
showDataShadow: false,
|
||||||
|
showDetail: true,
|
||||||
|
brushSelect: false,
|
||||||
|
|
||||||
|
handleIcon:
|
||||||
|
'path://M2,0 H4 Q6,0 6,2 V22 Q6,24 4,24 H2 Q0,24 0,22 V2 Q0,0 2,0 Z',
|
||||||
|
handleSize: 24,
|
||||||
|
handleStyle: {
|
||||||
|
color: '#031d9a',
|
||||||
|
borderColor: '#031d9a',
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
moveHandleSize: 0,
|
||||||
|
|
||||||
|
textStyle: {
|
||||||
|
color: '#738098',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
},
|
||||||
|
|
||||||
|
labelFormatter: (value: number) => {
|
||||||
|
return formatTooltipDate(value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'inside',
|
||||||
|
xAxisIndex: [0, 1, 2],
|
||||||
|
start: 0,
|
||||||
|
end: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
grid: [
|
||||||
|
{
|
||||||
|
id: 'events',
|
||||||
|
left: 90,
|
||||||
|
right: 90,
|
||||||
|
top: 140,
|
||||||
|
height: 170,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'temperature',
|
||||||
|
left: 90,
|
||||||
|
right: 90,
|
||||||
|
top: 470,
|
||||||
|
height: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'battery',
|
||||||
|
left: 90,
|
||||||
|
right: 90,
|
||||||
|
top: 750,
|
||||||
|
height: 150,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
xAxis: [
|
||||||
|
{
|
||||||
|
type: 'time',
|
||||||
|
gridIndex: 0,
|
||||||
|
min: periodStart,
|
||||||
|
max: periodEnd,
|
||||||
|
axisLabel: {
|
||||||
|
color: '#738098',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
hideOverlap: true,
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: '#dbe2ee',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#edf0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'time',
|
||||||
|
gridIndex: 1,
|
||||||
|
min: periodStart,
|
||||||
|
max: periodEnd,
|
||||||
|
axisLabel: {
|
||||||
|
color: '#738098',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
hideOverlap: true,
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: '#dbe2ee',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#edf0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'time',
|
||||||
|
gridIndex: 2,
|
||||||
|
min: periodStart,
|
||||||
|
max: periodEnd,
|
||||||
|
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',
|
||||||
|
gridIndex: 0,
|
||||||
|
data: visibleCategories.map((category) => category.label),
|
||||||
|
inverse: true,
|
||||||
|
axisLabel: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#edf0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
gridIndex: 1,
|
||||||
|
min: 0,
|
||||||
|
max: 60,
|
||||||
|
interval: 15,
|
||||||
|
position: 'right',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: '{value}°C',
|
||||||
|
color: '#738098',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#edf0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
gridIndex: 2,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
interval: 25,
|
||||||
|
position: 'right',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: '{value}%',
|
||||||
|
color: '#738098',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT_FAMILY,
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#edf0f5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
name: 'События',
|
||||||
|
xAxisIndex: 0,
|
||||||
|
yAxisIndex: 0,
|
||||||
|
renderItem: renderEventItem,
|
||||||
|
encode: {
|
||||||
|
x: [1, 2],
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
data: eventSeriesData,
|
||||||
|
universalTransition: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
name: 'Температура',
|
||||||
|
xAxisIndex: 1,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: temperature,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 7,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2,
|
||||||
|
color: '#ff8a34',
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: '#ff8a34',
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(255, 138, 52, 0.24)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(255, 138, 52, 0.04)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
markArea: {
|
||||||
|
silent: true,
|
||||||
|
itemStyle: {
|
||||||
|
color: 'rgba(224, 0, 0, 0.045)',
|
||||||
|
},
|
||||||
|
data: [[{ yAxis: 45 }, { yAxis: 60 }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
name: 'Аккумулятор',
|
||||||
|
xAxisIndex: 2,
|
||||||
|
yAxisIndex: 2,
|
||||||
|
data: battery,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 7,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2,
|
||||||
|
color: '#31b24a',
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: '#31b24a',
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(49, 178, 74, 0.28)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(49, 178, 74, 0.04)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
markArea: {
|
||||||
|
silent: true,
|
||||||
|
itemStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(224, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(224, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: [[{ yAxis: 0 }, { yAxis: 20 }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="device-history-chart">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="device-history-chart__state">
|
||||||
|
Загрузка телеметрии...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="device-history-chart__state device-history-chart__state--error">
|
||||||
|
Не удалось загрузить телеметрию
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ReactECharts option={option} notMerge lazyUpdate />
|
||||||
|
|
||||||
|
<div className="device-history-chart__events-title">
|
||||||
|
<h3>События</h3>
|
||||||
|
<p>Хронология активности устройства за выбранный период</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-history-events-legend">
|
||||||
|
{EVENT_CATEGORIES.map((category) => {
|
||||||
|
const checked = enabledCategories.includes(category.key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.key}
|
||||||
|
className={
|
||||||
|
checked
|
||||||
|
? 'device-history-events-legend__item is-active'
|
||||||
|
: 'device-history-events-legend__item'
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCategory(category.key)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="device-history-events-legend__checkbox"
|
||||||
|
style={{
|
||||||
|
backgroundColor: checked ? category.color : 'transparent',
|
||||||
|
borderColor: checked ? category.color : '#c9d2e3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{checked && (
|
||||||
|
<svg className="device-history-events-legend__check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><title>Check SVG Icon</title><path fill="currentColor" d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093l3.473-4.425z" /></svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>{category.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-history-chart__temperature-title">
|
||||||
|
<h3>Температура</h3>
|
||||||
|
<p>Динамика температуры устройства за выбранный период</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-history-chart__battery-title">
|
||||||
|
<h3>Аккумулятор</h3>
|
||||||
|
<p>Динамика уровня заряда за выбранный период</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,532 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.device-history-modal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 500;
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
animation: historyOverlayShow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 600;
|
||||||
|
inset: 24px;
|
||||||
|
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 26px 28px;
|
||||||
|
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22);
|
||||||
|
animation: historyModalShow 0.2s ease;
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: $gray50 transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: $gray50;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-modal__title {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
color: #111827;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-modal__meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.is-green {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #30394b;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background: $green;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-modal__close {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: #111827;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray20;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-tabs {
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-tabs__item {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: $blue;
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-period {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 16px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-range {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 100px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-range__labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
color: $blue;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-range__track {
|
||||||
|
position: relative;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-range__active {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 9px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-range__track input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-range__track input::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
background: $blue;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-modal__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-history-timeline {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 8px 100px;
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-grid {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-grid__axis {
|
||||||
|
margin-left: 100px;
|
||||||
|
height: 20px;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
transform: translateX(-25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
text-align: right;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row__label {
|
||||||
|
padding-right: 12px;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row__line {
|
||||||
|
position: relative;
|
||||||
|
border-top: 1px solid #dbe2ee;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: #dbe2ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
left: 75%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-segment {
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-segment--blue {
|
||||||
|
background: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-segment--purple {
|
||||||
|
background: #7a3fe8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-segment--green {
|
||||||
|
background: #18b89b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-segment--light-blue {
|
||||||
|
background: #2f80ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-point {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-point--red {
|
||||||
|
background: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-point--orange {
|
||||||
|
background: #ff7a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-chart {
|
||||||
|
position: relative;
|
||||||
|
height: 110px;
|
||||||
|
margin-left: 100px;
|
||||||
|
border-bottom: 1px solid #dbe2ee;
|
||||||
|
|
||||||
|
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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-chart__area {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
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%
|
||||||
|
);
|
||||||
|
|
||||||
|
clip-path: polygon(0 10%, 100% 90%, 100% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-chart__lines {
|
||||||
|
position: absolute;
|
||||||
|
left: -55px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-summary {
|
||||||
|
margin: 18px 0 0 100px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-card {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #dbe2ee;
|
||||||
|
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-list {
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-row {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #e4e8f0;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 220px 1fr auto 20px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-row__icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-row__name {
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-row__time {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-row__percent {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-app-row__arrow {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-apps-card__more {
|
||||||
|
margin-top: 12px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
color: $blue;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes historyOverlayShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes historyModalShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
|
||||||
|
import type { Device } from '../../types'
|
||||||
|
import {
|
||||||
|
GET_TELEMETRY_QUERY,
|
||||||
|
} from '../../../../entities/device/api/device.graphql'
|
||||||
|
import type {
|
||||||
|
GetTelemetryData,
|
||||||
|
GetTelemetryVariables,
|
||||||
|
} from '../../../../entities/device/model/types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeviceHistoryPeriodControl,
|
||||||
|
type DeviceHistoryPeriodValue,
|
||||||
|
} from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl'
|
||||||
|
import { DeviceHistoryChart } from '../DeviceHistoryChart/DeviceHistoryChart'
|
||||||
|
|
||||||
|
import './DeviceHistoryModal.scss'
|
||||||
|
|
||||||
|
type DeviceHistoryModalProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
device: Device
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceHistoryModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
device,
|
||||||
|
}: DeviceHistoryModalProps) {
|
||||||
|
const [selectedPeriod, setSelectedPeriod] =
|
||||||
|
useState<DeviceHistoryPeriodValue | null>(null)
|
||||||
|
|
||||||
|
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,
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
pollInterval: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="device-history-modal__overlay" />
|
||||||
|
|
||||||
|
<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}</span>
|
||||||
|
<span>Версия приложения: 244</span>
|
||||||
|
<span className="is-green">Исправно</span>
|
||||||
|
<span className="is-green">
|
||||||
|
{device.connectionText || 'В сети'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Close className="device-history-modal__close" type="button">
|
||||||
|
<X size={22} />
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeviceHistoryPeriodControl onChange={setSelectedPeriod} />
|
||||||
|
|
||||||
|
<DeviceHistoryChart
|
||||||
|
period={selectedPeriod}
|
||||||
|
telemetry={telemetryData?.getTelemetry ?? []}
|
||||||
|
isLoading={telemetryLoading}
|
||||||
|
isError={Boolean(telemetryError)}
|
||||||
|
/>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.history-period-control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-period-control__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-period-tabs {
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-period-tabs__item {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date-picker {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date-picker__trigger {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-active {
|
||||||
|
color: $blue;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(3, 29, 154, 0.16);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date-picker__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
z-index: 120;
|
||||||
|
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 14px;
|
||||||
|
|
||||||
|
border: 1.5px solid $gray20;
|
||||||
|
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
|
||||||
|
|
||||||
|
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-selected-border: 0;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-months {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-month_caption {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-caption_label {
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-nav {
|
||||||
|
button {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-weekday {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-day {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-day_button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-selected {
|
||||||
|
.rdp-day_button {
|
||||||
|
background: $blue;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-range_middle {
|
||||||
|
background-color: transparent;
|
||||||
|
.rdp-day_button {
|
||||||
|
background: #e8edff;
|
||||||
|
color: $blue;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-range_start {
|
||||||
|
.rdp-day_button {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-range_end {
|
||||||
|
.rdp-day_button {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-range_start.rdp-range_end {
|
||||||
|
.rdp-day_button {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-today {
|
||||||
|
.rdp-day_button {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-outside {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
color: $blue;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__track {
|
||||||
|
position: relative;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 10px;
|
||||||
|
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(3, 29, 154, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__active {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__track input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__track input::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $blue;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__track input::-moz-range-thumb {
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $blue;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { DayPicker } from 'react-day-picker'
|
||||||
|
import type { DateRange } from 'react-day-picker'
|
||||||
|
import { ru } from 'date-fns/locale/ru'
|
||||||
|
import { CalendarDays, ChevronDown } from 'lucide-react'
|
||||||
|
|
||||||
|
import './DeviceHistoryPeriodControl.scss'
|
||||||
|
|
||||||
|
type PeriodPreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom'
|
||||||
|
|
||||||
|
export type DeviceHistoryPeriodValue = {
|
||||||
|
preset: PeriodPreset
|
||||||
|
periodStart: Date
|
||||||
|
periodEnd: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceHistoryPeriodControlProps = {
|
||||||
|
onChange?: (value: DeviceHistoryPeriodValue) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetTabs: Array<{
|
||||||
|
value: PeriodPreset
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: 'today', label: 'Сегодня' },
|
||||||
|
{ value: 'yesterday', label: 'Вчера' },
|
||||||
|
{ value: 'week', label: 'Неделя' },
|
||||||
|
{ value: 'month', label: 'Месяц' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setHours(0, 0, 0, 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(date: Date) {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setHours(23, 59, 59, 999)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number) {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setDate(result.getDate() + days)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date: Date, months: number) {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setMonth(result.getMonth() + months)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPresetRange(preset: PeriodPreset) {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (preset === 'today') {
|
||||||
|
return {
|
||||||
|
start: startOfDay(now),
|
||||||
|
end: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'yesterday') {
|
||||||
|
const yesterday = addDays(now, -1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startOfDay(yesterday),
|
||||||
|
end: endOfDay(yesterday),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'week') {
|
||||||
|
return {
|
||||||
|
start: startOfDay(addDays(now, -6)),
|
||||||
|
end: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'month') {
|
||||||
|
return {
|
||||||
|
start: startOfDay(addMonths(now, -1)),
|
||||||
|
end: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startOfDay(now),
|
||||||
|
end: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceHistoryPeriodControl({
|
||||||
|
onChange,
|
||||||
|
}: DeviceHistoryPeriodControlProps) {
|
||||||
|
const initialRange = useMemo(() => getPresetRange('today'), [])
|
||||||
|
|
||||||
|
const [preset, setPreset] = useState<PeriodPreset>('today')
|
||||||
|
const [isCalendarOpen, setIsCalendarOpen] = useState(false)
|
||||||
|
|
||||||
|
const [periodStart, setPeriodStart] = useState(initialRange.start)
|
||||||
|
const [periodEnd, setPeriodEnd] = useState(initialRange.end)
|
||||||
|
|
||||||
|
const [calendarRange, setCalendarRange] = useState<DateRange | undefined>({
|
||||||
|
from: initialRange.start,
|
||||||
|
to: initialRange.end,
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyPeriod(nextPreset: PeriodPreset, start: Date, end: Date) {
|
||||||
|
setPreset(nextPreset)
|
||||||
|
setPeriodStart(start)
|
||||||
|
setPeriodEnd(end)
|
||||||
|
|
||||||
|
setCalendarRange({
|
||||||
|
from: start,
|
||||||
|
to: end,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePresetClick(nextPreset: PeriodPreset) {
|
||||||
|
const nextRange = getPresetRange(nextPreset)
|
||||||
|
applyPeriod(nextPreset, nextRange.start, nextRange.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCalendarSelect(nextRange: DateRange | undefined) {
|
||||||
|
setCalendarRange(nextRange)
|
||||||
|
|
||||||
|
if (!nextRange?.from) return
|
||||||
|
|
||||||
|
const from = startOfDay(nextRange.from)
|
||||||
|
const to = nextRange.to ? endOfDay(nextRange.to) : endOfDay(nextRange.from)
|
||||||
|
|
||||||
|
applyPeriod('custom', from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange?.({
|
||||||
|
preset,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
})
|
||||||
|
}, [preset, periodStart, periodEnd, onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-period-control">
|
||||||
|
<div className="history-period-control__top">
|
||||||
|
<div className="history-period-tabs">
|
||||||
|
{presetTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
className={
|
||||||
|
preset === tab.value
|
||||||
|
? 'history-period-tabs__item is-active'
|
||||||
|
: 'history-period-tabs__item'
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePresetClick(tab.value)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-date-picker">
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
preset === 'custom'
|
||||||
|
? 'history-date-picker__trigger is-active'
|
||||||
|
: 'history-date-picker__trigger'
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCalendarOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<CalendarDays size={16} />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
{formatDate(periodStart)} — {formatDate(periodEnd)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isCalendarOpen && (
|
||||||
|
<div className="history-date-picker__dropdown">
|
||||||
|
<DayPicker
|
||||||
|
mode="range"
|
||||||
|
locale={ru}
|
||||||
|
selected={calendarRange}
|
||||||
|
onSelect={handleCalendarSelect}
|
||||||
|
numberOfMonths={1}
|
||||||
|
defaultMonth={periodStart}
|
||||||
|
weekStartsOn={1}
|
||||||
|
showOutsideDays
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,37 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import { ShieldCheck, Signal, Smartphone, Trash2 } from 'lucide-react'
|
import { ShieldCheck, Signal, Smartphone, Trash2 } from 'lucide-react'
|
||||||
import type { Device } from '../../types'
|
import type { Device } from '../../types'
|
||||||
import { conditionText, connectionText, getStatusClass } from '../../types'
|
import { conditionText, connectionText, getStatusClass } from '../../types'
|
||||||
|
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
||||||
|
|
||||||
type DeviceMainCardProps = {
|
type DeviceMainCardProps = {
|
||||||
device: Device
|
device: Device
|
||||||
|
onOpenHistory: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceMainCard({ device }: DeviceMainCardProps) {
|
function getDeviceFullName(employee: Device) {
|
||||||
|
return [employee.serialNumber]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps) {
|
||||||
|
|
||||||
|
const [deletingDevice, setDeletingDevice] = useState<Device | null>(null)
|
||||||
|
|
||||||
|
function handleConfirmDeleteDevice() {
|
||||||
|
if (!deletingDevice) return
|
||||||
|
|
||||||
|
console.log('Удаление устройства пока без мутации', deletingDevice)
|
||||||
|
|
||||||
|
setDeletingDevice(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteDevice(device: Device) {
|
||||||
|
setDeletingDevice(device)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="device-card device-card--main">
|
<div className="device-card device-card--main">
|
||||||
<div className="device-main">
|
<div className="device-main">
|
||||||
|
|
@ -14,7 +39,7 @@ export function DeviceMainCard({ device }: DeviceMainCardProps) {
|
||||||
{device.image ? (
|
{device.image ? (
|
||||||
<img src={device.image} alt={device.model ?? 'Устройство'} />
|
<img src={device.image} alt={device.model ?? 'Устройство'} />
|
||||||
) : (
|
) : (
|
||||||
<Smartphone size={84} />
|
<img src='/devices/армафон3.3+.webp'></img>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -55,20 +80,36 @@ export function DeviceMainCard({ device }: DeviceMainCardProps) {
|
||||||
<Smartphone size={17} />
|
<Smartphone size={17} />
|
||||||
<div className="device-registered-info">
|
<div className="device-registered-info">
|
||||||
<span>Зарегистрирован</span>
|
<span>Зарегистрирован</span>
|
||||||
<b>{device.registeredAt ?? '10:00 20.04.2026'}</b>
|
<b>{device.registeredAt ?? 'Нет данных'}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="device-card__actions">
|
<div className="device-card__actions">
|
||||||
<button className="device-history-btn" type="button">
|
<button className="device-history-btn" type="button" onClick={onOpenHistory}>
|
||||||
Подробная история эксплуатации
|
Подробная история эксплуатации
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="device-delete-btn" type="button" aria-label="Удалить">
|
<button className="device-delete-btn" type="button" aria-label="Удалить" onClick={() => handleDeleteDevice(device)}>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
open={Boolean(deletingDevice)}
|
||||||
|
title="Удалить сотрудника?"
|
||||||
|
description={
|
||||||
|
deletingDevice
|
||||||
|
? `Устройство ${getDeviceFullName(deletingDevice)} будет удалено. Это действие нельзя будет отменить.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmText="Удалить"
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDeletingDevice(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmDeleteDevice}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
background: #eef1f6;
|
background: #eef1f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-map__coords {
|
.device-map__coords {
|
||||||
|
|
@ -85,3 +86,28 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-map__period-control {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 400;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map--modal {
|
||||||
|
padding: 0 !important;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.device-card__header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map__container {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useQuery } from '@apollo/client/react'
|
||||||
import {
|
import {
|
||||||
CircleMarker,
|
CircleMarker,
|
||||||
MapContainer,
|
MapContainer,
|
||||||
|
|
@ -10,13 +11,27 @@ import {
|
||||||
} from 'react-leaflet'
|
} from 'react-leaflet'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { Map } from 'lucide-react'
|
import { Map } from 'lucide-react'
|
||||||
import './DeviceMapCard.scss'
|
|
||||||
|
|
||||||
import type { Device } from '../../types'
|
import type { Device } from '../../types'
|
||||||
|
import {
|
||||||
|
GET_PHONE_GPS_TRACK_QUERY,
|
||||||
|
} from '../../../../entities/device/api/device.graphql'
|
||||||
|
import type {
|
||||||
|
DeviceGpsTrackPoint,
|
||||||
|
GetPhoneGpsTrackData,
|
||||||
|
GetPhoneGpsTrackVariables,
|
||||||
|
} from '../../../../entities/device/model/types'
|
||||||
import { FullscreenControl } from '../../../../widgets/FullscreenControlLeaflet/FullscreenControl'
|
import { FullscreenControl } from '../../../../widgets/FullscreenControlLeaflet/FullscreenControl'
|
||||||
|
import {
|
||||||
|
MapTrackPeriodControl,
|
||||||
|
type MapTrackPeriodValue,
|
||||||
|
} from '../../../MapPage/components/MapTrackPeriodControl/MapTrackPeriodControl'
|
||||||
|
|
||||||
|
import './DeviceMapCard.scss'
|
||||||
|
|
||||||
type DeviceMapCardProps = {
|
type DeviceMapCardProps = {
|
||||||
device: Device
|
device: Device
|
||||||
|
variant?: 'card' | 'modal'
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultLocation = {
|
const defaultLocation = {
|
||||||
|
|
@ -36,6 +51,31 @@ const deviceMarkerIcon = L.divIcon({
|
||||||
popupAnchor: [0, -18],
|
popupAnchor: [0, -18],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function formatLocationDate(timestamp: number) {
|
||||||
|
if (!timestamp) return 'Нет данных'
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
|
||||||
|
return [...track].sort((a, b) => a.date - b.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateInPeriod(date: number | undefined, period: MapTrackPeriodValue | null) {
|
||||||
|
if (!date || !period) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
date >= period.periodStart.getTime() &&
|
||||||
|
date <= period.periodEnd.getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MapResizeWatcher() {
|
function MapResizeWatcher() {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
|
|
||||||
|
|
@ -50,19 +90,113 @@ function MapResizeWatcher() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceMapCard({ device }: DeviceMapCardProps) {
|
type MapFitBoundsProps = {
|
||||||
const location = device.location ?? defaultLocation
|
positions: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapFitBounds({ positions }: MapFitBoundsProps) {
|
||||||
|
const map = useMap()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!positions.length) return
|
||||||
|
|
||||||
|
if (positions.length === 1) {
|
||||||
|
map.setView(positions[0], 14)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = L.latLngBounds(positions)
|
||||||
|
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: [40, 40],
|
||||||
|
maxZoom: 15,
|
||||||
|
animate: true,
|
||||||
|
})
|
||||||
|
}, [map, positions])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps) {
|
||||||
|
const [selectedPeriod, setSelectedPeriod] =
|
||||||
|
useState<MapTrackPeriodValue | null>(null)
|
||||||
|
|
||||||
|
const { data: gpsTrackData } = useQuery<
|
||||||
|
GetPhoneGpsTrackData,
|
||||||
|
GetPhoneGpsTrackVariables
|
||||||
|
>(GET_PHONE_GPS_TRACK_QUERY, {
|
||||||
|
variables: {
|
||||||
|
phoneId: String(device.id),
|
||||||
|
startDate: selectedPeriod?.periodStart.getTime() ?? 0,
|
||||||
|
endDate: selectedPeriod?.periodEnd.getTime(),
|
||||||
|
},
|
||||||
|
skip: !device.id || !selectedPeriod,
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
pollInterval: selectedPeriod?.preset === 'today' ? 15000 : 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedGpsTrack = useMemo(() => {
|
||||||
|
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
||||||
|
}, [gpsTrackData])
|
||||||
|
|
||||||
|
const shouldShowCurrentLocation = isDateInPeriod(
|
||||||
|
device.lastLocationDate,
|
||||||
|
selectedPeriod,
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCurrentLocation =
|
||||||
|
shouldShowCurrentLocation && device.location
|
||||||
|
? {
|
||||||
|
lat: device.location.lat,
|
||||||
|
lng: device.location.lng,
|
||||||
|
time: device.lastLocationAt,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const selectedRoutePoints = useMemo(() => {
|
||||||
|
if (!selectedCurrentLocation) {
|
||||||
|
return sortedGpsTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedGpsTrack.filter((point) => {
|
||||||
|
return (
|
||||||
|
point.lat !== selectedCurrentLocation.lat ||
|
||||||
|
point.lng !== selectedCurrentLocation.lng
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [sortedGpsTrack, selectedCurrentLocation])
|
||||||
|
|
||||||
|
const routePositions = useMemo<[number, number][]>(() => {
|
||||||
|
const positions = selectedRoutePoints.map(
|
||||||
|
(point) => [point.lat, point.lng] as [number, number],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!selectedCurrentLocation) {
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...positions,
|
||||||
|
[selectedCurrentLocation.lat, selectedCurrentLocation.lng],
|
||||||
|
]
|
||||||
|
}, [selectedRoutePoints, selectedCurrentLocation])
|
||||||
|
|
||||||
|
const location = selectedCurrentLocation ?? device.location ?? defaultLocation
|
||||||
|
|
||||||
const currentPosition = useMemo<[number, number]>(() => {
|
const currentPosition = useMemo<[number, number]>(() => {
|
||||||
return [location.lat, location.lng]
|
return [location.lat, location.lng]
|
||||||
}, [location.lat, location.lng])
|
}, [location.lat, location.lng])
|
||||||
|
|
||||||
const routePositions = useMemo<[number, number][]>(() => {
|
const fitPositions = routePositions.length > 0 ? routePositions : [currentPosition]
|
||||||
return device.route?.map((point) => [point.lat, point.lng]) ?? []
|
|
||||||
}, [device.route])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="device-card device-map">
|
<div
|
||||||
|
className={
|
||||||
|
variant === 'modal'
|
||||||
|
? 'device-card device-map device-map--modal'
|
||||||
|
: 'device-card device-map'
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="device-card__header">
|
<div className="device-card__header">
|
||||||
<div className="device-card__title">
|
<div className="device-card__title">
|
||||||
<Map size={18} />
|
<Map size={18} />
|
||||||
|
|
@ -71,22 +205,27 @@ export function DeviceMapCard({ device }: DeviceMapCardProps) {
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Последнее местоположение:{' '}
|
Последнее местоположение:{' '}
|
||||||
{device.lastLocationAt ?? '12:56 23.04.2026'}
|
{device.lastLocationAt ?? 'Нет данных'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="device-map__container">
|
<div className="device-map__container">
|
||||||
|
<div className="device-map__period-control">
|
||||||
|
<MapTrackPeriodControl onChange={setSelectedPeriod} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={currentPosition}
|
center={currentPosition}
|
||||||
zoom={14}
|
zoom={14}
|
||||||
scrollWheelZoom={true}
|
scrollWheelZoom
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
className="device-map__leaflet"
|
className="device-map__leaflet"
|
||||||
id='devices-map'
|
id="device-map"
|
||||||
>
|
>
|
||||||
<MapResizeWatcher />
|
<MapResizeWatcher />
|
||||||
|
<MapFitBounds positions={fitPositions} />
|
||||||
|
|
||||||
<TileLayer url={`https://api.maptiler.com/maps/hybrid/{z}/{x}/{y}.jpg?key=${import.meta.env.VITE_MAPTILER_KEY}`} />
|
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
|
||||||
|
|
||||||
{routePositions.length > 1 && (
|
{routePositions.length > 1 && (
|
||||||
<Polyline
|
<Polyline
|
||||||
|
|
@ -99,9 +238,9 @@ export function DeviceMapCard({ device }: DeviceMapCardProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{device.route?.map((point, index) => (
|
{selectedRoutePoints.map((point, index) => (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
key={`${point.lat}-${point.lng}-${index}`}
|
key={`${point.lat}-${point.lng}-${point.date}-${index}`}
|
||||||
center={[point.lat, point.lng]}
|
center={[point.lat, point.lng]}
|
||||||
radius={4}
|
radius={4}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
|
|
@ -114,12 +253,13 @@ export function DeviceMapCard({ device }: DeviceMapCardProps) {
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="device-map-popup">
|
<div className="device-map-popup">
|
||||||
<b>Точка маршрута</b>
|
<b>Точка маршрута</b>
|
||||||
{point.time && <span>{point.time}</span>}
|
<span>{formatLocationDate(point.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</CircleMarker>
|
</CircleMarker>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{selectedCurrentLocation && (
|
||||||
<Marker position={currentPosition} icon={deviceMarkerIcon}>
|
<Marker position={currentPosition} icon={deviceMarkerIcon}>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="device-map-popup">
|
<div className="device-map-popup">
|
||||||
|
|
@ -130,7 +270,9 @@ export function DeviceMapCard({ device }: DeviceMapCardProps) {
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
<FullscreenControl targetId="devices-map" />
|
)}
|
||||||
|
|
||||||
|
<FullscreenControl targetId="device-map" />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.device-permission__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: 26px;
|
||||||
|
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(16px);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission {
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
filter 0.2s ease;
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
svg,
|
||||||
|
span,
|
||||||
|
button {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-permission__switch {
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import type { ReactNode } from 'react'
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { useMutation } from '@apollo/client/react'
|
||||||
|
|
||||||
import type { Device } from '../../types'
|
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 { WifiIcon } from '../../../../assets/icons/Wifi'
|
import { WifiIcon } from '../../../../assets/icons/Wifi'
|
||||||
import { BluetoothIcon } from '../../../../assets/icons/Bluetooth'
|
import { BluetoothIcon } from '../../../../assets/icons/Bluetooth'
|
||||||
|
|
@ -8,19 +17,150 @@ import { CameraIcon } from '../../../../assets/icons/Camera'
|
||||||
import { SimIcon } from '../../../../assets/icons/Sim'
|
import { SimIcon } from '../../../../assets/icons/Sim'
|
||||||
import { VolumeIcon } from '../../../../assets/icons/Volume'
|
import { VolumeIcon } from '../../../../assets/icons/Volume'
|
||||||
|
|
||||||
|
import './DevicePermissionsCard.scss'
|
||||||
|
|
||||||
type DevicePermissionsCardProps = {
|
type DevicePermissionsCardProps = {
|
||||||
device: Device
|
device: Device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DevicePolicyKey = 'wifi' | 'bluetooth' | 'gps' | 'camera' | 'sim' | 'speaker'
|
||||||
|
|
||||||
|
type DevicePolicyState = Record<DevicePolicyKey, boolean>
|
||||||
|
|
||||||
|
type PermissionItemConfig = {
|
||||||
|
key: DevicePolicyKey
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionItems: PermissionItemConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'wifi',
|
||||||
|
icon: <WifiIcon />,
|
||||||
|
label: 'Wi-Fi',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bluetooth',
|
||||||
|
icon: <BluetoothIcon />,
|
||||||
|
label: 'Bluetooth',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gps',
|
||||||
|
icon: <GpsIcon />,
|
||||||
|
label: 'GPS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'camera',
|
||||||
|
icon: <CameraIcon />,
|
||||||
|
label: 'Камера',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sim',
|
||||||
|
icon: <SimIcon />,
|
||||||
|
label: 'SIM-карта',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'speaker',
|
||||||
|
icon: <VolumeIcon />,
|
||||||
|
label: 'Динамик',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function mapDevicePermissionsToState(device: Device): DevicePolicyState {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToMutationVariables(policy: DevicePolicyState) {
|
||||||
|
return {
|
||||||
|
canUseBluetooth: policy.bluetooth,
|
||||||
|
canUseCamera: policy.camera,
|
||||||
|
canUseGPS: policy.gps,
|
||||||
|
canUseSim: policy.sim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function DevicePermissionsCard({ device }: DevicePermissionsCardProps) {
|
export function DevicePermissionsCard({ device }: DevicePermissionsCardProps) {
|
||||||
|
const initialPolicy = useMemo(() => {
|
||||||
|
return mapDevicePermissionsToState(device)
|
||||||
|
}, [device])
|
||||||
|
|
||||||
|
const [policyState, setPolicyState] = useState<DevicePolicyState>(initialPolicy)
|
||||||
|
const [savingKey, setSavingKey] = useState<DevicePolicyKey | null>(null)
|
||||||
|
const [saveError, setSaveError] = useState(false)
|
||||||
|
|
||||||
|
const [changePhonePolicy] = useMutation<
|
||||||
|
ChangePhonePolicyData,
|
||||||
|
ChangePhonePolicyVariables
|
||||||
|
>(CHANGE_PHONE_POLICY_MUTATION)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPolicyState(initialPolicy)
|
||||||
|
}, [initialPolicy])
|
||||||
|
|
||||||
|
async function handleToggle(key: DevicePolicyKey, disabled?: boolean) {
|
||||||
|
if (disabled || savingKey) return
|
||||||
|
|
||||||
|
const previousPolicy = policyState
|
||||||
|
const nextPolicy = {
|
||||||
|
...policyState,
|
||||||
|
[key]: !policyState[key],
|
||||||
|
}
|
||||||
|
|
||||||
|
setPolicyState(nextPolicy)
|
||||||
|
setSavingKey(key)
|
||||||
|
setSaveError(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changePhonePolicy({
|
||||||
|
variables: {
|
||||||
|
id: String(device.id),
|
||||||
|
...mapStateToMutationVariables(nextPolicy),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setPolicyState(previousPolicy)
|
||||||
|
setSaveError(true)
|
||||||
|
} finally {
|
||||||
|
setSavingKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="device-card device-permissions">
|
<div className="device-card device-permissions">
|
||||||
<PermissionItem icon={<WifiIcon />} label="Wi-Fi" enabled={device.permissions?.wifi ?? true} />
|
{permissionItems.map((item) => {
|
||||||
<PermissionItem icon={<BluetoothIcon />} label="Bluetooth" enabled={device.permissions?.bluetooth ?? true} />
|
const enabled = policyState[item.key]
|
||||||
<PermissionItem icon={<GpsIcon />} label="GPS" enabled={device.permissions?.gps ?? true} />
|
const isSaving = savingKey === item.key
|
||||||
<PermissionItem icon={<CameraIcon />} label="Камера" enabled={device.permissions?.camera ?? true} />
|
const isDisabled = item.disabled || Boolean(savingKey)
|
||||||
<PermissionItem icon={<SimIcon />} label="SIM-карта" enabled={device.permissions?.sim ?? true} />
|
|
||||||
<PermissionItem icon={<VolumeIcon />} label="Динамик" enabled={device.permissions?.speaker ?? true} />
|
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)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<div className="device-permissions__error">
|
||||||
|
Не удалось сохранить политику устройства
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -29,19 +169,43 @@ type PermissionItemProps = {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
label: string
|
label: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
isUnavailable?: boolean
|
||||||
|
isSaving?: boolean
|
||||||
|
onToggle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function PermissionItem({ icon, label, enabled }: PermissionItemProps) {
|
function PermissionItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
enabled,
|
||||||
|
disabled = false,
|
||||||
|
isUnavailable = false,
|
||||||
|
isSaving = false,
|
||||||
|
onToggle,
|
||||||
|
}: PermissionItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className="device-permission">
|
<div
|
||||||
|
className={`device-permission ${isUnavailable ? 'is-disabled' : ''} ${
|
||||||
|
isSaving ? 'is-saving' : ''
|
||||||
|
}`}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|
||||||
<div className="device-permission__label">
|
<div className="device-permission__label">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
|
||||||
<span className={`device-permission__switch ${enabled ? 'is-enabled' : ''}`}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`device-permission__switch ${enabled ? 'is-enabled' : ''}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={enabled}
|
||||||
|
aria-label={`${enabled ? 'Выключить' : 'Включить'} ${label}`}
|
||||||
|
>
|
||||||
<span />
|
<span />
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,50 @@
|
||||||
import { Battery, RotateCcw, ShieldCheck, Smartphone } from 'lucide-react'
|
import { Battery, RotateCcw, ShieldCheck, Smartphone, ThermometerSun } from 'lucide-react'
|
||||||
import type { Device } from '../../types'
|
import type { Device } from '../../types'
|
||||||
|
|
||||||
type DeviceStatsCardsProps = {
|
type DeviceStatsCardsProps = {
|
||||||
device: Device
|
device: Device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatWorkTimeHours(worktime?: number | null) {
|
||||||
|
if (typeof worktime !== 'number') return '-'
|
||||||
|
|
||||||
|
const hours = worktime / 1000 / 60 / 60
|
||||||
|
|
||||||
|
if (hours < 1) {
|
||||||
|
const minutes = Math.round(worktime / 1000 / 60)
|
||||||
|
|
||||||
|
return `${minutes} мин`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours < 10) {
|
||||||
|
return `${hours.toFixed(1)} ч`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(hours)} ч`
|
||||||
|
}
|
||||||
|
|
||||||
export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
||||||
|
|
||||||
|
const batteryValue =
|
||||||
|
typeof device.battery === 'number'
|
||||||
|
? Math.max(0, Math.min(100, device.battery))
|
||||||
|
: null
|
||||||
|
|
||||||
|
const batterySize = 110
|
||||||
|
const batteryStroke = 10
|
||||||
|
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'
|
||||||
|
|
||||||
|
return '#031d9a'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="device-card-stats">
|
<div className="device-card-stats">
|
||||||
<div className="device-card device-battery">
|
<div className="device-card device-battery">
|
||||||
|
|
@ -13,7 +52,36 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
||||||
|
|
||||||
<div className="device-battery__content">
|
<div className="device-battery__content">
|
||||||
<div className="device-battery__circle">
|
<div className="device-battery__circle">
|
||||||
<span>{device.battery ?? '-'}%</span>
|
<svg
|
||||||
|
className="device-battery__circle-svg"
|
||||||
|
viewBox={`0 0 ${batterySize} ${batterySize}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g transform={`rotate(45 ${batterySize / 2} ${batterySize / 2})`}>
|
||||||
|
<circle
|
||||||
|
className="device-battery__circle-track"
|
||||||
|
cx={batterySize / 2}
|
||||||
|
cy={batterySize / 2}
|
||||||
|
r={batteryRadius}
|
||||||
|
strokeWidth={batteryStroke}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{batteryValue !== null && (
|
||||||
|
<circle
|
||||||
|
className="device-battery__circle-progress"
|
||||||
|
cx={batterySize / 2}
|
||||||
|
cy={batterySize / 2}
|
||||||
|
r={batteryRadius}
|
||||||
|
strokeWidth={batteryStroke}
|
||||||
|
stroke={getBatteryColor(batteryValue)}
|
||||||
|
strokeDasharray={batteryLength}
|
||||||
|
strokeDashoffset={batteryDashOffset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span>{batteryValue !== null ? `${batteryValue}%` : '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="device-stats">
|
<div className="device-stats">
|
||||||
|
|
@ -21,7 +89,7 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
||||||
<Battery size={18} />
|
<Battery size={18} />
|
||||||
|
|
||||||
<div className="device-stats-text">
|
<div className="device-stats-text">
|
||||||
<span>Максимальная емкость</span>
|
<span>Остаточная емкость</span>
|
||||||
<b>{device.batteryMaxCapacity ?? '-'}%</b>
|
<b>{device.batteryMaxCapacity ?? '-'}%</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -40,7 +108,7 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
|
||||||
|
|
||||||
<div className="device-stats-text">
|
<div className="device-stats-text">
|
||||||
<span>Общее время работы</span>
|
<span>Общее время работы</span>
|
||||||
<b>{device.totalWorkTime ?? '-'}</b>
|
<b>{formatWorkTimeHours(device.totalWorkTime)}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export type Device = {
|
||||||
imei: string
|
imei: string
|
||||||
imei2: string
|
imei2: string
|
||||||
serialNumber?: string
|
serialNumber?: string
|
||||||
|
organisation?: string
|
||||||
workTime: string | null
|
workTime: string | null
|
||||||
employee: string | null
|
employee: string | null
|
||||||
condition: DeviceCondition
|
condition: DeviceCondition
|
||||||
|
|
@ -15,11 +16,13 @@ export type Device = {
|
||||||
connectionText: string
|
connectionText: string
|
||||||
registeredAt?: string
|
registeredAt?: string
|
||||||
lastLocationAt?: string
|
lastLocationAt?: string
|
||||||
|
lastLocationDate?: number
|
||||||
battery?: number
|
battery?: number
|
||||||
batteryMaxCapacity?: number
|
batteryMaxCapacity?: number
|
||||||
chargeCycles?: string
|
chargeCycles?: string
|
||||||
totalWorkTime?: string
|
totalWorkTime?: number | null
|
||||||
mediumImpacts?: string
|
mediumImpacts?: string
|
||||||
|
overheats?: string
|
||||||
image?: string
|
image?: string
|
||||||
location?: {
|
location?: {
|
||||||
lat: number
|
lat: number
|
||||||
|
|
@ -52,7 +55,7 @@ export type Device = {
|
||||||
|
|
||||||
export const conditionText: Record<DeviceCondition, string> = {
|
export const conditionText: Record<DeviceCondition, string> = {
|
||||||
ok: 'Исправно',
|
ok: 'Исправно',
|
||||||
inspection: 'Требует осмотра',
|
inspection: 'Требует ТО',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const connectionText: Record<DeviceConnection, string> = {
|
export const connectionText: Record<DeviceConnection, string> = {
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,11 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $gray20;
|
background-color: rgba($blue, 0.025);
|
||||||
|
box-shadow: inset 3px 0 0 rgba($blue, 0.8);
|
||||||
|
|
||||||
.devices-map-btn {
|
.device-info__number {
|
||||||
background-color: white;
|
color: $blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +141,7 @@
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
transition: .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-info__imei,
|
.device-info__imei,
|
||||||
|
|
@ -169,15 +171,18 @@
|
||||||
padding: 0px 8px;
|
padding: 0px 8px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-weight: 550;
|
font-weight: 550;
|
||||||
&--green{
|
|
||||||
|
&--green {
|
||||||
background-color: hsla(128, 56%, 45%, 0.15);
|
background-color: hsla(128, 56%, 45%, 0.15);
|
||||||
color: $green;
|
color: $green;
|
||||||
}
|
}
|
||||||
&--gray{
|
|
||||||
|
&--gray {
|
||||||
background-color: $color-bg;
|
background-color: $color-bg;
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
}
|
}
|
||||||
&--red{
|
|
||||||
|
&--red {
|
||||||
background-color: hsla(0, 100%, 43%, 0.15);
|
background-color: hsla(0, 100%, 43%, 0.15);
|
||||||
color: $red;
|
color: $red;
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +235,7 @@
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: $color-bg;
|
background: $gray20;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -243,7 +248,7 @@
|
||||||
transition: .2s ease;
|
transition: .2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $gray20;
|
background-color: $blue20;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,7 +266,7 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
color: #738098;
|
color: #738098;
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-pagination__controls {
|
.devices-pagination__controls {
|
||||||
|
|
@ -279,7 +284,7 @@
|
||||||
background: #e9edf5;
|
background: #e9edf5;
|
||||||
|
|
||||||
color: #738098;
|
color: #738098;
|
||||||
font-size: 14px;
|
font-size: 17px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,32 @@ import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useApolloClient, useQuery } from '@apollo/client/react'
|
import { useApolloClient, useQuery } from '@apollo/client/react'
|
||||||
import {
|
import {
|
||||||
Bluetooth,
|
|
||||||
Camera,
|
|
||||||
Map,
|
Map,
|
||||||
MapPin,
|
|
||||||
SlidersHorizontal,
|
|
||||||
Volume2,
|
|
||||||
Wifi,
|
|
||||||
Lock,
|
Lock,
|
||||||
Store,
|
Store,
|
||||||
} from 'lucide-react'
|
} from 'lucide-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 './DevicesPage.scss'
|
import './DevicesPage.scss'
|
||||||
|
|
||||||
import { DevicesTabs } from './components/DevicesTabs/DevicesTabs'
|
import { DevicesTabs } from './components/DevicesTabs/DevicesTabs'
|
||||||
import { DevicesToolbar } from './components/DevicesToolbar/DevicesToolbar'
|
import { DevicesToolbar } from './components/DevicesToolbar/DevicesToolbar'
|
||||||
import { DevicesFiltersPanel } from './components/DevicesFiltersPanel/DevicesFiltersPanel'
|
import { DevicesFiltersPanel } from './components/DevicesFiltersPanel/DevicesFiltersPanel'
|
||||||
import { AddDeviceModal } from './components/AddDeviceModal/AddDeviceModal'
|
import { AddDeviceModal } from './components/AddDeviceModal/AddDeviceModal'
|
||||||
|
import { DeviceMapModal } from './components/DeviceMapModal/DeviceMapModal'
|
||||||
|
|
||||||
import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
||||||
import type { Device, GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types'
|
import type { Device, GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types'
|
||||||
|
import type { Device as PageDevice } from '../DevicePage/types'
|
||||||
|
import type { Device as ApiDevice } from '../../entities/device/model/types'
|
||||||
|
|
||||||
function formatLocationDate(timestamp: number) {
|
function formatDateTime(timestamp: number) {
|
||||||
if (!timestamp) return 'Нет данных'
|
if (!timestamp) return 'Нет данных'
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
|
@ -32,7 +36,19 @@ function formatLocationDate(timestamp: number) {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
}).format(new Date(timestamp * 1000))
|
}).format(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceConditionLabel(needMaintenance?: boolean) {
|
||||||
|
return needMaintenance ? 'Требует ТО' : 'Исправно'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceConditionClass(needMaintenance?: boolean) {
|
||||||
|
return needMaintenance ? 'devices-status--red' : 'devices-status--green'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceConditionDotClass(needMaintenance?: boolean) {
|
||||||
|
return needMaintenance ? 'devices-dot--red' : 'devices-dot--green'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevicesPage() {
|
export function DevicesPage() {
|
||||||
|
|
@ -49,6 +65,8 @@ export function DevicesPage() {
|
||||||
|
|
||||||
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false)
|
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false)
|
||||||
|
|
||||||
|
const [mapDevice, setMapDevice] = useState<PageDevice | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: firstPageData,
|
data: firstPageData,
|
||||||
loading: isFirstPageLoading,
|
loading: isFirstPageLoading,
|
||||||
|
|
@ -122,6 +140,71 @@ export function DevicesPage() {
|
||||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapTableDeviceToPageDevice(device: ApiDevice): PageDevice {
|
||||||
|
const policy = device.policy
|
||||||
|
const needMaintenance = device.techState?.needMaintenance ?? false
|
||||||
|
return {
|
||||||
|
id: device.id,
|
||||||
|
model: 'АРМАФОН S3.3+',
|
||||||
|
factoryNumber: device.serial || 'Заводской номер не указан',
|
||||||
|
imei: device.imei || 'IMEI не указан',
|
||||||
|
imei2: device.imei2 || 'IMEI 2 не указан',
|
||||||
|
serialNumber: device.serial || undefined,
|
||||||
|
|
||||||
|
workTime: null,
|
||||||
|
employee: device.org?.name ?? null,
|
||||||
|
organisation: device.org?.name,
|
||||||
|
|
||||||
|
condition: needMaintenance ? 'inspection' : 'ok',
|
||||||
|
connection: device.lastLocation ? 'online' : 'offline',
|
||||||
|
connectionText: device.lastLocation ? 'Есть геопозиция' : 'Нет данных',
|
||||||
|
|
||||||
|
registeredAt: device.registerDate
|
||||||
|
? formatDateTime(device.registerDate)
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
lastLocationDate: device.lastLocation?.date,
|
||||||
|
lastLocationAt: device.lastLocation?.date
|
||||||
|
? formatDateTime(device.lastLocation.date)
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
location: device.lastLocation
|
||||||
|
? {
|
||||||
|
lat: device.lastLocation.lat,
|
||||||
|
lng: device.lastLocation.lng,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
route: [],
|
||||||
|
|
||||||
|
permissions: {
|
||||||
|
wifi: false,
|
||||||
|
bluetooth: policy?.canUseBluetooth ?? false,
|
||||||
|
gps: policy?.canUseGPS ?? false,
|
||||||
|
camera: policy?.canUseCamera ?? false,
|
||||||
|
sim: policy?.canUseSim ?? false,
|
||||||
|
speaker: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
statusIcons: {
|
||||||
|
gps: policy?.canUseGPS ?? false,
|
||||||
|
wifi: false,
|
||||||
|
bluetooth: policy?.canUseBluetooth ?? false,
|
||||||
|
lock: false,
|
||||||
|
camera: policy?.canUseCamera ?? false,
|
||||||
|
sim: policy?.canUseSim ?? false,
|
||||||
|
sound: false,
|
||||||
|
kiosk: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
battery: undefined,
|
||||||
|
batteryMaxCapacity: undefined,
|
||||||
|
chargeCycles: undefined,
|
||||||
|
totalWorkTime: undefined,
|
||||||
|
mediumImpacts: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="devices-page">
|
<section className="devices-page">
|
||||||
<DevicesTabs />
|
<DevicesTabs />
|
||||||
|
|
@ -176,20 +259,25 @@ export function DevicesPage() {
|
||||||
IMEI 2: {device.imei2 || '—'}
|
IMEI 2: {device.imei2 || '—'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{device.lastLocation && (
|
|
||||||
<div className="device-info__employee">
|
<div className="device-info__employee">
|
||||||
Последняя геопозиция:{' '}
|
{device.org?.name || 'Не указана'}
|
||||||
{formatLocationDate(device.lastLocation.date)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div className={`devices-status ${device.lastLocation ? 'devices-status--green' : 'devices-status--red'}`}>
|
<div
|
||||||
<span className={`devices-dot ${device.lastLocation ? 'devices-dot--green' : 'devices-dot--red'}`} />
|
className={`devices-status ${getDeviceConditionClass(
|
||||||
|
device.techState?.needMaintenance,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`devices-dot ${getDeviceConditionDotClass(
|
||||||
|
device.techState?.needMaintenance,
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
|
||||||
{device.lastLocation ? 'Исправно' : 'Требует ТО'}
|
{getDeviceConditionLabel(device.techState?.needMaintenance)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
@ -208,19 +296,28 @@ export function DevicesPage() {
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div className="device-icons">
|
<div className="device-icons">
|
||||||
<MapPin
|
<GpsIcon
|
||||||
className={device.lastLocation ? 'is-active' : ''}
|
className={device.policy?.canUseGPS ? 'is-active' : ''}
|
||||||
size={16}
|
/>
|
||||||
|
|
||||||
|
<WifiIcon />
|
||||||
|
|
||||||
|
<BluetoothIcon
|
||||||
|
className={device.policy?.canUseBluetooth ? 'is-active' : ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Wifi className="is-active" size={16} />
|
|
||||||
<Bluetooth className="is-active" size={16} />
|
|
||||||
<Lock size={16} />
|
<Lock size={16} />
|
||||||
|
|
||||||
<Camera className="is-active" size={16} />
|
<CameraIcon
|
||||||
<SlidersHorizontal className="is-active" size={16} />
|
className={device.policy?.canUseCamera ? 'is-active' : ''}
|
||||||
<Volume2 size={16} />
|
/>
|
||||||
<Store size={16} />
|
|
||||||
|
<SimIcon
|
||||||
|
className={device.policy?.canUseSim ? 'is-active' : ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VolumeIcon />
|
||||||
|
<Store />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
@ -229,8 +326,10 @@ export function DevicesPage() {
|
||||||
className="devices-map-btn"
|
className="devices-map-btn"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
navigate(`/devices/${device.id}`)
|
|
||||||
|
setMapDevice(mapTableDeviceToPageDevice(device))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Map size={15} />
|
<Map size={15} />
|
||||||
|
|
@ -277,6 +376,15 @@ export function DevicesPage() {
|
||||||
open={isAddDeviceOpen}
|
open={isAddDeviceOpen}
|
||||||
onOpenChange={setIsAddDeviceOpen}
|
onOpenChange={setIsAddDeviceOpen}
|
||||||
/>
|
/>
|
||||||
|
<DeviceMapModal
|
||||||
|
open={Boolean(mapDevice)}
|
||||||
|
device={mapDevice}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setMapDevice(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
padding: 20px;
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
|
|
@ -110,6 +110,12 @@
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.add-device-qr__state, .add-device-qr__content{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-device-modal__footer {
|
.add-device-modal__footer {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as Dialog from '@radix-ui/react-dialog'
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
import { QrCode, X } from 'lucide-react'
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { QrCode, RefreshCw, X } from 'lucide-react'
|
||||||
|
import { useMutation } from '@apollo/client/react'
|
||||||
|
|
||||||
|
import { CREATE_PHONE_REGISTRATION_TOKEN_MUTATION } from '../../../../entities/device/api/device.graphql'
|
||||||
|
import type {
|
||||||
|
CreatePhoneRegistrationTokenData,
|
||||||
|
CreatePhoneRegistrationTokenVariables,
|
||||||
|
} from '../../../../entities/device/model/types'
|
||||||
|
|
||||||
import './AddDeviceModal.scss'
|
import './AddDeviceModal.scss'
|
||||||
|
|
||||||
|
|
@ -8,7 +17,53 @@ type AddDeviceModalProps = {
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatExpiresIn(expiresIn: number) {
|
||||||
|
if (!expiresIn) return '—'
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const expiresAt = expiresIn > 10_000_000_000
|
||||||
|
? expiresIn
|
||||||
|
: now + expiresIn * 1000
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(expiresAt))
|
||||||
|
}
|
||||||
|
|
||||||
export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
||||||
|
|
||||||
|
const [createToken, { data, loading, error }] = useMutation<
|
||||||
|
CreatePhoneRegistrationTokenData,
|
||||||
|
CreatePhoneRegistrationTokenVariables
|
||||||
|
>(CREATE_PHONE_REGISTRATION_TOKEN_MUTATION, {
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
})
|
||||||
|
|
||||||
|
const registrationData = data?.createPhoneRegistrationToken
|
||||||
|
|
||||||
|
const qrValue = useMemo(() => {
|
||||||
|
if (!registrationData) return ''
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
address: registrationData.address,
|
||||||
|
token: registrationData.token,
|
||||||
|
})
|
||||||
|
}, [registrationData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
createToken()
|
||||||
|
}, [open, createToken])
|
||||||
|
|
||||||
|
function handleRefreshToken() {
|
||||||
|
createToken()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
|
|
@ -33,8 +88,32 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
|
||||||
|
|
||||||
<div className="add-device-qr">
|
<div className="add-device-qr">
|
||||||
<div className="add-device-qr__box">
|
<div className="add-device-qr__box">
|
||||||
<QrCode size={92} />
|
{loading && (
|
||||||
<span>Здесь будет QR-код</span>
|
<div className="add-device-qr__state">
|
||||||
|
<QrCode size={64} />
|
||||||
|
<span>Генерация QR-кода...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="add-device-qr__state add-device-qr__state--error">
|
||||||
|
<QrCode size={64} />
|
||||||
|
<span>Не удалось получить QR-код</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && qrValue && (
|
||||||
|
<div className="add-device-qr__content">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={qrValue}
|
||||||
|
size={220}
|
||||||
|
level="M"
|
||||||
|
bgColor='transparent'
|
||||||
|
fgColor="#031D9A"
|
||||||
|
includeMargin
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.device-map-modal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 90;
|
||||||
|
|
||||||
|
background: rgba(15, 23, 42, 0.42);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
|
||||||
|
animation: deviceMapModalOverlayShow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
inset: 24px;
|
||||||
|
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
|
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.24);
|
||||||
|
animation: deviceMapModalShow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-modal__header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-modal__title {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-modal__description {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-modal__close {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue;
|
||||||
|
background: $gray20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map-modal__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.device-map {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map__container {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-map__leaflet {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes deviceMapModalOverlayShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes deviceMapModalShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { Device } from '../../../DevicePage/types'
|
||||||
|
import { DeviceMapCard } from '../../../DevicePage/components/DeviceMapCard/DeviceMapCard'
|
||||||
|
|
||||||
|
import './DeviceMapModal.scss'
|
||||||
|
|
||||||
|
type DeviceMapModalProps = {
|
||||||
|
open: boolean
|
||||||
|
device: Device | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceMapModal({
|
||||||
|
open,
|
||||||
|
device,
|
||||||
|
onOpenChange,
|
||||||
|
}: DeviceMapModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="device-map-modal__overlay" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{device && (
|
||||||
|
<div className="device-map-modal__body">
|
||||||
|
<DeviceMapCard device={device} variant="modal" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,10 @@
|
||||||
.devices-filter-item {
|
.devices-filter-item {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
&:nth-child(1)[data-state=open]{
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-filter-item__header {
|
.devices-filter-item__header {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
|
|
||||||
<Accordion.Item className="devices-filter-item" value="statuses">
|
{/* <Accordion.Item className="devices-filter-item" value="statuses">
|
||||||
<Accordion.Header className="devices-filter-item__header">
|
<Accordion.Header className="devices-filter-item__header">
|
||||||
<Accordion.Trigger className="devices-filter-item__trigger">
|
<Accordion.Trigger className="devices-filter-item__trigger">
|
||||||
<span>Статусы</span>
|
<span>Статусы</span>
|
||||||
|
|
@ -108,7 +108,7 @@ export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item> */}
|
||||||
|
|
||||||
<Accordion.Item className="devices-filter-item" value="network">
|
<Accordion.Item className="devices-filter-item" value="network">
|
||||||
<Accordion.Header className="devices-filter-item__header">
|
<Accordion.Header className="devices-filter-item__header">
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,17 @@
|
||||||
|
|
||||||
.devices-search {
|
.devices-search {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
|
height: calc(100% - 20px);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 20px;
|
border-radius: 24px;
|
||||||
background: $color-bg;
|
background: $color-bg;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
|
|
||||||
|
transition: .2s ease;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|
@ -37,6 +40,10 @@
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-within{
|
||||||
|
box-shadow: inset 0 0 0 1.5px rgba($blue, .15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-toolbar__right {
|
.devices-toolbar__right {
|
||||||
|
|
@ -47,7 +54,7 @@
|
||||||
button {
|
button {
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 450;
|
font-weight: 500;
|
||||||
background-color: $color-bg;
|
background-color: $color-bg;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -63,7 +70,7 @@
|
||||||
|
|
||||||
.devices-sort {
|
.devices-sort {
|
||||||
//height: 32px;
|
//height: 32px;
|
||||||
padding: 12px 14px 12px 20px !important;
|
padding: 11px 14px 11px 20px !important;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
|
|
@ -80,20 +87,17 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 0.2s ease;
|
transition: 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover{
|
||||||
color: $blue;
|
background-color: $gray20;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.devices-sort__chevron {
|
&[data-state='open'] {
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.devices-sort[data-state='open'] {
|
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
background-color: $blue20;
|
||||||
.devices-sort__chevron {
|
}
|
||||||
transform: rotate(180deg);
|
svg{
|
||||||
|
height: 22px;
|
||||||
|
width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,8 +172,13 @@
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover{
|
||||||
|
background-color: $gray20;
|
||||||
|
}
|
||||||
|
|
||||||
&--active{
|
&--active{
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
background-color: $blue20 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,9 @@ export function DevicesToolbar({
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<button className="devices-sort" type="button">
|
<button className="devices-sort" type="button">
|
||||||
{selectedSort.label}
|
{selectedSort.label}
|
||||||
<ChevronDown className="devices-sort__chevron" size={16} />
|
<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"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,40 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employees-sections {
|
||||||
|
width: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-toolbar--organisations {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.employees-table-container {
|
.employees-table-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -21,6 +55,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-table-card {
|
.employees-table-card {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
@ -52,7 +87,7 @@
|
||||||
|
|
||||||
.employees-table {
|
.employees-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 700px;
|
min-width: 1180px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
|
||||||
|
|
@ -66,7 +101,7 @@
|
||||||
th {
|
th {
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
border-bottom: 1px solid #e3e8f0;
|
border-bottom: 1px solid $gray20;
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
@ -82,27 +117,163 @@
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
color: #151a24;
|
color: $color-text;
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
th:nth-child(1),
|
th:nth-child(1),
|
||||||
td:nth-child(1) {
|
td:nth-child(1) {
|
||||||
width: 140px;
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(2),
|
||||||
|
td:nth-child(2) {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(3),
|
||||||
|
td:nth-child(3) {
|
||||||
|
width: 26%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(4),
|
||||||
|
td:nth-child(4) {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(5),
|
||||||
|
td:nth-child(5) {
|
||||||
|
width: 330px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisations-table {
|
||||||
|
min-width: 900px;
|
||||||
|
|
||||||
|
th:nth-child(1),
|
||||||
|
td:nth-child(1) {
|
||||||
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
th:nth-child(2),
|
th:nth-child(2),
|
||||||
td:nth-child(2) {
|
td:nth-child(2) {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
th:nth-child(3),
|
||||||
|
td:nth-child(3) {
|
||||||
|
width: 330px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-person {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-organisation,
|
||||||
|
.employee-organisation-name {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-organisation-name {
|
||||||
|
color: $color-text;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-role {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-role--admin {
|
||||||
|
background: $orange;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-table-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-action-btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
opacity: 0.88;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
opacity 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-action-btn--edit {
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-action-btn--delete {
|
||||||
|
background: rgba(224, 0, 0, 0.12);
|
||||||
|
color: $red;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px $red;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-table__row {
|
.employees-table__row {
|
||||||
|
position: relative;
|
||||||
transition: 0.2s ease;
|
transition: 0.2s ease;
|
||||||
|
box-shadow: inset 0px 0 0 transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $gray20;
|
background-color: rgba($blue, 0.025);
|
||||||
|
box-shadow: inset 3px 0 0 rgba($blue, 0.8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,16 +292,70 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employees-table-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-action-btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
opacity: 0.88;
|
||||||
|
|
||||||
|
box-shadow: inset 0px 0 0 transparent;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
opacity 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-action-btn--edit {
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.employees-action-btn--delete {
|
||||||
|
background: rgba(224, 0, 0, 0.12);
|
||||||
|
color: $red;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.employees-pagination {
|
.employees-pagination {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 12px 14px 0;
|
padding: 12px 14px 0;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
|
|
||||||
color: #738098;
|
color: #738098;
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-pagination__controls {
|
.employees-pagination__controls {
|
||||||
|
|
@ -139,21 +364,35 @@
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
min-width: 30px;
|
display: flex;
|
||||||
height: 30px;
|
align-items: center;
|
||||||
padding: 0 12px;
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
//padding: 0 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #e9edf5;
|
background: $gray20;
|
||||||
|
|
||||||
color: #738098;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: .2s ease;
|
||||||
|
&:hover{
|
||||||
|
background-color: rgba($blue, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: #031d9a;
|
background: $blue;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|
@ -163,6 +402,35 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.employees-state {
|
.employees-state {
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -1,115 +1,392 @@
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useApolloClient, useQuery } from '@apollo/client/react'
|
import { useApolloClient, useQuery } from '@apollo/client/react'
|
||||||
|
import { Pencil, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
import { GET_USERS_PAGE_QUERY } from '../../entities/employee/api/employee.graphql'
|
import {
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
|
GET_USERS_PAGE_QUERY,
|
||||||
|
} from '../../entities/employee/api/employee.graphql'
|
||||||
import type {
|
import type {
|
||||||
Employee,
|
Employee,
|
||||||
|
GetOrganisationsData,
|
||||||
|
GetOrganisationsVariables,
|
||||||
GetUsersPageData,
|
GetUsersPageData,
|
||||||
GetUsersPageVariables,
|
GetUsersPageVariables,
|
||||||
|
Organisation,
|
||||||
} from '../../entities/employee/model/types'
|
} from '../../entities/employee/model/types'
|
||||||
|
|
||||||
import { EmployeesToolbar } from './components/EmployeesToolbar/EmployeesToolbar'
|
import {
|
||||||
|
EmployeesToolbar,
|
||||||
|
employeesSortOptions,
|
||||||
|
type EmployeesSortOption,
|
||||||
|
} from './components/EmployeesToolbar/EmployeesToolbar'
|
||||||
import { AddEmployeeModal } from './components/AddEmployeeModal/AddEmployeeModal'
|
import { AddEmployeeModal } from './components/AddEmployeeModal/AddEmployeeModal'
|
||||||
|
import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
||||||
|
import { AddOrganisationModal } from './components/AddOrganisationModal/AddOrganisationModal'
|
||||||
|
|
||||||
import './EmployeesPage.scss'
|
import './EmployeesPage.scss'
|
||||||
|
|
||||||
|
type EmployeesSection = 'users' | 'organisations'
|
||||||
|
|
||||||
|
function getEmployeeFullName(employee: Employee) {
|
||||||
|
return [employee.lastName, employee.firstName, employee.middleName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmployeeRoleLabel(role: string) {
|
||||||
|
if (role === 'Admin') return 'Администратор'
|
||||||
|
if (role === 'User') return 'Пользователь'
|
||||||
|
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationItem = number | 'prev-ellipsis' | 'next-ellipsis'
|
||||||
|
|
||||||
|
function getPaginationItems(currentPage: number, totalPages: number): PaginationItem[] {
|
||||||
|
if (totalPages <= 0) return []
|
||||||
|
|
||||||
|
const current = currentPage + 1
|
||||||
|
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
return Array.from({ length: totalPages }, (_, index) => index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = new Set<number>()
|
||||||
|
|
||||||
|
pages.add(1)
|
||||||
|
pages.add(totalPages)
|
||||||
|
pages.add(current)
|
||||||
|
pages.add(current - 1)
|
||||||
|
pages.add(current + 1)
|
||||||
|
|
||||||
|
if (current <= 4) {
|
||||||
|
pages.add(2)
|
||||||
|
pages.add(3)
|
||||||
|
pages.add(4)
|
||||||
|
pages.add(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current >= totalPages - 3) {
|
||||||
|
pages.add(totalPages - 1)
|
||||||
|
pages.add(totalPages - 2)
|
||||||
|
pages.add(totalPages - 3)
|
||||||
|
pages.add(totalPages - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedPages = Array.from(pages)
|
||||||
|
.filter((page) => page >= 1 && page <= totalPages)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
|
||||||
|
const result: PaginationItem[] = []
|
||||||
|
|
||||||
|
sortedPages.forEach((page, index) => {
|
||||||
|
const previousPage = sortedPages[index - 1]
|
||||||
|
|
||||||
|
if (previousPage && page - previousPage > 1) {
|
||||||
|
if (page - previousPage === 2) {
|
||||||
|
result.push(previousPage + 1)
|
||||||
|
} else {
|
||||||
|
result.push(page < current ? 'prev-ellipsis' : 'next-ellipsis')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export function EmployeesPage() {
|
export function EmployeesPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const client = useApolloClient()
|
const client = useApolloClient()
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [activeSection, setActiveSection] = useState<EmployeesSection>('users')
|
||||||
const [loadedPages, setLoadedPages] = useState<Employee[][]>([])
|
|
||||||
const [loadedNextKeys, setLoadedNextKeys] = useState<Array<string | null>>([])
|
const [usersCurrentPage, setUsersCurrentPage] = useState(1)
|
||||||
const [isPageLoading, setIsPageLoading] = useState(false)
|
const [usersLoadedPages, setUsersLoadedPages] = useState<Employee[][]>([])
|
||||||
|
const [usersLoadedNextKeys, setUsersLoadedNextKeys] = useState<Array<string | null>>([])
|
||||||
|
const [isUsersPageLoading, setIsUsersPageLoading] = useState(false)
|
||||||
|
|
||||||
|
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 [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 {
|
const {
|
||||||
data: firstPageData,
|
data: firstUsersPageData,
|
||||||
loading: isFirstPageLoading,
|
loading: isFirstUsersPageLoading,
|
||||||
error,
|
error: usersError,
|
||||||
} = useQuery<GetUsersPageData, GetUsersPageVariables>(GET_USERS_PAGE_QUERY, {
|
} = useQuery<GetUsersPageData, GetUsersPageVariables>(GET_USERS_PAGE_QUERY, {
|
||||||
variables: {},
|
variables: {},
|
||||||
fetchPolicy: 'network-only',
|
fetchPolicy: 'network-only',
|
||||||
})
|
})
|
||||||
|
|
||||||
const firstPageEmployees = firstPageData?.getUsersPage.page ?? []
|
const {
|
||||||
const firstPageNextKey = firstPageData?.getUsersPage.nextKey ?? null
|
data: organisationsData,
|
||||||
|
previousData: previousOrganisationsData,
|
||||||
|
loading: organisationsLoading,
|
||||||
|
error: organisationsError,
|
||||||
|
refetch: refetchOrganisations,
|
||||||
|
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
page: organisationsCurrentPage,
|
||||||
|
query: debouncedOrganisationSearch || undefined,
|
||||||
|
sortDirection: organisationSort.sortDirection,
|
||||||
|
sortField: organisationSort.sortField,
|
||||||
|
},
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const pages = [firstPageEmployees, ...loadedPages]
|
const firstPageEmployees = firstUsersPageData?.getUsersPage.page ?? []
|
||||||
const employees = pages[currentPage - 1] ?? []
|
const firstUsersPageNextKey = firstUsersPageData?.getUsersPage.nextKey ?? null
|
||||||
|
|
||||||
const nextKey =
|
const usersPages = [firstPageEmployees, ...usersLoadedPages]
|
||||||
currentPage === 1
|
const employees = usersPages[usersCurrentPage - 1] ?? []
|
||||||
? firstPageNextKey
|
|
||||||
: loadedNextKeys[currentPage - 2] ?? null
|
|
||||||
|
|
||||||
const loading = isFirstPageLoading || isPageLoading
|
const usersNextKey =
|
||||||
|
usersCurrentPage === 1
|
||||||
|
? firstUsersPageNextKey
|
||||||
|
: usersLoadedNextKeys[usersCurrentPage - 2] ?? null
|
||||||
|
|
||||||
async function handleNextPage() {
|
const usersLoading = isFirstUsersPageLoading || isUsersPageLoading
|
||||||
const alreadyLoadedNextPage = pages[currentPage]
|
|
||||||
|
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) {
|
if (alreadyLoadedNextPage) {
|
||||||
setCurrentPage((prev) => prev + 1)
|
setUsersCurrentPage((prev) => prev + 1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextKey) return
|
if (!usersNextKey) return
|
||||||
|
|
||||||
setIsPageLoading(true)
|
setIsUsersPageLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await client.query<GetUsersPageData, GetUsersPageVariables>({
|
const result = await client.query<GetUsersPageData, GetUsersPageVariables>({
|
||||||
query: GET_USERS_PAGE_QUERY,
|
query: GET_USERS_PAGE_QUERY,
|
||||||
variables: {
|
variables: {
|
||||||
key: nextKey,
|
key: usersNextKey,
|
||||||
},
|
},
|
||||||
fetchPolicy: 'network-only',
|
fetchPolicy: 'network-only',
|
||||||
})
|
})
|
||||||
|
|
||||||
const nextPageData = result.data?.getUsersPage
|
const nextPageData = result.data?.getUsersPage
|
||||||
|
|
||||||
if (!nextPageData) {
|
if (!nextPageData) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadedPages((prev) => [...prev, nextPageData.page])
|
setUsersLoadedPages((prev) => [...prev, nextPageData.page])
|
||||||
setLoadedNextKeys((prev) => [...prev, nextPageData.nextKey])
|
setUsersLoadedNextKeys((prev) => [...prev, nextPageData.nextKey])
|
||||||
setCurrentPage((prev) => prev + 1)
|
setUsersCurrentPage((prev) => prev + 1)
|
||||||
} finally {
|
} finally {
|
||||||
setIsPageLoading(false)
|
setIsUsersPageLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePrevPage() {
|
function handlePrevUsersPage() {
|
||||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="employees-page">
|
<section className="employees-page">
|
||||||
<EmployeesToolbar onAddEmployee={() => setIsAddEmployeeOpen(true)} />
|
<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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="employees-table-container">
|
<div className="employees-table-container">
|
||||||
<div className="employees-table-card">
|
<div
|
||||||
{loading && employees.length === 0 && (
|
className={`employees-table-card ${activeSection === 'organisations' && isOrganisationsUpdating
|
||||||
|
? 'is-updating'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{activeSection === 'users' && (
|
||||||
|
<>
|
||||||
|
{usersLoading && employees.length === 0 && (
|
||||||
<div className="employees-state">Загрузка сотрудников...</div>
|
<div className="employees-state">Загрузка сотрудников...</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{usersError && (
|
||||||
<div className="employees-state employees-state--error">
|
<div className="employees-state employees-state--error">
|
||||||
Не удалось загрузить сотрудников
|
Не удалось загрузить сотрудников
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && employees.length === 0 && (
|
{!usersLoading && !usersError && employees.length === 0 && (
|
||||||
<div className="employees-state">Сотрудники не найдены</div>
|
<div className="employees-state">Сотрудники не найдены</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && employees.length > 0 && (
|
{!usersError && employees.length > 0 && (
|
||||||
<table className="employees-table">
|
<table className="employees-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
<th>ФИО</th>
|
||||||
|
<th>Организация</th>
|
||||||
<th>Роль</th>
|
<th>Роль</th>
|
||||||
|
<th>Управление</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
|
|
@ -117,46 +394,329 @@ export function EmployeesPage() {
|
||||||
{employees.map((employee) => (
|
{employees.map((employee) => (
|
||||||
<tr key={employee.id} className="employees-table__row">
|
<tr key={employee.id} className="employees-table__row">
|
||||||
<td>{employee.id}</td>
|
<td>{employee.id}</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<span className="employee-role">{employee.role}</span>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{activeSection === 'users' && (
|
||||||
<div className="employees-pagination">
|
<div className="employees-pagination">
|
||||||
<span>Страница {currentPage}</span>
|
<span>Страница {usersCurrentPage}</span>
|
||||||
|
|
||||||
<div className="employees-pagination__controls">
|
<div className="employees-pagination__controls">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={currentPage === 1 || loading}
|
disabled={usersCurrentPage === 1 || usersLoading}
|
||||||
onClick={handlePrevPage}
|
onClick={handlePrevUsersPage}
|
||||||
>
|
>
|
||||||
Назад
|
Назад
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="is-active" type="button">
|
<button className="is-active" type="button">
|
||||||
{currentPage}
|
{usersCurrentPage}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!nextKey || loading}
|
disabled={!usersNextKey || usersLoading}
|
||||||
onClick={handleNextPage}
|
onClick={handleNextUsersPage}
|
||||||
>
|
>
|
||||||
Вперед
|
Вперед
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'organisations' && (
|
||||||
|
<div className="employees-pagination">
|
||||||
|
|
||||||
|
<div className="employees-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasPrevOrganisationsPage || organisationsLoading}
|
||||||
|
onClick={handlePrevOrganisationsPage}
|
||||||
|
aria-label="Предыдущая страница"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{organisationsPaginationItems.map((item, index) => {
|
||||||
|
if (item === 'prev-ellipsis' || item === 'next-ellipsis') {
|
||||||
|
const isPrev = item === 'prev-ellipsis'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="employees-pagination__ellipsis-button"
|
||||||
|
type="button"
|
||||||
|
key={`${item}-${index}`}
|
||||||
|
disabled={organisationsLoading}
|
||||||
|
onClick={
|
||||||
|
isPrev
|
||||||
|
? handleJumpPrevOrganisationsPage
|
||||||
|
: handleJumpNextOrganisationsPage
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
isPrev
|
||||||
|
? 'Перейти на 5 страниц назад'
|
||||||
|
: 'Перейти на 5 страниц вперед'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="employees-pagination__ellipsis-dots">...</span>
|
||||||
|
<span className="employees-pagination__ellipsis-arrow">
|
||||||
|
{isPrev ? '«' : '»'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIndex = item - 1
|
||||||
|
const isActive = pageIndex === organisationsCurrentPage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={isActive ? 'is-active' : ''}
|
||||||
|
type="button"
|
||||||
|
key={item}
|
||||||
|
disabled={organisationsLoading || isActive}
|
||||||
|
onClick={() => setOrganisationsCurrentPage(pageIndex)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasNextOrganisationsPage || organisationsLoading}
|
||||||
|
onClick={handleNextOrganisationsPage}
|
||||||
|
aria-label="Следующая страница"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AddEmployeeModal
|
<AddEmployeeModal
|
||||||
open={isAddEmployeeOpen}
|
open={isAddEmployeeOpen}
|
||||||
|
mode="create"
|
||||||
onOpenChange={setIsAddEmployeeOpen}
|
onOpenChange={setIsAddEmployeeOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddEmployeeModal
|
||||||
|
open={Boolean(editingEmployee)}
|
||||||
|
mode="edit"
|
||||||
|
employee={editingEmployee}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingEmployee(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddOrganisationModal
|
||||||
|
open={isAddOrganisationOpen}
|
||||||
|
mode="create"
|
||||||
|
onOpenChange={setIsAddOrganisationOpen}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setOrganisationsCurrentPage(0)
|
||||||
|
|
||||||
|
await refetchOrganisations({
|
||||||
|
page: 0,
|
||||||
|
query: debouncedOrganisationSearch || undefined,
|
||||||
|
sortDirection: organisationSort.sortDirection,
|
||||||
|
sortField: organisationSort.sortField,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddOrganisationModal
|
||||||
|
open={Boolean(editingOrganisation)}
|
||||||
|
mode="edit"
|
||||||
|
organisation={editingOrganisation}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingOrganisation(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
open={Boolean(deletingOrganisation)}
|
||||||
|
title="Удалить организацию?"
|
||||||
|
description={
|
||||||
|
deletingOrganisation
|
||||||
|
? `Организация «${deletingOrganisation.name}» будет удалена из системы. Это действие нельзя будет отменить.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmText="Удалить"
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDeletingOrganisation(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmDeleteOrganisation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
open={Boolean(deletingEmployee)}
|
||||||
|
title="Удалить сотрудника?"
|
||||||
|
description={
|
||||||
|
deletingEmployee
|
||||||
|
? `Сотрудник «${getEmployeeFullName(deletingEmployee)}» будет удалён из системы. Это действие нельзя будет отменить.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmText="Удалить"
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDeletingEmployee(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmDeleteEmployee}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +82,11 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-employee-field {
|
.add-employee-field {
|
||||||
|
|
@ -95,7 +100,7 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input, select {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border: 1px solid #dfe5ef;
|
border: 1px solid #dfe5ef;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import * as Dialog from '@radix-ui/react-dialog'
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useMutation } from '@apollo/client/react'
|
import { useMutation, useQuery } from '@apollo/client/react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
GET_USERS_PAGE_QUERY,
|
GET_USERS_PAGE_QUERY,
|
||||||
SIGN_UP_MUTATION,
|
SIGN_UP_MUTATION,
|
||||||
} from '../../../../entities/employee/api/employee.graphql'
|
} from '../../../../entities/employee/api/employee.graphql'
|
||||||
import type {
|
import type {
|
||||||
|
Employee,
|
||||||
|
EmployeeRole,
|
||||||
|
GetOrganisationsData,
|
||||||
|
GetOrganisationsVariables,
|
||||||
SignUpData,
|
SignUpData,
|
||||||
SignUpVariables,
|
SignUpVariables,
|
||||||
} from '../../../../entities/employee/model/types'
|
} from '../../../../entities/employee/model/types'
|
||||||
|
|
@ -16,14 +21,73 @@ import './AddEmployeeModal.scss'
|
||||||
|
|
||||||
type AddEmployeeModalProps = {
|
type AddEmployeeModalProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
mode?: 'create' | 'edit'
|
||||||
|
employee?: Employee | null
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps) {
|
export function AddEmployeeModal({
|
||||||
const [organisationId, setOrganisationId] = useState('')
|
open,
|
||||||
|
mode = 'create',
|
||||||
|
employee = null,
|
||||||
|
onOpenChange,
|
||||||
|
}: AddEmployeeModalProps) {
|
||||||
|
const isEditMode = mode === 'edit'
|
||||||
|
const [orgId, setOrgId] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [middleName, setMiddleName] = useState('')
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [groupId, setGroupId] = useState('')
|
const [role, setRole] = useState<EmployeeRole>('User')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
if (isEditMode && employee) {
|
||||||
|
setOrgId(String(employee.org?.id ?? employee.orgId ?? ''))
|
||||||
|
setLastName(employee.lastName ?? '')
|
||||||
|
setFirstName(employee.firstName ?? '')
|
||||||
|
setMiddleName(employee.middleName ?? '')
|
||||||
|
setUsername('')
|
||||||
|
setPassword('')
|
||||||
|
setRole(employee.role ?? 'User')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditMode) {
|
||||||
|
setLastName('')
|
||||||
|
setFirstName('')
|
||||||
|
setMiddleName('')
|
||||||
|
setUsername('')
|
||||||
|
setPassword('')
|
||||||
|
setRole('User')
|
||||||
|
setOrgId('')
|
||||||
|
}
|
||||||
|
}, [open, isEditMode, employee])
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: organisationsData,
|
||||||
|
loading: organisationsLoading,
|
||||||
|
error: organisationsError,
|
||||||
|
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
|
{
|
||||||
|
variables: {},
|
||||||
|
skip: !open,
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const organisations = organisationsData?.getOrganisations.page ?? []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (orgId) return
|
||||||
|
if (!organisations.length) return
|
||||||
|
|
||||||
|
setOrgId(String(organisations[0].id))
|
||||||
|
}, [open, organisations, orgId])
|
||||||
|
|
||||||
const [signUp, { loading, error }] = useMutation<SignUpData, SignUpVariables>(
|
const [signUp, { loading, error }] = useMutation<SignUpData, SignUpVariables>(
|
||||||
SIGN_UP_MUTATION,
|
SIGN_UP_MUTATION,
|
||||||
|
|
@ -35,23 +99,49 @@ export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps)
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onCompleted: () => {
|
onCompleted: () => {
|
||||||
|
setLastName('')
|
||||||
|
setFirstName('')
|
||||||
|
setMiddleName('')
|
||||||
setUsername('')
|
setUsername('')
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setGroupId('')
|
setRole('User')
|
||||||
|
setOrgId('')
|
||||||
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
|
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!orgId) return
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
console.log('Редактирование сотрудника пока без мутации', {
|
||||||
|
id: employee?.id,
|
||||||
|
orgId,
|
||||||
|
lastName,
|
||||||
|
firstName,
|
||||||
|
middleName,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
role,
|
||||||
|
})
|
||||||
|
|
||||||
|
onOpenChange(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
signUp({
|
signUp({
|
||||||
variables: {
|
variables: {
|
||||||
organisationId,
|
orgId,
|
||||||
|
lastName,
|
||||||
|
firstName,
|
||||||
|
middleName,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
groupId: groupId.trim() ? groupId : null,
|
role,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -65,11 +155,13 @@ export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps)
|
||||||
<div className="add-employee-modal__header">
|
<div className="add-employee-modal__header">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title className="add-employee-modal__title">
|
<Dialog.Title className="add-employee-modal__title">
|
||||||
Добавить сотрудника
|
{isEditMode ? 'Редактировать' : 'Добавить сотрудника'}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
|
||||||
<Dialog.Description className="add-employee-modal__description">
|
<Dialog.Description className="add-employee-modal__description">
|
||||||
Создание пользователя для доступа к смартфонам
|
{isEditMode
|
||||||
|
? 'Изменение данных пользователя системы MDM'
|
||||||
|
: 'Создание пользователя для доступа к системе MDM'}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -80,44 +172,103 @@ export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps)
|
||||||
|
|
||||||
<form className="add-employee-form" onSubmit={handleSubmit}>
|
<form className="add-employee-form" onSubmit={handleSubmit}>
|
||||||
<label className="add-employee-field">
|
<label className="add-employee-field">
|
||||||
{/* <span>Логин</span> */}
|
<span>Организация</span>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={orgId}
|
||||||
|
onChange={(event) => setOrgId(event.target.value)}
|
||||||
|
required
|
||||||
|
disabled={organisationsLoading || !organisations.length}
|
||||||
|
>
|
||||||
|
{organisationsLoading && (
|
||||||
|
<option value="">Загрузка организаций...</option>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!organisationsLoading && organisations.length === 0 && (
|
||||||
|
<option value="">Организации не найдены</option>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{organisations.map((organisation) => (
|
||||||
|
<option key={organisation.id} value={String(organisation.id)}>
|
||||||
|
{organisation.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="add-employee-form__grid">
|
||||||
|
<label className="add-employee-field">
|
||||||
|
<span>Фамилия</span>
|
||||||
<input
|
<input
|
||||||
value={username}
|
value={lastName}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setLastName(event.target.value)}
|
||||||
placeholder="Логин*"
|
placeholder="Например: Иванов"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="add-employee-field">
|
<label className="add-employee-field">
|
||||||
{/* <span>Пароль</span> */}
|
<span>Имя</span>
|
||||||
|
<input
|
||||||
|
value={firstName}
|
||||||
|
onChange={(event) => setFirstName(event.target.value)}
|
||||||
|
placeholder="Например: Иван"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="add-employee-field">
|
||||||
|
<span>Отчество</span>
|
||||||
|
<input
|
||||||
|
value={middleName}
|
||||||
|
onChange={(event) => setMiddleName(event.target.value)}
|
||||||
|
placeholder="Например: Иванович"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="add-employee-form__grid">
|
||||||
|
<label className="add-employee-field">
|
||||||
|
<span>Логин</span>
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Например: user1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="add-employee-field">
|
||||||
|
<span>Пароль</span>
|
||||||
<input
|
<input
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
placeholder="Пароль*"
|
placeholder="Введите пароль"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="add-employee-field">
|
<label className="add-employee-field">
|
||||||
{/* <span>ID организации</span> */}
|
<span>Роль</span>
|
||||||
<input
|
|
||||||
value={organisationId}
|
<select
|
||||||
onChange={(event) => setOrganisationId(event.target.value)}
|
value={role}
|
||||||
placeholder="ID организации*"
|
onChange={(event) => setRole(event.target.value as EmployeeRole)}
|
||||||
required
|
required
|
||||||
/>
|
>
|
||||||
|
<option value="User">Пользователь</option>
|
||||||
|
<option value="Admin">Администратор</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="add-employee-field">
|
{organisationsError && (
|
||||||
{/* <span>ID группы</span> */}
|
<div className="add-employee-error">
|
||||||
<input
|
Не удалось загрузить список организаций
|
||||||
value={groupId}
|
</div>
|
||||||
onChange={(event) => setGroupId(event.target.value)}
|
)}
|
||||||
placeholder="ID группы"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="add-employee-error">
|
<div className="add-employee-error">
|
||||||
|
|
@ -133,9 +284,9 @@ export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps)
|
||||||
<button
|
<button
|
||||||
className="add-employee-submit"
|
className="add-employee-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading || organisationsLoading || !orgId}
|
||||||
>
|
>
|
||||||
{loading ? 'Добавление...' : 'Добавить'}
|
{loading ? 'Добавление...' : isEditMode ? 'Сохранить' : 'Добавить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,366 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.add-organisation-modal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
|
||||||
|
background: rgba(15, 23, 42, 0.42);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
|
||||||
|
animation: addOrganisationOverlayShow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 90;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
|
||||||
|
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: addOrganisationModalShow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-modal__header {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-modal__title {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-modal__description {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-modal__close {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue;
|
||||||
|
background: $gray20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #dfe5ef;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0 14px;
|
||||||
|
|
||||||
|
background: #f8fafc;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $blue;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-error {
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(224, 0, 0, 0.08);
|
||||||
|
padding: 12px 14px;
|
||||||
|
|
||||||
|
color: $red;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-modal__footer {
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-cancel,
|
||||||
|
.add-organisation-submit {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-cancel {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $gray50;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray20;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-organisation-submit {
|
||||||
|
background: $blue;
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
import { useEffect, useState, type FormEvent, type ReactNode } 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,
|
||||||
|
GET_ORGANISATION_QUERY,
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
|
} from '../../../../entities/employee/api/employee.graphql'
|
||||||
|
import type {
|
||||||
|
ChangeOrganisationData,
|
||||||
|
ChangeOrganisationVariables,
|
||||||
|
CreateOrganisationData,
|
||||||
|
CreateOrganisationVariables,
|
||||||
|
GetOrganisationData,
|
||||||
|
GetOrganisationVariables,
|
||||||
|
Organisation,
|
||||||
|
OrganisationPolicy,
|
||||||
|
} from '../../../../entities/employee/model/types'
|
||||||
|
|
||||||
|
import './AddOrganisationModal.scss'
|
||||||
|
|
||||||
|
type AddOrganisationModalProps = {
|
||||||
|
open: boolean
|
||||||
|
mode?: 'create' | 'edit'
|
||||||
|
organisation?: Organisation | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSuccess?: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModalPolicyKey =
|
||||||
|
| keyof OrganisationPolicy
|
||||||
|
| 'wifi'
|
||||||
|
| 'speaker'
|
||||||
|
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddOrganisationModal({
|
||||||
|
open,
|
||||||
|
mode = 'create',
|
||||||
|
organisation = null,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: AddOrganisationModalProps) {
|
||||||
|
const client = useApolloClient()
|
||||||
|
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [policy, setPolicy] = useState<OrganisationPolicy>(
|
||||||
|
defaultOrganisationPolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
const isEditMode = mode === 'edit'
|
||||||
|
|
||||||
|
const [createOrganisation, { loading: isCreating, error: createError }] =
|
||||||
|
useMutation<CreateOrganisationData, CreateOrganisationVariables>(
|
||||||
|
CREATE_ORGANISATION_MUTATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
const [changeOrganisation, { loading: isChanging, error: changeError }] =
|
||||||
|
useMutation<ChangeOrganisationData, ChangeOrganisationVariables>(
|
||||||
|
CHANGE_ORGANISATION_MUTATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLoading = isCreating || isChanging
|
||||||
|
const mutationError = createError || changeError
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
if (isEditMode && organisation) {
|
||||||
|
setName(organisation.name ?? '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setName('')
|
||||||
|
setPolicy(defaultOrganisationPolicy)
|
||||||
|
}, [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()
|
||||||
|
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
|
||||||
|
if (!trimmedName) return
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
if (!organisation) return
|
||||||
|
|
||||||
|
let currentPolicy = organisation.policy
|
||||||
|
|
||||||
|
if (!currentPolicy) {
|
||||||
|
const result = await client.query<
|
||||||
|
GetOrganisationData,
|
||||||
|
GetOrganisationVariables
|
||||||
|
>({
|
||||||
|
query: GET_ORGANISATION_QUERY,
|
||||||
|
variables: {
|
||||||
|
id: String(organisation.id),
|
||||||
|
},
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
})
|
||||||
|
|
||||||
|
currentPolicy = result.data?.getOrganisation?.policy ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyVariables = getPolicyVariables(currentPolicy)
|
||||||
|
|
||||||
|
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?.()
|
||||||
|
|
||||||
|
onOpenChange(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('createOrganisation variables', {
|
||||||
|
name: trimmedName,
|
||||||
|
...policy,
|
||||||
|
})
|
||||||
|
|
||||||
|
await createOrganisation({
|
||||||
|
variables: {
|
||||||
|
name: trimmedName,
|
||||||
|
...policy,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await onSuccess?.()
|
||||||
|
|
||||||
|
setName('')
|
||||||
|
setPolicy(defaultOrganisationPolicy)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="add-organisation-modal__overlay" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="add-organisation-form" onSubmit={handleSubmit}>
|
||||||
|
<label className="add-organisation-field">
|
||||||
|
<span>Название организации</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="Например: Organisation 1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!isEditMode && (
|
||||||
|
<div className="add-organisation-policy">
|
||||||
|
<div className="add-organisation-policy__header">
|
||||||
|
<h3>Начальная политика</h3>
|
||||||
|
<p>Будет применяться к сотрудникам этой организации</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,19 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
transition: .2s ease;
|
||||||
svg{
|
svg{
|
||||||
height: 18px;
|
height: 18px;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
&:hover{
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-sort {
|
.employees-sort {
|
||||||
//height: 32px;
|
//height: 32px;
|
||||||
padding: 12px 14px 12px 20px !important;
|
padding: 11px 14px 11px 20px !important;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
|
@ -23,7 +27,7 @@
|
||||||
|
|
||||||
color: $gray50;
|
color: $gray50;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 550 !important;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 0.2s ease;
|
transition: 0.2s ease;
|
||||||
|
|
@ -32,21 +36,24 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover{
|
||||||
|
background-color: $gray20;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='open'] {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
background-color: $blue20;
|
||||||
|
}
|
||||||
|
svg{
|
||||||
|
height: 22px;
|
||||||
|
width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-sort__chevron {
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employees-sort[data-state='open'] {
|
.employees-sort[data-state='open'] {
|
||||||
color: $blue;
|
color: $blue;
|
||||||
|
|
||||||
.employees-sort__chevron {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-sort-menu {
|
.employees-sort-menu {
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,154 @@
|
||||||
import { useState } from 'react'
|
|
||||||
import { ChevronDown, Search } from 'lucide-react'
|
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
OrganisationSortDirection,
|
||||||
|
OrganisationSortField,
|
||||||
|
} from '../../../../entities/employee/model/types'
|
||||||
|
|
||||||
import '../../../DevicesPage/components/DevicesToolbar/DevicesToolbar.scss'
|
|
||||||
import './EmployeesToolbar.scss'
|
import './EmployeesToolbar.scss'
|
||||||
|
|
||||||
type EmployeesToolbarProps = {
|
export type EmployeesSortOption = {
|
||||||
onAddEmployee: () => void
|
value: string
|
||||||
|
label: string
|
||||||
|
sortField: OrganisationSortField
|
||||||
|
sortDirection: OrganisationSortDirection
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortOptions = [
|
export const employeesSortOptions: EmployeesSortOption[] = [
|
||||||
{
|
|
||||||
value: 'default',
|
|
||||||
label: 'По умолчанию',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: 'id-asc',
|
value: 'id-asc',
|
||||||
label: 'ID по возрастанию',
|
label: 'ID по возрастанию',
|
||||||
|
sortField: 'ID',
|
||||||
|
sortDirection: 'ASC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'id-desc',
|
value: 'id-desc',
|
||||||
label: 'ID по убыванию',
|
label: 'ID по убыванию',
|
||||||
|
sortField: 'ID',
|
||||||
|
sortDirection: 'DESC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'role-asc',
|
value: 'name-asc',
|
||||||
label: 'Роль А–Я',
|
label: 'Название А–Я',
|
||||||
|
sortField: 'Name',
|
||||||
|
sortDirection: 'ASC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'role-desc',
|
value: 'name-desc',
|
||||||
label: 'Роль Я–А',
|
label: 'Название Я–А',
|
||||||
|
sortField: 'Name',
|
||||||
|
sortDirection: 'DESC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'date-desc',
|
||||||
|
label: 'Сначала новые',
|
||||||
|
sortField: 'Date',
|
||||||
|
sortDirection: 'DESC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'date-asc',
|
||||||
|
label: 'Сначала старые',
|
||||||
|
sortField: 'Date',
|
||||||
|
sortDirection: 'ASC',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function EmployeesToolbar({ onAddEmployee }: EmployeesToolbarProps) {
|
type EmployeesToolbarProps = {
|
||||||
const [selectedSort, setSelectedSort] = useState(sortOptions[0])
|
addButtonText: string
|
||||||
|
onAdd: () => void
|
||||||
|
|
||||||
|
searchValue?: string
|
||||||
|
onSearchChange?: (value: string) => void
|
||||||
|
|
||||||
|
selectedSort?: EmployeesSortOption
|
||||||
|
onSortChange?: (option: EmployeesSortOption) => void
|
||||||
|
|
||||||
|
showSort?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeesToolbar({
|
||||||
|
addButtonText,
|
||||||
|
onAdd,
|
||||||
|
searchValue = '',
|
||||||
|
onSearchChange,
|
||||||
|
selectedSort = employeesSortOptions[0],
|
||||||
|
onSortChange,
|
||||||
|
showSort = true,
|
||||||
|
}: EmployeesToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="devices-toolbar">
|
<div className="devices-toolbar">
|
||||||
<label className="devices-search">
|
<label className="devices-search">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
<input type="text" placeholder="Поиск" />
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(event) => onSearchChange?.(event.target.value)}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="devices-toolbar__right">
|
<div className="devices-toolbar__right">
|
||||||
<button className='add-device add-employees' type='button' onClick={onAddEmployee}>
|
<button
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
className="add-device add-employees"
|
||||||
<path d="M4.00004 4.66683C4.00004 3.78277 4.35123 2.93493 4.97635 2.30981C5.60147 1.68469 6.44932 1.3335 7.33337 1.3335C8.21743 1.3335 9.06527 1.68469 9.6904 2.30981C10.3155 2.93493 10.6667 3.78277 10.6667 4.66683C10.6667 5.55088 10.3155 6.39873 9.6904 7.02385C9.06527 7.64897 8.21743 8.00016 7.33337 8.00016C6.44932 8.00016 5.60147 7.64897 4.97635 7.02385C4.35123 6.39873 4.00004 5.55088 4.00004 4.66683ZM3.21471 9.7815C4.28337 9.1295 5.73671 8.66683 7.33337 8.66683C7.6316 8.66683 7.92404 8.68238 8.21071 8.7135C8.32522 8.72578 8.4346 8.76752 8.52818 8.83465C8.62177 8.90178 8.69636 8.99201 8.7447 9.09654C8.79304 9.20108 8.81348 9.31635 8.80401 9.43114C8.79455 9.54592 8.75551 9.65629 8.69071 9.7515C8.23917 10.4143 7.99844 11.1981 8.00004 12.0002C8.00004 12.6135 8.13804 13.1935 8.38337 13.7115C8.43118 13.8124 8.45286 13.9237 8.44645 14.0352C8.44004 14.1467 8.40572 14.2547 8.34666 14.3495C8.2876 14.4443 8.20568 14.5227 8.10843 14.5775C8.01117 14.6324 7.90169 14.662 7.79004 14.6635L7.33337 14.6668C5.84737 14.6668 4.44337 14.5735 3.39137 14.2948C2.86804 14.1562 2.37537 13.9575 2.00204 13.6575C1.60671 13.3402 1.33337 12.8968 1.33337 12.3335C1.33337 11.8088 1.57204 11.3182 1.89604 10.9075C2.22537 10.4908 2.68071 10.1075 3.21471 9.78083V9.7815ZM12 9.3335C12.1769 9.3335 12.3464 9.40373 12.4714 9.52876C12.5965 9.65378 12.6667 9.82335 12.6667 10.0002V11.3335H14C14.1769 11.3335 14.3464 11.4037 14.4714 11.5288C14.5965 11.6538 14.6667 11.8234 14.6667 12.0002C14.6667 12.177 14.5965 12.3465 14.4714 12.4716C14.3464 12.5966 14.1769 12.6668 14 12.6668H12.6667V14.0002C12.6667 14.177 12.5965 14.3465 12.4714 14.4716C12.3464 14.5966 12.1769 14.6668 12 14.6668C11.8232 14.6668 11.6537 14.5966 11.5286 14.4716C11.4036 14.3465 11.3334 14.177 11.3334 14.0002V12.6668H10C9.82323 12.6668 9.65366 12.5966 9.52864 12.4716C9.40361 12.3465 9.33337 12.177 9.33337 12.0002C9.33337 11.8234 9.40361 11.6538 9.52864 11.5288C9.65366 11.4037 9.82323 11.3335 10 11.3335H11.3334V10.0002C11.3334 9.82335 11.4036 9.65378 11.5286 9.52876C11.6537 9.40373 11.8232 9.3335 12 9.3335Z" fill="white" />
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
>
|
||||||
|
{addButtonText === 'Добавить организацию' ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<defs>
|
||||||
|
<mask id="SVGRejWFe9v">
|
||||||
|
<g fill="none" stroke="#fff" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
|
||||||
|
<path strokeDasharray="64" d="M9 7h11c0.55 0 1 0.45 1 1v11c0 0.55 -0.45 1 -1 1h-16c-0.55 0 -1 -0.45 -1 -1v-11c0 -0.55 0.45 -1 1 -1Z">
|
||||||
|
<animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="64;0" />
|
||||||
|
</path>
|
||||||
|
<path strokeDasharray="16" strokeDashoffset="16" d="M9 7v-3c0 -0.55 0.45 -1 1 -1h4c0.55 0 1 0.45 1 1v3">
|
||||||
|
<animate fill="freeze" attributeName="stroke-dashoffset" begin="0.6s" dur="0.3s" to="0" />
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
<path d="M19 13c3.31 0 6 2.69 6 6c0 3.31 -2.69 6 -6 6c-3.31 0 -6 -2.69 -6 -6c0 -3.31 2.69 -6 6 -6Z" opacity="0">
|
||||||
|
<set fill="freeze" attributeName="opacity" begin="1s" to="1" />
|
||||||
|
</path>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<path fill="currentColor" d="M0 0h24v24H0z" mask="url(#SVGRejWFe9v)" />
|
||||||
|
<g fill="none" stroke="currentColor" strokeDasharray="8" strokeDashoffset="8" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
|
||||||
|
<path d="M16 19h6">
|
||||||
|
<animate fill="freeze" attributeName="stroke-dashoffset" begin="1s" dur="0.2s" to="0" />
|
||||||
|
</path>
|
||||||
|
<path d="M19 16v6">
|
||||||
|
<animate fill="freeze" attributeName="stroke-dashoffset" begin="1.2s" dur="0.2s" to="0" />
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
Добавить
|
) : (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4.00004 4.66683C4.00004 3.78277 4.35123 2.93493 4.97635 2.30981C5.60147 1.68469 6.44932 1.3335 7.33337 1.3335C8.21743 1.3335 9.06527 1.68469 9.6904 2.30981C10.3155 2.93493 10.6667 3.78277 10.6667 4.66683C10.6667 5.55088 10.3155 6.39873 9.6904 7.02385C9.06527 7.64897 8.21743 8.00016 7.33337 8.00016C6.44932 8.00016 5.60147 7.64897 4.97635 7.02385C4.35123 6.39873 4.00004 5.55088 4.00004 4.66683ZM3.21471 9.7815C4.28337 9.1295 5.73671 8.66683 7.33337 8.66683C7.6316 8.66683 7.92404 8.68238 8.21071 8.7135C8.32522 8.72578 8.4346 8.76752 8.52818 8.83465C8.62177 8.90178 8.69636 8.99201 8.7447 9.09654C8.79304 9.20108 8.81348 9.31635 8.80401 9.43114C8.79455 9.54592 8.75551 9.65629 8.69071 9.7515C8.23917 10.4143 7.99844 11.1981 8.00004 12.0002C8.00004 12.6135 8.13804 13.1935 8.38337 13.7115C8.43118 13.8124 8.45286 13.9237 8.44645 14.0352C8.44004 14.1467 8.40572 14.2547 8.34666 14.3495C8.2876 14.4443 8.20568 14.5227 8.10843 14.5775C8.01117 14.6324 7.90169 14.662 7.79004 14.6635L7.33337 14.6668C5.84737 14.6668 4.44337 14.5735 3.39137 14.2948C2.86804 14.1562 2.37537 13.9575 2.00204 13.6575C1.60671 13.3402 1.33337 12.8968 1.33337 12.3335C1.33337 11.8088 1.57204 11.3182 1.89604 10.9075C2.22537 10.4908 2.68071 10.1075 3.21471 9.78083V9.7815ZM12 9.3335C12.1769 9.3335 12.3464 9.40373 12.4714 9.52876C12.5965 9.65378 12.6667 9.82335 12.6667 10.0002V11.3335H14C14.1769 11.3335 14.3464 11.4037 14.4714 11.5288C14.5965 11.6538 14.6667 11.8234 14.6667 12.0002C14.6667 12.177 14.5965 12.3465 14.4714 12.4716C14.3464 12.5966 14.1769 12.6668 14 12.6668H12.6667V14.0002C12.6667 14.177 12.5965 14.3465 12.4714 14.4716C12.3464 14.5966 12.1769 14.6668 12 14.6668C11.8232 14.6668 11.6537 14.5966 11.5286 14.4716C11.4036 14.3465 11.3334 14.177 11.3334 14.0002V12.6668H10C9.82323 12.6668 9.65366 12.5966 9.52864 12.4716C9.40361 12.3465 9.33337 12.177 9.33337 12.0002C9.33337 11.8234 9.40361 11.6538 9.52864 11.5288C9.65366 11.4037 9.82323 11.3335 10 11.3335H11.3334V10.0002C11.3334 9.82335 11.4036 9.65378 11.5286 9.52876C11.6537 9.40373 11.8232 9.3335 12 9.3335Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addButtonText}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{showSort && (
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<button className="employees-sort" type="button">
|
<button className="employees-sort" type="button">
|
||||||
{selectedSort.label}
|
{selectedSort.label}
|
||||||
<ChevronDown className="employees-sort__chevron" size={16} />
|
|
||||||
|
<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>
|
</button>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
|
@ -63,11 +158,11 @@ export function EmployeesToolbar({ onAddEmployee }: EmployeesToolbarProps) {
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
{sortOptions.map((option) => (
|
{employeesSortOptions.map((option) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className="employees-sort-menu__item"
|
className="employees-sort-menu__item"
|
||||||
onSelect={() => setSelectedSort(option)}
|
onSelect={() => onSortChange?.(option)}
|
||||||
>
|
>
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
|
|
||||||
|
|
@ -79,6 +174,7 @@ export function EmployeesToolbar({ onAddEmployee }: EmployeesToolbarProps) {
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ type LoginPageProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginPage({ onSuccess }: LoginPageProps) {
|
export function LoginPage({ onSuccess }: LoginPageProps) {
|
||||||
const [username, setUsername] = useState('User1')
|
const [username, setUsername] = useState('Admin1')
|
||||||
const [password, setPassword] = useState('123456')
|
const [password, setPassword] = useState('123456')
|
||||||
|
|
||||||
const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, {
|
const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
.map-page {
|
.map-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
z-index: 99;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -164,3 +166,62 @@
|
||||||
color: #738098;
|
color: #738098;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: none !important;
|
||||||
|
background-clip: padding-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: white !important;
|
||||||
|
color: $blue !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-top.leaflet-left {
|
||||||
|
top: 50%;
|
||||||
|
left: auto;
|
||||||
|
right: 12px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-track-filter {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 600;
|
||||||
|
border-radius: 24px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,38 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useQuery } from '@apollo/client/react'
|
import { useQuery } from '@apollo/client/react'
|
||||||
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
|
import {
|
||||||
|
CircleMarker,
|
||||||
|
MapContainer,
|
||||||
|
Marker,
|
||||||
|
Polyline,
|
||||||
|
Popup,
|
||||||
|
TileLayer,
|
||||||
|
useMap,
|
||||||
|
useMapEvents,
|
||||||
|
} from 'react-leaflet'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
|
|
||||||
import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
import {
|
||||||
import type { GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types'
|
GET_PHONE_GPS_TRACK_QUERY,
|
||||||
|
GET_PHONES_PAGE_QUERY,
|
||||||
|
} from '../../entities/device/api/device.graphql'
|
||||||
|
import type {
|
||||||
|
Device,
|
||||||
|
DeviceGpsTrackPoint,
|
||||||
|
GetPhoneGpsTrackData,
|
||||||
|
GetPhoneGpsTrackVariables,
|
||||||
|
GetPhonesPageData,
|
||||||
|
GetPhonesPageVariables,
|
||||||
|
} from '../../entities/device/model/types'
|
||||||
|
|
||||||
|
import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
|
||||||
|
import {
|
||||||
|
MapTrackPeriodControl,
|
||||||
|
type MapTrackPeriodValue,
|
||||||
|
} from './components/MapTrackPeriodControl/MapTrackPeriodControl'
|
||||||
|
|
||||||
import './MapPage.scss'
|
import './MapPage.scss'
|
||||||
import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
|
|
||||||
|
|
||||||
const markerIcon = L.divIcon({
|
const markerIcon = L.divIcon({
|
||||||
className: 'device-map-marker',
|
className: 'device-map-marker',
|
||||||
|
|
@ -30,20 +55,153 @@ function formatLocationDate(timestamp: number) {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
}).format(new Date(timestamp * 1000))
|
}).format(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
|
||||||
|
return [...track].sort((a, b) => a.date - b.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateInPeriod(date: number, period: MapTrackPeriodValue | null) {
|
||||||
|
if (!period) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
date >= period.periodStart.getTime() &&
|
||||||
|
date <= period.periodEnd.getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapResetHandlerProps = {
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapResetHandler({ onReset }: MapResetHandlerProps) {
|
||||||
|
useMapEvents({
|
||||||
|
click: () => {
|
||||||
|
onReset()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapFitBoundsProps = {
|
||||||
|
positions: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapFitBounds({ positions }: MapFitBoundsProps) {
|
||||||
|
const map = useMap()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!positions.length) return
|
||||||
|
|
||||||
|
if (positions.length === 1) {
|
||||||
|
map.setView(positions[0], 14)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = L.latLngBounds(positions)
|
||||||
|
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: [60, 60],
|
||||||
|
maxZoom: 15,
|
||||||
|
animate: true,
|
||||||
|
})
|
||||||
|
}, [map, positions])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapPage() {
|
export function MapPage() {
|
||||||
const { data, loading, error } = useQuery<GetPhonesPageData, GetPhonesPageVariables>(
|
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
|
||||||
GET_PHONES_PAGE_QUERY,
|
const [selectedPeriod, setSelectedPeriod] =
|
||||||
{
|
useState<MapTrackPeriodValue | null>(null)
|
||||||
|
|
||||||
|
const { data, loading, error } = useQuery<
|
||||||
|
GetPhonesPageData,
|
||||||
|
GetPhonesPageVariables
|
||||||
|
>(GET_PHONES_PAGE_QUERY, {
|
||||||
variables: {},
|
variables: {},
|
||||||
fetchPolicy: 'network-only',
|
fetchPolicy: 'network-only',
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: gpsTrackData,
|
||||||
|
loading: isGpsTrackLoading,
|
||||||
|
error: gpsTrackError,
|
||||||
|
} = useQuery<GetPhoneGpsTrackData, GetPhoneGpsTrackVariables>(
|
||||||
|
GET_PHONE_GPS_TRACK_QUERY,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
phoneId: String(selectedDevice?.id),
|
||||||
|
startDate: selectedPeriod?.periodStart.getTime() ?? 0,
|
||||||
|
endDate: selectedPeriod?.periodEnd.getTime(),
|
||||||
|
},
|
||||||
|
skip: !selectedDevice || !selectedPeriod,
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const devices = data?.getPhonesPage.page ?? []
|
const devices = data?.getPhonesPage.page ?? []
|
||||||
const devicesWithLocation = devices.filter((device) => device.lastLocation)
|
|
||||||
|
const devicesWithLocation = devices.filter((device) =>
|
||||||
|
Boolean(device.lastLocation),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedGpsTrack = useMemo(() => {
|
||||||
|
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
||||||
|
}, [gpsTrackData])
|
||||||
|
|
||||||
|
const shouldShowCurrentLocation = Boolean(
|
||||||
|
selectedDevice?.lastLocation &&
|
||||||
|
isDateInPeriod(selectedDevice.lastLocation.date, selectedPeriod),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCurrentLocation =
|
||||||
|
shouldShowCurrentLocation && selectedDevice?.lastLocation
|
||||||
|
? selectedDevice.lastLocation
|
||||||
|
: null
|
||||||
|
|
||||||
|
const selectedRoutePoints = useMemo(() => {
|
||||||
|
if (!selectedCurrentLocation) {
|
||||||
|
return sortedGpsTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedGpsTrack.filter((point) => {
|
||||||
|
return point.date !== selectedCurrentLocation.date
|
||||||
|
})
|
||||||
|
}, [sortedGpsTrack, selectedCurrentLocation])
|
||||||
|
|
||||||
|
const selectedRoutePositions = useMemo<[number, number][]>(() => {
|
||||||
|
const routePositions = selectedRoutePoints.map(
|
||||||
|
(point) => [point.lat, point.lng] as [number, number],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!selectedCurrentLocation) {
|
||||||
|
return routePositions
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...routePositions,
|
||||||
|
[selectedCurrentLocation.lat, selectedCurrentLocation.lng],
|
||||||
|
]
|
||||||
|
}, [selectedRoutePoints, selectedCurrentLocation])
|
||||||
|
|
||||||
|
const isSelectedMode = Boolean(selectedDevice)
|
||||||
|
|
||||||
|
const visibleMapPositions = useMemo<[number, number][]>(() => {
|
||||||
|
if (isSelectedMode && selectedRoutePositions.length > 0) {
|
||||||
|
return selectedRoutePositions
|
||||||
|
}
|
||||||
|
|
||||||
|
return devicesWithLocation.map(
|
||||||
|
(device) =>
|
||||||
|
[
|
||||||
|
device.lastLocation!.lat,
|
||||||
|
device.lastLocation!.lng,
|
||||||
|
] as [number, number],
|
||||||
|
)
|
||||||
|
}, [isSelectedMode, selectedRoutePositions, devicesWithLocation])
|
||||||
|
|
||||||
const firstLocation = devicesWithLocation[0]?.lastLocation
|
const firstLocation = devicesWithLocation[0]?.lastLocation
|
||||||
|
|
||||||
|
|
@ -51,44 +209,26 @@ export function MapPage() {
|
||||||
? [firstLocation.lat, firstLocation.lng]
|
? [firstLocation.lat, firstLocation.lng]
|
||||||
: [55.397243, 86.117034]
|
: [55.397243, 86.117034]
|
||||||
|
|
||||||
|
function handleSelectDevice(device: Device) {
|
||||||
|
setSelectedDevice(device)
|
||||||
|
setSelectedPeriod(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetSelection() {
|
||||||
|
setSelectedDevice(null)
|
||||||
|
setSelectedPeriod(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="map-page">
|
<section className="map-page">
|
||||||
{/* <div className="map-page__sidebar">
|
|
||||||
<div className="map-page__panel">
|
|
||||||
|
|
||||||
{loading && <p className="map-page__state">Загрузка устройств...</p>}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="map-page__state map-page__state--error">
|
|
||||||
Не удалось загрузить устройства
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && (
|
|
||||||
<>
|
|
||||||
|
|
||||||
<div className="map-page__list">
|
|
||||||
{devicesWithLocation.map((device) => (
|
|
||||||
<Link
|
|
||||||
key={device.id}
|
|
||||||
className="map-device-link"
|
|
||||||
to={`/devices/${device.id}`}
|
|
||||||
>
|
|
||||||
<span>{device.serial || `Устройство #${device.id}`}</span>
|
|
||||||
<small>
|
|
||||||
{device.lastLocation
|
|
||||||
? formatLocationDate(device.lastLocation.date)
|
|
||||||
: 'Нет данных'}
|
|
||||||
</small>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
<div className="map-page__map">
|
<div className="map-page__map">
|
||||||
|
{selectedDevice && (
|
||||||
|
<div className="map-track-filter">
|
||||||
|
|
||||||
|
<MapTrackPeriodControl onChange={setSelectedPeriod} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={mapCenter}
|
center={mapCenter}
|
||||||
zoom={12}
|
zoom={12}
|
||||||
|
|
@ -97,11 +237,13 @@ export function MapPage() {
|
||||||
className="map-page__leaflet"
|
className="map-page__leaflet"
|
||||||
id="devices-map"
|
id="devices-map"
|
||||||
>
|
>
|
||||||
<TileLayer
|
<MapResetHandler onReset={handleResetSelection} />
|
||||||
url={`https://api.maptiler.com/maps/hybrid/{z}/{x}/{y}.jpg?key=${import.meta.env.VITE_MAPTILER_KEY}`}
|
<MapFitBounds positions={visibleMapPositions} />
|
||||||
/>
|
|
||||||
|
|
||||||
{devicesWithLocation.map((device) => {
|
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
|
||||||
|
|
||||||
|
{!isSelectedMode &&
|
||||||
|
devicesWithLocation.map((device) => {
|
||||||
if (!device.lastLocation) return null
|
if (!device.lastLocation) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -109,10 +251,19 @@ export function MapPage() {
|
||||||
key={device.id}
|
key={device.id}
|
||||||
position={[device.lastLocation.lat, device.lastLocation.lng]}
|
position={[device.lastLocation.lat, device.lastLocation.lng]}
|
||||||
icon={markerIcon}
|
icon={markerIcon}
|
||||||
|
eventHandlers={{
|
||||||
|
click: (event) => {
|
||||||
|
event.originalEvent.stopPropagation()
|
||||||
|
handleSelectDevice(device)
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="map-popup">
|
<div className="map-popup">
|
||||||
<Link className="map-popup__title" to={`/devices/${device.id}`}>
|
<Link
|
||||||
|
className="map-popup__title"
|
||||||
|
to={`/devices/${device.id}`}
|
||||||
|
>
|
||||||
{device.serial || `Устройство #${device.id}`}
|
{device.serial || `Устройство #${device.id}`}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|
@ -128,6 +279,87 @@ export function MapPage() {
|
||||||
</Marker>
|
</Marker>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{isSelectedMode && (
|
||||||
|
<>
|
||||||
|
{selectedRoutePositions.length > 1 && (
|
||||||
|
<Polyline
|
||||||
|
positions={selectedRoutePositions}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#031d9a',
|
||||||
|
weight: 4,
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRoutePoints.map((point, index) => (
|
||||||
|
<CircleMarker
|
||||||
|
key={`${point.lat}-${point.lng}-${point.date}-${index}`}
|
||||||
|
center={[point.lat, point.lng]}
|
||||||
|
radius={4}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#031d9a',
|
||||||
|
fillColor: '#031d9a',
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<FullscreenControl targetId="devices-map" />
|
<FullscreenControl targetId="devices-map" />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,346 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.history-period-control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-period-control__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-period-tabs {
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba($color-bg, .8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-period-tabs__item {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 16px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 550;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date-picker {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date-picker__trigger {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba($color-bg, .8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $gray50;
|
||||||
|
transition: .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-active {
|
||||||
|
background: rgba($color-bg, 1);
|
||||||
|
color: $blue;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(3, 29, 154, 0.16);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date-picker__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
z-index: 120;
|
||||||
|
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 14px;
|
||||||
|
|
||||||
|
border: 1.5px solid $gray20;
|
||||||
|
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
|
||||||
|
|
||||||
|
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-selected-border: 0;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-months {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-month_caption {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-caption_label {
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-nav {
|
||||||
|
button {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-weekday {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-day {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-day_button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-selected {
|
||||||
|
.rdp-day_button {
|
||||||
|
background: $blue;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-range_middle {
|
||||||
|
background-color: transparent;
|
||||||
|
.rdp-day_button {
|
||||||
|
background: #e8edff;
|
||||||
|
color: $blue;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-range_start {
|
||||||
|
.rdp-day_button {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-range_end {
|
||||||
|
.rdp-day_button {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-range_start.rdp-range_end {
|
||||||
|
.rdp-day_button {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-today {
|
||||||
|
.rdp-day_button {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-outside {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
color: $blue;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__track {
|
||||||
|
position: relative;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 10px;
|
||||||
|
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(3, 29, 154, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__active {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__track input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__track input::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $blue;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-range__track input::-moz-range-thumb {
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $blue;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { DayPicker } from 'react-day-picker'
|
||||||
|
import type { DateRange } from 'react-day-picker'
|
||||||
|
import { ru } from 'date-fns/locale/ru'
|
||||||
|
import { CalendarDays, ChevronDown } from 'lucide-react'
|
||||||
|
|
||||||
|
import './MapTrackPeriodControl.scss'
|
||||||
|
|
||||||
|
type PeriodPreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom'
|
||||||
|
|
||||||
|
export type MapTrackPeriodValue = {
|
||||||
|
preset: PeriodPreset
|
||||||
|
periodStart: Date
|
||||||
|
periodEnd: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapTrackPeriodControlProps = {
|
||||||
|
onChange?: (value: MapTrackPeriodValue) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetTabs: Array<{
|
||||||
|
value: PeriodPreset
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: 'today', label: 'Сегодня' },
|
||||||
|
{ value: 'yesterday', label: 'Вчера' },
|
||||||
|
{ value: 'week', label: 'Неделя' },
|
||||||
|
{ value: 'month', label: 'Месяц' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setHours(0, 0, 0, 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(date: Date) {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setHours(23, 59, 59, 999)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number) {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setDate(result.getDate() + days)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date: Date, months: number) {
|
||||||
|
const result = new Date(date)
|
||||||
|
result.setMonth(result.getMonth() + months)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPresetRange(preset: PeriodPreset) {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (preset === 'today') {
|
||||||
|
return {
|
||||||
|
start: startOfDay(now),
|
||||||
|
end: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'yesterday') {
|
||||||
|
const yesterday = addDays(now, -1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startOfDay(yesterday),
|
||||||
|
end: endOfDay(yesterday),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'week') {
|
||||||
|
return {
|
||||||
|
start: startOfDay(addDays(now, -6)),
|
||||||
|
end: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'month') {
|
||||||
|
return {
|
||||||
|
start: startOfDay(addMonths(now, -1)),
|
||||||
|
end: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startOfDay(now),
|
||||||
|
end: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapTrackPeriodControl({ onChange }: MapTrackPeriodControlProps) {
|
||||||
|
const initialRange = useMemo(() => getPresetRange('today'), [])
|
||||||
|
|
||||||
|
const [preset, setPreset] = useState<PeriodPreset>('today')
|
||||||
|
const [isCalendarOpen, setIsCalendarOpen] = useState(false)
|
||||||
|
|
||||||
|
const [periodStart, setPeriodStart] = useState(initialRange.start)
|
||||||
|
const [periodEnd, setPeriodEnd] = useState(initialRange.end)
|
||||||
|
|
||||||
|
const [calendarRange, setCalendarRange] = useState<DateRange | undefined>({
|
||||||
|
from: initialRange.start,
|
||||||
|
to: initialRange.end,
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyPeriod(nextPreset: PeriodPreset, start: Date, end: Date) {
|
||||||
|
setPreset(nextPreset)
|
||||||
|
setPeriodStart(start)
|
||||||
|
setPeriodEnd(end)
|
||||||
|
|
||||||
|
setCalendarRange({
|
||||||
|
from: start,
|
||||||
|
to: end,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePresetClick(nextPreset: PeriodPreset) {
|
||||||
|
const nextRange = getPresetRange(nextPreset)
|
||||||
|
applyPeriod(nextPreset, nextRange.start, nextRange.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCalendarSelect(nextRange: DateRange | undefined) {
|
||||||
|
setCalendarRange(nextRange)
|
||||||
|
|
||||||
|
if (!nextRange?.from) return
|
||||||
|
|
||||||
|
const from = startOfDay(nextRange.from)
|
||||||
|
const to = nextRange.to ? endOfDay(nextRange.to) : endOfDay(nextRange.from)
|
||||||
|
|
||||||
|
applyPeriod('custom', from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange?.({
|
||||||
|
preset,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
})
|
||||||
|
}, [preset, periodStart, periodEnd, onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-period-control">
|
||||||
|
<div className="history-period-control__top">
|
||||||
|
<div className="history-period-tabs">
|
||||||
|
{presetTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
className={
|
||||||
|
preset === tab.value
|
||||||
|
? 'history-period-tabs__item is-active'
|
||||||
|
: 'history-period-tabs__item'
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePresetClick(tab.value)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-date-picker">
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
preset === 'custom'
|
||||||
|
? 'history-date-picker__trigger is-active'
|
||||||
|
: 'history-date-picker__trigger'
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCalendarOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<CalendarDays size={16} />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
{formatDate(periodStart)} — {formatDate(periodEnd)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isCalendarOpen && (
|
||||||
|
<div className="history-date-picker__dropdown">
|
||||||
|
<DayPicker
|
||||||
|
mode="range"
|
||||||
|
locale={ru}
|
||||||
|
selected={calendarRange}
|
||||||
|
onSelect={handleCalendarSelect}
|
||||||
|
numberOfMonths={1}
|
||||||
|
defaultMonth={periodStart}
|
||||||
|
weekStartsOn={1}
|
||||||
|
showOutsideDays
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,390 @@
|
||||||
|
@use '../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.organisation-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 11px 0;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $gray50;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span:last-child {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-page__top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(515px, 550px) minmax(420px, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-card {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-card__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-card__avatar {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
flex: 0 0 96px;
|
||||||
|
border-radius: 28px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 25% 20%, rgba(255, 255, 255, 0.1), transparent 32%),
|
||||||
|
linear-gradient(135deg, $blue, #425de8);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-card__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0px 0 4px;
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.1;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin: 0 0 0 8px;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0px 0 0;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-card__actions {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-action-btn {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.88;
|
||||||
|
|
||||||
|
box-shadow: inset 0 0 0 transparent;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
opacity 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-action-btn--edit {
|
||||||
|
background: $blue20;
|
||||||
|
color: $blue;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-action-btn--delete {
|
||||||
|
background: rgba($red, 0.12);
|
||||||
|
color: $red;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-employees-card {
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-employees-card__header {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid $gray20;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
>span {
|
||||||
|
min-width: 38px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $blue20;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $blue;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-employees-table-card {
|
||||||
|
min-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: $gray50 transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: $gray50;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-employees-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 900px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 14px 20px;
|
||||||
|
line-height: 1;
|
||||||
|
border-bottom: 1px solid $gray20;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 450;
|
||||||
|
text-align: left;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
height: 68px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid $gray20;
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(1),
|
||||||
|
td:nth-child(1) {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(2),
|
||||||
|
td:nth-child(2) {
|
||||||
|
width: 36%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(3),
|
||||||
|
td:nth-child(3) {
|
||||||
|
width: 34%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(4),
|
||||||
|
td:nth-child(4) {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
transition: 0.2s ease;
|
||||||
|
box-shadow: inset 0 0 0 transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba($blue, 0.025);
|
||||||
|
box-shadow: inset 3px 0 0 rgba($blue, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-employee-person {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-employee-org {
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-employee-role {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
background: rgba($blue, 0.08);
|
||||||
|
color: $blue;
|
||||||
|
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-employee-role--admin {
|
||||||
|
background: rgba($orange, .2);
|
||||||
|
color: $orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-page__state,
|
||||||
|
.organisation-page__empty {
|
||||||
|
min-height: 220px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $blue;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-page__state {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-page__state--error,
|
||||||
|
.organisation-page__empty--error {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.organisation-page__top {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,337 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useApolloClient, useQuery } from '@apollo/client/react'
|
||||||
|
import { Building2, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
GET_ORGANISATION_QUERY,
|
||||||
|
GET_USERS_PAGE_QUERY,
|
||||||
|
} from '../../entities/employee/api/employee.graphql'
|
||||||
|
import type {
|
||||||
|
Employee,
|
||||||
|
GetOrganisationData,
|
||||||
|
GetOrganisationVariables,
|
||||||
|
GetUsersPageData,
|
||||||
|
GetUsersPageVariables,
|
||||||
|
Organisation,
|
||||||
|
} from '../../entities/employee/model/types'
|
||||||
|
import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
||||||
|
import { AddOrganisationModal } from '../EmployeesPage/components/AddOrganisationModal/AddOrganisationModal'
|
||||||
|
import { OrganisationPolicyCard } from './components/OrganisationPolicyCard/OrganisationPolicyCard'
|
||||||
|
|
||||||
|
import './OrganisationPage.scss'
|
||||||
|
|
||||||
|
type LoadStatus = 'idle' | 'loading' | 'success' | 'error'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganisationPage() {
|
||||||
|
const client = useApolloClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { organisationId } = useParams()
|
||||||
|
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([])
|
||||||
|
const [employeesStatus, setEmployeesStatus] = useState<LoadStatus>('idle')
|
||||||
|
const [editingOrganisation, setEditingOrganisation] =
|
||||||
|
useState<Organisation | null>(null)
|
||||||
|
const [deletingOrganisation, setDeletingOrganisation] =
|
||||||
|
useState<Organisation | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: organisationData,
|
||||||
|
loading: organisationLoading,
|
||||||
|
error: organisationError,
|
||||||
|
} = useQuery<GetOrganisationData, GetOrganisationVariables>(
|
||||||
|
GET_ORGANISATION_QUERY,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
id: organisationId ?? '',
|
||||||
|
},
|
||||||
|
skip: !organisationId,
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const organisation = organisationData?.getOrganisation ?? null
|
||||||
|
|
||||||
|
const organisationInitials = useMemo(() => {
|
||||||
|
if (!organisation) return 'ОР'
|
||||||
|
|
||||||
|
return getOrganisationInitials(organisation.name)
|
||||||
|
}, [organisation])
|
||||||
|
|
||||||
|
const loadEmployees = useCallback(async () => {
|
||||||
|
if (!organisationId) {
|
||||||
|
setEmployeesStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmployeesStatus('loading')
|
||||||
|
|
||||||
|
try {
|
||||||
|
let nextKey: string | null = null
|
||||||
|
let pageCounter = 0
|
||||||
|
const allEmployees: Employee[] = []
|
||||||
|
|
||||||
|
do {
|
||||||
|
const result = await client.query<
|
||||||
|
GetUsersPageData,
|
||||||
|
GetUsersPageVariables
|
||||||
|
>({
|
||||||
|
query: GET_USERS_PAGE_QUERY,
|
||||||
|
variables: nextKey ? { key: nextKey } : {},
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageData = result.data?.getUsersPage
|
||||||
|
|
||||||
|
if (!pageData) break
|
||||||
|
|
||||||
|
allEmployees.push(
|
||||||
|
...pageData.page.filter(
|
||||||
|
(employee) => String(employee.orgId) === organisationId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
nextKey = pageData.nextKey
|
||||||
|
pageCounter += 1
|
||||||
|
} while (nextKey && pageCounter < 50)
|
||||||
|
|
||||||
|
setEmployees(allEmployees)
|
||||||
|
setEmployeesStatus('success')
|
||||||
|
} catch {
|
||||||
|
setEmployeesStatus('error')
|
||||||
|
}
|
||||||
|
}, [client, organisationId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadEmployees()
|
||||||
|
}, [loadEmployees])
|
||||||
|
|
||||||
|
function handleConfirmDeleteOrganisation() {
|
||||||
|
if (!deletingOrganisation) return
|
||||||
|
|
||||||
|
console.log('Удаление организации пока без мутации', deletingOrganisation)
|
||||||
|
|
||||||
|
setDeletingOrganisation(null)
|
||||||
|
navigate('/employees')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organisationId) {
|
||||||
|
return (
|
||||||
|
<section className="organisation-page">
|
||||||
|
<div className="organisation-page__empty">
|
||||||
|
<h2>Некорректный ID организации</h2>
|
||||||
|
<Link to="/employees">Вернуться к списку</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="organisation-page">
|
||||||
|
<div className="organisation-breadcrumbs">
|
||||||
|
<Link to="/employees">Сотрудники</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{organisation?.name ?? 'Организация'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{organisationLoading && !organisation && (
|
||||||
|
<div className="organisation-page__empty">Загрузка организации...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{organisationError && (
|
||||||
|
<div className="organisation-page__empty organisation-page__empty--error">
|
||||||
|
Не удалось загрузить организацию
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!organisationLoading && !organisationError && !organisation && (
|
||||||
|
<div className="organisation-page__empty">
|
||||||
|
<h2>Организация не найдена</h2>
|
||||||
|
<Link to="/employees">Вернуться к списку</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{organisation && (
|
||||||
|
<>
|
||||||
|
<div className="organisation-page__top">
|
||||||
|
<div>
|
||||||
|
<div className="organisation-card">
|
||||||
|
<div className="organisation-card__main">
|
||||||
|
<div className="organisation-card__avatar" aria-hidden="true">
|
||||||
|
{organisationInitials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="organisation-card__info">
|
||||||
|
<h2>
|
||||||
|
{organisation.name}
|
||||||
|
<span>ID: {organisation.id}</span>
|
||||||
|
</h2>
|
||||||
|
<p>Создана {formatCreationDate(organisation.creationDate)}</p>
|
||||||
|
<div className="organisation-card__actions">
|
||||||
|
<button
|
||||||
|
className="organisation-action-btn organisation-action-btn--edit"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingOrganisation(organisation)}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="organisation-action-btn organisation-action-btn--delete"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeletingOrganisation(organisation)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrganisationPolicyCard organisation={organisation} />
|
||||||
|
</div>
|
||||||
|
<div className="organisation-employees-card">
|
||||||
|
|
||||||
|
<div className="organisation-employees-table-card">
|
||||||
|
{employeesStatus === 'loading' && employees.length === 0 && (
|
||||||
|
<div className="organisation-page__state">
|
||||||
|
Загрузка сотрудников...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{employeesStatus === 'error' && (
|
||||||
|
<div className="organisation-page__state organisation-page__state--error">
|
||||||
|
Не удалось загрузить сотрудников
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{employeesStatus === 'success' && employees.length === 0 && (
|
||||||
|
<div className="organisation-page__state">
|
||||||
|
В этой организации пока нет сотрудников
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{employees.length > 0 && (
|
||||||
|
<table className="organisation-employees-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>ФИО</th>
|
||||||
|
<th>Организация</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{employees.map((employee) => (
|
||||||
|
<tr key={employee.id}>
|
||||||
|
<td>{employee.id}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div className="organisation-employee-person">
|
||||||
|
<span>
|
||||||
|
{getEmployeeFullName(employee) || 'ФИО не указано'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span className="organisation-employee-org">
|
||||||
|
{employee.org?.name || organisation.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
employee.role === 'Admin'
|
||||||
|
? 'organisation-employee-role organisation-employee-role--admin'
|
||||||
|
: 'organisation-employee-role'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getEmployeeRoleLabel(employee.role)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
@use '../../../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.organisation-policy-card {
|
||||||
|
//height: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-policy-card__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid $gray20;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-policy-card__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-policy-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $blue;
|
||||||
|
background: $color-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
.organisation-policy-item__content {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
svg,
|
||||||
|
span,
|
||||||
|
button {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-policy-switch {
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.organisation-policy-item__content {
|
||||||
|
min-height: 52px;
|
||||||
|
flex: 1;
|
||||||
|
border-top: 1px solid $gray20;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 26px;
|
||||||
|
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(16px);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { 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,
|
||||||
|
GET_ORGANISATION_QUERY,
|
||||||
|
GET_ORGANISATIONS_QUERY,
|
||||||
|
} from '../../../../entities/employee/api/employee.graphql'
|
||||||
|
import type {
|
||||||
|
ChangeOrganisationData,
|
||||||
|
ChangeOrganisationVariables,
|
||||||
|
Organisation,
|
||||||
|
OrganisationPolicy,
|
||||||
|
} from '../../../../entities/employee/model/types'
|
||||||
|
|
||||||
|
import './OrganisationPolicyCard.scss'
|
||||||
|
|
||||||
|
type PolicyKey = 'wifi' | 'bluetooth' | 'gps' | 'camera' | 'sim' | 'speaker'
|
||||||
|
|
||||||
|
type PolicyItem = {
|
||||||
|
key: PolicyKey
|
||||||
|
label: string
|
||||||
|
icon: ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganisationPolicyCardProps = {
|
||||||
|
organisation: Organisation
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyItems: PolicyItem[] = [
|
||||||
|
{
|
||||||
|
key: 'wifi',
|
||||||
|
label: 'Wi-Fi',
|
||||||
|
icon: <WifiIcon />,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bluetooth',
|
||||||
|
label: 'Bluetooth',
|
||||||
|
icon: <BluetoothIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gps',
|
||||||
|
label: 'GPS',
|
||||||
|
icon: <GpsIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'camera',
|
||||||
|
label: 'Камера',
|
||||||
|
icon: <CameraIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sim',
|
||||||
|
label: 'SIM-карта',
|
||||||
|
icon: <SimIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'speaker',
|
||||||
|
label: 'Динамик',
|
||||||
|
icon: <VolumeIcon />,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function mapApiPolicyToState(
|
||||||
|
policy?: OrganisationPolicy | null,
|
||||||
|
): Record<PolicyKey, boolean> {
|
||||||
|
return {
|
||||||
|
wifi: true,
|
||||||
|
bluetooth: policy?.canUseBluetooth ?? true,
|
||||||
|
gps: policy?.canUseGPS ?? true,
|
||||||
|
camera: policy?.canUseCamera ?? true,
|
||||||
|
sim: policy?.canUseSim ?? true,
|
||||||
|
speaker: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToMutationVariables(policy: Record<PolicyKey, boolean>) {
|
||||||
|
return {
|
||||||
|
canUseBluetooth: policy.bluetooth,
|
||||||
|
canUseCamera: policy.camera,
|
||||||
|
canUseGPS: policy.gps,
|
||||||
|
canUseSim: policy.sim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganisationPolicyCard({
|
||||||
|
organisation,
|
||||||
|
}: OrganisationPolicyCardProps) {
|
||||||
|
const initialPolicy = useMemo(
|
||||||
|
() => mapApiPolicyToState(organisation.policy),
|
||||||
|
[organisation.policy],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [policyState, setPolicyState] = useState(initialPolicy)
|
||||||
|
const [savingKey, setSavingKey] = useState<PolicyKey | null>(null)
|
||||||
|
const [saveError, setSaveError] = useState(false)
|
||||||
|
|
||||||
|
const [changeOrganisation] = useMutation<
|
||||||
|
ChangeOrganisationData,
|
||||||
|
ChangeOrganisationVariables
|
||||||
|
>(CHANGE_ORGANISATION_MUTATION)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPolicyState(initialPolicy)
|
||||||
|
}, [initialPolicy])
|
||||||
|
|
||||||
|
async function handleToggle(key: PolicyKey, disabled?: boolean) {
|
||||||
|
if (disabled || savingKey) return
|
||||||
|
|
||||||
|
const previousPolicy = policyState
|
||||||
|
const nextPolicy = {
|
||||||
|
...policyState,
|
||||||
|
[key]: !policyState[key],
|
||||||
|
}
|
||||||
|
|
||||||
|
setPolicyState(nextPolicy)
|
||||||
|
setSavingKey(key)
|
||||||
|
setSaveError(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changeOrganisation({
|
||||||
|
variables: {
|
||||||
|
id: String(organisation.id),
|
||||||
|
name: organisation.name,
|
||||||
|
...mapStateToMutationVariables(nextPolicy),
|
||||||
|
},
|
||||||
|
refetchQueries: [
|
||||||
|
{
|
||||||
|
query: GET_ORGANISATION_QUERY,
|
||||||
|
variables: {
|
||||||
|
id: String(organisation.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: GET_ORGANISATIONS_QUERY,
|
||||||
|
variables: {
|
||||||
|
page: 0,
|
||||||
|
query: undefined,
|
||||||
|
sortDirection: 'ID',
|
||||||
|
sortField: 'ASC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setPolicyState(previousPolicy)
|
||||||
|
setSaveError(true)
|
||||||
|
} finally {
|
||||||
|
setSavingKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="organisation-policy-card">
|
||||||
|
<div className="organisation-policy-card__header">
|
||||||
|
<div>
|
||||||
|
<h3>Групповая политика</h3>
|
||||||
|
<p>Применяется при авторизации сотрудника на устройстве</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="organisation-policy-card__list">
|
||||||
|
{policyItems.map((item) => {
|
||||||
|
const enabled = policyState[item.key]
|
||||||
|
const isSaving = savingKey === item.key
|
||||||
|
const isDisabled = item.disabled || Boolean(savingKey)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`organisation-policy-item ${item.disabled ? 'is-disabled' : ''
|
||||||
|
} ${isSaving ? 'is-saving' : ''}`}
|
||||||
|
key={item.key}
|
||||||
|
aria-disabled={item.disabled}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
|
||||||
|
<div className="organisation-policy-item__content">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`organisation-policy-switch ${enabled ? 'is-enabled' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleToggle(item.key, item.disabled)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-pressed={enabled}
|
||||||
|
aria-label={`${enabled ? 'Выключить' : 'Включить'
|
||||||
|
} ${item.label}`}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<div className="organisation-policy-card__error">
|
||||||
|
Не удалось сохранить групповую политику
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,23 @@ function isUnauthorizedError(error: unknown) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return error.errors.some((graphQLError) => {
|
return error.errors.some((graphQLError) => {
|
||||||
return graphQLError.extensions?.code === 'Unauthorized'
|
const code = String(graphQLError.extensions?.code ?? '').toLowerCase()
|
||||||
|
const classification = String(
|
||||||
|
graphQLError.extensions?.classification ?? '',
|
||||||
|
).toLowerCase()
|
||||||
|
const message = String(graphQLError.message ?? '').toLowerCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
code === 'unauthorized' ||
|
||||||
|
code === 'unauthenticated' ||
|
||||||
|
classification === 'unauthorized' ||
|
||||||
|
message.includes('unauthorized') ||
|
||||||
|
message.includes('unauthenticated') ||
|
||||||
|
message.includes('required any role') ||
|
||||||
|
message.includes('required role') ||
|
||||||
|
message.includes('access denied') ||
|
||||||
|
message.includes('forbidden')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,15 +82,23 @@ const errorLink = new ErrorLink(({ error, operation, forward }) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Важно: если Unauthorized прилетел уже на refreshSession,
|
|
||||||
* повторять refresh нельзя, иначе можно уйти в цикл.
|
|
||||||
*/
|
|
||||||
if (operation.operationName === 'RefreshSession') {
|
if (operation.operationName === 'RefreshSession') {
|
||||||
window.dispatchEvent(new Event('auth:logout'))
|
window.dispatchEvent(new Event('auth:logout'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alreadyRetried = operation.getContext().alreadyRetriedAfterRefresh
|
||||||
|
|
||||||
|
if (alreadyRetried) {
|
||||||
|
window.dispatchEvent(new Event('auth:logout'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operation.setContext({
|
||||||
|
...operation.getContext(),
|
||||||
|
alreadyRetriedAfterRefresh: true,
|
||||||
|
})
|
||||||
|
|
||||||
return new Observable((observer) => {
|
return new Observable((observer) => {
|
||||||
const retryRequest = () => {
|
const retryRequest = () => {
|
||||||
forward(operation).subscribe({
|
forward(operation).subscribe({
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
// colors light
|
// colors light
|
||||||
$color-bg: #EFF2F7;
|
$color-bg: #EFF2F7;
|
||||||
$gray50: #6A768E;
|
$gray50: #6A768E;
|
||||||
$gray30: hsla(219, 32%, 76%, 0.2);
|
$gray30: #aebcd533;
|
||||||
$gray20: #E2E7F0;
|
$gray20: #E2E7F0;
|
||||||
$color-text: #2F3747;
|
$color-text: #2F3747;
|
||||||
$red: #DD0000;
|
$red: #DD0000;
|
||||||
$red20: hsla(0, 100%, 43%, 0.2);
|
$red20: hsla(0, 100%, 43%, 0.2);
|
||||||
$green: #33B343;
|
$green: #33B343;
|
||||||
$blue: #031D9A;
|
$blue: #031D9A;
|
||||||
$blue20: hsla(230, 96%, 31%, 0.2);
|
//$blue20: hsla(230, 96%, 31%, 0.2);
|
||||||
|
$blue20: #d4deff;
|
||||||
$orange: #FF6B16;
|
$orange: #FF6B16;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
@use '../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.confirm-danger-dialog__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 500;
|
||||||
|
|
||||||
|
background: rgba(15, 23, 42, 0.42);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
|
||||||
|
animation: confirmDangerOverlayShow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
|
width: min(440px, calc(100vw - 32px));
|
||||||
|
padding: 22px;
|
||||||
|
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
|
||||||
|
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: confirmDangerDialogShow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog__icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(224, 0, 0, 0.08);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog__close {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $red;
|
||||||
|
background: rgba(224, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog__title {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
color: $color-text;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog__description {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog__footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog__cancel,
|
||||||
|
.confirm-danger-dialog__confirm {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition: 0.2s ease;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog__cancel {
|
||||||
|
background: $color-bg;
|
||||||
|
color: $gray50;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray20;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger-dialog__confirm {
|
||||||
|
background: $red;
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confirmDangerOverlayShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confirmDangerDialogShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -48%) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import { AlertTriangle, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import './ConfirmDangerDialog.scss'
|
||||||
|
|
||||||
|
type ConfirmDangerDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
isLoading?: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDangerDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmText = 'Удалить',
|
||||||
|
cancelText = 'Отмена',
|
||||||
|
isLoading = false,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}: ConfirmDangerDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="confirm-danger-dialog__overlay" />
|
||||||
|
|
||||||
|
<Dialog.Content className="confirm-danger-dialog">
|
||||||
|
<div className="confirm-danger-dialog__header">
|
||||||
|
<div className="confirm-danger-dialog__icon">
|
||||||
|
<AlertTriangle size={22} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Close
|
||||||
|
className="confirm-danger-dialog__close"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Title className="confirm-danger-dialog__title">
|
||||||
|
{title}
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
|
<Dialog.Description className="confirm-danger-dialog__description">
|
||||||
|
{description}
|
||||||
|
</Dialog.Description>
|
||||||
|
|
||||||
|
<div className="confirm-danger-dialog__footer">
|
||||||
|
<Dialog.Close
|
||||||
|
className="confirm-danger-dialog__cancel"
|
||||||
|
type="button"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</Dialog.Close>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="confirm-danger-dialog__confirm"
|
||||||
|
type="button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Удаление...' : confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,8 @@ function getPageTitle(pathname: string) {
|
||||||
if (pathname.startsWith('/devices/')) return 'Устройства'
|
if (pathname.startsWith('/devices/')) return 'Устройства'
|
||||||
|
|
||||||
if (pathname === '/employees') return 'Сотрудники'
|
if (pathname === '/employees') return 'Сотрудники'
|
||||||
|
if (pathname.startsWith('/employees/organisations/')) return 'Организация'
|
||||||
|
|
||||||
if (pathname === '/map') return 'Карта'
|
if (pathname === '/map') return 'Карта'
|
||||||
|
|
||||||
return 'Обзор'
|
return 'Обзор'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue