Промежуточный этап 1

This commit is contained in:
neizbejnoezlo 2026-05-13 11:30:45 +07:00
parent 9064a99373
commit 820bdb924f
70 changed files with 8610 additions and 625 deletions

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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 />,
},
], ],
}, },
]) ])

View File

@ -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>
) )

View File

@ -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>
) )

View File

@ -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>
) )

View File

@ -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" />

View File

@ -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>
) )

View File

@ -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>
) )

View File

@ -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>
) )

View File

@ -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>
) )
} }

View File

@ -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>

View File

@ -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>
) )

View File

@ -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
}
)
}
`

View File

@ -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>

View File

@ -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
}
} }
} }
` `

View File

@ -1,29 +1,123 @@
export type EmployeeRole = 'User' | 'Admin' | string
export type Employee = { export type Employee = {
id: number
firstName: string
lastName: string
middleName: string
orgId: number
role: EmployeeRole
org: {
id: number id: number
role: string 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 = {
getUsersPage: { getUsersPage: {
page: Employee[] page: Employee[]
nextKey: string | null nextKey: string | null
} }
} }
export type GetUsersPageVariables = { 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'

View File

@ -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
}
} }
} }
` `

View File

@ -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()
}} }}
/> />

View File

@ -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>,
) )

View File

@ -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;

View File

@ -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,104 +31,160 @@ 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: device.lastLocation.lat, lat: currentGpsPoint.lat,
lng: device.lastLocation.lng, lng: currentGpsPoint.lng,
} }
: undefined : device.lastLocation
? {
return {
id: device.id,
model: 'АРМАФОН S3.3+',
factoryNumber: device.serial || 'Заводской номер не указан',
imei: device.imei || 'IMEI не указан',
imei2: device.imei2 || 'IMEI 2 не указан',
serialNumber: device.serial || undefined,
employee: null,
condition: 'ok',
connection: device.lastLocation ? 'online' : 'offline',
connectionText: device.lastLocation ? 'В сети' : 'Нет геопозиции',
workTime: null,
registeredAt: undefined,
image: undefined,
location,
lastLocationAt: device.lastLocation
? formatLocationDate(device.lastLocation.date)
: 'Нет данных',
route: device.lastLocation
? [
{
lat: device.lastLocation.lat, lat: device.lastLocation.lat,
lng: device.lastLocation.lng, lng: device.lastLocation.lng,
time: formatLocationDate(device.lastLocation.date), }
}, : undefined
]
: [],
permissions: { const lastLocationAt = currentGpsPoint
wifi: true, ? formatLocationDate(currentGpsPoint.date)
bluetooth: true, : device.lastLocation
gps: Boolean(device.lastLocation), ? formatLocationDate(device.lastLocation.date)
camera: true, : 'Нет данных'
sim: true,
speaker: true,
},
statusIcons: { return {
gps: Boolean(device.lastLocation), id: device.id,
wifi: true,
bluetooth: true,
lock: false,
camera: true,
sim: true,
sound: true,
kiosk: false,
},
battery: undefined, model: 'АРМАФОН S3.3+',
batteryMaxCapacity: undefined, factoryNumber: device.serial || 'Заводской номер не указан',
chargeCycles: undefined, imei: device.imei || 'IMEI не указан',
totalWorkTime: undefined, imei2: device.imei2 || 'IMEI 2 не указан',
mediumImpacts: undefined, serialNumber: device.serial || undefined,
}
employee: device.org?.name ?? null,
organisation: device.org?.name,
condition: needMaintenance ? 'inspection' : 'ok',
connection: location ? 'online' : 'offline',
connectionText: location ? 'В сети' : 'Не в сети',
workTime: null,
registeredAt: device.registerDate
? formatLocationDate(device.registerDate)
: undefined,
image: undefined,
location,
lastLocationAt,
lastLocationDate: device.lastLocation?.date,
route: routeGpsPoints.map((point) => ({
lat: point.lat,
lng: point.lng,
time: formatLocationDate(point.date),
})),
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: techState?.batteryLevel ?? batteryLevel,
batteryMaxCapacity: techState?.batteryRemainingCapacity ?? undefined,
chargeCycles: formatTechValue(techState?.batteryCycles),
totalWorkTime: techState?.worktime ?? null,
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
variables: { >(GET_DEVICE_PAGE_QUERY, {
id: phoneId, variables: {
}, id: numericDeviceId,
skip: !phoneId, phoneId: String(numericDeviceId),
fetchPolicy: 'cache-and-network', telemetryStartDate: 0,
}, gpsStartDate: 0,
},
skip: !numericDeviceId,
fetchPolicy: 'network-only',
pollInterval: 15000,
})
const latestTelemetryItem = useMemo(() => {
return getLatestTelemetryItem(data?.getTelemetry ?? [])
}, [data])
const device = useMemo(() => {
if (!data?.getPhone) return null
return mapApiDeviceToPageDevice(
data.getPhone,
latestTelemetryItem?.batteryLevel,
data.getPhoneGpsTrack ?? [],
) )
}, [data, latestTelemetryItem])
const device = useMemo(() => { if (!numericDeviceId) {
if (!data?.getPhone) return null
return mapApiDeviceToPageDevice(data.getPhone)
}, [data])
if (!phoneId) {
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>
) )
} }

View File

@ -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;
}

View File

@ -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>
)
}

View File

@ -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;
}

View File

@ -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>
)
}

View File

@ -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);
}
}

View File

@ -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>
)
}

View File

@ -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;
}

View File

@ -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>
)
}

View File

@ -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>
) )
} }

View File

@ -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;
}
}

View File

@ -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,23 +253,26 @@ 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>
))} ))}
<Marker position={currentPosition} icon={deviceMarkerIcon}> {selectedCurrentLocation && (
<Popup> <Marker position={currentPosition} icon={deviceMarkerIcon}>
<div className="device-map-popup"> <Popup>
<b>{device.model ?? 'Устройство'}</b> <div className="device-map-popup">
<span>{device.factoryNumber}</span> <b>{device.model ?? 'Устройство'}</b>
<span>{device.imei}</span> <span>{device.factoryNumber}</span>
<span>{device.lastLocationAt ?? 'Время не указано'}</span> <span>{device.imei}</span>
</div> <span>{device.lastLocationAt ?? 'Время не указано'}</span>
</Popup> </div>
</Marker> </Popup>
<FullscreenControl targetId="devices-map" /> </Marker>
)}
<FullscreenControl targetId="device-map" />
</MapContainer> </MapContainer>
</div> </div>
</div> </div>

View File

@ -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);
}
}
}

View File

@ -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>
) )

View File

@ -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>
@ -50,8 +118,8 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
<div className="device-card device-impacts"> <div className="device-card device-impacts">
<ShieldCheck size={18} /> <ShieldCheck size={18} />
<div className="device-stats-text"> <div className="device-stats-text">
<span>Средних ударов</span> <span>Средних ударов</span>
<b>{device.mediumImpacts ?? '-'}</b> <b>{device.mediumImpacts ?? '-'}</b>
</div> </div>
</div> </div>
</div> </div>

View File

@ -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> = {

View File

@ -33,23 +33,23 @@
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: $gray50 transparent; scrollbar-color: $gray50 transparent;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
&::-webkit-scrollbar-track { &::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background: $gray30; background: $gray30;
border-radius: 999px; border-radius: 999px;
} }
&::-webkit-scrollbar-thumb:hover { &::-webkit-scrollbar-thumb:hover {
background: $gray50; background: $gray50;
} }
} }
.devices-table { .devices-table {
@ -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 {

View File

@ -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 || 'Не указана'}
Последняя геопозиция:{' '} </div>
{formatLocationDate(device.lastLocation.date)}
</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>
) )
} }

View File

@ -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 {

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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>
)
}

View File

@ -35,7 +35,10 @@
.devices-filter-item { .devices-filter-item {
border-radius: 14px; border-radius: 14px;
background: #ffffff; background: #ffffff;
overflow: visible; overflow: hidden;
&:nth-child(1)[data-state=open]{
overflow: visible;
}
} }
.devices-filter-item__header { .devices-filter-item__header {

View File

@ -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">

View File

@ -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; color: $blue;
} background-color: $blue20;
}
.devices-sort[data-state='open'] { svg{
color: $blue; height: 22px;
width: auto;
.devices-sort__chevron {
transform: rotate(180deg);
} }
} }
@ -168,8 +172,13 @@
cursor: pointer; cursor: pointer;
&:hover{
background-color: $gray20;
}
&--active{ &--active{
color: $blue; color: $blue;
background-color: $blue20 !important;
} }
svg { svg {

View File

@ -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>

View File

@ -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%;

View File

@ -1,162 +1,722 @@
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
<div className="employees-state">Загрузка сотрудников...</div> ? 'is-updating'
: ''
}`}
>
{activeSection === 'users' && (
<>
{usersLoading && employees.length === 0 && (
<div className="employees-state">Загрузка сотрудников...</div>
)}
{usersError && (
<div className="employees-state employees-state--error">
Не удалось загрузить сотрудников
</div>
)}
{!usersLoading && !usersError && employees.length === 0 && (
<div className="employees-state">Сотрудники не найдены</div>
)}
{!usersError && employees.length > 0 && (
<table className="employees-table">
<thead>
<tr>
<th>ID</th>
<th>ФИО</th>
<th>Организация</th>
<th>Роль</th>
<th>Управление</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.id} className="employees-table__row">
<td>{employee.id}</td>
<td>
<div className="employee-person">
<span>
{getEmployeeFullName(employee) || 'ФИО не указано'}
</span>
</div>
</td>
<td>
<span className="employee-organisation">
{employee.org?.name || 'Организация не указана'}
</span>
</td>
<td>
<span
className={
employee.role === 'Admin'
? 'employee-role employee-role--admin'
: 'employee-role'
}
>
{getEmployeeRoleLabel(employee.role)}
</span>
</td>
<td>
<div className="employees-table-actions">
<button
className="employees-action-btn employees-action-btn--edit"
type="button"
onClick={(event) => {
event.stopPropagation()
handleEditEmployee(employee)
}}
>
<Pencil size={16} />
Редактировать
</button>
<button
className="employees-action-btn employees-action-btn--delete"
type="button"
onClick={(event) => {
event.stopPropagation()
handleDeleteEmployee(employee)
}}
>
<Trash2 size={16} />
Удалить
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)} )}
{error && ( {activeSection === 'organisations' && (
<div className="employees-state employees-state--error"> <>
Не удалось загрузить сотрудников {isOrganisationsInitialLoading && (
<div className="employees-state">Загрузка организаций...</div>
)}
{organisationsError && (
<div className="employees-state employees-state--error">
Не удалось загрузить организации
</div>
)}
{!isOrganisationsInitialLoading &&
!organisationsError &&
organisations.length === 0 && (
<div className="employees-state">Организации не найдены</div>
)}
{!organisationsError && organisations.length > 0 && (
<table className="employees-table organisations-table">
<thead>
<tr>
<th>ID</th>
<th>Название организации</th>
<th>Управление</th>
</tr>
</thead>
<tbody>
{organisations.map((organisation) => (
<tr
key={organisation.id}
className="employees-table__row employees-table__row--clickable"
tabIndex={0}
onClick={() => navigate(`/employees/organisations/${organisation.id}`)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
navigate(`/employees/organisations/${organisation.id}`)
}
}}
>
<td>{organisation.id}</td>
<td>
<span className="employee-organisation-name">
{organisation.name}
</span>
</td>
<td>
<div className="employees-table-actions">
<button
className="employees-action-btn employees-action-btn--edit"
type="button"
onClick={(event) => {
event.stopPropagation()
handleEditOrganisation(organisation)
}}
>
<Pencil size={16} />
Редактировать
</button>
<button
className="employees-action-btn employees-action-btn--delete"
type="button"
onClick={(event) => {
event.stopPropagation()
handleDeleteOrganisation(organisation)
}}
>
<Trash2 size={16} />
Удалить
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
{activeSection === 'users' && (
<div className="employees-pagination">
<span>Страница {usersCurrentPage}</span>
<div className="employees-pagination__controls">
<button
type="button"
disabled={usersCurrentPage === 1 || usersLoading}
onClick={handlePrevUsersPage}
>
Назад
</button>
<button className="is-active" type="button">
{usersCurrentPage}
</button>
<button
type="button"
disabled={!usersNextKey || usersLoading}
onClick={handleNextUsersPage}
>
Вперед
</button>
</div> </div>
)}
{!loading && !error && employees.length === 0 && (
<div className="employees-state">Сотрудники не найдены</div>
)}
{!error && employees.length > 0 && (
<table className="employees-table">
<thead>
<tr>
<th>ID</th>
<th>Роль</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.id} className="employees-table__row">
<td>{employee.id}</td>
<td>
<span className="employee-role">{employee.role}</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="employees-pagination">
<span>Страница {currentPage}</span>
<div className="employees-pagination__controls">
<button
type="button"
disabled={currentPage === 1 || loading}
onClick={handlePrevPage}
>
Назад
</button>
<button className="is-active" type="button">
{currentPage}
</button>
<button
type="button"
disabled={!nextKey || loading}
onClick={handleNextPage}
>
Вперед
</button>
</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>
) )
} }

View File

@ -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;

View File

@ -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
value={lastName}
onChange={(event) => setLastName(event.target.value)}
placeholder="Например: Иванов"
required
/>
</label>
<label className="add-employee-field">
<span>Имя</span>
<input
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
placeholder="Например: Иван"
required
/>
</label>
</div>
<label className="add-employee-field">
<span>Отчество</span>
<input <input
value={username} value={middleName}
onChange={(event) => setUsername(event.target.value)} onChange={(event) => setMiddleName(event.target.value)}
placeholder="Логин*" placeholder="Например: Иванович"
required required
/> />
</label> </label>
<label className="add-employee-field"> <div className="add-employee-form__grid">
{/* <span>Пароль</span> */} <label className="add-employee-field">
<input <span>Логин</span>
value={password} <input
onChange={(event) => setPassword(event.target.value)} value={username}
placeholder="Пароль*" onChange={(event) => setUsername(event.target.value)}
type="password" placeholder="Например: user1"
required required
/> />
</label> </label>
<label className="add-employee-field">
<span>Пароль</span>
<input
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Введите пароль"
type="password"
required
/>
</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>

View File

@ -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);
}
}

View File

@ -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>
)
}

View File

@ -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 {

View File

@ -1,85 +1,181 @@
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', value: 'id-asc',
label: 'По умолчанию', label: 'ID по возрастанию',
}, sortField: 'ID',
{ sortDirection: 'ASC',
value: 'id-asc', },
label: 'ID по возрастанию', {
}, value: 'id-desc',
{ label: 'ID по убыванию',
value: 'id-desc', sortField: 'ID',
label: 'ID по убыванию', sortDirection: 'DESC',
}, },
{ {
value: 'role-asc', value: 'name-asc',
label: 'Роль А–Я', label: 'Название А–Я',
}, sortField: 'Name',
{ sortDirection: 'ASC',
value: 'role-desc', },
label: 'Роль Я–А', {
}, value: 'name-desc',
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
return ( searchValue?: string
<div className="devices-toolbar"> onSearchChange?: (value: string) => void
<label className="devices-search">
<Search size={16} />
<input type="text" placeholder="Поиск" />
</label>
<div className="devices-toolbar__right"> selectedSort?: EmployeesSortOption
<button className='add-device add-employees' type='button' onClick={onAddEmployee}> onSortChange?: (option: EmployeesSortOption) => void
<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>
Добавить
</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="employees-sort" type="button">
{selectedSort.label}
<ChevronDown className="employees-sort__chevron" size={16} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal> showSort?: boolean
<DropdownMenu.Content }
className="employees-sort-menu"
align="end" export function EmployeesToolbar({
sideOffset={8} addButtonText,
> onAdd,
{sortOptions.map((option) => ( searchValue = '',
<DropdownMenu.Item onSearchChange,
key={option.value} selectedSort = employeesSortOptions[0],
className="employees-sort-menu__item" onSortChange,
onSelect={() => setSelectedSort(option)} showSort = true,
> }: EmployeesToolbarProps) {
<span>{option.label}</span> return (
<div className="devices-toolbar">
{selectedSort.value === option.value && ( <label className="devices-search">
<span className="employees-sort-menu__check" /> <Search size={16} />
)}
</DropdownMenu.Item> <input
))} type="text"
</DropdownMenu.Content> placeholder="Поиск"
</DropdownMenu.Portal> value={searchValue}
</DropdownMenu.Root> onChange={(event) => onSearchChange?.(event.target.value)}
</div> />
</div> </label>
)
<div className="devices-toolbar__right">
<button
className="add-device add-employees"
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
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>
{showSort && (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="employees-sort" type="button">
{selectedSort.label}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6" />
</svg>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="employees-sort-menu"
align="end"
sideOffset={8}
>
{employeesSortOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="employees-sort-menu__item"
onSelect={() => onSortChange?.(option)}
>
<span>{option.label}</span>
{selectedSort.value === option.value && (
<span className="employees-sort-menu__check" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</div>
</div>
)
} }

View File

@ -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, {

View File

@ -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);
}

View File

@ -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: {},
fetchPolicy: 'network-only',
})
const {
data: gpsTrackData,
loading: isGpsTrackLoading,
error: gpsTrackError,
} = useQuery<GetPhoneGpsTrackData, GetPhoneGpsTrackVariables>(
GET_PHONE_GPS_TRACK_QUERY,
{ {
variables: {}, variables: {
phoneId: String(selectedDevice?.id),
startDate: selectedPeriod?.periodStart.getTime() ?? 0,
endDate: selectedPeriod?.periodEnd.getTime(),
},
skip: !selectedDevice || !selectedPeriod,
fetchPolicy: 'network-only', 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,37 +237,129 @@ 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}" />
if (!device.lastLocation) return null
return ( {!isSelectedMode &&
<Marker devicesWithLocation.map((device) => {
key={device.id} if (!device.lastLocation) return null
position={[device.lastLocation.lat, device.lastLocation.lng]}
icon={markerIcon}
>
<Popup>
<div className="map-popup">
<Link className="map-popup__title" to={`/devices/${device.id}`}>
{device.serial || `Устройство #${device.id}`}
</Link>
<div className="map-popup__row"> return (
<b>{formatLocationDate(device.lastLocation.date)}</b> <Marker
key={device.id}
position={[device.lastLocation.lat, device.lastLocation.lng]}
icon={markerIcon}
eventHandlers={{
click: (event) => {
event.originalEvent.stopPropagation()
handleSelectDevice(device)
},
}}
>
<Popup>
<div className="map-popup">
<Link
className="map-popup__title"
to={`/devices/${device.id}`}
>
{device.serial || `Устройство #${device.id}`}
</Link>
<div className="map-popup__row">
<b>{formatLocationDate(device.lastLocation.date)}</b>
</div>
<div className="map-popup__coords">
{device.lastLocation.lat}, {device.lastLocation.lng}
</div>
</div> </div>
</Popup>
</Marker>
)
})}
<div className="map-popup__coords"> {isSelectedMode && (
{device.lastLocation.lat}, {device.lastLocation.lng} <>
{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> </div>
</div> </Popup>
</Popup> </CircleMarker>
</Marker> ))}
)
})} {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>

View File

@ -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;
}

View File

@ -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>
)
}

View File

@ -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;
}
}

View File

@ -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>
)
}

View File

@ -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);
}
}

View File

@ -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>
)
}

View File

@ -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({

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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>
)
}

View File

@ -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 'Обзор'