Страница устройства, карта

This commit is contained in:
neizbejnoezlo 2026-04-29 09:07:36 +07:00
parent 7edf6d74be
commit b978875a0b
34 changed files with 1888 additions and 1016 deletions

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^4.1.9", "@apollo/client": "^4.1.9",
"@heroui/react": "^3.0.3",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@internationalized/date": "^3.12.1", "@internationalized/date": "^3.12.1",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
@ -24,7 +23,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^1.9.0", "lucide-react": "^1.9.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-datepicker": "^9.1.0", "react-day-picker": "^9.14.0",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-hook-form": "^7.73.1", "react-hook-form": "^7.73.1",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
@ -38,7 +37,6 @@
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-datepicker": "^6.2.0",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1", "eslint": "^10.2.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -3,7 +3,7 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
import { AppLayout } from '../layouts/AppLayout' import { AppLayout } from '../layouts/AppLayout'
import { DevicesPage } from '../../pages/DevicesPage/DevicesPage' 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'
@ -19,11 +19,11 @@ export const router = createBrowserRouter([
{ {
path: 'devices', path: 'devices',
element: <DevicesPage />, element: <DevicesPage />,
},/* },
{ {
path: 'devices/:deviceId', path: 'devices/:deviceId',
element: <DevicePage />, element: <DevicePage />,
}, */ },
{ {
path: 'map', path: 'map',
element: <MapPage />, element: <MapPage />,

View File

@ -0,0 +1,7 @@
export function BlockIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>
)
}

View File

@ -0,0 +1,7 @@
export function BluetoothIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>
)
}

View File

@ -0,0 +1,7 @@
export function CameraIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>
)
}

View File

@ -0,0 +1,9 @@
export function GpsIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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="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" />
</svg>
)
}

View File

@ -0,0 +1,7 @@
export function KioskIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>
)
}

View File

@ -0,0 +1,7 @@
export function MessageIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<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>
)
}

View File

@ -0,0 +1,7 @@
export function RebootIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>
)
}

View File

@ -0,0 +1,8 @@
export function SimIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
</svg>
)
}

View File

@ -0,0 +1,14 @@
export function VolumeIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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" />
</g>
<defs>
<clipPath id="clip0_1465_884">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
</svg>
)
}

View File

@ -0,0 +1,7 @@
export function WifiIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>
)
}

View File

@ -2,7 +2,7 @@
@font-face { @font-face {
font-family: 'Montserrat'; font-family: 'Montserrat';
src: url('../assets/fonts/montserrat/Montserrat-VariableFont_wght.ttf') format('truetype'); src: url('./assets/fonts/Montserrat-VariableFont_wght.ttf') format('truetype');
font-weight: 100 900; font-weight: 100 900;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@ -75,6 +75,12 @@
body { body {
margin: 0; margin: 0;
} }
button{
font-family: inherit;
}
a{
text-decoration: none;
}
h1, h1,
h2 { h2 {

View File

@ -3,7 +3,8 @@ import { createRoot } from 'react-dom/client'
import './index.scss' import './index.scss'
import { RouterProvider } from 'react-router-dom' import { RouterProvider } from 'react-router-dom'
import { router } from './app/router/router.tsx' import { router } from './app/router/router.tsx'
import 'react-datepicker/dist/react-datepicker.css' import 'react-day-picker/style.css'
import 'leaflet/dist/leaflet.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@ -0,0 +1,581 @@
@use '../../shared/styles/variables' as *;
.device-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.device-breadcrumbs {
display: flex;
align-items: center;
gap: 6px;
color: $gray50;
font-size: 16px;
font-weight: 500;
padding: 11px 0;
button, a {
padding: 0;
border: none;
background: transparent;
color: #738098;
font: inherit;
cursor: pointer;
transition: .2s ease;
&:hover {
color: $blue;
}
}
span:last-child {
color: #30394b;
}
}
.device-page__grid {
display: grid;
grid-template-columns: 420px 260px minmax(360px, 1fr);
gap: 20px;
align-items: start;
}
.device-card {
border-radius: 20px;
background: #ffffff;
padding: 20px;
}
.device-card--main {
grid-column: span 2;
}
.device-main {
display: flex;
flex-direction: row;
gap: 20px;
}
.device-main__image {
height: calc(50% - 40px);
width: 30%;
padding: 20px;
border-radius: 14px;
background: #f1f4f8;
display: flex;
align-items: center;
justify-content: center;
background-color: $color-bg;
img {
max-width: 100%;
max-height: 100%;
height: 100%;
width: auto;
object-fit: contain;
}
}
.device-main__info {
display: flex;
flex-direction: column;
flex: 1;
text-align: left;
h2 {
margin: 0 0 6px;
color: black;
font-size: 30px;
font-weight: 600;
line-height: 1.1;
}
p {
margin: 0;
color: $gray50;
font-size: 17px;
font-weight: 500;
line-height: 1.3;
}
}
.device-main__divider {
height: 1px;
margin: 12px 0 14px;
background: $gray20;
}
.device-main__statuses {
display: flex;
flex-direction: column;
gap: 4px;
}
.device-status {
display: inline-flex;
align-items: center;
gap: 9px;
color: black;
font-size: 18px;
font-weight: 500;
svg {
padding: 4px;
width: 24px;
height: 24px;
border-radius: 8px;
background: $color-bg;
}
&.is-success svg {
color: $green;
background: #eaf8ee;
}
&.is-danger svg {
color: $red;
background: #ffe8e8;
}
&.is-muted svg {
color: $gray50;
}
}
.device-main__bottom {
margin-top: 18px;
}
.device-registered {
width: 100%;
padding: 0;
display: inline-flex;
align-items: center;
gap: 10px;
color: black;
font-size: 18px;
font-weight: 500;
.device-registered-info {
display: flex;
flex: 1;
padding: 10px 0;
height: inherit;
justify-content: space-between;
border-bottom: 1px solid $gray20;
}
svg {
padding: 4px;
width: 24px;
height: 24px;
border-radius: 8px;
color: $gray50;
background: $color-bg;
}
b {
color: $gray50;
font-weight: 500;
}
}
.device-card__actions {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.device-history-btn {
padding: 12px;
border: none;
border-radius: 12px;
background: #dfe6ff;
color: $blue;
font-size: 18px;
font-weight: 500;
cursor: pointer;
transition: .2s ease;
line-height: 1;
&:hover {
background-color: $blue;
color: white;
}
}
.device-delete-btn {
padding: 12px;
border: none;
border-radius: 12px;
background: #ffdede;
display: inline-flex;
align-items: center;
justify-content: center;
color: $red;
cursor: pointer;
transition: .2s ease;
svg {
width: 18px;
height: 18px;
}
&:hover {
background-color: $red;
color: white;
}
}
.device-card__header {
margin-bottom: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
p {
margin: 0;
color: #738098;
font-size: 15px;
font-weight: 500;
}
}
.device-card__title {
display: flex;
align-items: center;
gap: 10px;
h3 {
margin: 0;
color: #30394b;
font-size: 18px;
font-weight: 600;
}
svg {
padding: 5px;
width: 30px;
height: 30px;
border-radius: 9px;
background: #eef1f6;
color: #30394b;
}
}
.device-map {
display: flex;
flex-direction: column;
grid-column: span 1;
min-height: 272px;
height: calc(100% - 40px);
}
.device-actions {
display: flex;
flex-direction: column;
justify-content: space-between;
height: calc(100% - 40px);
h3 {
line-height: 1;
margin: 0 0 12px;
padding-bottom: 12px;
border-bottom: 1px solid #e3e8f0;
text-align: left;
color: #30394b;
font-size: 18px;
font-weight: 600;
}
.actions-button-container {
display: flex;
flex-direction: column;
}
button {
width: 100%;
padding: 12px;
border: none;
border-radius: 12px;
background: $color-bg;
display: flex;
align-items: center;
gap: 8px;
color: $gray50;
font-size: 18px;
font-weight: 550;
cursor: pointer;
transition: .2s ease;
svg {
height: 20px;
width: 20px;
}
&:hover {
background-color: $gray20;
}
&+button {
margin-top: 8px;
}
}
}
.device-permissions {
padding: 10px 20px;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
height: calc(100% - 20px);
}
.device-permission {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #30394b;
font-size: 18px;
font-weight: 500;
&:nth-child(1) {
.device-permission__label {
border-top: none;
}
}
svg {
padding: 4px;
width: 24px;
height: 24px;
border-radius: 8px;
color: $blue;
background: #eef1f6;
}
}
.device-permission__label {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
gap: 10px;
padding: 10px 0;
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 {
display: flex;
flex-direction: column;
gap: 20px;
}
.device-battery {
h3 {
text-align: left;
line-height: 1;
margin: 0 0 12px;
padding-bottom: 12px;
border-bottom: 1px solid $gray20;
color: #30394b;
font-size: 18px;
font-weight: 600;
}
.device-stats-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
&:nth-child(2) {
.device-stats-text {
border-top: 1px solid $gray20;
border-bottom: 1px solid $gray20;
padding: 8px 0;
}
}
svg{
width: 24px;
height: 24px;
padding: 4px;
border-radius: 8px;
}
}
.device-stats-text {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.device-battery__content {
display: grid;
grid-template-columns: 120px 1fr;
gap: 20px;
align-items: center;
}
.device-battery__circle {
width: 96px;
height: 96px;
border-radius: 50%;
background:
radial-gradient(circle at center, #ffffff 58%, transparent 60%),
conic-gradient($blue 0 65%, #d6dce8 65% 100%);
display: flex;
align-items: center;
justify-content: center;
span {
color: #111827;
font-size: 24px;
font-weight: 600;
}
}
.device-stats {
display: flex;
flex-direction: column;
gap: 10px;
div {
display: grid;
grid-template-columns: 30px 1fr auto;
align-items: center;
gap: 10px;
color: #30394b;
font-size: 16px;
font-weight: 500;
}
svg {
padding: 5px;
width: 30px;
height: 30px;
border-radius: 9px;
color: #31b24a;
background: #eaf8ee;
}
b {
color: #30394b;
font-weight: 600;
}
}
.device-impacts {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
color: #30394b;
font-size: 18px;
font-weight: 500;
.device-stats-text{
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 20px;
}
svg {
padding: 4px;
width: 24px;
height: 24px;
border-radius: 9px;
color: $green;
background: #eaf8ee;
}
b {
font-weight: 600;
}
}
.device-page__empty {
min-height: 260px;
border-radius: 18px;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
h2 {
margin: 0;
}
button {
height: 40px;
padding: 0 16px;
border: none;
border-radius: 12px;
background: $blue;
color: #ffffff;
cursor: pointer;
}
}

View File

@ -0,0 +1,60 @@
import { useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Link } from 'react-router-dom'
import devices from '../DevicesPage/devices.mock.json'
import type { Device } from './types'
import { DeviceMainCard } from './components/DeviceMainCard/DeviceMainCard'
import { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard'
import { DeviceActionsCard } from './components/DeviceActionsCard/DeviceActionsCard'
import { DevicePermissionsCard } from './components/DevicePermissionsCard/DevicePermissionsCard'
import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards'
import './DevicePage.scss'
const typedDevices = devices as Device[]
export function DevicePage() {
const { deviceId } = useParams()
const navigate = useNavigate()
const device = useMemo(() => {
return typedDevices.find((item) => String(item.id) === deviceId)
}, [deviceId])
if (!device) {
return (
<section className="device-page">
<div className="device-page__empty">
<h2>Устройство не найдено</h2>
<button type="button" onClick={() => navigate('/devices')}>
Вернуться к списку
</button>
</div>
</section>
)
}
return (
<section className="device-page">
<div className="device-breadcrumbs">
<Link to="/devices">
Все устройства
</Link>
<span>/</span>
<span>{device.factoryNumber}</span>
</div>
<div className="device-page__grid">
<DeviceMainCard device={device} />
<DeviceMapCard device={device} />
<DeviceActionsCard />
<DevicePermissionsCard device={device} />
<DeviceStatsCards device={device} />
</div>
</section>
)
}

View File

@ -0,0 +1,34 @@
import { BlockIcon } from '../../../../assets/icons/Block'
import { KioskIcon } from '../../../../assets/icons/Kiosk'
import { RebootIcon } from '../../../../assets/icons/Reboot'
import { MessageIcon } from '../../../../assets/icons/Message'
export function DeviceActionsCard() {
return (
<div className="device-card device-actions">
<h3>Действия</h3>
<div className="actions-button-container">
<button type="button" disabled>
<BlockIcon />
Заблокировать
</button>
<button type="button" disabled>
<KioskIcon />
Включить режим киоска
</button>
<button type="button" disabled>
<RebootIcon />
Перезагрузить
</button>
<button type="button" disabled>
<MessageIcon />
Отправить сообщение
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,74 @@
import { ShieldCheck, Signal, Smartphone, Trash2 } from 'lucide-react'
import type { Device } from '../../types'
import { conditionText, connectionText, getStatusClass } from '../../types'
type DeviceMainCardProps = {
device: Device
}
export function DeviceMainCard({ device }: DeviceMainCardProps) {
return (
<div className="device-card device-card--main">
<div className="device-main">
<div className="device-main__image">
{device.image ? (
<img src={device.image} alt={device.model ?? 'Устройство'} />
) : (
<Smartphone size={84} />
)}
</div>
<div className="device-main__info">
<h2>{device.model ?? '-'}</h2>
<p>{device.factoryNumber}</p>
<p>
{device.imei}
{device.serialNumber ? ` · ${device.serialNumber}` : ''}
</p>
<div className="device-main__divider" />
<div className="device-main__statuses">
<div className={`device-status ${getStatusClass(device.condition)}`}>
<ShieldCheck size={17} />
{conditionText[device.condition]}
</div>
<div className={`device-status ${getStatusClass(device.connection)}`}>
<Signal size={17} />
{device.connectionText ?? connectionText[device.connection]}
</div>
{device.workTime && (
<div className="device-status is-muted">
<Smartphone size={17} />
В работе: {device.workTime}
</div>
)}
</div>
</div>
</div>
<div className="device-main__bottom">
<div className="device-registered">
<Smartphone size={17} />
<div className="device-registered-info">
<span>Зарегистрирован</span>
<b>{device.registeredAt ?? '10:00 20.04.2026'}</b>
</div>
</div>
<div className="device-card__actions">
<button className="device-history-btn" type="button">
Подробная история эксплуатации
</button>
<button className="device-delete-btn" type="button" aria-label="Удалить">
<Trash2 size={18} />
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,87 @@
@use '../../../../shared/styles/variables' as *;
.device-map__container {
display: flex;
flex: 1;
position: relative;
border-radius: 14px;
overflow: hidden;
background: #eef1f6;
}
.device-map__leaflet {
width: 100%;
height: 100%;
z-index: 1;
}
.device-map__coords {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 2;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
display: inline-flex;
align-items: center;
gap: 6px;
color: #30394b;
font-size: 12px;
font-weight: 600;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.12);
svg {
color: $blue;
}
}
.device-map-marker {
background: transparent;
border: none;
}
.device-map-marker__pin {
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(3, 29, 154, 0.18);
display: flex;
align-items: center;
justify-content: center;
}
.device-map-marker__inner {
width: 16px;
height: 16px;
border-radius: 50%;
background: $blue;
border: 3px solid #ffffff;
box-shadow: 0 8px 18px rgba(3, 29, 154, 0.35);
}
.device-map-popup {
display: flex;
flex-direction: column;
gap: 3px;
b {
color: #151a24;
font-size: 13px;
font-weight: 700;
}
span {
color: #738098;
font-size: 12px;
font-weight: 500;
}
}

View File

@ -0,0 +1,135 @@
import { useEffect, useMemo } from 'react'
import {
CircleMarker,
MapContainer,
Marker,
Polyline,
Popup,
TileLayer,
useMap,
} from 'react-leaflet'
import L from 'leaflet'
import { Map } from 'lucide-react'
import './DeviceMapCard.scss'
import type { Device } from '../../types'
type DeviceMapCardProps = {
device: Device
}
const defaultLocation = {
lat: 54.7558,
lng: 87.1099,
}
const deviceMarkerIcon = L.divIcon({
className: 'device-map-marker',
html: `
<div class="device-map-marker__pin">
<div class="device-map-marker__inner"></div>
</div>
`,
iconSize: [34, 34],
iconAnchor: [17, 17],
popupAnchor: [0, -18],
})
function MapResizeWatcher() {
const map = useMap()
useEffect(() => {
const timeoutId = window.setTimeout(() => {
map.invalidateSize()
}, 100)
return () => window.clearTimeout(timeoutId)
}, [map])
return null
}
export function DeviceMapCard({ device }: DeviceMapCardProps) {
const location = device.location ?? defaultLocation
const currentPosition = useMemo<[number, number]>(() => {
return [location.lat, location.lng]
}, [location.lat, location.lng])
const routePositions = useMemo<[number, number][]>(() => {
return device.route?.map((point) => [point.lat, point.lng]) ?? []
}, [device.route])
return (
<div className="device-card device-map">
<div className="device-card__header">
<div className="device-card__title">
<Map size={18} />
<h3>Устройство на карте</h3>
</div>
<p>
Последнее местоположение:{' '}
{device.lastLocationAt ?? '12:56 23.04.2026'}
</p>
</div>
<div className="device-map__container">
<MapContainer
center={currentPosition}
zoom={14}
scrollWheelZoom={true}
attributionControl={false}
className="device-map__leaflet"
>
<MapResizeWatcher />
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
{routePositions.length > 1 && (
<Polyline
positions={routePositions}
pathOptions={{
color: '#031d9a',
weight: 4,
opacity: 0.8,
}}
/>
)}
{device.route?.map((point, index) => (
<CircleMarker
key={`${point.lat}-${point.lng}-${index}`}
center={[point.lat, point.lng]}
radius={4}
pathOptions={{
color: '#031d9a',
fillColor: '#031d9a',
fillOpacity: 1,
weight: 2,
}}
>
<Popup>
<div className="device-map-popup">
<b>Точка маршрута</b>
{point.time && <span>{point.time}</span>}
</div>
</Popup>
</CircleMarker>
))}
<Marker position={currentPosition} icon={deviceMarkerIcon}>
<Popup>
<div className="device-map-popup">
<b>{device.model ?? 'Устройство'}</b>
<span>{device.factoryNumber}</span>
<span>{device.imei}</span>
<span>{device.lastLocationAt ?? 'Время не указано'}</span>
</div>
</Popup>
</Marker>
</MapContainer>
</div>
</div>
)
}

View File

@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import type { Device } from '../../types'
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'
type DevicePermissionsCardProps = {
device: Device
}
export function DevicePermissionsCard({ device }: DevicePermissionsCardProps) {
return (
<div className="device-card device-permissions">
<PermissionItem icon={<WifiIcon />} label="Wi-Fi" enabled={device.permissions?.wifi ?? true} />
<PermissionItem icon={<BluetoothIcon />} label="Bluetooth" enabled={device.permissions?.bluetooth ?? true} />
<PermissionItem icon={<GpsIcon />} label="GPS" enabled={device.permissions?.gps ?? true} />
<PermissionItem icon={<CameraIcon />} label="Камера" enabled={device.permissions?.camera ?? true} />
<PermissionItem icon={<SimIcon />} label="SIM-карта" enabled={device.permissions?.sim ?? true} />
<PermissionItem icon={<VolumeIcon />} label="Динамик" enabled={device.permissions?.speaker ?? true} />
</div>
)
}
type PermissionItemProps = {
icon: ReactNode
label: string
enabled: boolean
}
function PermissionItem({ icon, label, enabled }: PermissionItemProps) {
return (
<div className="device-permission">
{icon}
<div className="device-permission__label">
<span>{label}</span>
<span className={`device-permission__switch ${enabled ? 'is-enabled' : ''}`}>
<span />
</span>
</div>
</div>
)
}

View File

@ -0,0 +1,59 @@
import { Battery, RotateCcw, ShieldCheck, Smartphone } from 'lucide-react'
import type { Device } from '../../types'
type DeviceStatsCardsProps = {
device: Device
}
export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
return (
<div className="device-card-stats">
<div className="device-card device-battery">
<h3>Аккумулятор</h3>
<div className="device-battery__content">
<div className="device-battery__circle">
<span>{device.battery ?? '-'}%</span>
</div>
<div className="device-stats">
<div className="device-stats-card">
<Battery size={18} />
<div className="device-stats-text">
<span>Максимальная емкость</span>
<b>{device.batteryMaxCapacity ?? '-'}%</b>
</div>
</div>
<div className="device-stats-card">
<RotateCcw size={18} />
<div className="device-stats-text">
<span>Циклов перезарядки</span>
<b>{device.chargeCycles ?? '-'}</b>
</div>
</div>
<div className="device-stats-card">
<Smartphone size={18} />
<div className="device-stats-text">
<span>Общее время работы</span>
<b>{device.totalWorkTime ?? '-'}</b>
</div>
</div>
</div>
</div>
</div>
<div className="device-card device-impacts">
<ShieldCheck size={18} />
<div className="device-stats-text">
<span>Средних ударов</span>
<b>{device.mediumImpacts ?? '-'}</b>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,68 @@
export type DeviceCondition = 'ok' | 'inspection'
export type DeviceConnection = 'online' | 'offline' | 'offlineDanger'
export type Device = {
id: number
factoryNumber: string
model?: string
imei: string
serialNumber?: string
workTime: string | null
employee: string | null
condition: DeviceCondition
connection: DeviceConnection
connectionText: string
registeredAt?: string
lastLocationAt?: string
battery?: number
batteryMaxCapacity?: number
chargeCycles?: string
totalWorkTime?: string
mediumImpacts?: string
image?: string
location?: {
lat: number
lng: number
}
route?: {
lat: number
lng: number
time?: string
}[]
permissions?: {
wifi: boolean
bluetooth: boolean
gps: boolean
camera: boolean
sim: boolean
speaker: boolean
}
statusIcons: {
gps: boolean
wifi: boolean
bluetooth: boolean
lock: boolean
camera: boolean
sim: boolean
sound: boolean
kiosk: boolean
}
}
export const conditionText: Record<DeviceCondition, string> = {
ok: 'Исправно',
inspection: 'Требует осмотра',
}
export const connectionText: Record<DeviceConnection, string> = {
online: 'В сети',
offline: 'Не в сети',
offlineDanger: 'Долго не в сети',
}
export function getStatusClass(status: DeviceCondition | DeviceConnection) {
if (status === 'ok' || status === 'online') return 'is-success'
if (status === 'inspection' || status === 'offlineDanger') return 'is-danger'
return 'is-muted'
}

View File

@ -31,6 +31,17 @@
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
&__row{
transition: .2s ease;
cursor: pointer;
&:hover{
background-color: $gray20;
.devices-map-btn{
background-color: white;
}
}
}
th { th {
//height: 36px; //height: 36px;
padding: 14px 20px; padding: 14px 20px;

View File

@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { import {
Bluetooth, Bluetooth,
Camera, Camera,
@ -59,6 +60,8 @@ export function DevicesPage() {
const [isFiltersOpen, setIsFiltersOpen] = useState(true) const [isFiltersOpen, setIsFiltersOpen] = useState(true)
const navigate = useNavigate()
return ( return (
<section className="devices-page"> <section className="devices-page">
@ -84,7 +87,7 @@ export function DevicesPage() {
<tbody> <tbody>
{typedDevices.map((device) => ( {typedDevices.map((device) => (
<tr key={device.id}> <tr key={device.id} className="devices-table__row" onClick={() => navigate(`/devices/${device.id}`)}>
<td className="devices-table__id">{device.id}</td> <td className="devices-table__id">{device.id}</td>
<td> <td>

View File

@ -0,0 +1,246 @@
@use '../../../../shared/styles/variables' as *;
.devices-date-range {
position: relative;
width: 100%;
}
.devices-date-range__trigger {
width: 100%;
min-height: 34px;
padding: 0 10px;
border: none;
border-radius: 999px;
background: #eef1f6;
display: flex;
align-items: center;
gap: 8px;
color: #4f5b73;
font-size: 14px;
font-weight: 500;
text-align: left;
cursor: pointer;
span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.is-open {
box-shadow: 0 0 0 2px rgba(3, 29, 154, 0.15);
}
}
.devices-date-range__clear,
.devices-date-range__chevron {
flex: 0 0 auto;
color: $blue;
}
.devices-date-range__popover {
position: absolute;
z-index: 60;
top: calc(100% + 8px);
left: -13px;
width: 100%;
padding: 12px;
border-radius: 18px;
background: #ffffff;
border: 1.5px solid $gray20;
}
.devices-date-range__calendar {
--rdp-accent-color: $blue;
--rdp-accent-background-color: #e8edff;
--rdp-day_button-border-radius: 12px;
--rdp-day_button-height: 34px;
--rdp-day_button-width: 34px;
--rdp-day-height: 36px;
--rdp-day-width: 36px;
margin: 0;
font-family: inherit;
.rdp-months {
max-width: 100%;
}
.rdp-month {
width: 100%;
}
.rdp-caption_label {
font-size: 14px;
font-weight: 700;
color: #151a24;
}
.rdp-nav {
gap: 4px;
}
.rdp-button_previous,
.rdp-button_next {
width: 28px;
height: 28px;
color: $blue;
border-radius: 8px;
&:hover {
background: #eef1f6;
}
}
.rdp-weekday {
color: #8c96aa;
font-size: 12px;
font-weight: 600;
}
.rdp-day_button {
color: #30394b;
font-size: 12px;
font-weight: 600;
}
.rdp-day_button:hover {
background: #eef1f6;
}
.rdp-selected .rdp-day_button {
background: $blue;
color: #ffffff;
}
.rdp-range_middle .rdp-day_button {
background: #e8edff;
color: $blue;
border-radius: 0;
}
.rdp-range_start .rdp-day_button,
.rdp-range_end .rdp-day_button {
background: $blue;
color: #ffffff;
border-radius: 12px;
}
.rdp-outside {
opacity: 0.35;
}
}
.devices-date-range__time {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
label {
display: flex;
flex-direction: column;
gap: 5px;
span {
color: #738098;
font-size: 11px;
font-weight: 600;
}
input {
height: 32px;
padding: 0 10px;
border: none;
outline: none;
border-radius: 10px;
background: #eef1f6;
color: #30394b;
font-size: 13px;
font-weight: 600;
font-family: inherit;
}
}
}
.devices-date-range__presets {
margin-top: 10px;
display: flex;
gap: 6px;
button {
height: 28px;
padding: 0 10px;
border: none;
border-radius: 999px;
background: #eef1f6;
color: #4f5b73;
font-size: 12px;
font-weight: 600;
cursor: pointer;
&:hover {
background: #e8edff;
color: $blue;
}
}
}
.devices-date-range__footer {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e3e8f0;
display: flex;
flex-direction: column;
gap: 10px;
p {
margin: 0;
color: #738098;
font-size: 12px;
font-weight: 500;
}
}
.devices-date-range__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.devices-date-range__reset,
.devices-date-range__apply {
height: 32px;
padding: 0 12px;
border: none;
border-radius: 10px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.devices-date-range__reset {
background: #eef1f6;
color: #4f5b73;
}
.devices-date-range__apply {
background: $blue;
color: #ffffff;
}

View File

@ -0,0 +1,223 @@
import { useEffect, useRef, useState } from 'react'
import { DayPicker } from 'react-day-picker'
import type { DateRange } from 'react-day-picker'
import { ru } from 'date-fns/locale'
import { format } from 'date-fns'
import { CalendarDays, ChevronDown, X } from 'lucide-react'
import 'react-day-picker/style.css'
import './DevicesDateRangePicker.scss'
export type DevicesDateRangePickerValue = {
from: Date | null
to: Date | null
fromTime: string
toTime: string
}
type DevicesDateRangePickerProps = {
value: DevicesDateRangePickerValue
onChange: (value: DevicesDateRangePickerValue) => void
}
function formatDate(date: Date | null) {
if (!date) return ''
return format(date, 'dd.MM.yyyy', {
locale: ru,
})
}
function getLabel(value: DevicesDateRangePickerValue) {
if (!value.from && !value.to) {
return 'Выберите период'
}
if (value.from && !value.to) {
return `${formatDate(value.from)}, ${value.fromTime} — ...`
}
return `${formatDate(value.from)}, ${value.fromTime}${formatDate(value.to)}, ${value.toTime}`
}
export function DevicesDateRangePicker({
value,
onChange,
}: DevicesDateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (!rootRef.current) return
if (!rootRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const selectedRange: DateRange | undefined =
value.from || value.to
? {
from: value.from ?? undefined,
to: value.to ?? undefined,
}
: undefined
function handleSelect(range: DateRange | undefined) {
onChange({
...value,
from: range?.from ?? null,
to: range?.to ?? null,
})
}
function handleReset() {
onChange({
from: null,
to: null,
fromTime: '07:00',
toTime: '16:00',
})
}
function handleToday() {
const today = new Date()
onChange({
from: today,
to: today,
fromTime: '00:00',
toTime: '23:59',
})
}
function handleWeek() {
const today = new Date()
const start = new Date()
start.setDate(today.getDate() - 7)
onChange({
from: start,
to: today,
fromTime: '00:00',
toTime: '23:59',
})
}
return (
<div className="devices-date-range" ref={rootRef}>
<button
className={`devices-date-range__trigger ${isOpen ? 'is-open' : ''}`}
type="button"
onClick={() => setIsOpen((prev) => !prev)}
>
<CalendarDays size={15} />
<span>{getLabel(value)}</span>
{value.from || value.to ? (
<X
className="devices-date-range__clear"
size={15}
onClick={(event) => {
event.stopPropagation()
handleReset()
}}
/>
) : (
<ChevronDown className="devices-date-range__chevron" size={15} />
)}
</button>
{isOpen && (
<div className="devices-date-range__popover">
<DayPicker
mode="range"
selected={selectedRange}
onSelect={handleSelect}
locale={ru}
weekStartsOn={1}
numberOfMonths={1}
className="devices-date-range__calendar"
captionLayout="dropdown"
/>
<div className="devices-date-range__time">
<label>
<span>Время начала</span>
<input
type="time"
value={value.fromTime}
onChange={(event) =>
onChange({
...value,
fromTime: event.target.value,
})
}
/>
</label>
<label>
<span>Время окончания</span>
<input
type="time"
value={value.toTime}
onChange={(event) =>
onChange({
...value,
toTime: event.target.value,
})
}
/>
</label>
</div>
<div className="devices-date-range__presets">
<button type="button" onClick={handleToday}>
Сегодня
</button>
<button type="button" onClick={handleWeek}>
7 дней
</button>
</div>
<div className="devices-date-range__footer">
<p>
{value.from && value.to
? `Выбрано: ${formatDate(value.from)}${formatDate(value.to)}`
: 'Период не выбран'}
</p>
<div className="devices-date-range__actions">
<button
className="devices-date-range__reset"
type="button"
onClick={handleReset}
>
Сбросить
</button>
<button
className="devices-date-range__apply"
type="button"
onClick={() => setIsOpen(false)}
>
Применить
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -35,7 +35,7 @@
.devices-filter-item { .devices-filter-item {
border-radius: 14px; border-radius: 14px;
background: #ffffff; background: #ffffff;
overflow: hidden; overflow: visible;
} }
.devices-filter-item__header { .devices-filter-item__header {
@ -43,9 +43,9 @@
} }
.devices-filter-item__trigger { .devices-filter-item__trigger {
width: 100%; width: calc(100% - 32px);
min-height: 44px; min-height: 44px;
padding: 0 14px; margin: 0 16px;
border: none; border: none;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
@ -56,14 +56,14 @@
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
color: #30394b; color: black;
font-size: 15px; font-size: 18px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
&[data-state='open'] { &[data-state='open'] {
color: #031d9a; //color: $blue;
border-bottom-color: #e3e8f0; border-bottom-color: $gray20;
} }
} }
@ -77,7 +77,7 @@
} }
.devices-filter-item__content { .devices-filter-item__content {
overflow: hidden; //overflow: hidden;
&[data-state='open'] { &[data-state='open'] {
animation: filterSlideDown 0.2s ease; animation: filterSlideDown 0.2s ease;
@ -89,7 +89,7 @@
} }
.devices-filter-item__inner { .devices-filter-item__inner {
padding: 12px 14px 10px; padding: 12px 16px 16px;
} }
.devices-period { .devices-period {
@ -101,7 +101,7 @@
.devices-period__divider { .devices-period__divider {
width: 12px; width: 12px;
height: 1px; height: 1px;
background: #8c96aa; background: $gray50;
flex: 0 0 12px; flex: 0 0 12px;
} }
@ -115,14 +115,14 @@
border-radius: 999px; border-radius: 999px;
background: #eef1f6; background: #eef1f6;
color: #4f5b73; color: $gray50;
font-size: 12px; font-size: 16px;
font-weight: 400; font-weight: 400;
cursor: pointer; cursor: pointer;
} }
.devices-filter-reset { .devices-filter-reset {
margin: 8px 0 0 auto; margin: 12px 0 0 auto;
padding: 0; padding: 0;
display: block; display: block;
@ -130,8 +130,8 @@
border: none; border: none;
background: transparent; background: transparent;
color: #031d9a; color: $blue;
font-size: 12px; font-size: 14px;
font-weight: 400; font-weight: 400;
text-decoration: underline; text-decoration: underline;
cursor: pointer; cursor: pointer;
@ -145,18 +145,24 @@
min-height: 28px; min-height: 28px;
color: #30394b; color: $gray50;
font-size: 13px; font-size: 13px;
& + & { & + & {
margin-top: 8px; margin-top: 12px;
} }
} }
.devices-filter-row__label { .devices-filter-row__label {
color: #30394b; color: $gray50;
font-size: 13px; font-size: 16px;
font-weight: 400; font-weight: 500;
text-align: left;
}
.devices-checkbox-list{
display: flex;
flex-direction: column;
} }
.devices-radio, .devices-radio,
@ -166,7 +172,8 @@
gap: 6px; gap: 6px;
color: #30394b; color: #30394b;
font-size: 13px; font-size: 16px;
font-weight: 500;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;

View File

@ -1,13 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import DatePicker, { registerLocale } from 'react-datepicker'
import { ru } from 'date-fns/locale/ru'
import * as Accordion from '@radix-ui/react-accordion' import * as Accordion from '@radix-ui/react-accordion'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import './Datepicker.scss' import './Datepicker.scss'
import { DevicesDateRangePicker, type DevicesDateRangePickerValue } from '../DevicesDateRangePicker/DevicesDateRangePicker'
import './DevicesFiltersPanel.scss' import './DevicesFiltersPanel.scss'
registerLocale('ru', ru)
type DevicesFiltersPanelProps = { type DevicesFiltersPanelProps = {
isOpen: boolean isOpen: boolean
@ -15,13 +13,12 @@ type DevicesFiltersPanelProps = {
export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) { export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
const [startDate, setStartDate] = useState<Date | null>( const [workPeriod, setWorkPeriod] = useState<DevicesDateRangePickerValue>({
new Date(2026, 3, 20, 7, 0), from: new Date(2026, 3, 20),
) to: new Date(2026, 3, 22),
fromTime: '07:00',
const [endDate, setEndDate] = useState<Date | null>( toTime: '16:00',
new Date(2026, 3, 22, 16, 0), })
)
return ( return (
<aside className={`devices-filters ${isOpen ? 'devices-filters--open' : ''}`}> <aside className={`devices-filters ${isOpen ? 'devices-filters--open' : ''}`}>
@ -41,50 +38,9 @@ export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
<Accordion.Content className="devices-filter-item__content"> <Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner"> <div className="devices-filter-item__inner">
<div className="devices-period"> <div className="devices-period">
<DatePicker <DevicesDateRangePicker value={workPeriod} onChange={setWorkPeriod} />
selected={startDate}
onChange={(date) => setStartDate(date)}
selectsStart
startDate={startDate}
endDate={endDate}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="dd.MM.yyyy, HH:mm"
locale="ru"
placeholderText="Дата начала"
className="devices-period__input"
/>
<span className="devices-period__divider" />
<DatePicker
selected={endDate}
onChange={(date) => setEndDate(date)}
selectsEnd
startDate={startDate}
endDate={endDate}
minDate={startDate ?? undefined}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="dd.MM.yyyy, HH:mm"
locale="ru"
placeholderText="Дата окончания"
className="devices-period__input"
/>
</div> </div>
<button
className="devices-filter-reset"
type="button"
onClick={() => {
setStartDate(null)
setEndDate(null)
}}
>
Сбросить
</button>
</div> </div>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
@ -164,6 +120,7 @@ export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
<Accordion.Content className="devices-filter-item__content"> <Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner"> <div className="devices-filter-item__inner">
<div className='devices-checkbox-list'>
<label className="devices-checkbox"> <label className="devices-checkbox">
<input type="checkbox" defaultChecked /> <input type="checkbox" defaultChecked />
<span className="devices-checkbox__control" /> <span className="devices-checkbox__control" />
@ -182,6 +139,7 @@ export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
<span>Долго не в сети</span> <span>Долго не в сети</span>
</label> </label>
</div> </div>
</div>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
@ -195,6 +153,7 @@ export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
<Accordion.Content className="devices-filter-item__content"> <Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner"> <div className="devices-filter-item__inner">
<div className='devices-checkbox-list'>
<label className="devices-checkbox"> <label className="devices-checkbox">
<input type="checkbox" defaultChecked /> <input type="checkbox" defaultChecked />
<span className="devices-checkbox__control" /> <span className="devices-checkbox__control" />
@ -207,24 +166,6 @@ export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
<span>Требует осмотра</span> <span>Требует осмотра</span>
</label> </label>
</div> </div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item className="devices-filter-item" value="storage">
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Хранилище</span>
<ChevronDown className="devices-filter-item__chevron" size={16} />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<label className="devices-checkbox">
<input type="checkbox" />
<span className="devices-checkbox__control" />
<span>Заполнено больше 80%</span>
</label>
</div> </div>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>

View File

@ -2,12 +2,58 @@
{ {
"id": 1, "id": 1,
"factoryNumber": "Заводской номер", "factoryNumber": "Заводской номер",
"model": "АРМАФОН S3.3+",
"imei": "IMEI устройства", "imei": "IMEI устройства",
"serialNumber": "AF-S33-000001",
"workTime": "4 ч 15 мин", "workTime": "4 ч 15 мин",
"employee": "Иванов Иван Иванович", "employee": "Иванов Иван Иванович",
"condition": "ok", "condition": "ok",
"connection": "online", "connection": "online",
"connectionText": "В сети", "connectionText": "В сети",
"registeredAt": "10:00 20.04.2026",
"lastLocationAt": "12:56 23.04.2026",
"location": {
"lat": 55.397255,
"lng": 86.116905
},
"route": [
{
"lat": 55.392940,
"lng": 86.107870,
"time": "20.04.2026 07:10"
},
{
"lat": 55.394120,
"lng": 86.110420,
"time": "20.04.2026 07:35"
},
{
"lat": 55.395430,
"lng": 86.112980,
"time": "20.04.2026 08:05"
},
{
"lat": 55.396210,
"lng": 86.114640,
"time": "20.04.2026 08:35"
},
{
"lat": 55.396870,
"lng": 86.115760,
"time": "20.04.2026 09:00"
},
{
"lat": 55.397255,
"lng": 86.116905,
"time": "20.04.2026 09:20"
}
],
"battery": 65,
"batteryMaxCapacity": 90,
"chargeCycles": "100/500",
"totalWorkTime": "1000 ч",
"mediumImpacts": "50/300",
"image": "/devices/армафон3.3+.webp",
"statusIcons": { "statusIcons": {
"gps": true, "gps": true,
"wifi": true, "wifi": true,
@ -17,86 +63,14 @@
"sim": true, "sim": true,
"sound": false, "sound": false,
"kiosk": false "kiosk": false
}
}, },
{ "permissions": {
"id": 2,
"factoryNumber": "Заводской номер",
"imei": "IMEI устройства",
"workTime": null,
"employee": null,
"condition": "inspection",
"connection": "offline",
"connectionText": "Не в сети",
"statusIcons": {
"gps": true,
"wifi": true, "wifi": true,
"bluetooth": true, "bluetooth": true,
"lock": false, "gps": true,
"camera": true, "camera": true,
"sim": true, "sim": true,
"sound": false, "speaker": false
"kiosk": false
}
},
{
"id": 3,
"factoryNumber": "Заводской номер",
"imei": "IMEI устройства",
"workTime": "14 ч 15 мин",
"employee": "Иванов Иван Иванович",
"condition": "inspection",
"connection": "offlineDanger",
"connectionText": "Не в сети 10 ч 5 мин",
"statusIcons": {
"gps": true,
"wifi": true,
"bluetooth": true,
"lock": false,
"camera": true,
"sim": true,
"sound": false,
"kiosk": false
}
},
{
"id": 4,
"factoryNumber": "Заводской номер",
"imei": "IMEI устройства",
"workTime": null,
"employee": null,
"condition": "ok",
"connection": "online",
"connectionText": "В сети",
"statusIcons": {
"gps": true,
"wifi": true,
"bluetooth": true,
"lock": false,
"camera": true,
"sim": true,
"sound": false,
"kiosk": false
}
},
{
"id": 5,
"factoryNumber": "Заводской номер",
"imei": "IMEI устройства",
"workTime": null,
"employee": null,
"condition": "ok",
"connection": "offline",
"connectionText": "Не в сети",
"statusIcons": {
"gps": true,
"wifi": false,
"bluetooth": false,
"lock": true,
"camera": false,
"sim": true,
"sound": false,
"kiosk": true
} }
} }
] ]

View File

@ -1,16 +1,20 @@
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import './Navbar.scss' import './Navbar.scss'
const pageTitles: Record<string, string> = { function getPageTitle(pathname: string) {
'/devices': 'Устройства', if (pathname === '/devices') return 'Устройства'
'/employees': 'Сотрудники', if (pathname.startsWith('/devices/')) return 'Устройства'
'/map': 'Карта',
if (pathname === '/employees') return 'Сотрудники'
if (pathname === '/map') return 'Карта'
return 'Обзор'
} }
export function Navbar() { export function Navbar() {
const location = useLocation() const location = useLocation()
const title = pageTitles[location.pathname] ?? '' const title = getPageTitle(location.pathname)
return ( return (
<header className="navbar"> <header className="navbar">

View File

@ -1,7 +1,7 @@
@use '../../shared/styles/variables' as *; @use '../../shared/styles/variables' as *;
.sidebar { .sidebar {
width: 171px; width: 181px;
min-height: calc(100vh - 56px); min-height: calc(100vh - 56px);
max-height: 100vh; max-height: 100vh;
padding: 20px 20px 36px 20px; padding: 20px 20px 36px 20px;