Обновить интерфейс MDM

This commit is contained in:
neizbejnoezlo 2026-06-22 18:03:10 +07:00
parent 820bdb924f
commit ee339bf301
133 changed files with 26311 additions and 4487 deletions

View File

@ -18,5 +18,9 @@ export default defineConfig([
languageOptions: {
globals: globals.browser,
},
rules: {
'react-hooks/set-state-in-effect': 'warn',
'react-refresh/only-export-components': 'warn',
},
},
])

View File

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

View File

@ -14,11 +14,12 @@
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@tanstack/react-table": "^8.21.3",
"date-fns": "^4.1.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"framer-motion": "^12.38.0",
"framer-motion": "^12.40.0",
"graphql": "^16.13.2",
"leaflet": "^1.9.4",
"lucide-react": "^1.9.0",
@ -26,10 +27,13 @@
"react": "^19.2.5",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.5",
"react-easy-crop": "^5.5.7",
"react-hook-form": "^7.73.1",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.14.2",
"sass": "^1.99.0",
"simplebar": "^6.3.3",
"simplebar-react": "^3.3.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
@ -39,6 +43,7 @@
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
@ -1372,6 +1377,43 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@ -2323,6 +2365,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@ -2986,13 +3041,13 @@
"license": "ISC"
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"motion-dom": "^12.40.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
@ -3542,6 +3597,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -3578,18 +3645,18 @@
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": {
@ -3639,6 +3706,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/optimism": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz",
@ -3842,6 +3915,20 @@
"react": "^19.2.5"
}
},
"node_modules/react-easy-crop": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz",
"integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1",
"tslib": "^2.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-hook-form": {
"version": "7.73.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz",
@ -4108,6 +4195,37 @@
"node": ">=8"
}
},
"node_modules/simplebar": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/simplebar/-/simplebar-6.3.3.tgz",
"integrity": "sha512-ni9cIiA8GRitiaenV7A/gh8nTStiVqO3gLxrfCMmq6CSy/pB+fz+QxqvJLo142QHb2Tvgvr8ILwZxf3qzAq7pg==",
"license": "MIT",
"dependencies": {
"simplebar-core": "^1.3.2"
}
},
"node_modules/simplebar-core": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/simplebar-core/-/simplebar-core-1.3.2.tgz",
"integrity": "sha512-qKgTTuTqapjsFGkNhCjyPhysnbZGpQqNmjk0nOYjFN5ordC/Wjvg+RbYCyMSnW60l/Z0ZS82GbNltly6PMUH1w==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21",
"lodash-es": "^4.17.21"
}
},
"node_modules/simplebar-react": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/simplebar-react/-/simplebar-react-3.3.2.tgz",
"integrity": "sha512-ZsgcQhKLtt5ra0BRIJeApfkTBQCa1vUPA/WXI4HcYReFt+oCEOvdVz6rR/XsGJcKxTlCRPmdGx1uJIUChupo+A==",
"license": "MIT",
"dependencies": {
"simplebar-core": "^1.3.2"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",

View File

@ -16,11 +16,12 @@
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@tanstack/react-table": "^8.21.3",
"date-fns": "^4.1.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"framer-motion": "^12.38.0",
"framer-motion": "^12.40.0",
"graphql": "^16.13.2",
"leaflet": "^1.9.4",
"lucide-react": "^1.9.0",
@ -28,10 +29,13 @@
"react": "^19.2.5",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.5",
"react-easy-crop": "^5.5.7",
"react-hook-form": "^7.73.1",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.14.2",
"sass": "^1.99.0",
"simplebar": "^6.3.3",
"simplebar-react": "^3.3.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
@ -41,6 +45,7 @@
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",

View File

@ -1,10 +1,12 @@
@use './shared/styles/variables' as *;
.counter {
font-size: 16px;
font-size: $font-size-16;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
border: 2px solid $transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
@ -117,7 +119,7 @@
a {
color: var(--text-h);
font-size: 16px;
font-size: $font-size-16;
border-radius: 6px;
background: var(--social-bg);
display: flex;
@ -170,7 +172,7 @@
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
border: 5px solid $transparent;
}
&::before {

View File

@ -1,4 +1,3 @@
import { useState } from 'react'
//import reactLogo from './assets/react.svg'
//import viteLogo from './assets/vite.svg'
//import heroImg from './assets/hero.png'

View File

@ -1,13 +1,288 @@
@use '../../shared/styles/variables' as *;
.app-layout {
display: flex;
width: 100%;
min-width: 0;
height: 100vh;
min-height: 100vh;
background: #EFF2F7;
overflow: hidden;
background:
radial-gradient(
52% 42% at 16% 18%,
rgba(45, 140, 255, 0.055) 0%,
transparent 72%
),
radial-gradient(
50% 42% at 88% 16%,
rgba(3, 29, 154, 0.055) 0%,
transparent 74%
),
radial-gradient(
54% 44% at 14% 80%,
rgba(51, 179, 67, 0.06) 0%,
transparent 72%
),
radial-gradient(
56% 46% at 86% 78%,
rgba(66, 93, 232, 0.075) 0%,
transparent 74%
),
radial-gradient(
74% 44% at 50% 106%,
rgba(3, 29, 154, 0.075) 0%,
rgba(66, 93, 232, 0.04) 42%,
transparent 78%
),
linear-gradient(
180deg,
#eff2f7 0%,
#f8fafc 40%,
#f4f7fb 70%,
#eff2f7 100%
);
[data-theme='dark'] & {
--color-bg: #242a33;
background:
radial-gradient(
54% 46% at 16% 18%,
rgba(45, 140, 255, 0.085) 0%,
transparent 72%
),
radial-gradient(
52% 44% at 86% 16%,
rgba(99, 102, 241, 0.085) 0%,
transparent 72%
),
radial-gradient(
54% 44% at 14% 78%,
rgba(24, 184, 155, 0.07) 0%,
transparent 72%
),
radial-gradient(
58% 48% at 86% 78%,
rgba(45, 140, 255, 0.095) 0%,
transparent 74%
),
radial-gradient(
76% 46% at 50% 106%,
rgba(45, 140, 255, 0.16) 0%,
rgba(72, 96, 180, 0.085) 42%,
transparent 78%
),
linear-gradient(
180deg,
#242a33 0%,
#242a33 12%,
#222832 18%,
#1f252e 30%,
#1d232c 55%,
#171b22 100%
);
}
}
.app-layout__sidebar {
flex: 0 0 auto;
}
.app-layout__content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
height: 100vh;
max-height: 100vh;
//padding: 0px 20px 20px 20px;
}
.app-layout__scroll {
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
.simplebar-content {
min-height: 100%;
box-sizing: border-box;
padding:
$layout-page-padding-top
$layout-page-padding-right
$layout-page-padding-bottom
$layout-page-padding-left;
display: flex;
flex-direction: column;
flex: 1;
padding: 20px 36px 36px 0;
max-height: calc(100vh - 56px);
}
.simplebar-track.simplebar-vertical {
top: 74px;
right: -8px;
bottom: 8px;
width: 16px;
}
.simplebar-scrollbar::before {
background: $gray50;
border-radius: 8px;
opacity: 0.65;
}
}
.app-layout__route-content {
display: flex;
flex: 1 0 auto;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.app-layout__unsupported {
display: none !important;
}
.app-layout__drawer-overlay {
position: fixed;
inset: 0;
z-index: 800;
background: $color-backdrop-soft;
backdrop-filter: blur(4px);
&[data-state='open'] {
animation: appLayoutDrawerOverlayShow 0.18s ease both;
}
&[data-state='closed'] {
animation: appLayoutDrawerOverlayHide 0.18s ease both;
}
}
.app-layout__drawer {
position: fixed;
inset: 0 auto 0 0;
z-index: 801;
margin: 10px;
width: min(320px, calc(100vw - 32px));
padding: 0;
border: none;
border-radius: 20px;
//background: $color-bg;
box-shadow: $shadow-modal-dark;
overflow: hidden;
&[data-state='open'] {
animation: appLayoutDrawerShow 0.24s cubic-bezier(0.34, 1.02, 0.64, 1) both;
}
&[data-state='closed'] {
animation: appLayoutDrawerHide 0.2s cubic-bezier(0.4, 0, 0.2, 1) both;
}
}
.app-layout__drawer-scroll {
width: 100%;
height: 100%;
max-height: 100%;
.simplebar-track.simplebar-vertical {
width: 10px;
right: 2px;
}
.simplebar-scrollbar::before {
background: $gray50;
opacity: 0.55;
}
}
.app-layout__drawer-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@include wide-down {
.app-layout__sidebar {
display: none;
}
.app-layout__content {
height: 100vh;
max-height: 100vh;
box-sizing: border-box;
}
}
@media (max-width: 1279.98px) {
.app-layout {
min-height: 100vh;
}
.app-layout__sidebar,
.app-layout__content,
.app-layout__drawer-overlay,
.app-layout__drawer {
display: none;
}
.app-layout__unsupported {
display: flex !important;
min-height: 100vh !important;
padding: 32px !important;
background: $color-bg;
}
}
@keyframes appLayoutDrawerOverlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes appLayoutDrawerOverlayHide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes appLayoutDrawerShow {
from {
opacity: 0;
transform: translateX(-16px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes appLayoutDrawerHide {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-16px);
}
}

View File

@ -1,16 +1,99 @@
import {
useEffect,
useRef,
useState,
type Dispatch,
type SetStateAction,
} from 'react'
import { Outlet } from 'react-router-dom'
import * as Dialog from '@radix-ui/react-dialog'
import SimpleBar from 'simplebar-react'
import type SimpleBarCore from 'simplebar-core'
import 'simplebar-react/dist/simplebar.min.css'
import { Sidebar } from '../../widgets/Sidebar/Sidebar'
import './AppLayout.scss'
import { Navbar } from '../../widgets/Navbar/Navbar'
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
import type { NavbarBreadcrumb } from '../../widgets/Navbar/Navbar'
export type AppLayoutOutletContext = {
setNavbarBreadcrumbs: Dispatch<SetStateAction<NavbarBreadcrumb[]>>
}
export function AppLayout() {
const [isSidebarDrawerOpen, setIsSidebarDrawerOpen] = useState(false)
const contentScrollRef = useRef<SimpleBarCore | null>(null)
const [isContentScrolled, setIsContentScrolled] = useState(false)
const [navbarBreadcrumbs, setNavbarBreadcrumbs] = useState<
NavbarBreadcrumb[]
>([])
useEffect(() => {
const scrollElement = contentScrollRef.current?.getScrollElement()
if (!scrollElement) return undefined
const scrollContainer = scrollElement
function handleScroll() {
setIsContentScrolled(scrollContainer.scrollTop > 0)
}
handleScroll()
scrollContainer.addEventListener('scroll', handleScroll, {
passive: true,
})
return () => {
scrollContainer.removeEventListener('scroll', handleScroll)
}
}, [])
return (
<div className="app-layout">
<Sidebar />
<div className="app-layout__sidebar">
<Sidebar />
</div>
<main className="app-layout__content">
<Navbar />
<Outlet />
<SimpleBar ref={contentScrollRef} className="app-layout__scroll">
<Navbar
breadcrumbs={navbarBreadcrumbs}
isScrolled={isContentScrolled}
onOpenSidebar={() => setIsSidebarDrawerOpen(true)}
/>
<div className="app-layout__route-content">
<Outlet context={{ setNavbarBreadcrumbs }} />
</div>
</SimpleBar>
</main>
<Dialog.Root
open={isSidebarDrawerOpen}
onOpenChange={setIsSidebarDrawerOpen}
>
<Dialog.Portal>
<Dialog.Overlay className="app-layout__drawer-overlay" />
<Dialog.Content className="app-layout__drawer">
<Dialog.Title className="app-layout__drawer-title">
Навигация
</Dialog.Title>
<SimpleBar className="app-layout__drawer-scroll">
<Sidebar
mode="drawer"
onNavigate={() => setIsSidebarDrawerOpen(false)}
/>
</SimpleBar>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<EmptyState
className="app-layout__unsupported"
variant="no-items"
title="Экран пока не поддерживается"
description="Для работы с системой используйте экран шириной от 1280 пикселей."
/>
</div>
)
}

View File

@ -1,12 +1,42 @@
// src/app/router/router.tsx
import { lazy, Suspense, type ReactNode } from 'react'
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { AppLayout } from '../layouts/AppLayout'
import { DevicesPage } from '../../pages/DevicesPage/DevicesPage'
import { DevicePage } from '../../pages/DevicePage/DevicePage'
import { MapPage } from '../../pages/MapPage/MapPage'
import { EmployeesPage } from '../../pages/EmployeesPage/EmployeesPage'
import { OrganisationPage } from '../../pages/OrganisationPage/OrganisationPage'
const DevicesPage = lazy(() =>
import('../../pages/DevicesPage/DevicesPage').then((module) => ({
default: module.DevicesPage,
})),
)
const DevicePage = lazy(() =>
import('../../pages/DevicePage/DevicePage').then((module) => ({
default: module.DevicePage,
})),
)
const MapPage = lazy(() =>
import('../../pages/MapPage/MapPage').then((module) => ({
default: module.MapPage,
})),
)
const EmployeesPage = lazy(() =>
import('../../pages/EmployeesPage/EmployeesPage').then((module) => ({
default: module.EmployeesPage,
})),
)
const OrganisationPage = lazy(() =>
import('../../pages/OrganisationPage/OrganisationPage').then((module) => ({
default: module.OrganisationPage,
})),
)
function lazyRoute(element: ReactNode) {
return <Suspense fallback={null}>{element}</Suspense>
}
export const router = createBrowserRouter([
{
@ -19,23 +49,23 @@ export const router = createBrowserRouter([
},
{
path: 'devices',
element: <DevicesPage />,
element: lazyRoute(<DevicesPage />),
},
{
path: 'devices/:deviceId',
element: <DevicePage />,
element: lazyRoute(<DevicePage />),
},
{
path: 'map',
element: <MapPage />,
element: lazyRoute(<MapPage />),
},
{
path: 'employees',
element: <EmployeesPage />,
element: lazyRoute(<EmployeesPage />),
},
{
path: 'employees/organisations/:organisationId',
element: <OrganisationPage />,
element: lazyRoute(<OrganisationPage />),
},
],
},

View File

@ -0,0 +1,22 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1714_21821)">
<mask id="mask0_1714_21821" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="32">
<path d="M31.1882 0H0V31.183H31.1882V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_1714_21821)">
<path d="M15.4632 -0.00547545C12.2092 0.0490096 9.55806 2.70874 9.5127 5.96443C9.46561 9.34955 12.1941 12.1079 15.5672 12.1079C15.6579 12.1079 15.7486 12.1061 15.8388 12.1007L15.8375 12.1135C16.377 12.1382 16.897 12.2357 17.3881 12.3971C19.7192 13.1624 21.4023 15.3564 21.4023 17.9448C21.4023 19.1885 21.0122 20.3427 20.3475 21.29C20.3004 21.3596 20.249 21.4292 20.1977 21.4964C20.1497 21.5592 20.0996 21.6204 20.05 21.6814C18.9803 22.9651 17.3699 23.7811 15.5672 23.7811C13.7664 23.7811 12.159 22.9663 11.0875 21.6857C11.0875 21.6857 11.0862 21.6833 11.085 21.6814C10.9835 21.5592 10.8872 21.4334 10.7966 21.3027C10.7924 21.2984 10.7894 21.2943 10.7869 21.29C9.67657 19.9508 7.99982 19.0985 6.12454 19.0985C2.75323 19.0985 0.0253552 21.8562 0.0718754 25.2407C0.115974 28.4662 2.72057 31.1139 5.9437 31.2084C7.94051 31.2663 9.72686 30.3571 10.8704 28.9163C10.8716 28.9145 10.8734 28.9121 10.8746 28.9102C10.9036 28.8715 10.9316 28.8341 10.9611 28.7978C10.9665 28.7904 10.9708 28.7838 10.9774 28.7766C12.0447 27.4167 13.7035 26.5431 15.5672 26.5431C17.431 26.5431 19.0897 27.4167 20.1571 28.7766C20.1637 28.7838 20.1679 28.7904 20.1741 28.7978C20.2012 28.8358 20.2302 28.8733 20.2593 28.909L20.2598 28.9102C21.369 30.3124 23.0833 31.2107 25.0099 31.2107C28.3541 31.2107 31.065 28.4994 31.065 25.1555C31.065 25.0999 31.0643 25.0448 31.0626 24.9903C31.0396 24.129 30.8134 23.2851 30.432 22.512L30.1791 21.9991L21.1319 3.65973L20.8561 3.09954L20.855 3.09655C19.8184 1.24518 17.8391 -0.00634766 15.5669 -0.00634766C15.5325 -0.00634766 15.4978 -0.00605785 15.4632 -0.00547545Z" fill="url(#paint0_linear_1714_21821)"/>
<path d="M18.6719 17.9433C18.6719 18.6049 18.4645 19.2184 18.1112 19.7223C18.086 19.7593 18.0586 19.7963 18.0311 19.8319C18.0061 19.8653 17.9793 19.8978 17.9527 19.9305C17.3838 20.6129 16.5275 21.047 15.569 21.047C14.6114 21.047 13.7566 20.6136 13.187 19.9327L13.1855 19.9305C13.1315 19.8653 13.0803 19.7986 13.0322 19.7289C13.03 19.7268 13.0284 19.7244 13.027 19.7223C12.6736 19.2184 12.4663 18.6049 12.4663 17.9433C12.4663 16.2287 13.8552 14.8394 15.569 14.8394C15.6172 14.8394 15.6654 14.8402 15.7127 14.8424C17.3601 14.9179 18.6719 16.2769 18.6719 17.9433Z" fill="#DF5D13"/>
<path d="M30.4319 22.511C28.3985 19.543 22.0154 13.2158 18.0389 12.4679C17.6189 12.3889 17.1968 12.3204 16.7785 12.2321C16.4731 12.1678 16.1588 12.1272 15.8376 12.1123L15.8387 12.0996C15.7487 12.1052 15.658 12.107 15.5673 12.107C12.1942 12.107 9.46554 9.34859 9.51281 5.96344C9.55817 2.70775 12.2092 0.0480264 15.4632 -0.00645855C17.7787 -0.0451429 19.8027 1.21608 20.8549 3.09556L20.8562 3.09856L21.1319 3.65874L30.1791 21.998L30.4319 22.511Z" fill="white"/>
<path d="M20.2593 28.9091C20.2303 28.8735 20.2012 28.836 20.174 28.7979C20.1679 28.7906 20.1637 28.7839 20.1571 28.7768C19.0897 27.4169 17.431 26.5433 15.5672 26.5433C13.7035 26.5433 12.0447 27.4169 10.9774 28.7768C10.9708 28.7839 10.9665 28.7906 10.9611 28.7979C10.9315 28.8341 10.9036 28.8717 10.8747 28.9103C10.8734 28.9122 10.8716 28.9146 10.8704 28.9165C9.72677 30.3573 7.94051 31.2665 5.9437 31.2084C2.72056 31.1141 0.115976 28.4664 0.0718778 25.2409C0.0252597 21.8564 2.75323 19.0986 6.12454 19.0986C7.99973 19.0986 9.67657 19.951 10.7869 21.2902C10.7894 21.2945 10.7924 21.2986 10.7966 21.3029C10.8873 21.4336 10.9835 21.5594 11.085 21.6816C11.085 21.6816 11.9886 23.8877 15.5684 25.3704C19.0922 26.8301 20.2247 28.8462 20.2593 28.9091Z" fill="#DF5D13"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_1714_21821" x1="21.7418" y1="13.7739" x2="13.647" y2="23.985" gradientUnits="userSpaceOnUse">
<stop stop-color="#E0E0E0"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
<clipPath id="clip0_1714_21821">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,26 @@
<svg width="171" height="32" viewBox="0 0 171 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_1714_21830" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="32">
<path d="M31.7346 0.457031H0.546387V31.64H31.7346V0.457031Z" fill="white"/>
</mask>
<g mask="url(#mask0_1714_21830)">
<path d="M16.0097 0.452044C12.7556 0.506529 10.1045 3.16626 10.0591 6.42195C10.012 9.80707 12.7405 12.5655 16.1136 12.5655C16.2043 12.5655 16.2951 12.5637 16.3852 12.5583L16.3839 12.5711C16.9234 12.5957 17.4434 12.6932 17.9345 12.8547C20.2656 13.6199 21.9487 15.8139 21.9487 18.4024C21.9487 19.6461 21.5586 20.8003 20.8939 21.7476C20.8469 21.8171 20.7954 21.8867 20.7441 21.9539C20.6962 22.0167 20.646 22.0779 20.5964 22.139C19.5267 23.4226 17.9164 24.2386 16.1136 24.2386C14.3128 24.2386 12.7054 23.4238 11.6339 22.1433C11.6339 22.1433 11.6326 22.1408 11.6314 22.139C11.5299 22.0167 11.4336 21.8909 11.343 21.7603C11.3388 21.756 11.3358 21.7519 11.3333 21.7476C10.223 20.4083 8.54621 19.556 6.67093 19.556C3.29962 19.556 0.571742 22.3138 0.618262 25.6983C0.662361 28.9237 3.26695 31.5715 6.49008 31.6659C8.4869 31.7239 10.2733 30.8146 11.4168 29.3738C11.418 29.3721 11.4198 29.3696 11.421 29.3678C11.45 29.329 11.478 29.2916 11.5075 29.2553C11.5129 29.2479 11.5172 29.2413 11.5238 29.2341C12.5911 27.8742 14.2499 27.0007 16.1136 27.0007C17.9774 27.0007 19.6361 27.8742 20.7035 29.2341C20.7101 29.2413 20.7144 29.2479 20.7205 29.2553C20.7476 29.2933 20.7766 29.3308 20.8057 29.3665L20.8063 29.3678C21.9154 30.7699 23.6297 31.6682 25.5563 31.6682C28.9005 31.6682 31.6114 28.957 31.6114 25.6131C31.6114 25.5574 31.6107 25.5023 31.609 25.4479C31.586 24.5865 31.3598 23.7426 30.9784 22.9695L30.7255 22.4566L21.6784 4.11725L21.4026 3.55707L21.4014 3.55407C20.3649 1.7027 18.3855 0.451172 16.1134 0.451172C16.0789 0.451172 16.0442 0.451461 16.0097 0.452044Z" fill="url(#paint0_linear_1714_21830)"/>
<path d="M19.2183 18.4003C19.2183 19.062 19.0109 19.6755 18.6576 20.1793C18.6324 20.2164 18.605 20.2534 18.5775 20.2889C18.5525 20.3223 18.5257 20.3549 18.4991 20.3875C17.9302 21.0699 17.0739 21.504 16.1154 21.504C15.1578 21.504 14.303 21.0707 13.7334 20.3897L13.7319 20.3875C13.6779 20.3223 13.6267 20.2557 13.5786 20.1859C13.5764 20.1838 13.5748 20.1814 13.5734 20.1793C13.22 19.6755 13.0127 19.062 13.0127 18.4003C13.0127 16.6857 14.4016 15.2964 16.1154 15.2964C16.1636 15.2964 16.2118 15.2972 16.2592 15.2994C17.9065 15.3749 19.2183 16.7339 19.2183 18.4003Z" fill="#DF5D13"/>
<path d="M30.9783 22.968C28.9449 20.0001 22.5618 13.6728 18.5853 12.9249C18.1653 12.846 17.7432 12.7775 17.3249 12.6892C17.0195 12.6248 16.7052 12.5843 16.384 12.5694L16.3851 12.5567C16.2951 12.5622 16.2044 12.564 16.1137 12.564C12.7406 12.564 10.012 9.80562 10.0592 6.42047C10.1046 3.16478 12.7556 0.505058 16.0096 0.450573C18.3251 0.411888 20.3491 1.67311 21.4013 3.55259L21.4026 3.5556L21.6783 4.11577L30.7256 22.455L30.9783 22.968Z" fill="white"/>
<path d="M20.8057 29.3663C20.7767 29.3306 20.7476 29.2931 20.7204 29.255C20.7144 29.2477 20.7101 29.241 20.7035 29.2339C19.6361 27.874 17.9774 27.0004 16.1136 27.0004C14.2499 27.0004 12.5911 27.874 11.5238 29.2339C11.5172 29.241 11.5129 29.2477 11.5075 29.255C11.4779 29.2912 11.45 29.3288 11.4211 29.3675C11.4198 29.3693 11.418 29.3718 11.4168 29.3736C10.2732 30.8144 8.4869 31.7237 6.49008 31.6655C3.26695 31.5713 0.662363 28.9235 0.618264 25.6981C0.571647 22.3136 3.29962 19.5557 6.67093 19.5557C8.54612 19.5557 10.223 20.4081 11.3333 21.7474C11.3358 21.7517 11.3388 21.7558 11.343 21.7601C11.4337 21.8907 11.5299 22.0165 11.6314 22.1388C11.6314 22.1388 12.535 24.3448 16.1148 25.8275C19.6386 27.2872 20.7711 29.3034 20.8057 29.3663Z" fill="#DF5D13"/>
</g>
<mask id="mask1_1714_21830" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="44" y="6" width="127" height="20">
<path d="M170.633 6.61426H44.2109V25.4801H170.633V6.61426Z" fill="white"/>
</mask>
<g mask="url(#mask1_1714_21830)">
<path d="M50.9664 7.59375H53.6171L60.5189 25.3654H57.5682L55.6927 20.5346H48.8409L46.9904 25.3654H44.0146L50.9664 7.59375ZM55.1426 18.457L52.2918 10.6725L49.341 18.457H55.1426Z" fill="white"/>
<path d="M62.7148 25.3654V7.59375H70.1667C70.9669 7.59375 71.7005 7.76062 72.3673 8.09436C73.0341 8.4281 73.6176 8.87031 74.1177 9.42094C74.6179 9.97161 75.0014 10.5891 75.2681 11.2732C75.5348 11.9575 75.6682 12.6583 75.6682 13.3758C75.6682 14.3604 75.4431 15.3032 74.993 16.2042C74.5596 17.0887 73.9344 17.8063 73.1175 18.3569C72.3173 18.9076 71.3837 19.1829 70.3168 19.1829H65.5155V25.3654H62.7148ZM65.5155 16.7049H70.1418C70.6752 16.7049 71.142 16.563 71.5421 16.2794C71.9422 15.979 72.2506 15.5785 72.4674 15.0779C72.7007 14.5773 72.8174 14.01 72.8174 13.3758C72.8174 12.725 72.6841 12.1493 72.4173 11.6487C72.1506 11.1482 71.8005 10.7643 71.367 10.4974C70.9503 10.2137 70.4918 10.0718 69.9917 10.0718H65.5155V16.7049Z" fill="white"/>
<path d="M93.2234 25.3654V12.7L87.9975 22.3117H86.347L81.0956 12.7V25.3654H78.2949V7.59375H81.2956L87.1722 18.457L93.0487 7.59375H96.0499V25.3654H93.2234Z" fill="white"/>
<path d="M105.18 7.59375H107.831L114.733 25.3654H111.782L109.906 20.5346H103.054L101.204 25.3654H98.228L105.18 7.59375ZM109.356 18.457L106.506 10.6725L103.555 18.457H109.356Z" fill="white"/>
</g>
<defs>
<linearGradient id="paint0_linear_1714_21830" x1="22.2882" y1="14.2314" x2="14.1934" y2="24.4425" gradientUnits="userSpaceOnUse">
<stop stop-color="#E0E0E0"/>
<stop offset="1" stop-color="white"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -0,0 +1,13 @@
<svg width="170" height="105" viewBox="0 0 170 105" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="32.0584" cy="32.2273" rx="32.0584" ry="32.2273" fill="var(--empty-orb)"/>
<path d="M32.0605 11.2188C43.5945 11.2188 52.9541 20.62 52.9541 32.2275C52.9538 43.8349 43.5943 53.2354 32.0605 53.2354C20.527 53.2351 11.1682 43.8347 11.168 32.2275C11.168 20.6201 20.5268 11.219 32.0605 11.2188Z" fill="var(--empty-ring-fill)" stroke="var(--empty-ring-stroke)" stroke-width="2"/>
<ellipse cx="32.0621" cy="32.2272" rx="13.9136" ry="13.9869" fill="var(--empty-core)"/>
<ellipse cx="28.3626" cy="28.2433" rx="5.62041" ry="5.65001" fill="var(--empty-highlight)"/>
<ellipse cx="137.941" cy="32.2273" rx="32.0584" ry="32.2273" fill="var(--empty-orb)"/>
<path d="M137.943 11.2188C149.477 11.2188 158.837 20.62 158.837 32.2275C158.837 43.8349 149.477 53.2354 137.943 53.2354C126.41 53.2351 117.051 43.8347 117.051 32.2275C117.051 20.6201 126.41 11.219 137.943 11.2188Z" fill="var(--empty-ring-fill)" stroke="var(--empty-ring-stroke)" stroke-width="2"/>
<ellipse cx="137.945" cy="32.2272" rx="13.9136" ry="13.9869" fill="var(--empty-core)"/>
<ellipse cx="134.245" cy="28.2433" rx="5.62041" ry="5.65001" fill="var(--empty-highlight)"/>
<path d="M108.898 104.685C109.663 104.685 110.287 104.064 110.245 103.3C109.907 97.0904 107.304 91.204 102.907 86.7839C98.1846 82.0363 91.7792 79.3691 85.1003 79.3691C78.4214 79.3691 72.016 82.0363 67.2933 86.7839C62.8963 91.204 60.2933 97.0904 59.9551 103.3C59.9135 104.064 60.5377 104.685 61.3028 104.685C62.0679 104.685 62.6838 104.064 62.7305 103.3C63.0654 97.8298 65.3753 92.6511 69.2525 88.7534C73.4556 84.5282 79.1562 82.1545 85.1003 82.1545C91.0443 82.1545 96.7449 84.5282 100.948 88.7534C104.825 92.6511 107.135 97.8298 107.47 103.3C107.517 104.064 108.133 104.685 108.898 104.685Z" fill="var(--empty-mouth)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,21 @@
import type { SVGProps } from 'react'
type IconProps = SVGProps<SVGSVGElement>
export function Apps({ className, ...props }: IconProps) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
{...props}
>
<rect x="2" y="2" width="9.5" height="9.5" rx="2.5" fill="currentColor" />
<rect x="13" y="2" width="9.5" height="9.5" rx="2.5" fill="currentColor" />
<rect x="2" y="13" width="9.5" height="9.5" rx="2.5" fill="currentColor" />
<rect x="13" y="13" width="9.5" height="9.5" rx="2.5" fill="currentColor" />
</svg>
)
}

View File

@ -0,0 +1,32 @@
import type { SVGProps } from 'react'
type IconProps = SVGProps<SVGSVGElement>
export function LoadingTwotoneLoopIcon({ className, ...props }: IconProps) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path strokeDasharray="18" d="M12 3c4.97 0 9 4.03 9 9" />
<path
strokeDasharray="60"
d="M12 3c4.97 0 9 4.03 9 9c0 4.97 -4.03 9 -9 9c-4.97 0 -9 -4.03 -9 -9c0 -4.97 4.03 -9 9 -9Z"
opacity=".3"
/>
</g>
</svg>
)
}

View File

@ -0,0 +1,13 @@
<svg width="171" height="108" viewBox="0 0 171 108" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="32.5584" cy="32.2273" rx="32.0584" ry="32.2273" fill="var(--empty-orb)"/>
<path d="M53.452 32.2271C53.452 43.8346 44.0929 53.2354 32.559 53.2354C21.0251 53.2354 11.666 43.8346 11.666 32.2271C11.666 20.6195 21.0251 11.2188 32.559 11.2188C44.0929 11.2188 53.452 20.6195 53.452 32.2271Z" fill="var(--empty-ring-fill)" stroke="var(--empty-ring-stroke)" stroke-width="2"/>
<ellipse cx="32.5601" cy="32.2272" rx="13.9136" ry="13.9869" fill="var(--empty-core)"/>
<ellipse cx="28.8626" cy="28.2438" rx="5.62041" ry="5.65001" fill="var(--empty-highlight)"/>
<ellipse cx="138.441" cy="32.2273" rx="32.0584" ry="32.2273" fill="var(--empty-orb)"/>
<path d="M159.335 32.2271C159.335 43.8346 149.976 53.2354 138.442 53.2354C126.908 53.2354 117.549 43.8346 117.549 32.2271C117.549 20.6195 126.908 11.2188 138.442 11.2188C149.976 11.2188 159.335 20.6195 159.335 32.2271Z" fill="var(--empty-ring-fill)" stroke="var(--empty-ring-stroke)" stroke-width="2"/>
<ellipse cx="138.443" cy="32.2272" rx="13.9136" ry="13.9869" fill="var(--empty-core)"/>
<ellipse cx="134.745" cy="28.2438" rx="5.62041" ry="5.65001" fill="var(--empty-highlight)"/>
<path d="M66.7188 87.3417C66.1117 87.3417 65.6164 87.8343 65.6494 88.4405C65.9177 93.3675 67.9832 98.0381 71.472 101.545C75.2194 105.312 80.3018 107.429 85.6013 107.429C90.9008 107.429 95.9832 105.312 99.7305 101.545C103.219 98.0381 105.285 93.3675 105.553 88.4405C105.586 87.8343 105.091 87.3417 104.484 87.3417C103.877 87.3417 103.388 87.8345 103.351 88.4404C103.085 92.7808 101.252 96.8899 98.1759 99.9826C94.8409 103.335 90.3177 105.219 85.6013 105.219C80.8849 105.219 76.3616 103.335 73.0266 99.9826C69.9502 96.8899 68.1173 92.7808 67.8516 88.4404C67.8145 87.8345 67.3259 87.3417 66.7188 87.3417Z" fill="var(--empty-mouth)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,38 +1,32 @@
import { gql } from '@apollo/client'
export const GET_PHONES_PAGE_QUERY = gql`
query GetPhonesPage($key: String) {
getPhonesPage(key: $key) {
export const GET_PHONES_QUERY = gql`
query GetPhones(
$page: Int!
$query: String
$locked: Boolean
$needMaintenance: Boolean
$networkStatus: [PhoneNetworkStatus!]
$orgs: [ID!]
$sortDirection: SortDirection!
$sortField: PhoneSortField!
) {
getPhones(
page: $page
query: $query
locked: $locked
needMaintenance: $needMaintenance
networkStatus: $networkStatus
orgs: $orgs
sortDirection: $sortDirection
sortField: $sortField
) {
page {
id
imei
imei2
serial
orgId
registerDate
org {
id
name
creationDate
policy {
canUseBluetooth
canUseGPS
canUseCamera
canUseSim
}
}
policy {
canUseBluetooth
canUseCamera
canUseGPS
canUseSim
}
techState {
needMaintenance
}
networkStatus
lastLocation {
alt
@ -40,9 +34,102 @@ export const GET_PHONES_PAGE_QUERY = gql`
lat
lng
}
org {
id
logoUrl
name
policy {
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
orgId
policy {
bluetooth
camera
GPS
sim
locked
}
techState {
batteryLevel
needMaintenance
temperature
}
}
nextKey
totalElements
totalPages
}
}
`
export const GET_PHONES_TABS_STATS_QUERY = gql`
query GetPhonesTabsStats($query: String, $orgs: [ID!]) {
totalPhones: getPhones(
page: 0
query: $query
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
onlinePhones: getPhones(
page: 0
query: $query
networkStatus: [Online]
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
lockedPhones: getPhones(
page: 0
query: $query
locked: true
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
lostPhones: getPhones(
page: 0
query: $query
networkStatus: [Lost]
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
maintenancePhones: getPhones(
page: 0
query: $query
needMaintenance: true
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
}
`
@ -53,6 +140,7 @@ export const GET_PHONE_QUERY = gql`
id
imei
imei2
networkStatus
lastLocation {
alt
@ -66,20 +154,26 @@ export const GET_PHONE_QUERY = gql`
id
name
policy {
canUseBluetooth
canUseCamera
canUseGPS
canUseSim
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
orgId
policy {
canUseBluetooth
canUseCamera
canUseGPS
canUseSim
bluetooth
camera
GPS
sim
locked
}
registerDate
@ -109,15 +203,62 @@ export const GET_PHONE_GPS_TRACK_QUERY = gql`
}
`
export const GET_PHONE_PACKAGES_QUERY = gql`
query GetPhonePackages($phoneId: ID!) {
getPhonePackages(phoneId: $phoneId) {
category
iconUrl
installDate
name
packageName
version
versionName
}
}
`
export const GET_TELEMETRY_QUERY = gql`
query GetTelemetry($phoneId: Int!, $startDate: Float!, $endDate: Float) {
query GetTelemetry(
$phoneId: Int!
$packagesPhoneId: ID!
$startDate: Float!
$endDate: Float
) {
getTelemetry(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
batteryCapacity
batteryCycles
batteryLevel
chargeType
date
screenOn
temperature
}
getPhoneStateEvents(phoneId: $packagesPhoneId, startDate: $startDate, endDate: $endDate) {
date
phoneId
type
}
getPhonePackagesUseEvents(
phoneId: $packagesPhoneId
startDate: $startDate
endDate: $endDate
) {
events {
type
packageName
date
}
phonePackage {
category
iconUrl
installDate
name
packageName
version
versionName
}
}
}
`
@ -134,6 +275,7 @@ export const GET_DEVICE_PAGE_QUERY = gql`
id
imei
imei2
networkStatus
lastLocation {
alt
@ -147,33 +289,40 @@ export const GET_DEVICE_PAGE_QUERY = gql`
id
name
policy {
canUseBluetooth
canUseCamera
canUseGPS
canUseSim
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
orgId
policy {
canUseBluetooth
canUseCamera
canUseGPS
canUseSim
bluetooth
camera
GPS
sim
locked
}
registerDate
serial
techState {
batteryCycles
batteryLevel
batteryRemainingCapacity
hits
needMaintenance
overheats
worktime
batteryCycles
batteryLevel
batteryRemainingCapacity
hits
needMaintenance
overheats
temperature
worktime
}
}
@ -182,10 +331,10 @@ export const GET_DEVICE_PAGE_QUERY = gql`
startDate: $telemetryStartDate
endDate: $telemetryEndDate
) {
batteryCapacity
batteryCycles
batteryLevel
chargeType
date
screenOn
temperature
}
@ -215,19 +364,27 @@ export const CREATE_PHONE_REGISTRATION_TOKEN_MUTATION = gql`
export const CHANGE_PHONE_POLICY_MUTATION = gql`
mutation ChangePhonePolicy(
$id: ID!
$canUseBluetooth: Boolean!
$canUseCamera: Boolean!
$canUseGPS: Boolean!
$canUseSim: Boolean!
$bluetooth: ServiceUseOption!
$camera: ComponentUseOption!
$GPS: ServiceUseOption!
$sim: ServiceUseOption!
$locked: Boolean!
) {
changePhonePolicy(
id: $id
policy: {
canUseBluetooth: $canUseBluetooth
canUseCamera: $canUseCamera
canUseGPS: $canUseGPS
canUseSim: $canUseSim
bluetooth: $bluetooth
camera: $camera
GPS: $GPS
sim: $sim
locked: $locked
}
)
) {
bluetooth
camera
GPS
sim
locked
}
}
`

View File

@ -0,0 +1,85 @@
import type {
ComponentUseOption,
LegacyPhoneUsePolicyOption,
PhoneUsePolicyOption,
ServiceUseOption,
} from '../model/types'
type PhonePolicyValue =
| PhoneUsePolicyOption
| LegacyPhoneUsePolicyOption
| boolean
| null
| undefined
export function isPhonePolicyOptionEnabled(value: PhonePolicyValue) {
if (typeof value === 'boolean') return value
return value === 'Enabled' || value === 'Allowed'
}
export function booleanToPhonePolicyOption(
enabled: boolean,
): ServiceUseOption {
return enabled ? 'Enabled' : 'Disabled'
}
export function booleanToServiceUseOption(enabled: boolean): ServiceUseOption {
return enabled ? 'Enabled' : 'Disabled'
}
export function booleanToComponentUseOption(
enabled: boolean,
): ComponentUseOption {
return enabled ? 'Allowed' : 'Disallowed'
}
export function normalizeServiceUseOption(
value?: ServiceUseOption | LegacyPhoneUsePolicyOption | boolean | null,
fallbackBoolean?: boolean | null,
): ServiceUseOption {
if (value === 'Disabled' || value === 'Enabled' || value === 'Unspecified') {
return value
}
if (value === 'Allowed') return 'Enabled'
if (value === 'Disallowed') return 'Disabled'
if (value === 'Changeable') return 'Unspecified'
if (typeof value === 'boolean') {
return booleanToServiceUseOption(value)
}
if (typeof fallbackBoolean === 'boolean') {
return booleanToServiceUseOption(fallbackBoolean)
}
return 'Unspecified'
}
export function normalizeComponentUseOption(
value?:
| ComponentUseOption
| ServiceUseOption
| LegacyPhoneUsePolicyOption
| boolean
| null,
fallbackBoolean?: boolean | null,
): ComponentUseOption {
if (value === 'Allowed' || value === 'Disallowed') {
return value
}
if (value === 'Enabled') return 'Allowed'
if (value === 'Disabled') return 'Disallowed'
if (typeof value === 'boolean') {
return booleanToComponentUseOption(value)
}
if (typeof fallbackBoolean === 'boolean') {
return booleanToComponentUseOption(fallbackBoolean)
}
return 'Disallowed'
}

View File

@ -5,11 +5,45 @@ export type DeviceLocation = {
lng: number
}
export type ServiceUseOption =
| 'Disabled'
| 'Enabled'
| 'Unspecified'
export type ComponentUseOption =
| 'Allowed'
| 'Disallowed'
export type PhoneUsePolicyOption = ServiceUseOption | ComponentUseOption
export type LegacyPhoneUsePolicyOption =
| 'Allowed'
| 'Disallowed'
| 'Changeable'
export type GroupUsePolicy = {
bluetooth?: ServiceUseOption | LegacyPhoneUsePolicyOption
bluetoothEditable?: boolean
camera?: ComponentUseOption | LegacyPhoneUsePolicyOption
cameraEditable?: boolean
GPS?: ServiceUseOption | LegacyPhoneUsePolicyOption
gpsEditable?: boolean
sim?: ServiceUseOption | LegacyPhoneUsePolicyOption
simEditable?: boolean
offlineTimeThreshold?: number
date?: number
}
export type DevicePolicy = {
canUseBluetooth: boolean
canUseCamera: boolean
canUseGPS: boolean
canUseSim: boolean
bluetooth?: ServiceUseOption | LegacyPhoneUsePolicyOption
camera?: ComponentUseOption | LegacyPhoneUsePolicyOption
GPS?: ServiceUseOption | LegacyPhoneUsePolicyOption
sim?: ServiceUseOption | LegacyPhoneUsePolicyOption
canUseBluetooth?: boolean
canUseCamera?: boolean
canUseGPS?: boolean
canUseSim?: boolean
locked?: boolean
}
export type DeviceTechState = {
@ -19,14 +53,18 @@ export type DeviceTechState = {
hits: number | null
needMaintenance: boolean
overheats: number | null
temperature?: number | null
worktime: number | null
}
export type DeviceNetworkStatus = 'Online' | 'Offline' | 'Lost'
export type DeviceOrganisation = {
creationDate?: number
id: number
logoUrl?: string | null
name: string
policy?: DevicePolicy | null
policy?: GroupUsePolicy | null
}
export type Device = {
@ -34,6 +72,7 @@ export type Device = {
imei: string
imei2: string
serial: string
networkStatus?: DeviceNetworkStatus
orgId: number
registerDate: number
org: DeviceOrganisation | null
@ -42,15 +81,40 @@ export type Device = {
lastLocation: DeviceLocation | null
}
export type GetPhonesPageData = {
getPhonesPage: {
export type GetPhonesData = {
getPhones: {
page: Device[]
nextKey: string | null
totalElements: number
totalPages: number
}
}
export type GetPhonesPageVariables = {
key?: string
export type GetPhonesVariables = {
page: number
query?: string
locked?: boolean
needMaintenance?: boolean
networkStatus?: DeviceNetworkStatus[]
orgs?: string[]
sortDirection: 'ASC' | 'DESC'
sortField: 'ID' | 'Serial' | 'Date'
}
type GetPhonesStatsPage = {
totalElements: number
}
export type GetPhonesTabsStatsData = {
totalPhones: GetPhonesStatsPage
onlinePhones: GetPhonesStatsPage
lockedPhones: GetPhonesStatsPage
lostPhones: GetPhonesStatsPage
maintenancePhones: GetPhonesStatsPage
}
export type GetPhonesTabsStatsVariables = {
query?: string
orgs?: string[]
}
export type GetPhoneData = {
@ -61,27 +125,89 @@ export type GetPhoneVariables = {
id: number
}
export type DeviceChargeType =
| 'USB'
| 'Wireless'
| 'AC'
| 'Disconnected'
export type DeviceTelemetryItem = {
batteryCapacity: number | null
batteryCycles: number | null
batteryLevel: number | null
date: number
temperature: number | null
}
export type DeviceTelemetryEventItem = {
date: number;
type: 'event';
eventType: DevicePhoneStateEventType;
label: string;
};
export type DeviceHistoryChartItem =
| DeviceTelemetryItem
| DeviceTelemetryEventItem;
export type DevicePhoneStateEventType =
| 'ScreenOn'
| 'ScreenOff'
| 'EnterNetwork'
| 'LeaveNetwork'
| 'PowerOn'
| 'PowerOff'
| 'Hit'
| 'Overheat'
| 'PowerAC'
| 'PowerUSB'
| 'PowerWireless'
| 'PowerDisconnected'
export type DevicePhoneStateEvent = {
date: number
phoneId: string
type: DevicePhoneStateEventType
}
export type DevicePackage = {
category: string | null
iconUrl: string | null
installDate: number | null
name: string | null
packageName: string
version: number | null
versionName: string | null
}
export type GetPhonePackagesData = {
getPhonePackages: DevicePackage[]
}
export type GetPhonePackagesVariables = {
phoneId: string
}
export type DevicePackageUseEventType =
| 'ResumeActivity'
| 'PauseActivity'
| 'StopActivity'
| string
export type DeviceTelemetryItem = {
batteryLevel: number
chargeType: DeviceChargeType
export type DevicePackageUseEvent = {
date: number
screenOn: boolean
temperature: number
packageName: string
type: DevicePackageUseEventType
}
export type DevicePackageUseEventsGroup = {
events: DevicePackageUseEvent[]
phonePackage: DevicePackage | null
}
export type GetTelemetryData = {
getTelemetry: DeviceTelemetryItem[]
getPhoneStateEvents: DevicePhoneStateEvent[]
getPhonePackagesUseEvents: DevicePackageUseEventsGroup[]
}
export type GetTelemetryVariables = {
phoneId: number
packagesPhoneId: string
startDate: number
endDate?: number
}
@ -119,15 +245,16 @@ export type GetDevicePageVariables = {
}
export type ChangePhonePolicyData = {
changePhonePolicy: boolean
changePhonePolicy: DevicePolicy
}
export type ChangePhonePolicyVariables = {
id: string
canUseBluetooth: boolean
canUseCamera: boolean
canUseGPS: boolean
canUseSim: boolean
bluetooth: ServiceUseOption
camera: ComponentUseOption
GPS: ServiceUseOption
sim: ServiceUseOption
locked: boolean
}
export type CreatePhoneRegistrationTokenData = {

View File

@ -1,21 +1,40 @@
import { gql } from '@apollo/client'
export const GET_USERS_PAGE_QUERY = gql`
query GetUsersPage($key: String) {
getUsersPage(key: $key) {
export const GET_USERS_QUERY = gql`
query GetUsers(
$page: Int!
$query: String
$orgs: [ID!]
$roles: [Role!]
$sortDirection: SortDirection!
$sortField: UserSortField!
) {
getUsers(
page: $page
query: $query
orgs: $orgs
roles: $roles
sortDirection: $sortDirection
sortField: $sortField
) {
totalPages
totalElements
page {
id
avatarUrl
firstName
id
lastName
middleName
org {
creationDate
id
logoUrl
name
}
orgId
role
username
}
nextKey
}
}
`
@ -26,16 +45,28 @@ export const GET_ORGANISATION_QUERY = gql`
creationDate
id
name
logoUrl
policy {
canUseBluetooth
canUseCamera
canUseGPS
canUseSim
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
}
`
export const DELETE_ORGANISATION_MUTATION = gql`
mutation DeleteOrganisation($id: ID!) {
deleteOrganisation(id: $id)
}
`
export const GET_ORGANISATIONS_QUERY = gql`
query GetOrganisations(
$page: Int!
@ -55,13 +86,7 @@ export const GET_ORGANISATIONS_QUERY = gql`
creationDate
id
name
policy {
canUseBluetooth
canUseCamera
canUseGPS
canUseSim
date
}
logoUrl
}
}
}
@ -70,18 +95,28 @@ export const GET_ORGANISATIONS_QUERY = gql`
export const CREATE_ORGANISATION_MUTATION = gql`
mutation CreateOrganisation(
$name: String!
$canUseBluetooth: Boolean!
$canUseCamera: Boolean!
$canUseGPS: Boolean!
$canUseSim: Boolean!
$bluetooth: ServiceUseOption!
$bluetoothEditable: Boolean!
$camera: ComponentUseOption!
$cameraEditable: Boolean!
$GPS: ServiceUseOption!
$gpsEditable: Boolean!
$sim: ServiceUseOption!
$simEditable: Boolean!
$offlineTimeThreshold: Float!
) {
createOrganisation(
name: $name
policy: {
canUseBluetooth: $canUseBluetooth
canUseCamera: $canUseCamera
canUseGPS: $canUseGPS
canUseSim: $canUseSim
bluetooth: $bluetooth
bluetoothEditable: $bluetoothEditable
camera: $camera
cameraEditable: $cameraEditable
GPS: $GPS
gpsEditable: $gpsEditable
sim: $sim
simEditable: $simEditable
offlineTimeThreshold: $offlineTimeThreshold
}
) {
id
@ -94,19 +129,29 @@ export const CHANGE_ORGANISATION_MUTATION = gql`
mutation ChangeOrganisation(
$id: ID!
$name: String!
$canUseBluetooth: Boolean!
$canUseCamera: Boolean!
$canUseGPS: Boolean!
$canUseSim: Boolean!
$bluetooth: ServiceUseOption!
$bluetoothEditable: Boolean!
$camera: ComponentUseOption!
$cameraEditable: Boolean!
$GPS: ServiceUseOption!
$gpsEditable: Boolean!
$sim: ServiceUseOption!
$simEditable: Boolean!
$offlineTimeThreshold: Float!
) {
changeOrganisation(
id: $id
name: $name
policy: {
canUseBluetooth: $canUseBluetooth
canUseCamera: $canUseCamera
canUseGPS: $canUseGPS
canUseSim: $canUseSim
bluetooth: $bluetooth
bluetoothEditable: $bluetoothEditable
camera: $camera
cameraEditable: $cameraEditable
GPS: $GPS
gpsEditable: $gpsEditable
sim: $sim
simEditable: $simEditable
offlineTimeThreshold: $offlineTimeThreshold
}
) {
creationDate
@ -116,7 +161,43 @@ export const CHANGE_ORGANISATION_MUTATION = gql`
}
`
export const SIGN_UP_MUTATION = gql`
export const CHANGE_ORGANISATION_POLICY_MUTATION = gql`
mutation ChangeOrganisationPolicy(
$id: ID!
$name: String!
$bluetooth: ServiceUseOption!
$bluetoothEditable: Boolean!
$camera: ComponentUseOption!
$cameraEditable: Boolean!
$GPS: ServiceUseOption!
$gpsEditable: Boolean!
$sim: ServiceUseOption!
$simEditable: Boolean!
$offlineTimeThreshold: Float!
) {
changeOrganisation(
id: $id
name: $name
policy: {
bluetooth: $bluetooth
bluetoothEditable: $bluetoothEditable
camera: $camera
cameraEditable: $cameraEditable
GPS: $GPS
gpsEditable: $gpsEditable
sim: $sim
simEditable: $simEditable
offlineTimeThreshold: $offlineTimeThreshold
}
) {
creationDate
id
name
}
}
`
export const CREATE_USER_MUTATION = gql`
mutation createUser(
$orgId: ID!
$firstName: String!
@ -150,3 +231,95 @@ export const SIGN_UP_MUTATION = gql`
}
}
`
export const CHANGE_USER_MUTATION = gql`
mutation ChangeUser(
$userId: ID!
$orgId: ID!
$firstName: String!
$lastName: String!
$middleName: String!
$username: String!
$role: Role!
) {
changeUser(
userId: $userId
form: {
orgId: $orgId
firstName: $firstName
lastName: $lastName
middleName: $middleName
username: $username
role: $role
}
) {
avatarUrl
firstName
id
lastName
middleName
org {
creationDate
id
logoUrl
name
}
orgId
role
username
}
}
`
export const CHANGE_USER_WITH_PASSWORD_MUTATION = gql`
mutation ChangeUserWithPassword(
$userId: ID!
$orgId: ID!
$firstName: String!
$lastName: String!
$middleName: String!
$username: String!
$password: String!
$role: Role!
) {
changeUser(
userId: $userId
form: {
orgId: $orgId
firstName: $firstName
lastName: $lastName
middleName: $middleName
username: $username
password: $password
role: $role
}
) {
avatarUrl
firstName
id
lastName
middleName
org {
creationDate
id
logoUrl
name
}
orgId
role
username
}
}
`
export const CREATE_UPLOAD_USER_AVATAR_URL_MUTATION = gql`
mutation CreateUploadUserAvatarUrl($userId: ID!) {
createUploadUserAvatarUrl(userId: $userId)
}
`
export const CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION = gql`
mutation CreateUploadOrganisationLogoUrl($orgId: ID!) {
createUploadOrganisationLogoUrl(orgId: $orgId)
}
`

View File

@ -1,23 +1,71 @@
export type EmployeeRole = 'User' | 'Admin' | string
export type Employee = {
avatarUrl?: string | null
id: number
firstName: string
lastName: string
middleName: string
username?: string
orgId: number
role: EmployeeRole
org: {
creationDate?: number
id: number
logoUrl?: string | null
name: string
} | null
}
export type UserSortDirection = 'ASC' | 'DESC'
export type UserSortField = 'ID' | 'Name' | 'Date'
export type ServiceUseOption =
| 'Disabled'
| 'Enabled'
| 'Unspecified'
export type ComponentUseOption =
| 'Allowed'
| 'Disallowed'
export type GroupUsePolicyOption =
| 'Allowed'
| 'Disallowed'
| 'Changeable'
export type GetUsersData = {
getUsers: {
totalPages: number
totalElements: number
page: Employee[]
}
}
export type GetUsersVariables = {
page: number
query?: string
orgs?: string[]
roles?: EmployeeRole[]
sortDirection: UserSortDirection
sortField: UserSortField
}
export type OrganisationPolicy = {
canUseBluetooth: boolean
canUseCamera: boolean
canUseGPS: boolean
canUseSim: boolean
bluetooth?: ServiceUseOption | GroupUsePolicyOption
bluetoothEditable?: boolean
camera?: ComponentUseOption | GroupUsePolicyOption
cameraEditable?: boolean
GPS?: ServiceUseOption | GroupUsePolicyOption
gpsEditable?: boolean
sim?: ServiceUseOption | GroupUsePolicyOption
simEditable?: boolean
canUseBluetooth?: boolean
canUseCamera?: boolean
canUseGPS?: boolean
canUseSim?: boolean
offlineTimeThreshold: number
date?: number
}
@ -26,6 +74,7 @@ export type Organisation = {
id: number
name: string
policy?: OrganisationPolicy | null
logoUrl?: string | null
}
export type GetOrganisationData = {
@ -46,10 +95,29 @@ export type ChangeOrganisationData = {
export type ChangeOrganisationVariables = {
id: string
name: string
canUseBluetooth: boolean
canUseCamera: boolean
canUseGPS: boolean
canUseSim: boolean
bluetooth: ServiceUseOption
bluetoothEditable: boolean
camera: ComponentUseOption
cameraEditable: boolean
GPS: ServiceUseOption
gpsEditable: boolean
sim: ServiceUseOption
simEditable: boolean
offlineTimeThreshold: number
}
export type ChangeOrganisationPolicyVariables = {
id: string
name: string
bluetooth: ServiceUseOption
bluetoothEditable: boolean
camera: ComponentUseOption
cameraEditable: boolean
GPS: ServiceUseOption
gpsEditable: boolean
sim: ServiceUseOption
simEditable: boolean
offlineTimeThreshold: number
}
export type GetUsersPageData = {
@ -78,19 +146,44 @@ export type GetOrganisationsVariables = {
sortField: OrganisationSortField
}
export type SignUpData = {
signUp: {
id: number
firstName: string
lastName: string
middleName: string
orgId: number
role: EmployeeRole
org: {
id: number
name: string
} | null
}
export type DeleteOrganisationData = {
deleteOrganisation: boolean
}
export type DeleteOrganisationVariables = {
id: string
}
export type CreateUserData = {
createUser: Employee
}
export type CreateUserVariables = {
orgId: string
firstName: string
lastName: string
middleName: string
username: string
password: string
role: EmployeeRole
}
export type ChangeUserData = {
changeUser: Employee
}
export type ChangeUserVariables = {
userId: string
orgId: string
firstName: string
lastName: string
middleName: string
username: string
role: EmployeeRole
}
export type ChangeUserWithPasswordVariables = ChangeUserVariables & {
password: string
}
export type SignUpVariables = {
@ -112,12 +205,33 @@ export type CreateOrganisationData = {
export type CreateOrganisationVariables = {
name: string
canUseBluetooth: boolean
canUseCamera: boolean
canUseGPS: boolean
canUseSim: boolean
bluetooth: ServiceUseOption
bluetoothEditable: boolean
camera: ComponentUseOption
cameraEditable: boolean
GPS: ServiceUseOption
gpsEditable: boolean
sim: ServiceUseOption
simEditable: boolean
offlineTimeThreshold: number
}
export type OrganisationSortDirection = 'ASC' | 'DESC'
export type OrganisationSortField = 'ID' | 'Name' | 'Date'
export type CreateUploadUserAvatarUrlData = {
createUploadUserAvatarUrl: string
}
export type CreateUploadUserAvatarUrlVariables = {
userId: string
}
export type CreateUploadOrganisationLogoUrlData = {
createUploadOrganisationLogoUrl: string
}
export type CreateUploadOrganisationLogoUrlVariables = {
orgId: string
}

View File

@ -29,8 +29,17 @@ export const REFRESH_SESSION_MUTATION = gql`
export const CURRENT_USER_QUERY = gql`
query CurrentUser {
currentUser {
avatarUrl
id
role
firstName
middleName
lastName
username
org {
id
name
}
}
}
`

View File

@ -11,6 +11,15 @@ import { LoginPage } from '../../../pages/LoginPage/LoginPage'
type CurrentUser = {
id: string
role: string
avatarUrl: string
firstName: string
middleName: string
lastName: string
username: string
org: {
id: number
name: string
} | null
}
type CurrentUserQueryData = {

View File

@ -1,4 +1,6 @@
@use './shared/styles/variables' as *;
@use './shared/styles/themes';
@use './shared/styles/table-surfaces';
@font-face {
font-family: 'Montserrat';
@ -9,25 +11,12 @@
}
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;;
--heading: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;;
--mono: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;;
font: 18px/145% var(--sans);
font: var(--font-size-18) / 145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: $color-bg;
font-synthesis: none;
@ -36,27 +25,7 @@
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
font-size: $font-size-16;
}
}
@ -74,7 +43,12 @@
body {
margin: 0;
background: $color-bg;
}
html{
background: $color-bg;
}
button{
font-family: inherit;
}
@ -82,28 +56,32 @@ a{
text-decoration: none;
}
.simplebar-content{
height: 100%;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: black;
color: $color-text-strong;
}
h1 {
font-size: 32px;
font-size: $font-size-32;
margin: 0;
@media (max-width: 1024px) {
font-size: 36px;
font-size: $font-size-36;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
font-size: $font-size-24;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
font-size: $font-size-20;
}
}
p {
@ -119,7 +97,7 @@ code,
}
code {
font-size: 15px;
font-size: $font-size-15;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);

View File

@ -1,16 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.scss'
import { RouterProvider } from 'react-router-dom'
import { ApolloProvider } from '@apollo/client/react'
import { apolloClient } from './shared/api/apolloClient'
import { router } from './app/router/router.tsx'
import 'react-day-picker/style.css'
import 'leaflet/dist/leaflet.css'
import { RouterProvider } from 'react-router-dom'
import { ApolloProvider } from '@apollo/client/react'
import { LazyMotion, domAnimation } from 'framer-motion'
import { apolloClient } from './shared/api/apolloClient'
import { router } from './app/router/router.tsx'
import { AuthGate } from './features/auth/ui/AuthGate'
import { initTheme } from './shared/theme/theme'
const isAuthEnabled = import.meta.env.VITE_AUTH_ENABLED === 'true'
initTheme()
const app = isAuthEnabled ? (
<AuthGate>
<RouterProvider router={router} />
@ -22,7 +26,9 @@ const app = isAuthEnabled ? (
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApolloProvider client={apolloClient}>
{app}
<LazyMotion features={domAnimation}>
{app}
</LazyMotion>
</ApolloProvider>
</StrictMode>,
)

View File

@ -2,56 +2,57 @@
.device-page {
display: flex;
flex: 1;
min-height: calc(80% - 40px);
max-height: calc(100% - 40px);
height: auto;
flex-direction: column;
gap: 18px;
}
.device-breadcrumbs {
display: flex;
align-items: center;
gap: 6px;
color: $gray50;
font-size: 16px;
font-weight: 500;
padding: 11px 0;
button, a {
padding: 0;
border: none;
background: transparent;
color: #738098;
font: inherit;
cursor: pointer;
transition: .2s ease;
&:hover {
color: $blue;
}
}
span:last-child {
color: #30394b;
}
overflow: visible;
padding: 20px 20px 36px 20px;
}
.device-page__grid {
display: grid;
grid-template-columns: 420px 260px minmax(360px, 1fr);
grid-template-columns: 340px 340px minmax(360px, 1fr);
gap: 20px;
align-items: start;
width: 100%;
//min-height: 100%;
}
.device-page__motion-view {
display: flex;
flex: 1 0 auto;
min-height: 0;
flex-direction: column;
}
.device-card {
border-radius: 20px;
background: #ffffff;
background: rgba($color-surface-rgb, .5);
backdrop-filter: blur(22px);
padding: 20px;
box-shadow: $shadow-card;
}
.device-card--main {
grid-column: span 2;
}
.device-page__details-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
align-items: start;
>.device-card,
>.device-card-stats {
min-width: 0;
}
}
.device-main {
display: flex;
flex-direction: row;
@ -60,10 +61,10 @@
.device-main__image {
height: calc(50% - 40px);
width: 30%;
width: 27.1%;
padding: 20px;
border-radius: 14px;
background: #f1f4f8;
background: $color-surface-subtle;
display: flex;
align-items: center;
@ -88,8 +89,8 @@
h2 {
margin: 0 0 6px;
color: black;
font-size: 30px;
color: $color-text-strong;
font-size: $font-size-30;
font-weight: 600;
line-height: 1.1;
}
@ -97,7 +98,7 @@
p {
margin: 0;
color: $gray50;
font-size: 17px;
font-size: $font-size-17;
font-weight: 500;
line-height: 1.3;
}
@ -120,8 +121,8 @@
align-items: center;
gap: 9px;
color: black;
font-size: 18px;
color: $color-text-strong;
font-size: $font-size-18;
font-weight: 500;
svg {
@ -134,12 +135,12 @@
&.is-success svg {
color: $green;
background: #eaf8ee;
background: $color-success-bg;
}
&.is-danger svg {
color: $red;
background: #ffe8e8;
background: $color-danger-bg;
}
&.is-muted svg {
@ -159,17 +160,46 @@
align-items: center;
gap: 10px;
color: black;
font-size: 18px;
color: $color-text-strong;
font-size: $font-size-18;
font-weight: 500;
.device-registered-info {
display: flex;
flex: 1;
align-items: center;
padding: 10px 0;
height: inherit;
justify-content: space-between;
border-bottom: 1px solid $gray20;
a {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
color: $gray50;
font-weight: 500;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: $color-primary-hover;
svg {
color: $color-primary-hover;
}
}
svg {
padding: 0;
background-color: transparent;
height: 20px;
width: auto;
margin-bottom: 2px;
transition: .2s ease;
}
}
}
svg {
@ -185,6 +215,7 @@
color: $gray50;
font-weight: 500;
}
}
.device-card__actions {
@ -202,10 +233,10 @@
border: none;
border-radius: 12px;
background: #dfe6ff;
background: $color-primary-soft;
color: $blue;
font-size: 18px;
font-size: $font-size-18;
font-weight: 500;
cursor: pointer;
transition: .2s ease;
@ -213,7 +244,8 @@
&:hover {
background-color: $blue;
color: white;
color: $color-text-inverse;
box-shadow: var(--shadow-primary);
}
}
@ -222,7 +254,7 @@
border: none;
border-radius: 12px;
background: #ffdede;
background: $color-danger-bg-strong;
display: inline-flex;
align-items: center;
@ -238,7 +270,8 @@
&:hover {
background-color: $red;
color: white;
color: $color-text-inverse;
box-shadow: var(--shadow-danger);
}
}
@ -252,8 +285,8 @@
p {
margin: 0;
color: #738098;
font-size: 15px;
color: $color-text-muted;
font-size: $font-size-15;
font-weight: 500;
}
}
@ -265,8 +298,8 @@
h3 {
margin: 0;
color: #30394b;
font-size: 18px;
color: $color-text-heading;
font-size: $font-size-18;
font-weight: 600;
}
@ -275,8 +308,8 @@
width: 24px;
height: 24px;
border-radius: 9px;
background: #eef1f6;
color: #30394b;
background: $color-surface-hover;
color: $color-text-heading;
}
}
@ -288,66 +321,13 @@
height: calc(100% - 40px);
}
.device-actions {
display: flex;
flex-direction: column;
justify-content: space-between;
height: calc(100% - 40px);
h3 {
line-height: 1;
margin: 0 0 12px;
padding-bottom: 12px;
border-bottom: 1px solid #e3e8f0;
text-align: left;
color: #30394b;
font-size: 18px;
font-weight: 600;
}
.actions-button-container {
display: flex;
flex-direction: column;
}
button {
width: 100%;
padding: 12px;
border: none;
border-radius: 12px;
background: $color-bg;
display: flex;
align-items: center;
gap: 8px;
color: $gray50;
font-size: 18px;
font-weight: 550;
cursor: pointer;
transition: .2s ease;
svg {
height: 20px;
width: 20px;
}
&:hover {
background-color: $gray20;
}
&+button {
margin-top: 8px;
}
}
}
.device-permissions {
padding: 10px 20px;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
justify-content: space-between;
height: calc(100% - 20px);
}
@ -357,8 +337,8 @@
justify-content: space-between;
gap: 10px;
color: #30394b;
font-size: 18px;
color: $color-text-heading;
font-size: $font-size-18;
font-weight: 500;
&:nth-child(1) {
@ -373,7 +353,7 @@
height: 24px;
border-radius: 8px;
color: $blue;
background: #eef1f6;
background: $color-surface-hover;
}
}
@ -394,6 +374,11 @@
}
.device-battery {
--device-battery-progress-empty: #cfd5df;
--device-battery-progress-danger: #e00000;
--device-battery-progress-warning: #ff8a34;
--device-battery-progress-strong: #031d9a;
h3 {
text-align: left;
line-height: 1;
@ -401,8 +386,8 @@
padding-bottom: 12px;
border-bottom: 1px solid $gray20;
color: #30394b;
font-size: 18px;
color: $color-text-heading;
font-size: $font-size-18;
font-weight: 600;
}
@ -420,7 +405,7 @@
}
}
svg{
svg {
width: 24px;
height: 24px;
padding: 4px;
@ -447,9 +432,9 @@
.device-battery__circle {
position: relative;
width: 110px;
height: 110px;
flex: 0 0 110px;
width: 120px;
height: 120px;
flex: 0 0 120px;
display: flex;
align-items: center;
@ -466,15 +451,15 @@
justify-content: center;
color: $color-text;
font-size: 26px;
font-size: $font-size-26;
font-weight: 650;
line-height: 1;
}
}
.device-battery__circle-svg {
width: 110px;
height: 110px;
width: 120px;
height: 120px;
display: block;
}
@ -506,8 +491,8 @@
align-items: center;
gap: 10px;
color: #30394b;
font-size: 16px;
color: $color-text-heading;
font-size: $font-size-16;
font-weight: 500;
}
@ -516,12 +501,12 @@
width: 30px;
height: 30px;
border-radius: 9px;
color: #31b24a;
background: #eaf8ee;
color: $color-success-strong;
background: $color-success-bg;
}
b {
color: #30394b;
color: $color-text-heading;
font-weight: 600;
}
}
@ -532,11 +517,11 @@
align-items: center;
gap: 10px;
color: #30394b;
font-size: 18px;
color: $color-text-heading;
font-size: $font-size-18;
font-weight: 500;
.device-stats-text{
.device-stats-text {
display: flex;
flex: 1;
flex-direction: row;
@ -551,7 +536,7 @@
height: 24px;
border-radius: 9px;
color: $green;
background: #eaf8ee;
background: $color-success-bg;
}
b {
@ -562,7 +547,7 @@
.device-page__empty {
min-height: 260px;
border-radius: 18px;
background: #ffffff;
background: $color-surface;
display: flex;
flex-direction: column;
@ -580,7 +565,278 @@
border: none;
border-radius: 12px;
background: $blue;
color: #ffffff;
color: $color-text-inverse;
cursor: pointer;
}
}
[data-theme='dark'] {
.device-battery {
--device-battery-progress-empty: #36414f;
--device-battery-progress-danger: #ff4f5e;
--device-battery-progress-warning: #ff9f43;
--device-battery-progress-strong: #2d8cff;
}
}
@include desktop-down {
.device-page__grid {
display: grid;
grid-template-columns: 280px 280px minmax(380px, 1fr);
gap: 14px;
align-items: start;
.device-page__details-row {
gap: 14px;
grid-template-columns: 1.25fr 1fr 1.25fr;
.device-card-stats {
gap: 14px;
}
}
.device-actions {
button {
font-size: var(--font-size-16);
padding: 12px;
gap: 12px;
svg {
width: 28px;
height: 28px;
}
}
.future-label {
font-size: var(--font-size-12);
padding: 8px 12px;
}
}
}
.device-permission {
font-size: var(--font-size-16) !important;
svg {
width: 20px;
height: 20px;
}
&__label {
padding: 8px 0;
}
}
.device-permissions__icon-btn {
width: 32px;
height: 32px;
border-radius: 10px;
}
.device-battery__content {
grid-template-columns: 100px 1fr;
}
.device-battery__circle {
width: 100px;
height: 100px;
span {
font-size: var(--font-size-22);
}
}
.device-stats {
gap: 8px;
div {
font-size: var(--font-size-15);
}
.device-stats-card {
svg {
width: 20px;
height: 20px;
}
}
}
.device-impacts {
font-size: var(--font-size-16);
svg {
width: 20px;
height: 20px;
}
}
.device-main {
&__info {
h2 {
font-size: var(--font-size-28);
}
p {
font-size: var(--font-size-15);
}
}
}
.device-main__image {
width: 26.5%;
}
.device-status {
font-size: var(--font-size-16);
svg {
width: 20px;
height: 20px;
}
}
.device-registered {
font-size: var(--font-size-16);
svg {
height: 20px;
width: 20px;
}
.device-registered-info {
padding: 8px 0;
a {
svg {
width: 18px;
height: 18px;
}
}
}
}
.device-card__actions {
margin-top: 18px;
}
.device-history-btn {
font-size: var(--font-size-16);
}
.device-delete-btn {
svg {
width: 16px;
height: 16px;
}
}
.history-period-tabs__item {
font-size: var(--font-size-12);
height: 30px;
}
}
@include laptop-down {
.device-page {
gap: 14px;
}
.device-page__grid {
grid-template-columns: 540px minmax(360px, 1fr);;
}
.device-card--main {
grid-column: span 1;
}
.device-page__grid,
.device-page__details-row {
gap: $layout-card-gap;
}
.device-card {
padding: 18px;
height: calc(100% - 36px);
border-radius: 16px;
}
.device-card-stats{
height: calc(100%);
}
.device-impacts{
flex: 0;
}
.device-battery{
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
}
.device-permissions{
height: calc(100% - 12px);
padding: 8px 8px 4px 18px;
}
.device-main {
gap: 16px;
}
.device-main__image {
padding: 16px;
}
.device-map {
min-height: 252px;
}
.history-period-tabs{
display: none;
}
.device-card__title{
gap: 8px;
svg{
width: 22px;
height: 22px;
}
h3{
font-size: var(--font-size-16);
}
}
.device-card__header{
p{
font-size: var(--font-size-14);
}
}
}
@include tablet-down {
.device-main {
flex-direction: column;
}
.device-main__image {
width: 100%;
height: 178px;
box-sizing: border-box;
}
.device-main__info {
h2 {
font-size: $font-size-26;
}
}
.device-map {
grid-column: span 1;
min-height: 300px;
height: auto;
}
.device-page__details-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.device-card__actions {
gap: 10px;
}
}

View File

@ -1,13 +1,26 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import {
useNavigate,
useOutletContext,
useParams,
useSearchParams,
} from 'react-router-dom'
import { AnimatePresence, m } from 'framer-motion'
import { useQuery } from '@apollo/client/react'
import { DeviceHistoryModal } from './components/DeviceHistoryModal/DeviceHistoryModal'
import type { Device as PageDevice } from './types'
import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql'
import type {
Device as ApiDevice,
DeviceNetworkStatus,
GetPhoneGpsTrackData,
GetTelemetryData,
GetDevicePageData,
@ -19,8 +32,27 @@ import { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard'
import { DeviceActionsCard } from './components/DeviceActionsCard/DeviceActionsCard'
import { DevicePermissionsCard } from './components/DevicePermissionsCard/DevicePermissionsCard'
import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards'
import { DeviceAppsView } from './components/DeviceAppsView/DeviceAppsView'
import { pageSwapVariants, uiFadeTransition } from '../../shared/lib/motion'
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
import './DevicePage.scss'
import { DevicePageSkeleton } from './components/DevicePageSkeleton/DevicePageSkeleton'
import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout'
import {
preloadOnIdle,
useLazyMount,
} from '../../shared/lib/lazyMount'
const loadDeviceHistoryModal = () =>
import('./components/DeviceHistoryModal/DeviceHistoryModal').then(
(module) => ({
default: module.DeviceHistoryModal,
}),
)
const DeviceHistoryModal = lazy(loadDeviceHistoryModal)
function formatLocationDate(timestamp: number) {
if (!timestamp) return 'Нет данных'
@ -48,15 +80,39 @@ function getSortedGpsTrack(track: GetPhoneGpsTrackData['getPhoneGpsTrack']) {
return [...track].sort((a, b) => a.date - b.date)
}
function getDeviceNetworkConnection(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'online'
if (status === 'Lost') return 'offlineDanger'
return 'offline'
}
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'В сети'
if (status === 'Lost') return 'Потерян'
return 'Не в сети'
}
function mapApiDeviceToPageDevice(
device: ApiDevice,
batteryLevel?: number,
gpsTrack: GetPhoneGpsTrackData['getPhoneGpsTrack'] = [],
telemetry: GetTelemetryData['getTelemetry'] = [],
): PageDevice {
const sortedGpsTrack = getSortedGpsTrack(gpsTrack)
const policy = device.policy
const techState = device.techState
const needMaintenance = techState?.needMaintenance ?? false
const bluetoothEnabled = isPhonePolicyOptionEnabled(
policy?.bluetooth ?? policy?.canUseBluetooth,
)
const gpsEnabled = isPhonePolicyOptionEnabled(policy?.GPS ?? policy?.canUseGPS)
const cameraEnabled = isPhonePolicyOptionEnabled(
policy?.camera ?? policy?.canUseCamera,
)
const simEnabled = isPhonePolicyOptionEnabled(policy?.sim ?? policy?.canUseSim)
const isLocked = policy?.locked ?? false
const currentGpsPoint =
sortedGpsTrack.length > 0
@ -97,10 +153,13 @@ function mapApiDeviceToPageDevice(
employee: device.org?.name ?? null,
organisation: device.org?.name,
organisationId: device.org?.id ?? device.orgId,
policy,
organisationPolicy: device.org?.policy ?? null,
condition: needMaintenance ? 'inspection' : 'ok',
connection: location ? 'online' : 'offline',
connectionText: location ? 'В сети' : 'Не в сети',
connection: getDeviceNetworkConnection(device.networkStatus),
connectionText: getDeviceNetworkLabel(device.networkStatus),
workTime: null,
registeredAt: device.registerDate
@ -121,26 +180,28 @@ function mapApiDeviceToPageDevice(
permissions: {
wifi: false,
bluetooth: policy?.canUseBluetooth ?? false,
gps: policy?.canUseGPS ?? false,
camera: policy?.canUseCamera ?? false,
sim: policy?.canUseSim ?? false,
bluetooth: bluetoothEnabled,
gps: gpsEnabled,
camera: cameraEnabled,
sim: simEnabled,
speaker: false,
locked: isLocked,
},
statusIcons: {
gps: policy?.canUseGPS ?? false,
gps: gpsEnabled,
wifi: false,
bluetooth: policy?.canUseBluetooth ?? false,
lock: false,
camera: policy?.canUseCamera ?? false,
sim: policy?.canUseSim ?? false,
bluetooth: bluetoothEnabled,
lock: isLocked,
camera: cameraEnabled,
sim: simEnabled,
sound: false,
kiosk: false,
},
battery: techState?.batteryLevel ?? batteryLevel,
batteryMaxCapacity: techState?.batteryRemainingCapacity ?? undefined,
telemetry,
chargeCycles: formatTechValue(techState?.batteryCycles),
totalWorkTime: techState?.worktime ?? null,
mediumImpacts: formatTechValue(techState?.hits),
@ -151,103 +212,227 @@ function mapApiDeviceToPageDevice(
export function DevicePage() {
const { deviceId } = useParams()
const navigate = useNavigate()
const { setNavbarBreadcrumbs } =
useOutletContext<AppLayoutOutletContext>()
const [searchParams, setSearchParams] = useSearchParams()
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const shouldMountHistoryModal = useLazyMount(isHistoryOpen)
const numericDeviceId = Number(deviceId)
const isAppsView = searchParams.get('view') === 'apps'
const { data, loading, error } = useQuery<
GetDevicePageData,
GetDevicePageVariables
>(GET_DEVICE_PAGE_QUERY, {
variables: {
id: numericDeviceId,
phoneId: String(numericDeviceId),
telemetryStartDate: 0,
gpsStartDate: 0,
},
skip: !numericDeviceId,
fetchPolicy: 'network-only',
pollInterval: 15000,
})
useEffect(() => {
return preloadOnIdle([loadDeviceHistoryModal])
}, [])
const {
data,
loading,
error,
refetch,
startPolling,
stopPolling,
} = useQuery<
GetDevicePageData,
GetDevicePageVariables
>(GET_DEVICE_PAGE_QUERY, {
variables: {
id: numericDeviceId,
phoneId: String(numericDeviceId),
telemetryStartDate: 0,
gpsStartDate: 0,
},
skip: !numericDeviceId,
fetchPolicy: 'network-only',
pollInterval: 15000,
})
const handlePermissionsEditingChange = useCallback(
(isEditing: boolean) => {
if (isEditing) {
stopPolling()
return
}
if (numericDeviceId) {
startPolling(15000)
}
},
[numericDeviceId, startPolling, stopPolling],
)
const latestTelemetryItem = useMemo(() => {
return getLatestTelemetryItem(data?.getTelemetry ?? [])
}, [data])
return getLatestTelemetryItem(data?.getTelemetry ?? [])
}, [data])
const device = useMemo(() => {
if (!data?.getPhone) return null
const device = useMemo(() => {
if (!data?.getPhone) return null
return mapApiDeviceToPageDevice(
data.getPhone,
latestTelemetryItem?.batteryLevel,
data.getPhoneGpsTrack ?? [],
)
}, [data, latestTelemetryItem])
return mapApiDeviceToPageDevice(
data.getPhone,
latestTelemetryItem?.batteryLevel ?? undefined,
data.getPhoneGpsTrack ?? [],
data.getTelemetry ?? [],
)
}, [data, latestTelemetryItem])
useEffect(() => {
if (!device) {
setNavbarBreadcrumbs([])
return
}
setNavbarBreadcrumbs([
{
label: device.factoryNumber,
to: isAppsView ? `/devices/${device.id}` : undefined,
},
...(isAppsView ? [{ label: 'Приложения' }] : []),
])
return () => {
setNavbarBreadcrumbs([])
}
}, [device, isAppsView, setNavbarBreadcrumbs])
const handleOpenApps = useCallback(() => {
const nextParams = new URLSearchParams(searchParams)
nextParams.set('view', 'apps')
setSearchParams(nextParams)
}, [searchParams, setSearchParams])
const handleCloseApps = useCallback(() => {
const nextParams = new URLSearchParams(searchParams)
nextParams.delete('view')
setSearchParams(nextParams, {
replace: true,
})
}, [searchParams, setSearchParams])
const handleOpenHistory = useCallback(() => {
setIsHistoryOpen(true)
}, [])
const handleRefetchPolicy = useCallback(async () => {
const result = await refetch()
return result.data?.getPhone?.policy
}, [refetch])
const handleLockStateChange = useCallback(async () => {
const result = await refetch()
return result.data?.getPhone?.policy?.locked
}, [refetch])
if (!numericDeviceId) {
return (
<section className="device-page">
<div className="device-page__empty">
<h2>Некорректный ID устройства</h2>
<button type="button" onClick={() => navigate('/devices')}>
<EmptyState
className="device-page__empty"
variant="not-found"
title="Некорректный ID устройства"
description="Проверьте ссылку или вернитесь к списку устройств."
>
<button
type="button"
onClick={() => navigate('/devices')}
>
Вернуться к списку
</button>
</div>
</EmptyState>
</section>
)
}
if (loading && !device) {
return (
<section className="device-page">
<div className="device-page__empty">
<h2>Загрузка устройства...</h2>
</div>
</section>
)
return <DevicePageSkeleton />
}
if (error || !device) {
return (
<section className="device-page">
<div className="device-page__empty">
<h2>Устройство не найдено</h2>
<button type="button" onClick={() => navigate('/devices')}>
<EmptyState
className="device-page__empty"
variant="not-found"
title="Устройство не найдено"
description="Возможно, устройство было удалено или ссылка устарела."
>
<button
type="button"
onClick={() => navigate('/devices')}
>
Вернуться к списку
</button>
</div>
</EmptyState>
</section>
)
}
return (
<section className="device-page">
<div className="device-breadcrumbs">
<Link to="/devices">
Все устройства
</Link>
<AnimatePresence mode="wait" initial={false}>
{isAppsView ? (
<m.div
key="apps"
className="device-page__motion-view"
variants={pageSwapVariants}
initial="initial"
animate="animate"
exit="exit"
transition={uiFadeTransition}
>
<DeviceAppsView
deviceId={device.id}
deviceName={device.factoryNumber}
onBack={handleCloseApps}
/>
</m.div>
) : (
<m.div
key="overview"
className="device-page__motion-view"
variants={pageSwapVariants}
initial="initial"
animate="animate"
exit="exit"
transition={uiFadeTransition}
>
<div className="device-page__grid">
<DeviceMainCard
device={device}
onOpenHistory={handleOpenHistory}
/>
<DeviceMapCard device={device} />
<span>/</span>
<span>{device.factoryNumber}</span>
</div>
<div className="device-page__grid">
<DeviceMainCard
device={device}
onOpenHistory={() => setIsHistoryOpen(true)}
/>
<DeviceMapCard device={device} />
<DeviceActionsCard />
<DevicePermissionsCard device={device} />
<DeviceStatsCards device={device} />
</div>
<DeviceHistoryModal
open={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
device={device}
/>
<div className="device-page__details-row">
<DeviceStatsCards device={device} />
<DevicePermissionsCard
device={device}
onEditingChange={handlePermissionsEditingChange}
onRefetchPolicy={handleRefetchPolicy}
/>
<DeviceActionsCard
device={device}
onOpenApps={handleOpenApps}
onLockStateChange={handleLockStateChange}
/>
</div>
</div>
</m.div>
)}
</AnimatePresence>
{shouldMountHistoryModal && (
<Suspense fallback={null}>
<DeviceHistoryModal
open={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
device={device}
/>
</Suspense>
)}
</section>
)
}

View File

@ -1,3 +1,5 @@
@use '../../../../shared/styles/variables' as *;
.battery-circle {
position: relative;
width: 180px;
@ -18,7 +20,7 @@
}
.battery-circle__track {
stroke: #cfd5df;
stroke: $color-border-accent;
}
.battery-circle__progress {
@ -35,8 +37,8 @@
align-items: center;
justify-content: center;
font-size: 44px;
font-size: $font-size-44;
font-weight: 500;
line-height: 1;
color: #000;
color: $black;
}

View File

@ -0,0 +1,235 @@
@use '../../../../shared/styles/variables' as *;
.device-actions {
display: flex;
flex-direction: column;
justify-content: space-between;
height: calc(100% - 40px);
h3 {
line-height: 1;
margin: 0 0 12px;
padding-bottom: 12px;
border-bottom: 1px solid $color-border;
text-align: left;
color: $color-text-heading;
font-size: $font-size-18;
font-weight: 600;
}
.actions-button-container {
display: flex;
flex-direction: column;
}
button {
display: flex;
flex-direction: row;
gap: 14px;
align-items: center;
justify-content: flex-start;
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
background: $color-bg;
display: flex;
align-items: center;
color: $gray50;
font-size: $font-size-18;
font-weight: 550;
cursor: pointer;
transition: .2s ease;
small{
font-weight: 500;
opacity: .8;
}
.actions-button-text{
display: flex;
flex-direction: column;
align-items: flex-start;
}
svg {
height: 32px;
width: 32px;
}
&:not(:disabled):hover {
background-color: rgba($gray20-rgb, .9);
//color: $blue;
box-shadow: inset 0 0 0 1.5px rgba($gray50-rgb, .1);
}
&:disabled {
opacity: .74;
cursor: not-allowed;
&:hover {
background: $color-bg;
color: $gray50;
}
}
&+button {
margin-top: 8px;
}
}
.device-actions__lock-button {
overflow: hidden;
.device-actions__lock-content {
position: relative;
display: grid;
width: 100%;
min-height: 40px;
min-width: 0;
overflow: hidden;
}
.device-actions__lock-state {
grid-area: 1 / 1;
display: flex;
align-items: center;
gap: 14px;
width: 100%;
min-height: 40px;
min-width: 0;
}
.device-actions__lock-icon {
flex: 0 0 32px;
transition:
color 0.2s ease,
transform 0.24s cubic-bezier(0.34, 1.42, 0.64, 1);
}
&.is-loading {
opacity: 1;
cursor: wait;
.device-actions__loading-icon {
color: $blue;
transform: none;
transform-box: fill-box;
transform-origin: center;
backface-visibility: hidden;
transition: color 0.2s ease;
will-change: transform;
animation: device-actions-loading-spin 0.85s linear infinite;
}
&:disabled:hover {
background: $color-bg;
color: $gray50;
}
}
&.is-lock:not(.is-loading) {
&:not(:disabled):hover {
.device-actions__lock-icon {
color: $red;
transform: translateY(-1px) scale(1.06);
}
}
}
&.is-unlock:not(.is-loading) {
&:not(:disabled):hover {
.device-actions__lock-icon {
color: $blue;
transform: rotate(-8deg) translateY(-1px) scale(1.06);
}
}
}
}
.device-actions__error {
margin-top: 10px;
padding: 7px 10px;
border-radius: 10px;
background: $color-danger-tint;
color: $red;
font-size: $font-size-13;
font-weight: 600;
line-height: 1.25;
}
.future-label{
display: flex;
flex-direction: row;
align-items: center;
font-size: $font-size-14;
font-weight: 550;
padding: 10px 14px;
margin-top: 20px;
gap: 14px;
color: $blue;
background-color: $blue20;
border-radius: 14px;
svg{
height: 20px;
width: auto;
}
}
}
.device-actions__apps-icon {
transition: color 0.2s ease;
rect {
transform-box: fill-box;
transform-origin: center;
transition: transform 0.22s ease;
}
}
.device-actions__apps-button:not(:disabled):hover {
.device-actions__apps-icon {
color: $blue;
rect:nth-child(2) {
--tile-pop-x: 1.7px;
--tile-pop-y: -1.7px;
--tile-rest-x: 0.8px;
--tile-rest-y: -0.8px;
animation: device-actions-app-tile 0.48s cubic-bezier(0.34, 1.42, 0.64, 1);
transform: translate(var(--tile-rest-x), var(--tile-rest-y)) scale(1);
}
}
}
@keyframes device-actions-app-tile {
0% {
transform: translate(0, 0) scale(1);
}
45% {
transform: translate(var(--tile-pop-x), var(--tile-pop-y)) scale(0.86);
}
100% {
transform: translate(var(--tile-rest-x, 0), var(--tile-rest-y, 0)) scale(1);
}
}
@keyframes device-actions-loading-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.device-actions {
.device-actions__lock-button.is-loading {
.device-actions__loading-icon {
animation: none;
}
}
}
}

View File

@ -1,34 +1,272 @@
import { BlockIcon } from '../../../../assets/icons/Block'
import { KioskIcon } from '../../../../assets/icons/Kiosk'
import { RebootIcon } from '../../../../assets/icons/Reboot'
import { MessageIcon } from '../../../../assets/icons/Message'
import { memo, useEffect, useState } from 'react'
import { AnimatePresence, m, useReducedMotion } from 'framer-motion'
import { useMutation } from '@apollo/client/react'
import { CircleAlert, Unlock } from 'lucide-react'
import { Apps } from '../../../../assets/icons/Apps'
import { BlockIcon } from '../../../../assets/icons/Block'
import { LoadingTwotoneLoopIcon } from '../../../../assets/icons/LoadingTwotoneLoop'
import type { Device } from '../../types'
import {
CHANGE_PHONE_POLICY_MUTATION,
} from '../../../../entities/device/api/device.graphql'
import type {
ChangePhonePolicyData,
ChangePhonePolicyVariables,
} from '../../../../entities/device/model/types'
import {
normalizeComponentUseOption,
normalizeServiceUseOption,
} from '../../../../entities/device/lib/phonePolicy'
import './DeviceActionsCard.scss'
type DeviceActionsCardProps = {
device: Device
onOpenApps: () => void
onLockStateChange?: () => Promise<boolean | null | undefined> | boolean | null | void
}
function getChangeLockVariables(
device: Device,
locked: boolean,
): ChangePhonePolicyVariables {
const policy = device.policy
const permissions = device.permissions
return {
id: String(device.id),
bluetooth: normalizeServiceUseOption(
policy?.bluetooth ?? policy?.canUseBluetooth,
permissions?.bluetooth,
),
camera: normalizeComponentUseOption(
policy?.camera ?? policy?.canUseCamera,
permissions?.camera,
),
GPS: normalizeServiceUseOption(
policy?.GPS ?? policy?.canUseGPS,
permissions?.gps,
),
sim: normalizeServiceUseOption(
policy?.sim ?? policy?.canUseSim,
permissions?.sim,
),
locked,
}
}
function areDeviceActionsCardPropsEqual(
prev: DeviceActionsCardProps,
next: DeviceActionsCardProps,
) {
const prevPolicy = prev.device.policy
const nextPolicy = next.device.policy
const prevPermissions = prev.device.permissions
const nextPermissions = next.device.permissions
return (
prev.onOpenApps === next.onOpenApps &&
prev.onLockStateChange === next.onLockStateChange &&
prev.device.id === next.device.id &&
prev.device.connection === next.device.connection &&
prevPolicy?.bluetooth === nextPolicy?.bluetooth &&
prevPolicy?.camera === nextPolicy?.camera &&
prevPolicy?.GPS === nextPolicy?.GPS &&
prevPolicy?.sim === nextPolicy?.sim &&
prevPolicy?.locked === nextPolicy?.locked &&
prevPermissions?.bluetooth === nextPermissions?.bluetooth &&
prevPermissions?.camera === nextPermissions?.camera &&
prevPermissions?.gps === nextPermissions?.gps &&
prevPermissions?.sim === nextPermissions?.sim &&
prevPermissions?.locked === nextPermissions?.locked
)
}
export const DeviceActionsCard = memo(function DeviceActionsCard({
device,
onOpenApps,
onLockStateChange,
}: DeviceActionsCardProps) {
const deviceLocked = device.permissions?.locked ?? device.policy?.locked ?? false
const isLost = device.connection === 'offlineDanger'
const [displayLocked, setDisplayLocked] = useState(deviceLocked)
const [pendingLockTarget, setPendingLockTarget] = useState<boolean | null>(null)
const [lockError, setLockError] = useState(false)
const [changePhonePolicy] = useMutation<
ChangePhonePolicyData,
ChangePhonePolicyVariables
>(CHANGE_PHONE_POLICY_MUTATION)
const shouldReduceMotion = useReducedMotion()
const isLockLoading = pendingLockTarget !== null
const lockStateTransition = shouldReduceMotion
? { duration: 0 }
: {
duration: 0.18,
ease: [0.34, 1.42, 0.64, 1] as [number, number, number, number],
}
const lockLoadingTarget = pendingLockTarget ?? !displayLocked
const lockButtonView = isLockLoading
? {
key: 'loading',
icon: (
<LoadingTwotoneLoopIcon
className="device-actions__lock-icon device-actions__loading-icon"
aria-hidden="true"
/>
),
title: lockLoadingTarget ? 'Блокируем...' : 'Разблокируем...',
description: 'Отправляем команду на устройство',
}
: isLost
? {
key: 'lost',
icon: (
<BlockIcon
className="device-actions__lock-icon"
aria-hidden="true"
/>
),
title: 'Долго не в сети',
description: 'Управление блокировкой недоступно',
}
: displayLocked
? {
key: 'unlock',
icon: (
<Unlock
className="device-actions__lock-icon"
aria-hidden="true"
/>
),
title: 'Разблокировать',
description: 'Вернуть устройству доступ к работе',
}
: {
key: 'lock',
icon: (
<BlockIcon
className="device-actions__lock-icon"
aria-hidden="true"
/>
),
title: 'Заблокировать',
description: 'Удаленно заблокировать устройство',
}
useEffect(() => {
setDisplayLocked(deviceLocked)
}, [device.id, deviceLocked])
async function handleToggleLock() {
if (isLost || isLockLoading) return
const nextLocked = !displayLocked
let changedLocked = nextLocked
setLockError(false)
setPendingLockTarget(nextLocked)
try {
const result = await changePhonePolicy({
variables: getChangeLockVariables(device, nextLocked),
})
changedLocked = result.data?.changePhonePolicy.locked ?? nextLocked
} catch (error) {
console.error('Не удалось изменить блокировку устройства', error)
setLockError(true)
setPendingLockTarget(null)
return
}
try {
const refetchResult = onLockStateChange?.()
const refetchedLocked = await Promise.resolve(refetchResult)
setDisplayLocked(
typeof refetchedLocked === 'boolean'
? refetchedLocked
: changedLocked,
)
} catch (error) {
console.error('Не удалось обновить данные устройства', error)
setDisplayLocked(changedLocked)
} finally {
setPendingLockTarget(null)
}
}
export function DeviceActionsCard() {
return (
<div className="device-card device-actions">
<h3>Действия</h3>
<h3>Управление</h3>
<div className="actions-button-container">
<button type="button" disabled>
<BlockIcon />
Заблокировать
<button
className={`device-actions__lock-button ${
displayLocked ? 'is-unlock' : 'is-lock'
} ${isLockLoading ? 'is-loading' : ''}`}
type="button"
disabled={isLost || isLockLoading}
onClick={handleToggleLock}
>
<span className="device-actions__lock-content">
<AnimatePresence initial={false}>
<m.span
key={lockButtonView.key}
className="device-actions__lock-state"
initial={{
opacity: 0,
x: shouldReduceMotion ? 0 : 10,
}}
animate={{
opacity: 1,
x: 0,
transition: lockStateTransition,
}}
exit={{
opacity: 0,
x: shouldReduceMotion ? 0 : -10,
transition: {
duration: shouldReduceMotion ? 0 : 0.14,
ease: 'easeOut',
},
}}
>
{lockButtonView.icon}
<span className="actions-button-text">
<span>{lockButtonView.title}</span>
<small>{lockButtonView.description}</small>
</span>
</m.span>
</AnimatePresence>
</span>
</button>
<button type="button" disabled>
<KioskIcon />
Включить режим киоска
<button
className="device-actions__apps-button"
type="button"
onClick={onOpenApps}
>
<Apps className="device-actions__apps-icon" aria-hidden="true" />
<div className="actions-button-text">
<span>Приложения</span>
<small>Управление установленными приложениями</small>
</div>
</button>
<button type="button" disabled>
<RebootIcon />
Перезагрузить
</button>
<div className="future-label">
<CircleAlert />
Дополнительные функции будут доступны позже
</div>
<button type="button" disabled>
<MessageIcon />
Отправить сообщение
</button>
{lockError && (
<div className="device-actions__error">
Не удалось изменить блокировку
</div>
)}
</div>
</div>
)
}
}, areDeviceActionsCardPropsEqual)

View File

@ -0,0 +1,269 @@
@use '../../../../shared/styles/variables' as *;
.device-apps {
margin-top: 34px;
padding: 0 90px;
border-radius: 22px;
}
.device-apps__header {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
h3 {
margin: 0;
color: $color-text-heading;
font-size: $font-size-20;
font-weight: 650;
}
p {
margin: 4px 0 0;
color: $gray50;
font-size: $font-size-14;
font-weight: 500;
}
span {
padding: 8px 12px;
border-radius: 999px;
background: $color-surface;
color: $gray50;
font-size: $font-size-13;
font-weight: 600;
&.is-error {
background: $color-danger-tint;
color: $red;
}
}
}
.device-apps__empty {
min-height: 240px;
padding: 24px 18px;
}
.device-apps__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.device-app-item {
border: 1px solid $gray20;
border-radius: 18px;
background: $color-surface;
overflow: hidden;
}
.device-app-item__header {
margin: 0;
}
.device-app-item__trigger {
width: 100%;
padding: 14px 16px;
border: none;
background: $transparent;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
font-family: inherit;
cursor: pointer;
text-align: left;
transition: background-color 0.2s ease;
&:hover {
background: $color-surface-muted;
}
&[data-state='open'] {
&:hover{
background-color: $color-surface;
}
.device-app-item__chevron {
transform: rotate(180deg);
}
}
}
.device-app-item__main {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.device-app-item__icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: $color-text-inverse;
background: $blue;
flex: 0 0 auto;
&.is-radio {
background: $accent-purple;
}
&.is-message {
background: $accent-sky;
}
&.is-system {
background: $color-text-heading;
}
&.is-security {
background: $accent-teal;
}
&.is-package {
background: $color-warning;
}
}
.device-app-item__info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
strong {
color: $color-text-heading;
font-size: $font-size-16;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
small {
color: $gray50;
font-size: $font-size-13;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.device-app-item__right {
display: flex;
align-items: center;
gap: 18px;
flex: 0 0 auto;
}
.device-app-item__usage {
min-width: 84px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
strong {
color: $color-text-heading;
font-size: $font-size-20;
font-weight: 600;
line-height: 1;
}
span {
color: $gray50;
font-size: $font-size-12;
font-weight: 500;
}
}
.device-app-item__chevron {
color: $gray50;
transition: transform 0.2s ease;
}
.device-app-item__content {
overflow: hidden;
&[data-state='open'] {
animation: deviceAppAccordionDown 0.2s ease;
}
&[data-state='closed'] {
animation: deviceAppAccordionUp 0.2s ease;
}
}
.device-app-item__body {
padding: 0 16px 16px;
}
.device-app-item__meta {
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 14px;
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
color: $gray50;
font-size: $font-size-13;
font-weight: 500;
b {
color: $color-text-heading;
font-weight: 650;
}
}
.device-app-chart {
height: 220px;
border-radius: 16px;
background: $color-surface;
border: 1px solid $gray20;
.echarts-for-react {
width: 100%;
height: 220px !important;
}
}
@keyframes deviceAppAccordionDown {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-accordion-content-height);
opacity: 1;
}
}
@keyframes deviceAppAccordionUp {
from {
height: var(--radix-accordion-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}

View File

@ -0,0 +1,535 @@
import { useMemo, useState } from 'react'
import * as Accordion from '@radix-ui/react-accordion'
import ReactECharts from 'echarts-for-react'
import {
AppWindow,
Box,
ChevronDown,
MessageCircle,
Radio,
Settings,
ShieldCheck,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { DevicePackage } from '../../../../entities/device/model/types'
import type { DeviceHistoryPeriodValue } from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl'
import { EmptyState } from '../../../../shared/ui/EmptyState/EmptyState'
import './DeviceAppsAccordion.scss'
type DeviceAppsAccordionProps = {
packages: DevicePackage[]
period: DeviceHistoryPeriodValue | null
isLoading?: boolean
isError?: boolean
}
type AppEventCategory = 'active' | 'background' | 'network'
type MockAppEvent = {
category: AppEventCategory
name: string
start: number
end: number
color: string
}
type RectShape = {
x: number
y: number
width: number
height: number
}
type AppEventRenderParams = {
coordSys: RectShape
}
type AppEventRenderApi = {
value: (index: number) => number
coord: (value: [number, number]) => [number, number]
style: () => Record<string, unknown>
}
type AppEventTooltipParams = {
data?: {
meta?: MockAppEvent
}
}
const FONT_FAMILY = 'Montserrat Variable, Montserrat, sans-serif'
const DAY = 24 * 60 * 60 * 1000
const EVENT_CATEGORIES: Array<{
key: AppEventCategory
label: string
}> = [
{ key: 'active', label: 'Открыто' },
{ key: 'background', label: 'Фон' },
{ key: 'network', label: 'Сеть' },
]
const EVENT_COLORS: Record<AppEventCategory, string> = {
active: '#031d9a',
background: '#18b89b',
network: '#ff8a34',
}
function getHash(value: string) {
return value.split('').reduce((sum, char) => {
return sum + char.charCodeAt(0)
}, 0)
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function getPeriodBounds(period: DeviceHistoryPeriodValue | null) {
const end = period?.periodEnd.getTime() ?? Date.now()
const start = period?.periodStart.getTime() ?? end - DAY
return { start, end }
}
function getAppName(app: DevicePackage) {
return app.name || app.packageName || 'Без названия'
}
function formatDate(timestamp?: number | null) {
if (!timestamp) return 'Дата установки не указана'
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(timestamp))
}
function formatTooltipDate(value: number) {
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
function formatDuration(start: number, end: number) {
const totalMinutes = Math.max(1, Math.round((end - start) / 60000))
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (!hours) return `${minutes} мин`
if (!minutes) return `${hours} ч`
return `${hours} ч ${minutes} мин`
}
function getUsagePercent(app: DevicePackage, period: DeviceHistoryPeriodValue | null) {
const { start, end } = getPeriodBounds(period)
const periodSeed = Math.round((end - start) / DAY)
const hash = getHash(`${app.packageName}-${app.name}-${periodSeed}`)
return clamp(4 + (hash % 72), 1, 96)
}
function getAppVisual(app: DevicePackage): {
Icon: LucideIcon
modifier: string
} {
const value = `${app.name} ${app.packageName}`.toLowerCase()
if (value.includes('radio') || value.includes('fm')) {
return { Icon: Radio, modifier: 'is-radio' }
}
if (value.includes('sms') || value.includes('ims') || value.includes('message')) {
return { Icon: MessageCircle, modifier: 'is-message' }
}
if (value.includes('settings') || value.includes('android')) {
return { Icon: Settings, modifier: 'is-system' }
}
if (value.includes('security') || value.includes('cts')) {
return { Icon: ShieldCheck, modifier: 'is-security' }
}
if (value.includes('package')) {
return { Icon: Box, modifier: 'is-package' }
}
return { Icon: AppWindow, modifier: 'is-default' }
}
function createMockEvents(
app: DevicePackage,
period: DeviceHistoryPeriodValue | null,
usagePercent: number,
): MockAppEvent[] {
const { start, end } = getPeriodBounds(period)
const duration = end - start
const hash = getHash(app.packageName || app.name || '')
const eventsCount = clamp(Math.round(usagePercent / 12), 2, 8)
return Array.from({ length: eventsCount }).map((_, index) => {
const category = EVENT_CATEGORIES[(hash + index) % EVENT_CATEGORIES.length].key
const eventStart =
start +
duration * ((index + 1) / (eventsCount + 1)) +
((hash + index * 17) % 30) * 60 * 1000
const eventDuration =
duration * (0.015 + ((hash + index * 13) % 9) / 100)
const safeStart = clamp(eventStart, start, end)
const safeEnd = clamp(eventStart + eventDuration, safeStart + 60 * 1000, end)
return {
category,
name:
category === 'active'
? 'Приложение открыто'
: category === 'background'
? 'Фоновая активность'
: 'Сетевое событие',
start: safeStart,
end: safeEnd,
color: EVENT_COLORS[category],
}
})
}
function clipRectByRect(shape: RectShape, rect: RectShape) {
const x = Math.max(shape.x, rect.x)
const y = Math.max(shape.y, rect.y)
const x2 = Math.min(shape.x + shape.width, rect.x + rect.width)
const y2 = Math.min(shape.y + shape.height, rect.y + rect.height)
if (x2 < x || y2 < y) return null
return {
x,
y,
width: x2 - x,
height: y2 - y,
}
}
function renderAppEventItem(
params: AppEventRenderParams,
api: AppEventRenderApi,
) {
const categoryIndex = api.value(0)
const start = api.coord([api.value(1), categoryIndex])
const end = api.coord([api.value(2), categoryIndex])
const height = 12
const rectShape = clipRectByRect(
{
x: start[0],
y: start[1] - height / 2,
width: end[0] - start[0],
height,
},
{
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height,
},
)
if (!rectShape) return null
return {
type: 'rect',
shape: {
...rectShape,
r: 999,
},
style: api.style(),
}
}
function getChartOption(
events: MockAppEvent[],
period: DeviceHistoryPeriodValue | null,
) {
const { start, end } = getPeriodBounds(period)
return {
animation: true,
animationDuration: 220,
textStyle: {
fontFamily: FONT_FAMILY,
fontWeight: 500,
},
tooltip: {
trigger: 'item',
borderWidth: 0,
backgroundColor: '#20263a',
padding: 12,
textStyle: {
color: '#ffffff',
fontSize: 13,
fontFamily: FONT_FAMILY,
},
extraCssText:
'border-radius: 14px; box-shadow: 0 12px 32px rgba(16, 24, 40, 0.22);',
formatter: (params: AppEventTooltipParams) => {
const event = params.data?.meta
if (!event) return ''
return `
<div style="min-width:260px;">
<div style="color:#aeb8cc;font-size:12px;margin-bottom:8px;">
${formatTooltipDate(event.start)} ${formatTooltipDate(event.end)}
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:18px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="width:10px;height:10px;border-radius:999px;background:${event.color};display:inline-block;"></span>
<span>${event.name}</span>
</div>
<strong>${formatDuration(event.start, event.end)}</strong>
</div>
</div>
`
},
},
grid: {
left: 92,
right: 24,
top: 24,
bottom: 44,
height: 120,
},
dataZoom: [
{
type: 'slider',
height: 22,
bottom: 8,
borderColor: 'transparent',
backgroundColor: '#eff2f7',
fillerColor: 'rgba(3, 28, 154, 0.15)',
showDataShadow: false,
showDetail: false,
brushSelect: false,
},
{
type: 'inside',
},
],
xAxis: {
type: 'time',
min: start,
max: end,
axisLabel: {
color: '#738098',
fontSize: 12,
fontFamily: FONT_FAMILY,
hideOverlap: true,
},
axisLine: {
lineStyle: {
color: '#dbe2ee',
},
},
axisTick: {
show: false,
},
splitLine: {
show: true,
lineStyle: {
color: '#edf0f5',
},
},
},
yAxis: {
type: 'category',
inverse: true,
data: EVENT_CATEGORIES.map((category) => category.label),
axisLabel: {
color: '#738098',
fontSize: 12,
fontFamily: FONT_FAMILY,
},
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
show: true,
lineStyle: {
color: '#edf0f5',
},
},
},
series: [
{
type: 'custom',
name: 'События приложения',
renderItem: renderAppEventItem,
encode: {
x: [1, 2],
y: 0,
},
data: events.map((event) => {
const categoryIndex = EVENT_CATEGORIES.findIndex(
(category) => category.key === event.category,
)
return {
value: [categoryIndex, event.start, event.end],
itemStyle: {
color: event.color,
},
meta: event,
}
}),
},
],
}
}
export function DeviceAppsAccordion({
packages,
period,
isLoading = false,
isError = false,
}: DeviceAppsAccordionProps) {
const [openedApps, setOpenedApps] = useState<string[]>([])
const apps = useMemo(() => {
return packages
.map((app) => {
const usagePercent = getUsagePercent(app, period)
const events = createMockEvents(app, period, usagePercent)
return {
app,
usagePercent,
events,
}
})
.sort((a, b) => b.usagePercent - a.usagePercent)
}, [packages, period])
return (
<section className="device-apps">
<div className="device-apps__header">
<div>
<h3>Приложения</h3>
<p>Использование приложений за выбранный период</p>
</div>
{isLoading && <span>Загрузка...</span>}
{isError && <span className="is-error">Не удалось загрузить</span>}
</div>
{!isLoading && apps.length === 0 && (
<EmptyState
className="device-apps__empty"
variant="no-items"
title="Данных по приложениям пока нет"
description="На устройстве пока нет данных по использованию приложений за выбранный период."
/>
)}
{apps.length > 0 && (
<Accordion.Root
className="device-apps__list"
type="multiple"
value={openedApps}
onValueChange={setOpenedApps}
>
{apps.map(({ app, usagePercent, events }) => {
const appName = getAppName(app)
const appKey = `${app.packageName}-${app.version ?? app.versionName ?? appName}`
const { Icon, modifier } = getAppVisual(app)
const isOpen = openedApps.includes(appKey)
return (
<Accordion.Item
key={appKey}
className="device-app-item"
value={appKey}
>
<Accordion.Header className="device-app-item__header">
<Accordion.Trigger className="device-app-item__trigger">
<div className="device-app-item__main">
<span className={`device-app-item__icon ${modifier}`}>
<Icon size={22} />
</span>
<span className="device-app-item__info">
<strong>{appName}</strong>
<small>
{app.packageName}
{app.versionName ? ` · v${app.versionName}` : ''}
</small>
</span>
</div>
<div className="device-app-item__right">
<div className="device-app-item__usage">
<strong>{usagePercent}%</strong>
<span>за период</span>
</div>
<ChevronDown className="device-app-item__chevron" size={20} />
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content className="device-app-item__content">
<div className="device-app-item__body">
<div className="device-app-item__meta">
<span>
Категория: <b>{app.category || 'Не указана'}</b>
</span>
<span>
Установлено: <b>{formatDate(app.installDate)}</b>
</span>
<span>
Код версии: <b>{app.version ?? '—'}</b>
</span>
</div>
<div className="device-app-chart">
{isOpen && (
<ReactECharts
option={getChartOption(events, period)}
notMerge
lazyUpdate
/>
)}
</div>
</div>
</Accordion.Content>
</Accordion.Item>
)
})}
</Accordion.Root>
)}
</section>
)
}

View File

@ -0,0 +1,679 @@
@use '../../../../shared/styles/variables' as *;
.device-apps-view {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 14px;
}
.device-apps-view__header {
display: grid;
grid-template-columns: 180px minmax(0, 1fr) 180px;
align-items: center;
gap: 16px;
min-height: 42px;
}
.device-apps-view__back,
.device-apps-view__install {
height: 42px;
padding: 0 14px;
border: none;
border-radius: 12px;
text-wrap: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: $font-size-16;
font-weight: 600;
cursor: pointer;
transition: 0.2s ease;
}
.device-apps-view__back {
background: $color-surface;
color: $color-text-heading;
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
width: fit-content;
padding: 0 14px 0 8px;
svg {
color: $blue;
width: 24px;
height: auto;
}
&:hover {
color: $blue;
}
}
.device-apps-view__install {
background: $color-bg;
color: $gray50;
cursor: not-allowed;
justify-self: end;
}
.device-apps-view__title {
height: 42px;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
h2 {
margin: 0;
color: $color-text-heading;
font-size: $font-size-22;
font-weight: 650;
line-height: 1.05;
}
span {
display: block;
max-width: 100%;
margin-top: 1px;
color: $gray50;
font-size: $font-size-13;
font-weight: 500;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.device-apps-view__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.device-apps-view__search {
flex: 1;
min-width: 0;
height: 44px;
padding: 0 14px;
border-radius: 14px;
background: $color-surface;
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
display: flex;
align-items: center;
gap: 10px;
svg {
flex: 0 0 auto;
color: $gray50;
}
input {
width: 100%;
border: none;
outline: none;
background: $transparent;
color: $color-text-heading;
font: inherit;
font-size: $font-size-15;
font-weight: 500;
&::placeholder {
color: $gray50;
}
}
}
.device-apps-view__toolbar-right {
display: inline-flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.device-apps-view__sort {
height: 44px;
padding: 0 13px 0 15px;
border: none;
border-radius: 14px;
background: $color-surface;
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
display: inline-flex;
align-items: center;
gap: 9px;
color: $gray50;
font-size: $font-size-14;
font-weight: 650;
white-space: nowrap;
cursor: pointer;
transition: 0.2s ease;
svg {
transition: .2s ease;
color: $gray50;
}
&:hover,
&[data-state='open'] {
color: $blue;
background: $blue20;
svg {
color: $blue;
}
}
}
.device-apps-view__sort-menu {
z-index: 200;
min-width: 210px;
padding: 6px;
border-radius: 16px;
background: $color-surface;
box-shadow: $shadow-popover;
animation: deviceAppsSortMenuShow 0.16s ease;
}
.device-apps-view__sort-item {
min-height: 38px;
padding: 0 10px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: $color-text-heading;
font-size: $font-size-14;
font-weight: 500;
outline: none;
cursor: pointer;
&[data-highlighted] {
background: $color-surface-subtle;
color: $blue;
}
}
.device-apps-view__sort-check {
width: 7px;
height: 7px;
border-radius: 50%;
background: $blue;
flex: 0 0 7px;
}
.device-apps-view__summary {
height: 44px;
padding: 0 14px;
border-radius: 14px;
background: $color-surface;
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
display: inline-flex;
align-items: center;
gap: 14px;
color: $gray50;
font-size: $font-size-14;
font-weight: 600;
white-space: nowrap;
b {
color: $color-text-heading;
font-weight: 700;
}
}
.device-apps-view__panel {
min-height: 0;
flex: 1;
border-radius: 20px;
background: $color-surface;
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
overflow: hidden;
}
.device-apps-view__scroll {
height: 100%;
.simplebar-content {
min-height: 100%;
}
.simplebar-track.simplebar-vertical {
top: 14px;
right: 6px;
bottom: 14px;
width: 8px;
}
.simplebar-scrollbar::before {
background: $gray50;
border-radius: 4px;
opacity: 1;
}
}
.device-apps-view__state {
min-height: 220px;
padding: 24px;
display: flex;
align-items: center;
justify-content: center;
color: $gray50;
font-size: $font-size-16;
font-weight: 600;
&.is-error {
color: $red;
}
}
.device-apps-view__list {
display: flex;
flex-direction: column;
}
.device-apps-view__item {
border-bottom: 1px solid $gray20;
&:last-child {
border-bottom: none;
}
}
.device-apps-view__item-header {
margin: 0;
}
.device-apps-view__trigger {
width: 100%;
min-height: 72px;
padding: 14px 20px;
border: none;
background: $color-surface;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
text-align: left;
cursor: pointer;
outline: none;
transition: 0.2s ease;
&:hover {
background: $color-surface-muted;
}
&:focus-visible {
box-shadow: inset 0 0 0 2px rgba($color-primary-rgb, 0.16);
}
&[data-state='open'] {
background: $color-surface-muted;
.device-apps-view__chevron {
transform: rotate(180deg);
}
}
}
.device-apps-view__app-main {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 12px;
}
.device-apps-view__app-icon {
width: 44px;
height: 44px;
flex: 0 0 44px;
border-radius: 14px;
background: $color-bg;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: $blue;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&.is-fallback {
background: $blue20;
}
}
.device-apps-view__app-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
strong,
small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
color: $color-text-heading;
font-size: $font-size-17;
font-weight: 650;
}
small {
color: $gray50;
font-size: $font-size-14;
font-weight: 500;
}
}
.device-apps-view__app-meta {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 12px;
span {
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: $color-bg;
display: inline-flex;
align-items: center;
color: $gray50;
font-size: $font-size-13;
font-weight: 650;
}
}
.device-apps-view__chevron {
color: $gray50;
transition: transform 0.2s ease;
}
.device-apps-view__content {
overflow: hidden;
&[data-state='open'] {
animation: deviceAppsViewDown 0.2s ease;
}
&[data-state='closed'] {
animation: deviceAppsViewUp 0.2s ease;
}
}
.device-apps-view__details {
padding: 0 20px 18px 76px;
}
.device-apps-view__details-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.device-apps-view__detail-card {
min-width: 0;
//min-height: 58px;
padding: 9px 12px;
border-radius: 14px;
background: $color-surface-muted;
box-shadow: inset 0 0 0 1px rgba($gray20-rgb, 0.8);
display: flex;
align-items: center;
gap: 11px;
}
.device-apps-view__detail-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: $color-bg;
flex: 0 0 40px;
display: inline-flex;
align-items: center;
justify-content: center;
color: $blue;
svg{
width: 20px;
height: auto;
}
}
.device-apps-view__detail-text {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
small {
color: $gray50;
font-size: $font-size-14;
font-weight: 500;
line-height: 1.2;
}
b {
min-width: 0;
color: $color-text-heading;
font-size: $font-size-16;
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.device-apps-view__stats {
margin-top: 10px;
padding: 14px;
border-radius: 14px;
background: $color-surface;
border: 1px dashed $color-border-accent;
text-align: left;
h3 {
margin: 0;
color: $color-text-heading;
font-size: $font-size-15;
font-weight: 650;
}
p {
margin-top: 4px;
color: $gray50;
font-size: $font-size-14;
font-weight: 500;
}
}
.device-apps-view__actions {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
button {
height: 36px;
padding: 0 12px;
border: none;
border-radius: 11px;
background: $color-bg;
display: inline-flex;
align-items: center;
gap: 8px;
color: $gray50;
font-size: $font-size-14;
font-weight: 650;
cursor: not-allowed;
}
}
@keyframes deviceAppsViewDown {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-accordion-content-height);
opacity: 1;
}
}
@keyframes deviceAppsSortMenuShow {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes deviceAppsViewUp {
from {
height: var(--radix-accordion-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
[data-theme='dark'] {
.device-apps-view__back,
.device-apps-view__search,
.device-apps-view__sort,
.device-apps-view__summary,
.device-apps-view__panel {
box-shadow: none;
outline: 1px solid rgba($gray50-rgb, 0.08);
}
.device-apps-view__sort:hover,
.device-apps-view__sort[data-state='open'] {
background: $color-surface-hover;
color: $color-primary-hover;
svg {
color: $color-primary-hover;
}
}
.device-apps-view__trigger:hover {
background: rgba($color-surface-rgb, 0.42);
}
.device-apps-view__trigger[data-state='open'] {
background: $color-surface-hover;
}
.device-apps-view__detail-card {
background: rgba($color-surface-rgb, 0.58);
box-shadow: none;
outline: 1px solid rgba($gray50-rgb, 0.08);
}
.device-apps-view__stats {
background: rgba($color-surface-rgb, 0.58);
}
}
@include laptop-down {
.device-apps-view__header {
grid-template-columns: 160px minmax(0, 1fr) 160px;
gap: 12px;
}
.device-apps-view__toolbar {
gap: 12px;
}
.device-apps-view__summary {
padding-inline: 12px;
gap: 10px;
}
}
@include tablet-down {
.device-apps-view__header {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
}
.device-apps-view__back,
.device-apps-view__install {
padding-inline: 12px;
}
.device-apps-view__toolbar {
flex-wrap: wrap;
align-items: stretch;
}
.device-apps-view__search {
flex-basis: 100%;
}
.device-apps-view__toolbar-right {
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
}
.device-apps-view__trigger {
padding-inline: 16px;
}
.device-apps-view__app-meta {
gap: 8px;
}
.device-apps-view__details {
padding: 0 16px 16px 72px;
}
.device-apps-view__details-grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,522 @@
import {
memo,
type ChangeEvent,
useCallback,
useDeferredValue,
useMemo,
useState,
} from 'react'
import { useQuery } from '@apollo/client/react'
import * as Accordion from '@radix-ui/react-accordion'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import SimpleBar from 'simplebar-react'
import 'simplebar-react/dist/simplebar.min.css'
import {
AppWindow,
ArrowUpDown,
CalendarDays,
ChevronDown,
Download,
Hash,
Package,
Search,
Square,
Tags,
Trash2,
} from 'lucide-react'
import { GET_PHONE_PACKAGES_QUERY } from '../../../../entities/device/api/device.graphql'
import type {
DevicePackage,
GetPhonePackagesData,
GetPhonePackagesVariables,
} from '../../../../entities/device/model/types'
import { ListSkeleton } from '../../../../shared/ui/ListSkeleton/ListSkeleton'
import { RevealContent } from '../../../../shared/ui/RevealContent/RevealContent'
import { EmptyState } from '../../../../shared/ui/EmptyState/EmptyState'
import './DeviceAppsView.scss'
type DeviceAppsViewProps = {
deviceId: number
deviceName: string
onBack: () => void
}
type DeviceAppIconProps = {
app: DevicePackage
}
type DeviceAppsListProps = {
apps: DevicePackage[]
openedApp?: string
onOpenedAppChange: (value: string) => void
}
type DeviceAppDetailsProps = {
app: DevicePackage
}
type DeviceAppListItemProps = {
app: DevicePackage
isOpen: boolean
}
type AppsSortValue =
| 'name-asc'
| 'name-desc'
| 'package-asc'
| 'install-desc'
| 'install-asc'
type AppsSortOption = {
value: AppsSortValue
label: string
}
const appsSortOptions: AppsSortOption[] = [
{
value: 'name-asc',
label: 'Название А-Я',
},
{
value: 'name-desc',
label: 'Название Я-А',
},
{
value: 'package-asc',
label: 'Пакет А-Я',
},
{
value: 'install-desc',
label: 'Сначала новые',
},
{
value: 'install-asc',
label: 'Сначала старые',
},
]
function getAppName(app: DevicePackage) {
return app.name || app.packageName || 'Без названия'
}
function getAppCategory(app: DevicePackage) {
if (!app.category || app.category === 'Undefined') return 'Не указана'
return app.category
}
function getAppVersion(app: DevicePackage) {
if (app.versionName && app.version) {
return `${app.versionName} (${app.version})`
}
return app.versionName || app.version || 'Не указана'
}
function formatInstallDate(timestamp?: number | null) {
if (!timestamp) return 'Не указана'
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(timestamp))
}
function compareText(a: string, b: string) {
return a.localeCompare(b, 'ru', {
sensitivity: 'base',
numeric: true,
})
}
function compareInstallDate(
a: DevicePackage,
b: DevicePackage,
direction: 'ASC' | 'DESC',
) {
const firstDate = a.installDate ?? 0
const secondDate = b.installDate ?? 0
if (firstDate === secondDate) {
return compareText(getAppName(a), getAppName(b))
}
return direction === 'ASC'
? firstDate - secondDate
: secondDate - firstDate
}
const DeviceAppIcon = memo(function DeviceAppIcon({ app }: DeviceAppIconProps) {
const [isImageFailed, setIsImageFailed] = useState(false)
const appName = getAppName(app)
if (app.iconUrl && !isImageFailed) {
return (
<span className="device-apps-view__app-icon">
<img
src={app.iconUrl}
alt=""
onError={() => setIsImageFailed(true)}
/>
</span>
)
}
return (
<span className="device-apps-view__app-icon is-fallback">
<AppWindow size={22} aria-label={appName} />
</span>
)
})
const DeviceAppDetails = memo(function DeviceAppDetails({
app,
}: DeviceAppDetailsProps) {
return (
<div className="device-apps-view__details">
<div className="device-apps-view__details-grid">
<div className="device-apps-view__detail-card">
<span className="device-apps-view__detail-icon">
<Package size={17} />
</span>
<span className="device-apps-view__detail-text">
<small>Пакет</small>
<b>{app.packageName}</b>
</span>
</div>
<div className="device-apps-view__detail-card">
<span className="device-apps-view__detail-icon">
<Tags size={17} />
</span>
<span className="device-apps-view__detail-text">
<small>Категория</small>
<b>{getAppCategory(app)}</b>
</span>
</div>
<div className="device-apps-view__detail-card">
<span className="device-apps-view__detail-icon">
<CalendarDays size={17} />
</span>
<span className="device-apps-view__detail-text">
<small>Установлено</small>
<b>{formatInstallDate(app.installDate)}</b>
</span>
</div>
<div className="device-apps-view__detail-card">
<span className="device-apps-view__detail-icon">
<Hash size={17} />
</span>
<span className="device-apps-view__detail-text">
<small>Версия</small>
<b>{getAppVersion(app)}</b>
</span>
</div>
</div>
<div className="device-apps-view__stats">
<h3>Статистика</h3>
<p>Сводных данных по использованию пока нет</p>
</div>
<div className="device-apps-view__actions">
<button type="button" disabled>
<Square size={17} />
Остановить
</button>
<button type="button" disabled>
<Trash2 size={17} />
Удалить
</button>
<button type="button" disabled>
<Download size={17} />
Установить обновление
</button>
</div>
</div>
)
})
const DeviceAppListItem = memo(function DeviceAppListItem({
app,
isOpen,
}: DeviceAppListItemProps) {
const appName = getAppName(app)
const appKey = app.packageName || appName
return (
<Accordion.Item
className="device-apps-view__item"
value={appKey}
>
<Accordion.Header className="device-apps-view__item-header">
<Accordion.Trigger className="device-apps-view__trigger">
<span className="device-apps-view__app-main">
<DeviceAppIcon app={app} />
<span className="device-apps-view__app-text">
<strong>{appName}</strong>
<small>{app.packageName}</small>
</span>
</span>
<span className="device-apps-view__app-meta">
<span>{getAppCategory(app)}</span>
<ChevronDown
className="device-apps-view__chevron"
size={20}
/>
</span>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content className="device-apps-view__content">
{isOpen && <DeviceAppDetails app={app} />}
</Accordion.Content>
</Accordion.Item>
)
})
const DeviceAppsList = memo(function DeviceAppsList({
apps,
openedApp,
onOpenedAppChange,
}: DeviceAppsListProps) {
return (
<Accordion.Root
className="device-apps-view__list"
type="single"
collapsible
value={openedApp}
onValueChange={onOpenedAppChange}
>
{apps.map((app) => {
const appName = getAppName(app)
const appKey = app.packageName || appName
return (
<DeviceAppListItem
key={appKey}
app={app}
isOpen={openedApp === appKey}
/>
)
})}
</Accordion.Root>
)
})
export function DeviceAppsView({
deviceId,
onBack,
}: DeviceAppsViewProps) {
const [search, setSearch] = useState('')
const deferredSearch = useDeferredValue(search)
const [openedApp, setOpenedApp] = useState<string>()
const [sortValue, setSortValue] = useState<AppsSortValue>('name-asc')
const { data, loading, error } = useQuery<
GetPhonePackagesData,
GetPhonePackagesVariables
>(GET_PHONE_PACKAGES_QUERY, {
variables: {
phoneId: String(deviceId),
},
fetchPolicy: 'network-only',
})
const packages = useMemo(
() => data?.getPhonePackages ?? [],
[data?.getPhonePackages],
)
const searchablePackages = useMemo(() => {
return packages.map((app) => ({
app,
searchText: [
app.name,
app.packageName,
app.category,
app.versionName,
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
}))
}, [packages])
const normalizedSearch = useMemo(() => {
return deferredSearch.trim().toLowerCase()
}, [deferredSearch])
const selectedSort = useMemo(() => {
return (
appsSortOptions.find((option) => option.value === sortValue) ??
appsSortOptions[0]
)
}, [sortValue])
const handleSearchChange = useCallback((
event: ChangeEvent<HTMLInputElement>,
) => {
setSearch(event.target.value)
}, [])
const filteredPackages = useMemo(() => {
return searchablePackages
.filter(({ searchText }) => {
if (!normalizedSearch) return true
return searchText.includes(normalizedSearch)
})
.map(({ app }) => app)
.sort((a, b) => {
if (sortValue === 'name-desc') {
return compareText(getAppName(b), getAppName(a))
}
if (sortValue === 'package-asc') {
return compareText(a.packageName, b.packageName)
}
if (sortValue === 'install-desc') {
return compareInstallDate(a, b, 'DESC')
}
if (sortValue === 'install-asc') {
return compareInstallDate(a, b, 'ASC')
}
return compareText(getAppName(a), getAppName(b))
})
}, [normalizedSearch, searchablePackages, sortValue])
return (
<section className="device-apps-view">
<div className="device-apps-view__header">
<button
className="device-apps-view__back"
type="button"
onClick={onBack}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M15 18L9 12L15 6"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
К устройству
</button>
<div className="device-apps-view__title">
<h2>Приложения устройства</h2>
{/* <span>{deviceName}</span> */}
</div>
<button className="device-apps-view__install" type="button" disabled>
<Download size={18} />
Установить
</button>
</div>
<div className="device-apps-view__toolbar">
<label className="device-apps-view__search">
<Search size={18} />
<input
value={search}
onChange={handleSearchChange}
placeholder="Поиск по названию или пакету"
/>
</label>
<div className="device-apps-view__toolbar-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="device-apps-view__sort" type="button">
<span>{selectedSort.label}</span>
<ArrowUpDown size={17} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="device-apps-view__sort-menu"
align="end"
sideOffset={8}
>
{appsSortOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="device-apps-view__sort-item"
onSelect={() => setSortValue(option.value)}
>
<span>{option.label}</span>
{selectedSort.value === option.value && (
<span className="device-apps-view__sort-check" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<div className="device-apps-view__summary">
<span>
Всего <b>{packages.length}</b>
</span>
<span>
Показано <b>{filteredPackages.length}</b>
</span>
</div>
</div>
</div>
<div className="device-apps-view__panel">
<SimpleBar className="device-apps-view__scroll">
<RevealContent loading={loading} fallback={<ListSkeleton />}>
{error && (
<div className="device-apps-view__state is-error">
Не удалось загрузить список приложений
</div>
)}
{!error && filteredPackages.length === 0 && (
<EmptyState
className="device-apps-view__state"
variant={normalizedSearch ? 'not-found' : 'no-items'}
title={
normalizedSearch
? 'Не найдено результатов'
: 'Приложений пока нет'
}
description={
normalizedSearch
? 'По заданному поиску не найдено ни одного приложения. Попробуйте изменить запрос.'
: 'На устройстве пока нет данных по установленным приложениям.'
}
/>
)}
{!error && filteredPackages.length > 0 && (
<DeviceAppsList
apps={filteredPackages}
openedApp={openedApp}
onOpenedAppChange={setOpenedApp}
/>
)}
</RevealContent>
</SimpleBar>
</div>
</section>
)
}

View File

@ -3,13 +3,13 @@
.device-history-chart {
position: relative;
width: 100%;
min-height: 1000px;
margin-top: 40px;
min-height: 0;
margin-top: 200px;
}
.device-history-chart .echarts-for-react {
width: 100%;
height: 1000px !important;
height: 100% !important;
}
.device-history-chart__events-title {
@ -21,15 +21,15 @@
h3 {
margin: 0;
color: #30394b;
font-size: 18px;
color: $color-text-heading;
font-size: $font-size-18;
font-weight: 650;
}
p {
margin: 0;
color: #738098;
font-size: 14px;
color: $color-text-muted;
font-size: $font-size-14;
font-weight: 450;
line-height: 1.3;
}
@ -49,16 +49,16 @@
.device-history-events-legend__item {
border: none;
background: transparent;
background: $transparent;
padding: 0;
display: inline-flex;
align-items: center;
gap: 8px;
color: #30394b;
color: $color-text-heading;
font-family: inherit;
font-size: 14px;
font-size: $font-size-14;
font-weight: 500;
cursor: pointer;
@ -80,7 +80,7 @@
width: 16px;
height: 16px;
border: 1.5px solid #c9d2e3;
border: 1.5px solid $color-border-accent;
border-radius: 5px;
display: inline-flex;
@ -96,13 +96,12 @@
width: 18px;
height: 18px;
//border-radius: 2px;
//background: #ffffff;
color: white;
color: $color-text-inverse;
}
.device-history-chart__battery-title,
.device-history-chart__temperature-title {
.device-history-chart__temperature-title,
.device-history-chart__apps-title {
position: absolute;
left: 90px;
z-index: 2;
@ -110,15 +109,15 @@
h3 {
margin: 0;
color: #30394b;
font-size: 18px;
color: $color-text-heading;
font-size: $font-size-18;
font-weight: 650;
}
p {
margin: 0;
color: #738098;
font-size: 14px;
color: $color-text-muted;
font-size: $font-size-14;
font-weight: 450;
line-height: 1.2;
}
@ -140,14 +139,290 @@
padding: 8px 12px;
border-radius: 999px;
background: #f1f4f8;
background: $color-surface-subtle;
color: #738098;
font-size: 13px;
color: $color-text-muted;
font-size: $font-size-13;
font-weight: 600;
}
.device-history-chart__state--error {
background: rgba(224, 0, 0, 0.08);
background: $color-danger-tint;
color: $red;
}
.device-history-app-events-legend {
position: absolute;
left: 270px;
z-index: 3;
display: flex;
flex-wrap: wrap;
gap: 12px;
color: $color-text-muted;
font-size: $font-size-13;
font-weight: 600;
span {
display: inline-flex;
align-items: center;
gap: 7px;
}
i {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
}
}
.device-history-app-legend__item {
position: absolute;
left: 90px;
z-index: 4;
width: 164px;
border: none;
background: $transparent;
padding: 0;
display: flex;
align-items: center;
gap: 8px;
color: $color-text-heading;
font-family: inherit;
text-align: left;
cursor: pointer;
opacity: 0.48;
transition:
opacity 0.2s ease,
transform 0.2s ease;
&:hover {
opacity: 0.82;
}
&.is-active {
opacity: 1;
}
}
.device-history-app-legend__image{
width: 30px;
height: 30px;
box-shadow: 0 0 16px -4px rgba($black, 0.4);
border-radius: 8px;
}
.device-history-app-legend__icon {
width: 30px;
height: 30px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: $color-text-inverse;
background: $blue;
img{
width: 24px;
height: auto;
}
&.is-radio {
background: $accent-purple;
}
&.is-message {
background: $accent-sky;
}
&.is-health {
background: $accent-teal;
}
&.is-home {
background: $blue;
}
&.is-security {
background: $color-danger-strong;
}
&.is-system {
background: $color-text-heading;
}
}
.device-history-app-legend__text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
strong {
color: $color-text-heading;
font-size: $font-size-13;
font-weight: 650;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
small {
color: $color-text-muted;
font-size: $font-size-11;
font-weight: 550;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.device-history-apps-empty {
position: absolute;
left: 270px;
right: 90px;
z-index: 3;
width: auto;
min-width: 0;
box-sizing: border-box;
padding: 18px 24px;
//border-radius: 18px;
//background: rgba($color-surface-rgb, 0.1);
box-shadow: inset 0 0 0 1px $color-border-muted;
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: center;
text-align: center;
pointer-events: none;
strong {
color: $color-text-heading;
font-size: $font-size-15;
font-weight: 700;
line-height: 1.2;
}
span {
max-width: 320px;
color: $color-text-muted;
font-size: $font-size-12;
font-weight: 550;
line-height: 1.35;
}
}
.device-history-app-selector {
position: absolute;
left: 90px;
right: 90px;
z-index: 4;
height: fit-content;
padding-top: 10px;
display: grid;
grid-template-columns: 164px minmax(0, 1fr);
align-items: start;
gap: 16px;
}
.device-history-app-selector__count {
color: $gray50;
font-size: $font-size-13;
font-weight: 650;
text-align: left;
height: 30px;
display: flex;
align-items: center;
}
.device-history-app-selector__count {
line-height: 1.2;
}
.device-history-app-selector__scroll {
height: fit-content;
min-width: 0;
.simplebar-track.simplebar-vertical {
right: -10px;
width: 6px;
}
.simplebar-scrollbar::before {
background: $gray50;
border-radius: 4px;
opacity: 1;
}
}
.device-history-app-selector__list {
display: flex;
flex-wrap: wrap;
gap: 7px;
padding: 0px 6px 0px 0;
}
.device-history-app-selector__item {
max-width: 230px;
height: 30px;
padding: 0 10px;
border: none;
border-radius: 10px;
background: $color-surface;
display: inline-flex;
align-items: center;
gap: 8px;
color: $gray50;
font-family: inherit;
font-size: $font-size-12;
font-weight: 650;
cursor: pointer;
transition:
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
flex: 0 0 auto;
color: inherit;
opacity: 0.72;
font-size: $font-size-11;
font-weight: 650;
}
&:hover {
color: $blue;
background: $color-primary-soft;
}
&.is-active {
color: $blue;
background: $blue20;
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.12);
}
}

View File

@ -4,61 +4,121 @@
position: fixed;
inset: 0;
z-index: 500;
background: rgba(15, 23, 42, 0.35);
animation: historyOverlayShow 0.2s ease;
background: $color-backdrop-soft;
}
.device-history-modal {
position: fixed;
z-index: 600;
inset: 24px;
display: flex;
flex-direction: column;
min-height: 0;
max-height: calc(100vh - 48px);
border-radius: 24px;
background: #ffffff;
padding: 26px 28px;
background: $color-surface;
padding: 20px;
overflow: auto;
//overflow: hidden;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22);
animation: historyModalShow 0.2s ease;
box-shadow: 0 24px 80px rgba($color-shadow-rgb, 0.22);
}
scrollbar-width: thin;
scrollbar-color: $gray50 transparent;
.device-history-modal__scroll {
flex: 1 1 auto;
height: 100%;
min-height: 0;
&::-webkit-scrollbar {
width: 6px;
.simplebar-content {
min-height: 100%;
}
&::-webkit-scrollbar-track {
background: transparent;
.simplebar-track.simplebar-vertical {
top: 14px;
right: -18px;
bottom: 14px;
width: 8px;
}
&::-webkit-scrollbar-thumb {
.simplebar-scrollbar::before {
background: $gray50;
border-radius: 999px;
border-radius: 4px;
opacity: 1;
}
}
.device-history-modal__chart-placeholder {
min-height: 720px;
padding-top: 220px;
color: $color-text-muted;
font-size: $font-size-14;
font-weight: 600;
text-align: center;
}
.device-history-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 14px;
.device-history-modal__title{
padding-left: 18px;
padding-top: 18px;
}
.device-history-modal__meta{
padding-left: 18px;
}
}
.device-history-modal {
.history-period-control__top {
//padding-left: 18px;
padding-bottom: 18px;
}
}
.device-history-chart__zoom {
position: absolute;
top: 20px;
left: 20px;
right: 20px;
z-index: 50;
padding: 8px;
//margin: 0 64px;
//border-bottom-right-radius: 20px;
//border-bottom-left-radius: 20px;
border-radius: 20px;
background: rgba($white, 0.3);
backdrop-filter: blur(12px);
box-shadow: 0 0 40px -6px rgba($gray50-rgb, .3);
.sticky-line{
display: flex;
flex: 1;
height: 1px;
background-color: $gray20;
margin-top: 26px;
margin-right: 90px;
}
}
.device-history-modal__title {
margin: 0;
color: #111827;
font-size: 20px;
color: $color-text-strong;
font-size: $font-size-20;
font-weight: 650;
text-transform: uppercase;
span {
margin-left: 6px;
color: $gray50;
font-size: 14px;
font-size: $font-size-14;
font-weight: 500;
text-transform: none;
}
@ -73,17 +133,21 @@
gap: 14px;
color: $gray50;
font-size: 14px;
font-size: $font-size-14;
.is-green {
position: relative;
padding-left: 12px;
color: #30394b;
padding-left: 20px;
padding-right: 8px;
border-radius: 8px;
color: $green;
font-weight: 500;
font-size: $font-size-16;
background-color: rgba($color-success-rgb, .1);
&::before {
content: '';
position: absolute;
left: 0;
left: 8px;
top: 50%;
width: 6px;
@ -94,6 +158,53 @@
transform: translateY(-50%);
}
}
.is-red {
position: relative;
padding-left: 20px;
padding-right: 8px;
border-radius: 8px;
color: $red;
font-weight: 500;
font-size: $font-size-16;
background-color: rgba($color-danger-rgb, .1);
&::before {
content: '';
position: absolute;
left: 8px;
top: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: $red;
transform: translateY(-50%);
}
}
.is-gray {
position: relative;
padding-left: 20px;
padding-right: 8px;
border-radius: 8px;
color: $gray50;
font-weight: 500;
font-size: $font-size-16;
background-color: rgba($gray50-rgb, .1);
&::before {
content: '';
position: absolute;
left: 8px;
top: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: $gray50;
transform: translateY(-50%);
}
}
}
.device-history-modal__close {
@ -101,13 +212,13 @@
height: 36px;
border: none;
border-radius: 12px;
background: transparent;
background: $transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: #111827;
color: $color-text-strong;
cursor: pointer;
&:hover {
@ -139,17 +250,17 @@
border: none;
border-radius: 999px;
background: transparent;
background: $transparent;
color: $gray50;
font-size: 14px;
font-size: $font-size-14;
font-weight: 500;
cursor: pointer;
&.is-active {
background: #ffffff;
background: $color-surface;
color: $blue;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
box-shadow: 0 2px 8px rgba($color-shadow-rgb, 0.08);
}
}
@ -165,8 +276,8 @@
align-items: center;
gap: 8px;
color: #30394b;
font-size: 14px;
color: $color-text-heading;
font-size: $font-size-14;
cursor: pointer;
svg {
@ -185,7 +296,7 @@
margin-bottom: 8px;
color: $blue;
font-size: 14px;
font-size: $font-size-14;
}
.device-history-range__track {
@ -210,7 +321,7 @@
margin: 0;
appearance: none;
background: transparent;
background: $transparent;
pointer-events: none;
}
@ -244,8 +355,8 @@
h3 {
margin: 0 0 8px 100px;
color: #30394b;
font-size: 16px;
color: $color-text-heading;
font-size: $font-size-16;
font-weight: 600;
}
}
@ -262,7 +373,7 @@
grid-template-columns: repeat(5, 1fr);
color: $gray50;
font-size: 13px;
font-size: $font-size-13;
span {
transform: translateX(-50%);
@ -289,12 +400,12 @@
text-align: right;
color: $gray50;
font-size: 14px;
font-size: $font-size-14;
}
.history-row__line {
position: relative;
border-top: 1px solid #dbe2ee;
border-top: 1px solid $color-border-muted;
&::before,
&::after {
@ -303,7 +414,7 @@
top: -1px;
bottom: 0;
width: 1px;
background: #dbe2ee;
background: $color-border-muted;
}
&::before {
@ -327,15 +438,15 @@
}
.history-segment--purple {
background: #7a3fe8;
background: $accent-purple;
}
.history-segment--green {
background: #18b89b;
background: $accent-teal;
}
.history-segment--light-blue {
background: #2f80ed;
background: $accent-sky;
}
.history-point {
@ -354,20 +465,20 @@
}
.history-point--orange {
background: #ff7a1a;
background: $color-warning-strong;
}
.battery-chart {
position: relative;
height: 110px;
margin-left: 100px;
border-bottom: 1px solid #dbe2ee;
border-bottom: 1px solid $color-border-muted;
background:
linear-gradient(to bottom, transparent 19%, #dbe2ee 20%, transparent 21%),
linear-gradient(to bottom, transparent 39%, #dbe2ee 40%, transparent 41%),
linear-gradient(to bottom, transparent 59%, #dbe2ee 60%, transparent 61%),
linear-gradient(to bottom, transparent 79%, #dbe2ee 80%, transparent 81%);
linear-gradient(to bottom, $transparent 19%, $color-border-muted 20%, $transparent 21%),
linear-gradient(to bottom, $transparent 39%, $color-border-muted 40%, $transparent 41%),
linear-gradient(to bottom, $transparent 59%, $color-border-muted 60%, $transparent 61%),
linear-gradient(to bottom, $transparent 79%, $color-border-muted 80%, $transparent 81%);
}
.battery-chart__area {
@ -376,10 +487,10 @@
background: linear-gradient(
168deg,
rgba(49, 178, 74, 0.38) 0%,
rgba(49, 178, 74, 0.28) 45%,
rgba(49, 178, 74, 0.05) 46%,
rgba(49, 178, 74, 0.05) 100%
rgba($color-success-strong-rgb, 0.38) 0%,
rgba($color-success-strong-rgb, 0.28) 45%,
rgba($color-success-strong-rgb, 0.05) 46%,
rgba($color-success-strong-rgb, 0.05) 100%
);
clip-path: polygon(0 10%, 100% 90%, 100% 100%, 0 100%);
@ -396,7 +507,7 @@
justify-content: space-between;
color: $gray50;
font-size: 13px;
font-size: $font-size-13;
}
.history-summary {
@ -412,10 +523,10 @@
background: $color-bg;
color: $gray50;
font-size: 13px;
font-size: $font-size-13;
b {
color: #111827;
color: $color-text-strong;
font-weight: 600;
}
}
@ -429,17 +540,17 @@
h3 {
margin: 0 0 10px;
padding-bottom: 10px;
border-bottom: 1px solid #dbe2ee;
border-bottom: 1px solid $color-border-muted;
color: #30394b;
font-size: 16px;
color: $color-text-heading;
font-size: $font-size-16;
font-weight: 600;
}
}
.device-apps-list {
border-radius: 14px;
background: #ffffff;
background: $color-surface;
overflow: hidden;
}
@ -449,8 +560,8 @@
padding: 0 16px;
border: none;
border-bottom: 1px solid #e4e8f0;
background: #ffffff;
border-bottom: 1px solid $color-border-muted;
background: $color-surface;
display: grid;
grid-template-columns: 28px 220px 1fr auto 20px;
@ -465,7 +576,7 @@
}
&:hover {
background: #f8fafc;
background: $color-surface-muted;
}
}
@ -477,56 +588,58 @@
}
.device-app-row__name {
color: #30394b;
font-size: 15px;
color: $color-text-heading;
font-size: $font-size-15;
font-weight: 500;
}
.device-app-row__time {
color: $gray50;
font-size: 13px;
font-size: $font-size-13;
}
.device-app-row__percent {
color: $gray50;
font-size: 13px;
font-size: $font-size-13;
}
.device-app-row__arrow {
color: $gray50;
font-size: 22px;
font-size: $font-size-22;
}
.device-apps-card__more {
margin-top: 12px;
border: none;
background: transparent;
background: $transparent;
color: $blue;
font-size: 14px;
font-size: $font-size-14;
font-weight: 500;
cursor: pointer;
}
@keyframes historyOverlayShow {
from {
opacity: 0;
[data-theme='dark'] {
.device-history-modal {
background: $color-surface;
box-shadow: 0 26px 80px rgba($color-shadow-rgb, 0.62);
}
to {
opacity: 1;
}
}
@keyframes historyModalShow {
from {
opacity: 0;
transform: translateY(10px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
.device-history-chart__zoom {
background: rgba($color-surface-rgb, 0.76);
backdrop-filter: blur(14px);
box-shadow:
0 18px 44px rgba($color-shadow-rgb, 0.38),
inset 0 0 0 1px rgba($gray50-rgb, 0.08);
.sticky-line {
background-color: rgba($gray50-rgb, 0.16);
}
}
.device-history-modal__close:hover {
background: $color-surface-hover;
color: $color-primary-hover;
}
}

View File

@ -1,22 +1,37 @@
import { useState } from 'react'
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { useQuery } from '@apollo/client/react'
import SimpleBar from 'simplebar-react'
import 'simplebar-react/dist/simplebar.min.css'
import type { Device } from '../../types'
import { conditionText, connectionText } from '../../types'
import {
GET_TELEMETRY_QUERY,
} from '../../../../entities/device/api/device.graphql'
import type {
GetTelemetryData,
GetTelemetryVariables,
DeviceHistoryChartItem,
DevicePackageUseEventsGroup,
DevicePhoneStateEventType
} from '../../../../entities/device/model/types'
import {
DeviceHistoryPeriodControl,
type DeviceHistoryPeriodValue,
} from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl'
import { DeviceHistoryChart } from '../DeviceHistoryChart/DeviceHistoryChart'
import { TimelineZoom } from '../TimelineZoom/TimelineZoom'
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import './DeviceHistoryModal.scss'
@ -26,6 +41,50 @@ type DeviceHistoryModalProps = {
device: Device
}
const PHONE_STATE_EVENT_LABELS: Record<DevicePhoneStateEventType, string> = {
ScreenOn: 'Экран включен',
ScreenOff: 'Экран выключен',
EnterNetwork: 'В сети',
LeaveNetwork: 'Не в сети',
PowerOn: 'Включение',
PowerOff: 'Выключение',
Hit: 'Удар',
Overheat: 'Перегрев',
PowerAC: 'Зарядка AC',
PowerUSB: 'Зарядка USB',
PowerWireless: 'Беспроводная зарядка',
PowerDisconnected: 'Зарядка отключена',
}
const CHART_MOUNT_DELAY_MS = 160
const EMPTY_HISTORY_ITEMS: DeviceHistoryChartItem[] = []
const EMPTY_PACKAGE_USE_EVENTS: DevicePackageUseEventsGroup[] = []
const DeviceHistoryChart = lazy(() =>
import('../DeviceHistoryChart/DeviceHistoryChart').then((module) => ({
default: module.DeviceHistoryChart,
})),
)
function DeviceHistoryChartPlaceholder() {
return (
<div className="device-history-modal__chart-placeholder">
Подготавливаем графики...
</div>
)
}
function getHistoryConditionClass(device: Device) {
return device.condition === 'inspection' ? 'is-red' : 'is-green'
}
function getHistoryConnectionClass(device: Device) {
if (device.connection === 'online') return 'is-green'
if (device.connection === 'offlineDanger') return 'is-red'
return 'is-gray'
}
export function DeviceHistoryModal({
open,
onOpenChange,
@ -34,59 +93,183 @@ export function DeviceHistoryModal({
const [selectedPeriod, setSelectedPeriod] =
useState<DeviceHistoryPeriodValue | null>(null)
const [zoomRange, setZoomRange] = useState<{
start: number
end: number
} | null>(null)
const [isChartReady, setIsChartReady] = useState(false)
const closeFrameRef = useRef<number | null>(null)
useEffect(() => {
if (!selectedPeriod) return
setZoomRange({
start: selectedPeriod.periodStart.getTime(),
end: selectedPeriod.periodEnd.getTime(),
})
}, [selectedPeriod])
useEffect(() => {
if (!open) {
setIsChartReady(false)
return
}
setIsChartReady(false)
let firstFrameId = 0
let secondFrameId = 0
let timeoutId = 0
firstFrameId = window.requestAnimationFrame(() => {
secondFrameId = window.requestAnimationFrame(() => {
timeoutId = window.setTimeout(() => {
setIsChartReady(true)
}, CHART_MOUNT_DELAY_MS)
})
})
return () => {
window.cancelAnimationFrame(firstFrameId)
window.cancelAnimationFrame(secondFrameId)
window.clearTimeout(timeoutId)
}
}, [device.id, open])
useEffect(() => {
return () => {
if (closeFrameRef.current !== null) {
window.cancelAnimationFrame(closeFrameRef.current)
}
}
}, [])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
onOpenChange(true)
return
}
setIsChartReady(false)
if (closeFrameRef.current !== null) {
window.cancelAnimationFrame(closeFrameRef.current)
}
closeFrameRef.current = window.requestAnimationFrame(() => {
closeFrameRef.current = null
onOpenChange(false)
})
},
[onOpenChange],
)
const shouldRenderChart = Boolean(open && isChartReady && selectedPeriod && zoomRange)
const telemetryVariables = useMemo<GetTelemetryVariables>(
() => ({
phoneId: Number(device.id),
packagesPhoneId: String(device.id),
startDate: selectedPeriod?.periodStart.getTime() ?? 0,
endDate: selectedPeriod?.periodEnd.getTime(),
}),
[device.id, selectedPeriod],
)
const {
data: telemetryData,
loading: telemetryLoading,
error: telemetryError,
} = useQuery<GetTelemetryData, GetTelemetryVariables>(GET_TELEMETRY_QUERY, {
variables: {
phoneId: Number(device.id),
startDate: selectedPeriod?.periodStart.getTime() ?? 0,
endDate: selectedPeriod?.periodEnd.getTime(),
},
skip: !open || !selectedPeriod,
variables: telemetryVariables,
skip: !shouldRenderChart,
fetchPolicy: 'network-only',
pollInterval: 15000,
//pollInterval: 15000,
})
const telemetryWithEvents = useMemo<DeviceHistoryChartItem[]>(() => {
if (!telemetryData) return EMPTY_HISTORY_ITEMS
return [
...telemetryData.getTelemetry,
...telemetryData.getPhoneStateEvents.map(
(event): DeviceHistoryChartItem => ({
date: event.date,
type: 'event',
eventType: event.type,
label: PHONE_STATE_EVENT_LABELS[event.type] ?? event.type,
}),
),
].sort((a, b) => a.date - b.date)
}, [telemetryData])
const packageUseEvents =
telemetryData?.getPhonePackagesUseEvents ?? EMPTY_PACKAGE_USE_EVENTS
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="device-history-modal__overlay" />
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<MotionDialogContent
open={open}
overlayClassName="device-history-modal__overlay"
contentClassName="device-history-modal"
placement="inset"
>
<div className="device-history-chart__zoom">
<div className="device-history-modal__header">
<div>
<Dialog.Title className="device-history-modal__title">
{device.factoryNumber}
<span>ID: {device.id}</span>
</Dialog.Title>
<Dialog.Content className="device-history-modal">
<div className="device-history-modal__header">
<div>
<Dialog.Title className="device-history-modal__title">
{device.factoryNumber}
<span>ID: {device.id}</span>
</Dialog.Title>
<div className="device-history-modal__meta">
<span>{device.imei} · {device.imei2}</span>
<span className={getHistoryConditionClass(device)}>
{conditionText[device.condition]}
</span>
<span className={getHistoryConnectionClass(device)}>
{device.connectionText || connectionText[device.connection]}
</span>
</div>
</div>
<div className="device-history-modal__meta">
<span>{device.imei}</span>
<span>Версия приложения: 244</span>
<span className="is-green">Исправно</span>
<span className="is-green">
{device.connectionText || 'В сети'}
</span>
<Dialog.Close className="device-history-modal__close" type="button">
<X size={22} />
</Dialog.Close>
</div>
</div>
<Dialog.Close className="device-history-modal__close" type="button">
<X size={22} />
</Dialog.Close>
<DeviceHistoryPeriodControl onChange={setSelectedPeriod} />
{selectedPeriod && zoomRange && (
<TimelineZoom
min={selectedPeriod.periodStart.getTime()}
max={selectedPeriod.periodEnd.getTime()}
start={zoomRange.start}
end={zoomRange.end}
onChange={setZoomRange}
/>
)}
</div>
<DeviceHistoryPeriodControl onChange={setSelectedPeriod} />
<DeviceHistoryChart
period={selectedPeriod}
telemetry={telemetryData?.getTelemetry ?? []}
isLoading={telemetryLoading}
isError={Boolean(telemetryError)}
/>
</Dialog.Content>
</Dialog.Portal>
<SimpleBar className="device-history-modal__scroll">
{shouldRenderChart && selectedPeriod && zoomRange ? (
<Suspense fallback={<DeviceHistoryChartPlaceholder />}>
<DeviceHistoryChart
period={selectedPeriod}
zoomRange={zoomRange}
telemetry={telemetryWithEvents}
packageUseEvents={packageUseEvents}
isLoading={telemetryLoading}
isError={Boolean(telemetryError)}
/>
</Suspense>
) : (
<DeviceHistoryChartPlaceholder />
)}
</SimpleBar>
</MotionDialogContent>
</Dialog.Root>
)
}

View File

@ -15,7 +15,7 @@
.history-period-tabs {
padding: 4px;
border-radius: 999px;
background: $color-bg;
//background: $color-bg;
display: flex;
align-items: center;
@ -39,11 +39,17 @@
&:hover {
color: $blue;
background: rgba($color-surface-rgb, .6) !important;
}
&.is-active {
background: #ffffff;
color: $blue;
box-shadow: 0 0 10px -6px $gray50;
}
[data-theme='dark'] &.is-active{
background: $color-surface;
box-shadow: 0 0 10px -6px rgba($gray50-rgb, .4);
}
}
@ -57,13 +63,13 @@
border: none;
border-radius: 999px;
background: $color-bg;
//background: $color-bg;
display: inline-flex;
align-items: center;
gap: 8px;
color: #30394b;
//color: #30394b;
font-size: 14px;
font-weight: 500;
@ -77,7 +83,7 @@
&:hover,
&.is-active {
color: $blue;
box-shadow: inset 0 0 0 1px rgba(3, 29, 154, 0.16);
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.16);
svg {
color: $blue;
@ -91,94 +97,102 @@
top: calc(100% + 8px);
z-index: 120;
min-width: 100%;
width: max-content;
border-radius: 18px;
background: #ffffff;
padding: 14px;
background: $color-surface;
padding: 12px;
border: 1.5px solid $gray20;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
box-shadow: none;
button{
transition: .18s ease;
}
.rdp-root {
--rdp-accent-color: $blue;
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
--rdp-day_button-border-radius: 10px;
--rdp-accent-color: #{$blue};
--rdp-accent-background-color: #{$color-primary-soft};
--rdp-day_button-border-radius: 12px;
--rdp-day_button-height: 34px;
--rdp-day_button-width: 34px;
--rdp-day-height: 36px;
--rdp-day-width: 36px;
--rdp-selected-border: 0;
margin: 0;
font-family: inherit;
}
.rdp-months {
max-width: 100%;
gap: 18px;
}
.rdp-month {
width: 100%;
}
.rdp-month_caption {
margin-bottom: 8px;
}
.rdp-caption_label {
color: #30394b;
font-size: 15px;
font-weight: 600;
color: $color-text-strong;
font-size: $font-size-14;
font-weight: 700;
}
.rdp-nav {
button {
width: 30px;
height: 30px;
border-radius: 10px;
gap: 4px;
}
color: $gray50;
.rdp-button_previous,
.rdp-button_next,
.rdp-nav button {
width: 28px;
height: 28px;
border-radius: 8px;
&:hover {
background: $color-bg;
color: $blue;
}
color: $blue;
&:hover {
background: $color-surface-hover;
}
}
.rdp-weekday {
color: $gray50;
font-size: 12px;
font-weight: 500;
}
.rdp-day {
width: 38px;
height: 38px;
padding: 0;
color: $color-text-muted;
font-size: $font-size-12;
font-weight: 600;
}
.rdp-day_button {
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
color: #30394b;
font-size: 14px;
font-weight: 500;
color: $color-text-heading;
font-size: $font-size-12;
font-weight: 600;
width: 100%;
height: 100%;
&:hover {
background: $color-bg;
background: $color-surface-hover;
}
}
.rdp-selected {
.rdp-day_button {
background: $blue;
color: #ffffff;
color: $color-text-inverse;
}
}
.rdp-range_middle {
background-color: transparent;
padding: 0;
.rdp-day_button {
background: #e8edff;
background: $color-primary-soft;
color: $blue;
border-radius: 0;
}
@ -186,19 +200,23 @@
.rdp-range_start {
.rdp-day_button {
border-radius: 10px;
background: $blue;
color: $color-text-inverse;
border-radius: 12px;
}
}
.rdp-range_end {
.rdp-day_button {
border-radius: 10px;
background: $blue;
color: $color-text-inverse;
border-radius: 12px;
}
}
.rdp-range_start.rdp-range_end {
.rdp-day_button {
border-radius: 10px;
border-radius: 12px;
}
.rdp-today {
@ -241,7 +259,7 @@
height: 4px;
border-radius: 999px;
background: rgba(3, 29, 154, 0.16);
background: rgba($color-primary-rgb, 0.16);
}
.history-range__active {
@ -293,51 +311,3 @@
cursor: pointer;
}
/* react-day-picker */
.history-date-picker__dropdown .rdp-root {
--rdp-accent-color: $blue;
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
--rdp-day_button-border-radius: 10px;
--rdp-selected-border: 0;
margin: 0;
}
.history-date-picker__dropdown .rdp-months {
gap: 18px;
}
.history-date-picker__dropdown .rdp-caption_label {
color: #30394b;
font-size: 15px;
font-weight: 600;
}
.history-date-picker__dropdown .rdp-weekday {
color: $gray50;
font-size: 12px;
font-weight: 500;
}
.history-date-picker__dropdown .rdp-day_button {
width: 36px;
height: 36px;
color: #30394b;
font-size: 14px;
}
.history-date-picker__dropdown .rdp-selected .rdp-day_button {
background: $blue;
color: #ffffff;
}
.history-date-picker__dropdown .rdp-range_middle .rdp-day_button {
background: #e8edff;
color: $blue;
}
.history-date-picker__dropdown .rdp-today .rdp-day_button {
font-weight: 700;
}

View File

@ -1,36 +1,109 @@
import { useState } from 'react'
import { ShieldCheck, Signal, Smartphone, Trash2 } from 'lucide-react'
import { memo, useState } from 'react'
import { AnimatePresence, m, useReducedMotion } from 'framer-motion'
import { Link } from 'react-router-dom'
import {
Building2,
Lock,
ShieldCheck,
Signal,
Smartphone,
Trash2,
} from 'lucide-react'
import type { Device } from '../../types'
import { conditionText, connectionText, getStatusClass } from '../../types'
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
import { useDeferredModalPayload } from '../../../../shared/lib/lazyMount'
type DeviceMainCardProps = {
device: Device
onOpenHistory: () => void
}
function getDeviceFullName(employee: Device) {
return [employee.serialNumber]
.filter(Boolean)
.join(' ')
function getDeviceFullName(device: Device) {
return [device.serialNumber].filter(Boolean).join(' ')
}
function areDeviceMainCardPropsEqual(
prev: DeviceMainCardProps,
next: DeviceMainCardProps,
) {
return (
prev.onOpenHistory === next.onOpenHistory &&
prev.device.id === next.device.id &&
prev.device.image === next.device.image &&
prev.device.model === next.device.model &&
prev.device.factoryNumber === next.device.factoryNumber &&
prev.device.serialNumber === next.device.serialNumber &&
prev.device.imei === next.device.imei &&
prev.device.imei2 === next.device.imei2 &&
prev.device.condition === next.device.condition &&
prev.device.connection === next.device.connection &&
prev.device.connectionText === next.device.connectionText &&
prev.device.permissions?.locked === next.device.permissions?.locked &&
prev.device.workTime === next.device.workTime &&
prev.device.organisationId === next.device.organisationId &&
prev.device.organisation === next.device.organisation &&
prev.device.registeredAt === next.device.registeredAt
)
}
export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps) {
export const DeviceMainCard = memo(function DeviceMainCard({
device,
onOpenHistory,
}: DeviceMainCardProps) {
const [deletingDevice, setDeletingDevice] = useState<Device | null>(null)
const renderDeletingDevice = useDeferredModalPayload(deletingDevice)
const shouldReduceMotion = useReducedMotion()
const statusTransition = shouldReduceMotion
? { duration: 0 }
: {
duration: 0.24,
ease: [0.34, 1.42, 0.64, 1] as [number, number, number, number],
}
const statusItems = [
{
key: `condition-${device.condition}`,
className: getStatusClass(device.condition),
icon: <ShieldCheck size={17} />,
content: conditionText[device.condition],
},
{
key: `connection-${device.connection}-${device.connectionText ?? ''}`,
className: getStatusClass(device.connection),
icon: <Signal size={17} />,
content: device.connectionText ?? connectionText[device.connection],
},
]
if (device.permissions?.locked) {
statusItems.push({
key: 'locked',
className: 'is-danger',
icon: <Lock size={17} />,
content: 'Заблокирован',
})
}
if (device.workTime) {
statusItems.push({
key: 'work-time',
className: 'is-muted',
icon: <Smartphone size={17} />,
content: `В работе: ${device.workTime}`,
})
}
function handleConfirmDeleteDevice() {
if (!deletingDevice) return
console.log('Удаление устройства пока без мутации', deletingDevice)
setDeletingDevice(null)
}
function handleDeleteDevice(device: Device) {
setDeletingDevice(device)
}
setDeletingDevice(device)
}
return (
<div className="device-card device-card--main">
@ -39,7 +112,7 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
{device.image ? (
<img src={device.image} alt={device.model ?? 'Устройство'} />
) : (
<img src='/devices/армафон3.3+.webp'></img>
<img src="/devices/армафон3.3+.webp" alt="АРМАФОН" />
)}
</div>
@ -49,33 +122,60 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
<p>{device.factoryNumber}</p>
<p>
{device.imei}
{device.serialNumber ? ` · ${device.serialNumber}` : ''}
{device.imei2 ? ` · ${device.imei2}` : ''}
</p>
<div className="device-main__divider" />
<div className="device-main__statuses">
<div className={`device-status ${getStatusClass(device.condition)}`}>
<ShieldCheck size={17} />
{conditionText[device.condition]}
</div>
<div className={`device-status ${getStatusClass(device.connection)}`}>
<Signal size={17} />
{device.connectionText ?? connectionText[device.connection]}
</div>
{device.workTime && (
<div className="device-status is-muted">
<Smartphone size={17} />
В работе: {device.workTime}
</div>
)}
<AnimatePresence>
{statusItems.map((status, index) => (
<m.div
key={status.key}
layout={!shouldReduceMotion}
className={`device-status ${status.className}`}
initial={{ opacity: 0, x: shouldReduceMotion ? 0 : -8 }}
animate={{
opacity: 1,
x: 0,
transition: {
...statusTransition,
delay: shouldReduceMotion ? 0 : index * 0.04,
},
}}
exit={{
opacity: 0,
x: shouldReduceMotion ? 0 : -8,
transition: statusTransition,
}}
>
{status.icon}
{status.content}
</m.div>
))}
</AnimatePresence>
</div>
</div>
</div>
<div className="device-main__bottom">
<div className="device-registered device-organisation">
<Building2 size={17} />
<div className="device-registered-info">
<span>Организация</span>
{device.organisationId ? (
<Link to={`/employees/organisations/${device.organisationId}`}>
{device.organisation ?? `Организация #${device.organisationId}`}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18.607 11.035v7.929a2.27 2.27 0 0 1-2.3 2.286H5.05a2.27 2.27 0 0 1-2.299-2.3V7.693a2.273 2.273 0 0 1 2.3-2.3h7.928M21.25 2.75L10.679 13.321M15.964 2.75h5.286v5.286"></path>
</svg>
</Link>
) : (
<b>{device.organisation ?? 'Не указана'}</b>
)}
</div>
</div>
<div className="device-registered">
<Smartphone size={17} />
<div className="device-registered-info">
@ -85,21 +185,31 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
</div>
<div className="device-card__actions">
<button className="device-history-btn" type="button" onClick={onOpenHistory}>
<button
className="device-history-btn"
type="button"
onClick={onOpenHistory}
>
Подробная история эксплуатации
</button>
<button className="device-delete-btn" type="button" aria-label="Удалить" onClick={() => handleDeleteDevice(device)}>
<button
className="device-delete-btn"
type="button"
aria-label="Удалить"
onClick={() => handleDeleteDevice(device)}
>
<Trash2 size={18} />
</button>
</div>
</div>
<ConfirmDangerDialog
open={Boolean(deletingDevice)}
title="Удалить сотрудника?"
title="Удалить смартфон?"
description={
deletingDevice
? `Устройство ${getDeviceFullName(deletingDevice)} будет удалено. Это действие нельзя будет отменить.`
renderDeletingDevice
? `Устройство ${getDeviceFullName(renderDeletingDevice)} будет удалено. Это действие нельзя будет отменить.`
: ''
}
confirmText="Удалить"
@ -112,4 +222,4 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
/>
</div>
)
}
}, areDeviceMainCardPropsEqual)

View File

@ -7,7 +7,7 @@
position: relative;
border-radius: 14px;
overflow: visible;
background: #eef1f6;
background: $color-media-bg;
}
.device-map__leaflet {
@ -27,17 +27,17 @@
padding: 0 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
background: rgba($color-surface-rgb, 0.92);
display: inline-flex;
align-items: center;
gap: 6px;
color: #30394b;
color: $color-text-heading;
font-size: 12px;
font-weight: 600;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.12);
box-shadow: 0 8px 22px rgba($color-shadow-rgb, 0.12);
svg {
color: $blue;
@ -53,7 +53,7 @@
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(3, 29, 154, 0.18);
background: rgba($color-primary-rgb, 0.18);
display: flex;
align-items: center;
@ -65,8 +65,41 @@
height: 16px;
border-radius: 50%;
background: $blue;
border: 3px solid #ffffff;
box-shadow: 0 8px 18px rgba(3, 29, 154, 0.35);
border: 3px solid $color-surface;
box-shadow: 0 8px 18px rgba($color-primary-rgb, 0.35);
}
.device-map-marker.is-online {
.device-map-marker__pin {
background: rgba($color-success-rgb, 0.18);
}
.device-map-marker__inner {
background: $green;
box-shadow: 0 8px 18px rgba($color-success-rgb, 0.35);
}
}
.device-map-marker.is-offline {
.device-map-marker__pin {
background: rgba($gray50-rgb, 0.18);
}
.device-map-marker__inner {
background: $gray50;
box-shadow: 0 8px 18px rgba($gray50-rgb, 0.28);
}
}
.device-map-marker.is-lost {
.device-map-marker__pin {
background: rgba($color-danger-rgb, 0.16);
}
.device-map-marker__inner {
background: $red;
box-shadow: 0 8px 18px rgba($color-danger-rgb, 0.28);
}
}
.device-map-popup {
@ -75,13 +108,13 @@
gap: 3px;
b {
color: #151a24;
color: $color-text-heading;
font-size: 13px;
font-weight: 700;
}
span {
color: #738098;
color: $color-text-muted;
font-size: 12px;
font-weight: 500;
}
@ -95,6 +128,179 @@
border-radius: 24px;
}
.device-map-info-card {
position: absolute;
left: 12px;
top: 70px;
z-index: 430;
//transform: translateY(-50%);
width: min(180px, calc(100% - 24px));
padding: 14px;
border: 1px solid rgba($color-border-rgb, 0.72);
border-radius: 16px;
background: rgba($color-surface-rgb, 0.94);
backdrop-filter: blur(14px);
box-shadow: $shadow-popover;
}
.device-map-info-card__close {
position: absolute;
top: 8px;
right: 8px;
width: 26px;
height: 26px;
border: none;
border-radius: 8px;
background: $color-surface-muted;
display: inline-flex;
align-items: center;
justify-content: center;
color: $color-text-muted;
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease;
&:hover {
background: $color-surface-hover;
color: $color-primary;
}
}
.device-map-info-card__header {
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 28px;
margin-bottom: 12px;
line-height: 1;
text-align: left;
b,
span {
display: block;
}
b {
overflow: hidden;
color: $color-text-heading;
font-size: $font-size-15;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
margin-top: 3px;
overflow: hidden;
color: $color-text-muted;
font-size: $font-size-12;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.device-map-info-card__icon {
width: 34px;
height: 34px;
border-radius: 10px;
background: $color-primary-tint;
display: inline-flex;
align-items: center;
justify-content: center;
color: $color-primary;
}
.device-map-info-card__rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-map-info-card__row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
justify-content: flex-start;
gap: 8px;
color: $color-text;
font-size: $font-size-12;
font-weight: 500;
svg {
color: $color-primary;
}
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left ;
}
}
.device-map-info-card__status {
grid-column: 1 / -1;
min-height: 22px;
width: fit-content;
padding: 0 8px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 6px;
color: $color-text-heading;
font-size: $font-size-11;
font-weight: 700;
text-transform: uppercase;
&::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
&.is-online {
background: rgba($color-success-rgb, 0.14);
color: $green;
}
&.is-offline {
background: $color-bg;
color: $gray50;
}
&.is-lost {
background: rgba($color-danger-rgb, 0.14);
color: $red;
}
}
.device-map-info-card__footer {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid rgba($color-border-rgb, 0.72);
color: $color-text-muted;
font-size: $font-size-11;
font-weight: 600;
}
.device-map--modal {
padding: 0 !important;
box-shadow: none;

View File

@ -1,23 +1,21 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@apollo/client/react'
import {
CircleMarker,
MapContainer,
Marker,
Polyline,
Popup,
TileLayer,
useMap,
} from 'react-leaflet'
import L from 'leaflet'
import { Map } from 'lucide-react'
import { Battery, Clock3, Map as MapIcon, MapPin, Thermometer, X } from 'lucide-react'
import type { Device } from '../../types'
import {
GET_PHONE_GPS_TRACK_QUERY,
} from '../../../../entities/device/api/device.graphql'
import { GET_PHONE_GPS_TRACK_QUERY } from '../../../../entities/device/api/device.graphql'
import type {
DeviceGpsTrackPoint,
DeviceTelemetryItem,
GetPhoneGpsTrackData,
GetPhoneGpsTrackVariables,
} from '../../../../entities/device/model/types'
@ -34,24 +32,63 @@ type DeviceMapCardProps = {
variant?: 'card' | 'modal'
}
type TelemetryLine = Array<[number, number]>
type DeviceMapPoint = {
key: string
kind: 'current' | 'route'
title: string
subtitle?: string
date?: number
lat: number
lng: number
}
type MapFitBoundsProps = {
positions: [number, number][]
fitKey: string
}
const routeColor = 'var(--color-primary)'
const defaultLocation = {
lat: 54.7558,
lng: 87.1099,
}
const deviceMarkerIcon = L.divIcon({
className: 'device-map-marker',
html: `
<div class="device-map-marker__pin">
<div class="device-map-marker__inner"></div>
</div>
`,
iconSize: [34, 34],
iconAnchor: [17, 17],
popupAnchor: [0, -18],
})
const markerIconCache = new Map<string, L.DivIcon>()
function formatLocationDate(timestamp: number) {
function getConnectionStatusClass(connection: Device['connection']) {
if (connection === 'online') return 'is-online'
if (connection === 'offlineDanger') return 'is-lost'
return 'is-offline'
}
function createDeviceMarkerIcon(connection: Device['connection']) {
const className = getConnectionStatusClass(connection)
const cachedIcon = markerIconCache.get(className)
if (cachedIcon) return cachedIcon
const icon = L.divIcon({
className: `device-map-marker ${className}`,
html: `
<div class="device-map-marker__pin">
<div class="device-map-marker__inner"></div>
</div>
`,
iconSize: [34, 34],
iconAnchor: [17, 17],
popupAnchor: [0, -18],
})
markerIconCache.set(className, icon)
return icon
}
function formatLocationDate(timestamp?: number) {
if (!timestamp) return 'Нет данных'
return new Intl.DateTimeFormat('ru-RU', {
@ -63,11 +100,79 @@ function formatLocationDate(timestamp: number) {
}).format(new Date(timestamp))
}
function formatCoordinate(value: number) {
return value.toFixed(6)
}
function formatMetric(value: number | null, suffix: string) {
if (value === null) return '-'
return `${value}${suffix}`
}
function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
return [...track].sort((a, b) => a.date - b.date)
}
function isDateInPeriod(date: number | undefined, period: MapTrackPeriodValue | null) {
function sortTelemetry(telemetry: DeviceTelemetryItem[]) {
return [...telemetry].sort((a, b) => a.date - b.date)
}
function createTelemetryLine(
telemetry: DeviceTelemetryItem[],
getValue: (item: DeviceTelemetryItem) => number | null,
): TelemetryLine {
return telemetry
.map((item) => [item.date, getValue(item)] as const)
.filter((item): item is [number, number] => typeof item[1] === 'number')
}
function getLineValueAtTime(data: TelemetryLine, timestamp?: number) {
if (!timestamp || !data.length) return null
const firstPoint = data[0]
const lastPoint = data[data.length - 1]
if (timestamp <= firstPoint[0]) return firstPoint[1]
if (timestamp >= lastPoint[0]) return lastPoint[1]
let left = 0
let right = data.length - 1
while (right - left > 1) {
const middle = Math.floor((left + right) / 2)
if (data[middle][0] <= timestamp) {
left = middle
} else {
right = middle
}
}
const currentPoint = data[left]
const nextPoint = data[right]
const currentTime = currentPoint[0]
const currentValue = currentPoint[1]
const nextTime = nextPoint[0]
const nextValue = nextPoint[1]
if (nextTime === currentTime) return Math.round(currentValue)
const progress = (timestamp - currentTime) / (nextTime - currentTime)
const interpolatedValue =
currentValue + (nextValue - currentValue) * progress
return Math.round(interpolatedValue)
}
function createPointKey(kind: DeviceMapPoint['kind'], lat: number, lng: number, date?: number) {
return `${kind}-${date ?? 'no-date'}-${lat}-${lng}`
}
function isDateInPeriod(
date: number | undefined,
period: MapTrackPeriodValue | null,
) {
if (!date || !period) return false
return (
@ -90,15 +195,14 @@ function MapResizeWatcher() {
return null
}
type MapFitBoundsProps = {
positions: [number, number][]
}
function MapFitBounds({ positions }: MapFitBoundsProps) {
function MapFitBounds({ positions, fitKey }: MapFitBoundsProps) {
const map = useMap()
const lastFitKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!positions.length) return
if (!positions.length || lastFitKeyRef.current === fitKey) return
lastFitKeyRef.current = fitKey
if (positions.length === 1) {
map.setView(positions[0], 14)
@ -112,14 +216,23 @@ function MapFitBounds({ positions }: MapFitBoundsProps) {
maxZoom: 15,
animate: true,
})
}, [map, positions])
}, [fitKey, map, positions])
return null
}
export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps) {
export function DeviceMapCard({
device,
variant = 'card',
}: DeviceMapCardProps) {
const [selectedPeriod, setSelectedPeriod] =
useState<MapTrackPeriodValue | null>(null)
const [selectedPointKey, setSelectedPointKey] = useState<string | null>(null)
const networkStatusClass = getConnectionStatusClass(device.connection)
const deviceMarkerIcon = useMemo(
() => createDeviceMarkerIcon(device.connection),
[device.connection],
)
const { data: gpsTrackData } = useQuery<
GetPhoneGpsTrackData,
@ -139,6 +252,19 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
}, [gpsTrackData])
const telemetry = useMemo(() => {
return sortTelemetry(device.telemetry ?? [])
}, [device.telemetry])
const telemetryLines = useMemo(() => {
return {
battery: createTelemetryLine(telemetry, (item) => item.batteryLevel),
temperature: createTelemetryLine(telemetry, (item) => item.temperature),
capacity: createTelemetryLine(telemetry, (item) => item.batteryCapacity),
cycles: createTelemetryLine(telemetry, (item) => item.batteryCycles),
}
}, [telemetry])
const shouldShowCurrentLocation = isDateInPeriod(
device.lastLocationDate,
selectedPeriod,
@ -147,10 +273,10 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
const selectedCurrentLocation =
shouldShowCurrentLocation && device.location
? {
lat: device.location.lat,
lng: device.location.lng,
time: device.lastLocationAt,
}
lat: device.location.lat,
lng: device.location.lng,
date: device.lastLocationDate,
}
: null
const selectedRoutePoints = useMemo(() => {
@ -187,7 +313,81 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
return [location.lat, location.lng]
}, [location.lat, location.lng])
const fitPositions = routePositions.length > 0 ? routePositions : [currentPosition]
const fitPositions =
routePositions.length > 0 ? routePositions : [currentPosition]
const fitKey = useMemo(() => {
if (!selectedPeriod) {
return `${device.id}-${device.location ? 'located' : 'fallback'}`
}
return [
device.id,
selectedPeriod.periodStart.getTime(),
selectedPeriod.periodEnd.getTime(),
routePositions.length > 0 ? 'route' : 'point',
].join('-')
}, [device.id, device.location, routePositions.length, selectedPeriod])
const routeMapPoints = useMemo<DeviceMapPoint[]>(() => {
return selectedRoutePoints.map((point) => ({
key: createPointKey('route', point.lat, point.lng, point.date),
kind: 'route',
title: 'Точка маршрута',
date: point.date,
lat: point.lat,
lng: point.lng,
}))
}, [selectedRoutePoints])
const currentMapPoint = useMemo<DeviceMapPoint | null>(() => {
if (!selectedCurrentLocation) return null
return {
key: createPointKey(
'current',
selectedCurrentLocation.lat,
selectedCurrentLocation.lng,
selectedCurrentLocation.date,
),
kind: 'current',
title: device.model ?? 'Устройство',
subtitle: device.factoryNumber,
date: selectedCurrentLocation.date,
lat: selectedCurrentLocation.lat,
lng: selectedCurrentLocation.lng,
}
}, [device.factoryNumber, device.model, selectedCurrentLocation])
const selectedMapPoint = useMemo(() => {
if (!selectedPointKey) return null
if (currentMapPoint?.key === selectedPointKey) {
return currentMapPoint
}
return (
routeMapPoints.find((point) => point.key === selectedPointKey) ?? null
)
}, [currentMapPoint, routeMapPoints, selectedPointKey])
useEffect(() => {
if (selectedPointKey && !selectedMapPoint) {
setSelectedPointKey(null)
}
}, [selectedMapPoint, selectedPointKey])
const selectedTelemetry = useMemo(() => {
return {
battery: getLineValueAtTime(telemetryLines.battery, selectedMapPoint?.date),
temperature: getLineValueAtTime(
telemetryLines.temperature,
selectedMapPoint?.date,
),
capacity: getLineValueAtTime(telemetryLines.capacity, selectedMapPoint?.date),
cycles: getLineValueAtTime(telemetryLines.cycles, selectedMapPoint?.date),
}
}, [selectedMapPoint, telemetryLines])
return (
<div
@ -199,7 +399,7 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
>
<div className="device-card__header">
<div className="device-card__title">
<Map size={18} />
<MapIcon size={18} />
<h3>Устройство на карте</h3>
</div>
@ -214,6 +414,63 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
<MapTrackPeriodControl onChange={setSelectedPeriod} />
</div>
{selectedMapPoint && (
<div className="device-map-info-card">
<button
className="device-map-info-card__close"
type="button"
aria-label="Закрыть информацию о точке"
onClick={() => setSelectedPointKey(null)}
>
<X size={15} />
</button>
<div className="device-map-info-card__header">
<b>{selectedMapPoint.title}</b>
<span>
{selectedMapPoint.subtitle ?? device.factoryNumber}
</span>
</div>
<div className="device-map-info-card__rows">
<div className="device-map-info-card__row">
<span
className={`device-map-info-card__status ${networkStatusClass}`}
>
{device.connectionText}
</span>
</div>
<div className="device-map-info-card__row">
<Clock3 size={15} />
<span>{formatLocationDate(selectedMapPoint.date)}</span>
</div>
<div className="device-map-info-card__row">
<Battery size={15} />
<span>
Заряд АКБ: {formatMetric(selectedTelemetry.battery, '%')}
</span>
</div>
<div className="device-map-info-card__row">
<Thermometer size={15} />
<span>
Температура: {formatMetric(selectedTelemetry.temperature, '°C')}
</span>
</div>
<div className="device-map-info-card__row">
<MapPin size={15} />
<span>
{formatCoordinate(selectedMapPoint.lat)},{' '}
{formatCoordinate(selectedMapPoint.lng)}
</span>
</div>
</div>
</div>
)}
<MapContainer
center={currentPosition}
zoom={14}
@ -223,7 +480,7 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
id="device-map"
>
<MapResizeWatcher />
<MapFitBounds positions={fitPositions} />
<MapFitBounds positions={fitPositions} fitKey={fitKey} />
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
@ -231,45 +488,44 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
<Polyline
positions={routePositions}
pathOptions={{
color: '#031d9a',
color: routeColor,
weight: 4,
opacity: 0.8,
}}
/>
)}
{selectedRoutePoints.map((point, index) => (
{routeMapPoints.map((point) => (
<CircleMarker
key={`${point.lat}-${point.lng}-${point.date}-${index}`}
key={point.key}
center={[point.lat, point.lng]}
radius={4}
pathOptions={{
color: '#031d9a',
fillColor: '#031d9a',
color: routeColor,
fillColor: routeColor,
fillOpacity: 1,
weight: 2,
}}
>
<Popup>
<div className="device-map-popup">
<b>Точка маршрута</b>
<span>{formatLocationDate(point.date)}</span>
</div>
</Popup>
</CircleMarker>
eventHandlers={{
click: (event) => {
event.originalEvent.stopPropagation()
setSelectedPointKey(point.key)
},
}}
/>
))}
{selectedCurrentLocation && (
<Marker position={currentPosition} icon={deviceMarkerIcon}>
<Popup>
<div className="device-map-popup">
<b>{device.model ?? 'Устройство'}</b>
<span>{device.factoryNumber}</span>
<span>{device.imei}</span>
<span>{device.lastLocationAt ?? 'Время не указано'}</span>
</div>
</Popup>
</Marker>
{currentMapPoint && (
<Marker
position={currentPosition}
icon={deviceMarkerIcon}
eventHandlers={{
click: (event) => {
event.originalEvent.stopPropagation()
setSelectedPointKey(currentMapPoint.key)
},
}}
/>
)}
<FullscreenControl targetId="device-map" />

View File

@ -0,0 +1,243 @@
@use '../../../../shared/styles/variables' as *;
.device-page-skeleton {
position: relative;
display: block;
overflow: hidden;
flex: 0 0 auto;
border-radius: 12px;
background: $color-skeleton;
&::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg,
$transparent,
$color-skeleton-highlight,
$transparent);
animation: device-page-skeleton-shimmer 1.35s ease-in-out infinite;
}
}
.device-page-skeleton--text {
height: 1em;
min-width: 32px;
border-radius: 999px;
}
.device-page-skeleton--icon {
width: 32px;
height: 32px;
border-radius: 9px;
}
.device-page-skeleton--button {
width: 100%;
height: 44px;
border-radius: 12px;
}
.device-page-skeleton--square-button {
width: 42px;
height: 42px;
border-radius: 12px;
}
.device-page-skeleton--mini-button {
width: 34px;
height: 34px;
border-radius: 10px;
}
.device-page-skeleton--switch {
width: 48px;
height: 26px;
border-radius: 999px;
}
.device-page-skeleton--battery-circle {
width: 120px;
height: 120px;
border-radius: 50%;
}
.device-page-loading__grid {
align-items: stretch;
}
.device-page-loading__main-card,
.device-page-loading__map-card,
.device-page-loading__actions-card,
.device-page-loading__permissions-card,
.device-page-loading__battery-card,
.device-page-loading__impacts-card {
pointer-events: none;
}
.device-page-loading__device-image {
min-height: 220px;
}
.device-page-loading__phone {
width: 42%;
min-width: 92px;
max-width: 138px;
height: 100%;
min-height: 180px;
border-radius: 28px;
}
.device-page-loading__main-info {
padding-top: 2px;
.device-page-skeleton--text {
height: 17px;
}
}
.device-page-loading__title {
height: 30px !important;
margin-bottom: 2px;
}
.device-page-loading__status-row {
.device-page-skeleton--text {
height: 18px;
}
}
.device-page-loading__registered {
.device-registered-info {
min-height: 44px;
}
}
.device-page-loading__history-button {
max-width: 286px;
}
.device-page-loading__map-card {
min-height: 420px;
}
.device-page-loading__map-header {
align-items: flex-start;
.device-card__title {
flex: 0 0 auto;
}
>.device-page-skeleton--text {
height: 15px;
margin-top: 7px;
}
}
.device-page-loading__map-container {
position: relative;
display: flex;
flex: 1;
min-height: 332px;
border-radius: 16px;
}
.device-page-loading__period-control {
position: absolute;
z-index: 1;
top: 12px;
left: 12px;
right: 12px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
.device-page-skeleton--button {
height: 34px;
border-radius: 10px;
background: rgba($white, 0.86);
}
}
.device-page-loading__map {
width: 100%;
min-height: 332px;
border-radius: 16px;
background:
linear-gradient(135deg, rgba($color-primary-rgb, 0.05), $transparent 48%),
$color-skeleton;
}
.device-page-loading__actions-card {
min-height: 174px;
.device-page-loading__card-title {
height: 18px;
margin-bottom: 12px;
padding-bottom: 12px;
}
.actions-button-container {
gap: 8px;
}
}
.device-page-loading__permissions-card {
min-height: 236px;
}
.device-page-loading__permissions-header {
margin-bottom: 12px;
.device-permissions__header-actions {
display: flex;
gap: 8px;
}
}
.device-page-loading__card-title {
height: 18px;
}
.device-page-loading__permission {
.device-permission__label {
min-height: 43px;
}
}
.device-page-loading__stats-column {
align-self: stretch;
}
.device-page-loading__battery-card {
min-height: 194px;
>.device-page-loading__card-title {
margin-bottom: 12px;
padding-bottom: 12px;
}
}
.device-page-loading__stat {
.device-stats-text {
min-height: 30px;
}
}
.device-page-loading__impacts-card {
min-height: 34px;
}
@keyframes device-page-skeleton-shimmer {
to {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.device-page-skeleton::after {
animation: none;
}
}

View File

@ -0,0 +1,184 @@
import type { CSSProperties } from 'react'
import './DevicePageSkeleton.scss'
type SkeletonProps = {
className?: string
width?: CSSProperties['width']
height?: CSSProperties['height']
}
function Skeleton({ className = '', width, height }: SkeletonProps) {
return (
<span
className={`device-page-skeleton ${className}`}
style={{ width, height }}
aria-hidden="true"
/>
)
}
function SkeletonText({
className = '',
width = '100%',
}: Omit<SkeletonProps, 'height'>) {
return (
<Skeleton
className={`device-page-skeleton--text ${className}`}
width={width}
/>
)
}
function SkeletonIcon({ className = '' }: Pick<SkeletonProps, 'className'>) {
return <Skeleton className={`device-page-skeleton--icon ${className}`} />
}
function SkeletonButton({ className = '' }: Pick<SkeletonProps, 'className'>) {
return <Skeleton className={`device-page-skeleton--button ${className}`} />
}
export function DevicePageSkeleton() {
return (
<section className="device-page device-page-loading" aria-busy="true">
<div className="device-page__grid device-page-loading__grid">
<div className="device-card device-card--main device-page-loading__main-card">
<div className="device-main">
<div className="device-main__image device-page-loading__device-image">
<Skeleton className="device-page-loading__phone" />
</div>
<div className="device-main__info device-page-loading__main-info">
<SkeletonText className="device-page-loading__title" width="210px" />
<SkeletonText width="260px" />
<SkeletonText width="360px" />
<div className="device-main__divider" />
<div className="device-main__statuses">
<div className="device-status device-page-loading__status-row">
<SkeletonIcon />
<SkeletonText width="176px" />
</div>
<div className="device-status device-page-loading__status-row">
<SkeletonIcon />
<SkeletonText width="136px" />
</div>
<div className="device-status device-page-loading__status-row">
<SkeletonIcon />
<SkeletonText width="158px" />
</div>
</div>
</div>
</div>
<div className="device-main__bottom">
<div className="device-registered device-page-loading__registered">
<SkeletonIcon />
<div className="device-registered-info">
<SkeletonText width="142px" />
<SkeletonText width="150px" />
</div>
</div>
<div className="device-card__actions">
<SkeletonButton className="device-page-loading__history-button" />
<Skeleton className="device-page-skeleton--square-button" />
</div>
</div>
</div>
<div className="device-card device-map device-page-loading__map-card">
<div className="device-card__header device-page-loading__map-header">
<div className="device-card__title">
<SkeletonIcon />
<SkeletonText width="184px" />
</div>
<SkeletonText width="230px" />
</div>
<div className="device-map__container device-page-loading__map-container">
<div className="device-page-loading__period-control">
<SkeletonButton />
<SkeletonButton />
<SkeletonButton />
</div>
<Skeleton className="device-page-loading__map" />
</div>
</div>
<div className="device-page__details-row">
<div className="device-card device-actions device-page-loading__actions-card">
<SkeletonText className="device-page-loading__card-title" width="118px" />
<div className="actions-button-container">
<SkeletonButton />
<SkeletonButton />
</div>
</div>
<div className="device-card device-permissions device-page-loading__permissions-card">
<div className="device-permissions__header device-page-loading__permissions-header">
<SkeletonText className="device-page-loading__card-title" width="72px" />
<div className="device-permissions__header-actions">
<Skeleton className="device-page-skeleton--mini-button" />
<Skeleton className="device-page-skeleton--mini-button" />
</div>
</div>
<div className="device-permissions__list">
{Array.from({ length: 4 }).map((_, index) => (
<div className="device-permission device-page-loading__permission" key={index}>
<SkeletonIcon />
<div className="device-permission__label">
<SkeletonText width={index === 3 ? '108px' : '86px'} />
<Skeleton className="device-page-skeleton--switch" />
</div>
</div>
))}
</div>
</div>
<div className="device-card-stats device-page-loading__stats-column">
<div className="device-card device-battery device-page-loading__battery-card">
<SkeletonText className="device-page-loading__card-title" width="126px" />
<div className="device-battery__content">
<Skeleton className="device-page-skeleton--battery-circle" />
<div className="device-stats">
{Array.from({ length: 3 }).map((_, index) => (
<div className="device-stats-card device-page-loading__stat" key={index}>
<SkeletonIcon />
<div className="device-stats-text">
<SkeletonText width={index === 0 ? '150px' : '132px'} />
<SkeletonText width={index === 2 ? '54px' : '42px'} />
</div>
</div>
))}
</div>
</div>
</div>
<div className="device-card device-impacts device-page-loading__impacts-card">
<SkeletonIcon />
<div className="device-stats-text">
<SkeletonText width="132px" />
<SkeletonText width="38px" />
</div>
</div>
</div>
</div>
</div>
</section>
)
}

View File

@ -1,21 +1,358 @@
@use '../../../../shared/styles/variables' as *;
.device-permissions {
justify-content: space-between;
}
.device-permissions__header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 4px;
border-bottom: 1px solid $gray20;
h3 {
line-height: 1;
margin: 0;
text-align: left;
color: $color-text-heading;
font-size: $font-size-18;
font-weight: 600;
}
}
.device-permissions__title {
position: relative;
display: inline-flex;
align-items: center;
gap: 7px;
}
.device-permissions__help {
position: relative;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
color: $color-text-muted;
cursor: help;
outline: none;
transition: color 0.18s ease;
svg {
width: 16px;
height: 16px;
stroke-width: 2.2;
transition: .2s ease;
}
&:hover,
&:focus-visible {
color: $blue;
}
&:hover .device-permissions__help-tooltip,
&:focus-visible .device-permissions__help-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(8px);
}
}
.device-permissions__help-tooltip {
position: absolute;
z-index: 30;
top: 100%;
left: -34px;
width: 400px;
padding: 11px;
border-radius: 12px;
//background: rgba($color-surface-rgb, 0.94);
backdrop-filter: blur(18px);
box-shadow:
0 14px 30px rgba($color-shadow-rgb, 0.16),
inset 0 0 0 1px rgba($color-primary-rgb, 0.08);
display: flex;
flex-direction: column;
gap: 12px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(2px);
transition:
opacity 0.18s ease,
visibility 0.18s ease,
transform 0.18s ease;
&::before {
content: '';
position: absolute;
left: 38px;
bottom: 100%;
width: 9px;
height: 9px;
background: rgba($color-surface-rgb, 0.94);
box-shadow: -1px -1px 0 rgba($color-primary-rgb, 0.08);
transform: translateY(5px) rotate(45deg);
}
}
.device-permissions__help-row {
display: grid;
grid-template-columns: 22px 1fr;
gap: 9px;
align-items: center;
>svg {
margin-top: 1px;
width: 20px;
height: 20px;
color: $blue;
}
span {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 2px;
}
b {
color: $color-text-heading;
font-size: $font-size-14;
font-weight: 700;
line-height: 1.2;
}
small {
color: $color-text-muted;
font-size: $font-size-13;
font-weight: 500;
line-height: 1.28;
}
}
.device-permissions__header-actions {
position: relative;
width: 82px;
height: 36px;
flex: 0 0 82px;
}
.device-permissions__icon-btn {
position: absolute;
top: 0;
right: 0;
width: 34px;
height: 34px;
padding: 0;
border: none;
border-radius: 12px;
background: $color-bg;
display: inline-flex;
align-items: center;
justify-content: center;
color: $gray50;
cursor: pointer;
transition:
background-color 0.18s ease,
color 0.18s ease,
opacity 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
&:hover:not(:disabled) {
background: $gray20;
color: $color-text;
//transform: scale(1.05);
}
&:disabled {
opacity: 0.45;
cursor: default;
}
}
.device-permissions__icon-btn--edit {
z-index: 2;
background: $blue20;
color: $blue;
&:hover:not(:disabled) {
background: $blue;
color: $color-text-inverse;
box-shadow: 0 8px 20px rgba($color-primary-rgb, 0.18);
}
}
.device-permissions__icon-btn--reset {
z-index: 1;
opacity: 0;
pointer-events: none;
transform: translateX(0) scale(0.92);
transition:
background-color 0.18s ease,
color 0.18s ease,
opacity 0.18s ease,
transform 0.28s cubic-bezier(0.34, 1.42, 0.64, 1);
}
.device-permissions.is-editing {
.device-permissions__icon-btn--reset {
opacity: 1;
pointer-events: auto;
transform: translateX(-46px) scale(1);
&:disabled {
opacity: 0.45;
pointer-events: none;
}
}
}
.device-permissions:not(.is-policy-editing) {
.device-permission {
opacity: 0.72;
}
.device-permission__switch {
cursor: not-allowed;
}
}
.device-permissions__list {
display: flex;
flex-direction: column;
}
.device-permission__controls {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.device-permission__policy-lock {
position: relative;
z-index: 3;
width: 34px;
height: 34px;
flex: 0 0 34px;
border-radius: 9px;
background: $color-surface-hover;
display: inline-flex;
align-items: center;
justify-content: center;
color: $color-text-muted;
cursor: help;
outline: none;
transition:
background-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
svg {
width: 18px !important;
height: 18px !important;
padding: 0 !important;
border-radius: 0 !important;
background: $transparent !important;
color: currentColor;
stroke-width: 2.35;
}
&:hover,
&:focus-visible {
z-index: 120;
color: $blue;
background: $blue20;
transform: translateY(-1px);
}
&:hover .device-permission__policy-lock-tooltip,
&:focus-visible .device-permission__policy-lock-tooltip {
opacity: 1;
visibility: visible;
transform: translate(-50%, -8px);
}
}
.device-permission__policy-lock-tooltip {
position: absolute;
z-index: 110;
left: 50%;
bottom: calc(100% + 4px);
width: 260px;
padding: 9px 11px;
border-radius: 10px;
//background: rgba($color-surface-rgb, 0.94);
backdrop-filter: blur(18px);
box-shadow:
0 12px 28px rgba($color-shadow-rgb, 0.14),
inset 0 0 0 1px rgba($color-primary-rgb, 0.08);
color: $color-text-heading;
font-size: $font-size-13;
font-weight: 500;
line-height: 1.32;
text-align: left;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate(-50%, -2px);
transition:
opacity 0.18s ease,
visibility 0.18s ease,
transform 0.18s ease;
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
width: 9px;
height: 9px;
background: rgba($color-surface-rgb, 0.94);
box-shadow: 1px 1px 0 rgba($color-primary-rgb, 0.08);
transform: translate(-50%, -5px) rotate(45deg);
}
}
.device-permission__switch {
position: relative;
width: 48px;
height: 24px;
flex: 0 0 48px;
padding: 0;
width: 118px;
height: 28px;
flex: 0 0 118px;
padding: 3px;
border: none;
outline: none;
display: inline-flex;
align-items: center;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
border-radius: 999px;
background: $gray20;
box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
background: $color-bg;
box-shadow: inset 0 0 0 1px rgba($gray50-rgb, 0.08);
cursor: pointer;
user-select: none;
@ -25,19 +362,29 @@
box-shadow 0.28s ease,
transform 0.18s ease;
> span {
&--component {
width: 78px;
flex-basis: 78px;
grid-template-columns: repeat(2, minmax(0, 1fr));
.device-permission__switch-thumb {
width: calc((100% - 6px) / 2) !important;
}
}
.device-permission__switch-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 26px;
height: 18px;
width: calc((100% - 6px) / 3);
height: calc(100% - 6px);
border-radius: 100px;
background: #ffffff;
background: $color-surface;
box-shadow:
0 2px 5px rgba(15, 23, 42, 0.18),
0 1px 1px rgba(15, 23, 42, 0.08);
0 2px 5px rgba($color-shadow-rgb, 0.18),
0 1px 1px rgba($color-shadow-rgb, 0.08);
transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
@ -45,40 +392,124 @@
width 0.18s ease;
}
&.is-enabled {
background: $blue;
box-shadow: inset 0 0 0 1px rgba($blue, 0.12);
.device-permission__switch-option {
position: relative;
z-index: 1;
> span {
transform: translateX(16px);
min-width: 0;
padding: 0;
border: none;
background: $transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: $gray50;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: $transparent;
&:focus-visible {
border-radius: 999px;
//box-shadow: 0 0 0 2px rgba($color-primary-rgb, 0.22);
}
}
.device-permission__switch-option[aria-disabled='true'] {
cursor: not-allowed;
}
.device-permission__switch-label {
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 0.2s ease,
opacity 0.2s ease;
svg {
width: 14px;
height: 14px;
stroke-width: 2.35;
background-color: transparent;
}
}
&.is-unspecified {
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.1);
.device-permission__switch-thumb {
transform: translateX(100%);
background: $blue20;
box-shadow:
0 3px 7px rgba(3, 29, 154, 0.24),
0 1px 1px rgba(15, 23, 42, 0.08);
0 3px 7px -4px rgba($color-primary-rgb),
0 1px 1px rgba($color-shadow-rgb, 0.08);
}
.device-permission__switch-option.is-active .device-permission__switch-label {
color: $blue;
}
}
&:hover {
box-shadow:
inset 0 0 0 1px rgba($gray50, 0.12),
0 4px 10px rgba(15, 23, 42, 0.06);
&.is-enabled,
&.is-allowed {
background: rgba($color-primary-rgb, 0.1);
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.12);
.device-permission__switch-thumb {
background: $blue;
box-shadow:
0 3px 7px rgba($color-primary-rgb, 0.24),
0 1px 1px rgba($color-shadow-rgb, 0.08);
}
.device-permission__switch-option.is-active .device-permission__switch-label {
color: $color-text-inverse;
svg{
color: $white;
}
}
}
&:active {
&.is-enabled {
.device-permission__switch-thumb {
transform: translateX(200%);
}
}
&.is-allowed {
.device-permission__switch-thumb {
transform: translateX(100%);
}
}
&.is-disabled,
&.is-disallowed {
.device-permission__switch-option.is-active .device-permission__switch-label {
color: $color-text;
}
}
&:hover:not(.is-disabled) {
//box-shadow:
//inset 0 0 0 1px rgba($gray50-rgb, 0.12),
//0 4px 10px rgba($color-shadow-rgb, 0.06);
}
&:active:not(.is-disabled) {
transform: scale(0.96);
> span {
width: 23px;
}
}
&.is-enabled:active > span {
transform: translateX(17px);
}
&:focus-visible {
&:focus-within {
box-shadow:
0 0 0 3px rgba($blue, 0.18),
inset 0 0 0 1px rgba($blue, 0.18);
//0 0 0 3px rgba($color-primary-rgb, 0.18),
inset 0 0 0 1px rgba($color-primary-rgb, 0.18);
}
&.is-disabled {
cursor: not-allowed;
}
}
@ -87,19 +518,91 @@
opacity 0.2s ease,
filter 0.2s ease;
&.is-disabled {
opacity: 0.45;
&.is-policy-locked {
cursor: not-allowed;
svg,
span,
button {
pointer-events: none;
>svg,
.device-permission__label>span:first-child,
.device-permission__switch {
opacity: 0.52;
}
.device-permission__switch {
cursor: not-allowed;
box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
box-shadow: inset 0 0 0 1px rgba($gray50-rgb, 0.08);
}
}
}
.device-permissions:not(.is-policy-editing) {
.device-permission.is-policy-locked {
opacity: 1;
}
}
.device-permissions__error {
margin-top: 10px;
padding: 6px 8px;
border-radius: 9px;
background: rgba($color-danger-rgb, 0.1);
color: $red;
font-size: $font-size-13;
font-weight: 600;
line-height: 1.25;
text-align: left;
}
[data-theme='dark'] {
.device-permission__switch:not(.is-enabled):not(.is-allowed):not(.is-unspecified) {
svg {
color: $blue !important;
stroke-width: 3px;
path {
box-shadow: 0 0 1px 0px $blue;
}
}
.device-permission__switch-thumb {
background: $white;
}
}
.device-permission__switch {
&.is-disabled,
&.is-disallowed {
.device-permission__switch-option.is-active .device-permission__switch-label {
color: $neutral-900;
}
}
&:hover:not(.is-disabled) {
//box-shadow:
//inset 0 0 0 1px rgba($gray50-rgb, 0.16),
//0 4px 10px rgba($color-shadow-rgb, 0.08);
}
}
.device-permissions__icon-btn--reset {
background: $color-surface-hover;
color: $color-text-muted;
}
.device-permissions__icon-btn:not(.device-permissions__icon-btn--edit):hover:not(:disabled) {
background: $color-surface-hover;
color: $color-text-heading;
}
.device-permissions__icon-btn--reset:hover:not(:disabled) {
background: $color-surface-active;
color: $color-text-heading;
}
.device-permissions__icon-btn--edit:hover:not(:disabled) {
background: $blue;
color: $color-text-inverse;
box-shadow: 0 8px 20px rgba($color-primary-rgb, 0.18);
}
}

View File

@ -1,5 +1,15 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useMutation } from '@apollo/client/react'
import {
Check,
CircleHelp,
Lock,
Pencil,
Power,
PowerOff,
Smartphone,
X,
} from 'lucide-react'
import type { Device } from '../../types'
import {
@ -8,186 +18,573 @@ import {
import type {
ChangePhonePolicyData,
ChangePhonePolicyVariables,
ComponentUseOption,
DevicePolicy,
GroupUsePolicy,
ServiceUseOption,
} from '../../../../entities/device/model/types'
import {
normalizeComponentUseOption,
normalizeServiceUseOption,
} from '../../../../entities/device/lib/phonePolicy'
import { WifiIcon } from '../../../../assets/icons/Wifi'
import { BluetoothIcon } from '../../../../assets/icons/Bluetooth'
import { GpsIcon } from '../../../../assets/icons/Gps'
import { CameraIcon } from '../../../../assets/icons/Camera'
import { GpsIcon } from '../../../../assets/icons/Gps'
import { SimIcon } from '../../../../assets/icons/Sim'
import { VolumeIcon } from '../../../../assets/icons/Volume'
import {
Notification,
type NotificationVariant,
} from '../../../../widgets/Notification/Notification'
import './DevicePermissionsCard.scss'
type DevicePermissionsCardProps = {
device: Device
onEditingChange?: (isEditing: boolean) => void
onRefetchPolicy?: () => Promise<DevicePolicy | null | undefined>
}
type DevicePolicyKey = 'wifi' | 'bluetooth' | 'gps' | 'camera' | 'sim' | 'speaker'
type DevicePolicyKey = 'bluetooth' | 'gps' | 'camera' | 'sim'
type ServicePolicyKey = Exclude<DevicePolicyKey, 'camera'>
type PolicyKind = 'service' | 'component'
type PolicyValue = ServiceUseOption | ComponentUseOption
type OrganisationPolicyEditableKey =
| 'bluetoothEditable'
| 'gpsEditable'
| 'cameraEditable'
| 'simEditable'
type DevicePolicyState = Record<DevicePolicyKey, boolean>
type DevicePolicyState = {
bluetooth: ServiceUseOption
gps: ServiceUseOption
camera: ComponentUseOption
sim: ServiceUseOption
locked: boolean
}
type PermissionItemConfig = {
key: DevicePolicyKey
editableKey: OrganisationPolicyEditableKey
kind: PolicyKind
icon: ReactNode
label: string
disabled?: boolean
}
type PolicyNotification = {
id: number
variant: NotificationVariant
title: string
description?: string
} | null
const SERVICE_POLICY_OPTION_ORDER: ServiceUseOption[] = [
'Disabled',
'Unspecified',
'Enabled',
]
const COMPONENT_POLICY_OPTION_ORDER: ComponentUseOption[] = [
'Disallowed',
'Allowed',
]
const policyOptionFullLabels: Record<PolicyValue, string> = {
Disabled: 'Выключено',
Unspecified: 'Свободно',
Enabled: 'Включено',
Allowed: 'Включено',
Disallowed: 'Выключено',
}
const policyOptionDescriptions: Record<PolicyValue, string> = {
Disabled: 'Опция отключена политикой устройства.',
Unspecified: 'Политика устройства не фиксирует значение. Можно менять на смартфоне.',
Enabled: 'Опция включена политикой устройства.',
Allowed: 'Компонент разрешен политикой устройства.',
Disallowed: 'Компонент запрещен политикой устройства.',
}
const permissionItems: PermissionItemConfig[] = [
{
key: 'wifi',
icon: <WifiIcon />,
label: 'Wi-Fi',
disabled: true,
},
{
key: 'bluetooth',
editableKey: 'bluetoothEditable',
kind: 'service',
icon: <BluetoothIcon />,
label: 'Bluetooth',
},
{
key: 'gps',
editableKey: 'gpsEditable',
kind: 'service',
icon: <GpsIcon />,
label: 'GPS',
},
{
key: 'camera',
editableKey: 'cameraEditable',
kind: 'component',
icon: <CameraIcon />,
label: 'Камера',
},
{
key: 'sim',
editableKey: 'simEditable',
kind: 'service',
icon: <SimIcon />,
label: 'SIM-карта',
},
{
key: 'speaker',
icon: <VolumeIcon />,
label: 'Динамик',
disabled: true,
},
]
function getServicePolicyKey(key: DevicePolicyKey): ServicePolicyKey | null {
return key === 'camera' ? null : key
}
function getPolicyOptions(kind: PolicyKind): PolicyValue[] {
return kind === 'service'
? SERVICE_POLICY_OPTION_ORDER
: COMPONENT_POLICY_OPTION_ORDER
}
function getPolicyOptionIcon(option: PolicyValue, size = 14) {
if (option === 'Unspecified') {
return <Smartphone size={size} aria-hidden="true" />
}
if (option === 'Enabled' || option === 'Allowed') {
return <Power size={size} aria-hidden="true" />
}
return <PowerOff size={size} aria-hidden="true" />
}
function isOrganisationPolicyLocked(
policy: GroupUsePolicy | null | undefined,
key: OrganisationPolicyEditableKey,
) {
return policy?.[key] === false
}
function mapDevicePermissionsToState(device: Device): DevicePolicyState {
const policy = device.policy
const permissions = device.permissions
return {
wifi: device.permissions?.wifi ?? false,
bluetooth: device.permissions?.bluetooth ?? false,
gps: device.permissions?.gps ?? false,
camera: device.permissions?.camera ?? false,
sim: device.permissions?.sim ?? false,
speaker: device.permissions?.speaker ?? false,
bluetooth: normalizeServiceUseOption(
policy?.bluetooth ?? policy?.canUseBluetooth,
permissions?.bluetooth,
),
gps: normalizeServiceUseOption(
policy?.GPS ?? policy?.canUseGPS,
permissions?.gps,
),
camera: normalizeComponentUseOption(
policy?.camera ?? policy?.canUseCamera,
permissions?.camera,
),
sim: normalizeServiceUseOption(
policy?.sim ?? policy?.canUseSim,
permissions?.sim,
),
locked: policy?.locked ?? permissions?.locked ?? false,
}
}
function mapStateToMutationVariables(policy: DevicePolicyState) {
function mapApiPolicyToState(
policy: DevicePolicy | null | undefined,
fallback: DevicePolicyState,
): DevicePolicyState {
return {
canUseBluetooth: policy.bluetooth,
canUseCamera: policy.camera,
canUseGPS: policy.gps,
canUseSim: policy.sim,
bluetooth: normalizeServiceUseOption(
policy?.bluetooth ?? policy?.canUseBluetooth ?? fallback.bluetooth,
),
gps: normalizeServiceUseOption(
policy?.GPS ?? policy?.canUseGPS ?? fallback.gps,
),
camera: normalizeComponentUseOption(
policy?.camera ?? policy?.canUseCamera ?? fallback.camera,
),
sim: normalizeServiceUseOption(
policy?.sim ?? policy?.canUseSim ?? fallback.sim,
),
locked: policy?.locked ?? fallback.locked,
}
}
export function DevicePermissionsCard({ device }: DevicePermissionsCardProps) {
function mapStateToMutationVariables(
policy: DevicePolicyState,
): Omit<ChangePhonePolicyVariables, 'id'> {
return {
bluetooth: policy.bluetooth,
camera: policy.camera,
GPS: policy.gps,
sim: policy.sim,
locked: policy.locked,
}
}
function isPolicyChanged(
current: DevicePolicyState,
saved: DevicePolicyState,
) {
return (
current.bluetooth !== saved.bluetooth ||
current.gps !== saved.gps ||
current.camera !== saved.camera ||
current.sim !== saved.sim
)
}
function areDevicePermissionsCardPropsEqual(
prev: DevicePermissionsCardProps,
next: DevicePermissionsCardProps,
) {
const prevPolicy = prev.device.policy
const nextPolicy = next.device.policy
const prevPermissions = prev.device.permissions
const nextPermissions = next.device.permissions
const prevOrganisationPolicy = prev.device.organisationPolicy
const nextOrganisationPolicy = next.device.organisationPolicy
return (
prev.onEditingChange === next.onEditingChange &&
prev.onRefetchPolicy === next.onRefetchPolicy &&
prev.device.id === next.device.id &&
prevPolicy?.bluetooth === nextPolicy?.bluetooth &&
prevPolicy?.camera === nextPolicy?.camera &&
prevPolicy?.GPS === nextPolicy?.GPS &&
prevPolicy?.sim === nextPolicy?.sim &&
prevPolicy?.locked === nextPolicy?.locked &&
prevPermissions?.bluetooth === nextPermissions?.bluetooth &&
prevPermissions?.camera === nextPermissions?.camera &&
prevPermissions?.gps === nextPermissions?.gps &&
prevPermissions?.sim === nextPermissions?.sim &&
prevPermissions?.locked === nextPermissions?.locked &&
prevOrganisationPolicy?.bluetoothEditable ===
nextOrganisationPolicy?.bluetoothEditable &&
prevOrganisationPolicy?.cameraEditable ===
nextOrganisationPolicy?.cameraEditable &&
prevOrganisationPolicy?.gpsEditable ===
nextOrganisationPolicy?.gpsEditable &&
prevOrganisationPolicy?.simEditable === nextOrganisationPolicy?.simEditable
)
}
export const DevicePermissionsCard = memo(function DevicePermissionsCard({
device,
onEditingChange,
onRefetchPolicy,
}: DevicePermissionsCardProps) {
const initialPolicy = useMemo(() => {
return mapDevicePermissionsToState(device)
}, [device])
const [policyState, setPolicyState] = useState<DevicePolicyState>(initialPolicy)
const [savingKey, setSavingKey] = useState<DevicePolicyKey | null>(null)
const [savedPolicyState, setSavedPolicyState] =
useState<DevicePolicyState>(initialPolicy)
const [isSaving, setIsSaving] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [saveError, setSaveError] = useState(false)
const [notification, setNotification] =
useState<PolicyNotification>(null)
const [changePhonePolicy] = useMutation<
ChangePhonePolicyData,
ChangePhonePolicyVariables
>(CHANGE_PHONE_POLICY_MUTATION)
const hasChanges = isPolicyChanged(policyState, savedPolicyState)
const hasHeaderActionsOpen = isEditing || hasChanges
useEffect(() => {
if (isEditing || isSaving) return
setPolicyState(initialPolicy)
}, [initialPolicy])
setSavedPolicyState(initialPolicy)
setSaveError(false)
setIsEditing(false)
}, [initialPolicy, isEditing, isSaving])
async function handleToggle(key: DevicePolicyKey, disabled?: boolean) {
if (disabled || savingKey) return
useEffect(() => {
onEditingChange?.(isEditing)
const previousPolicy = policyState
const nextPolicy = {
...policyState,
[key]: !policyState[key],
return () => {
if (isEditing) {
onEditingChange?.(false)
}
}
}, [isEditing, onEditingChange])
function showNotification(
variant: NotificationVariant,
title: string,
description?: string,
) {
setNotification({
id: Date.now(),
variant,
title,
description,
})
}
function handlePolicyOptionChange(
item: PermissionItemConfig,
option: PolicyValue,
disabled?: boolean,
) {
if (disabled || !isEditing || isSaving) return
setPolicyState((currentPolicy) => {
if (item.kind === 'component') {
if (!COMPONENT_POLICY_OPTION_ORDER.includes(option as ComponentUseOption)) {
return currentPolicy
}
return {
...currentPolicy,
camera: option as ComponentUseOption,
}
}
const key = getServicePolicyKey(item.key)
if (
!key ||
!SERVICE_POLICY_OPTION_ORDER.includes(option as ServiceUseOption)
) {
return currentPolicy
}
return {
...currentPolicy,
[key]: option as ServiceUseOption,
}
})
setSaveError(false)
}
function handleReset() {
if ((!isEditing && !hasChanges) || isSaving) return
setPolicyState(savedPolicyState)
setSaveError(false)
setIsEditing(false)
}
function handleEditClick() {
setIsEditing(true)
}
async function handleConfirmClick() {
if (isSaving) return
if (!hasChanges) {
setIsEditing(false)
setSaveError(false)
return
}
setPolicyState(nextPolicy)
setSavingKey(key)
await handleSave()
}
async function handleSave() {
if (!hasChanges || isSaving) return
const requestedPolicy = policyState
setIsSaving(true)
setSaveError(false)
try {
await changePhonePolicy({
const result = await changePhonePolicy({
variables: {
id: String(device.id),
...mapStateToMutationVariables(nextPolicy),
...mapStateToMutationVariables(requestedPolicy),
},
})
const changedApiPolicy = result.data?.changePhonePolicy
if (!changedApiPolicy) {
throw new Error('Phone policy was not saved')
}
const refreshedApiPolicy = await onRefetchPolicy?.()
const refreshedPolicy = mapApiPolicyToState(
refreshedApiPolicy ?? changedApiPolicy,
requestedPolicy,
)
setPolicyState(refreshedPolicy)
setSavedPolicyState(refreshedPolicy)
if (isPolicyChanged(requestedPolicy, refreshedPolicy)) {
setSaveError(true)
showNotification(
'error',
'Политика не изменилась',
'Сервер ответил без ошибки, но после повторной загрузки вернулась прежняя политика.',
)
return
}
setIsEditing(false)
showNotification('success', 'Политика устройства сохранена')
} catch {
setPolicyState(previousPolicy)
setSaveError(true)
showNotification(
'error',
'Не удалось сохранить политику устройства',
'Изменения остались в черновике. Попробуйте сохранить еще раз.',
)
} finally {
setSavingKey(null)
setIsSaving(false)
}
}
return (
<div className="device-card device-permissions">
{permissionItems.map((item) => {
const enabled = policyState[item.key]
const isSaving = savingKey === item.key
const isDisabled = item.disabled || Boolean(savingKey)
<div
className={`device-card device-permissions ${
hasHeaderActionsOpen ? 'is-editing' : ''
} ${isEditing ? 'is-policy-editing' : ''}`}
>
<div className="device-permissions__header">
<div className="device-permissions__title">
<h3>Опции</h3>
return (
<PermissionItem
key={item.key}
icon={item.icon}
label={item.label}
enabled={enabled}
disabled={isDisabled}
isUnavailable={item.disabled}
isSaving={isSaving}
onToggle={() => handleToggle(item.key, item.disabled)}
/>
)
})}
<span
className="device-permissions__help"
tabIndex={0}
aria-label="Пояснение значений опций"
>
<CircleHelp size={16} />
<span className="device-permissions__help-tooltip" role="tooltip">
<span className="device-permissions__help-row">
{getPolicyOptionIcon('Disabled', 15)}
<span>
<b>Выключено</b>
<small>Опция отключена политикой устройства.</small>
</span>
</span>
<span className="device-permissions__help-row">
{getPolicyOptionIcon('Unspecified', 15)}
<span>
<b>Свободно</b>
<small>Можно включить или выключить на смартфоне.</small>
</span>
</span>
<span className="device-permissions__help-row">
{getPolicyOptionIcon('Enabled', 15)}
<span>
<b>Включено</b>
<small>Опция включена политикой устройства.</small>
</span>
</span>
</span>
</span>
</div>
<div className="device-permissions__header-actions">
<button
className="device-permissions__icon-btn device-permissions__icon-btn--reset"
type="button"
disabled={(!isEditing && !hasChanges) || isSaving}
onClick={handleReset}
aria-label="Отменить редактирование"
>
<X size={18} />
</button>
<button
className="device-permissions__icon-btn device-permissions__icon-btn--edit"
type="button"
disabled={isSaving}
onClick={
hasHeaderActionsOpen ? handleConfirmClick : handleEditClick
}
aria-label={
hasHeaderActionsOpen
? 'Сохранить изменения'
: 'Редактировать опции'
}
>
{hasHeaderActionsOpen ? <Check size={18} /> : <Pencil size={18} />}
</button>
</div>
</div>
<div className="device-permissions__list">
{permissionItems.map((item) => {
const value = policyState[item.key]
const isPolicyLocked = isOrganisationPolicyLocked(
device.organisationPolicy,
item.editableKey,
)
const isDisabled = isPolicyLocked || !isEditing || isSaving
return (
<PermissionItem
key={item.key}
icon={item.icon}
label={item.label}
kind={item.kind}
value={value}
options={getPolicyOptions(item.kind)}
disabled={isDisabled}
isPolicyLocked={isPolicyLocked}
onChange={(option) => {
handlePolicyOptionChange(item, option, isPolicyLocked)
}}
/>
)
})}
</div>
{saveError && (
<div className="device-permissions__error">
Не удалось сохранить политику устройства
</div>
)}
{notification && (
<Notification
key={notification.id}
variant={notification.variant}
title={notification.title}
description={notification.description}
onClose={() => setNotification(null)}
/>
)}
</div>
)
}
}, areDevicePermissionsCardPropsEqual)
type PermissionItemProps = {
icon: ReactNode
label: string
enabled: boolean
kind: PolicyKind
value: PolicyValue
options: PolicyValue[]
disabled?: boolean
isUnavailable?: boolean
isSaving?: boolean
onToggle: () => void
isPolicyLocked?: boolean
onChange: (value: PolicyValue) => void
}
function PermissionItem({
icon,
label,
enabled,
kind,
value,
options,
disabled = false,
isUnavailable = false,
isSaving = false,
onToggle,
isPolicyLocked = false,
onChange,
}: PermissionItemProps) {
return (
<div
className={`device-permission ${isUnavailable ? 'is-disabled' : ''} ${
isSaving ? 'is-saving' : ''
className={`device-permission ${
isPolicyLocked ? 'is-policy-locked' : ''
}`}
aria-disabled={disabled}
>
@ -196,16 +593,66 @@ function PermissionItem({
<div className="device-permission__label">
<span>{label}</span>
<button
type="button"
className={`device-permission__switch ${enabled ? 'is-enabled' : ''}`}
onClick={onToggle}
disabled={disabled}
aria-pressed={enabled}
aria-label={`${enabled ? 'Выключить' : 'Включить'} ${label}`}
>
<span />
</button>
<span className="device-permission__controls">
{isPolicyLocked && (
<span
className="device-permission__policy-lock"
tabIndex={0}
aria-label="Опция зафиксирована политикой организации"
>
<Lock size={14} aria-hidden="true" />
<span
className="device-permission__policy-lock-tooltip"
role="tooltip"
>
Опция зафиксирована политикой организации. Изменить ее на странице устройства нельзя.
</span>
</span>
)}
<div
className={`device-permission__switch device-permission__switch--${kind} is-${value.toLowerCase()} ${
disabled ? 'is-disabled' : ''
}`}
role="radiogroup"
aria-label={`${label}: ${policyOptionFullLabels[value]}`}
aria-disabled={disabled}
>
<span
className="device-permission__switch-thumb"
aria-hidden="true"
/>
{options.map((option) => {
const isActive = value === option
return (
<button
className={`device-permission__switch-option ${
isActive ? 'is-active' : ''
}`}
key={option}
type="button"
role="radio"
aria-checked={isActive}
aria-disabled={disabled}
aria-label={`${policyOptionFullLabels[option]}: ${policyOptionDescriptions[option]}`}
tabIndex={disabled ? -1 : 0}
onClick={() => {
if (!disabled && !isActive) {
onChange(option)
}
}}
>
<span className="device-permission__switch-label">
{getPolicyOptionIcon(option)}
</span>
</button>
)
})}
</div>
</span>
</div>
</div>
)

View File

@ -1,4 +1,5 @@
import { Battery, RotateCcw, ShieldCheck, Smartphone, ThermometerSun } from 'lucide-react'
import { memo } from 'react'
import { Battery, RotateCcw, ShieldCheck, Smartphone } from 'lucide-react'
import type { Device } from '../../types'
type DeviceStatsCardsProps = {
@ -23,7 +24,22 @@ function formatWorkTimeHours(worktime?: number | null) {
return `${Math.round(hours)} ч`
}
export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
function areStatsCardsPropsEqual(
prev: DeviceStatsCardsProps,
next: DeviceStatsCardsProps,
) {
return (
prev.device.battery === next.device.battery &&
prev.device.batteryMaxCapacity === next.device.batteryMaxCapacity &&
prev.device.chargeCycles === next.device.chargeCycles &&
prev.device.totalWorkTime === next.device.totalWorkTime &&
prev.device.mediumImpacts === next.device.mediumImpacts
)
}
export const DeviceStatsCards = memo(function DeviceStatsCards({
device,
}: DeviceStatsCardsProps) {
const batteryValue =
typeof device.battery === 'number'
@ -31,18 +47,18 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
: null
const batterySize = 110
const batteryStroke = 10
const batteryStroke = 12
const batteryRadius = (batterySize - batteryStroke) / 2
const batteryLength = 2 * Math.PI * batteryRadius
const batteryProgress = batteryValue !== null ? batteryValue / 100 : 0
const batteryDashOffset = batteryLength * (1 - batteryProgress)
function getBatteryColor(value: number | null) {
if (value === null) return '#cfd5df'
if (value <= 20) return '#e00000'
if (value <= 50) return '#ff8a34'
if (value === null) return 'var(--device-battery-progress-empty)'
if (value <= 20) return 'var(--device-battery-progress-danger)'
if (value <= 50) return 'var(--device-battery-progress-warning)'
return '#031d9a'
return 'var(--device-battery-progress-strong)'
}
return (
@ -124,4 +140,4 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
</div>
</div>
)
}
}, areStatsCardsPropsEqual)

View File

@ -0,0 +1,107 @@
@use '../../../../shared/styles/variables' as *;
.timeline-zoom {
position: sticky;
top: 0;
z-index: 50;
padding: 10px 20px 20px 20px;
}
.timeline-zoom__track {
position: relative;
height: 28px;
border-radius: 8px;
background: $color-surface-active;
overflow: visible;
}
.timeline-zoom__selection {
position: absolute;
top: 0;
bottom: 0;
min-width: 24px;
border-radius: 8px;
background: $color-primary-focus;
}
.timeline-zoom__window {
position: absolute;
inset: 0;
border: none;
background: $transparent;
cursor: grab;
}
.timeline-zoom__window:active {
cursor: grabbing;
}
.timeline-zoom__label--left {
transform: translate(50%, -50%);
&.out-place{
transform: translate(-90%, -50%);
}
}
.timeline-zoom__label--right {
transform: translate(-150%, -50%);
&.out-place{
transform: translate(-5%, -50%);
}
}
.timeline-zoom__handle {
position: absolute;
top: 2px;
bottom: 2px;
width: 6px;
border: none;
border-radius: 999px;
background: $blue;
cursor: ew-resize;
z-index: 10;
}
.timeline-zoom__handle--left {
left: 2px;
}
.timeline-zoom__handle--right {
right: 2px;
}
.timeline-zoom__label {
position: absolute;
top: 50%;
color: $gray50;
font-size: $font-size-12;
font-weight: 550;
line-height: 1;
pointer-events: none;
white-space: nowrap;
transition: transform .3s ease;
}
.timeline-zoom__label--left {
margin-left: -12px;
}
.timeline-zoom__label--right {
margin-left: 12px;
}

View File

@ -0,0 +1,406 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import './TimelineZoom.scss'
type TimelineZoomProps = {
min: number
max: number
start: number
end: number
onChange: (range: {
start: number
end: number
}) => void
formatLabel?: (value: number) => string
minWindow?: number
}
type DragType =
| 'left'
| 'right'
| 'move'
| null
const CHANGE_THROTTLE_MS = 90
export function TimelineZoom({
min,
max,
start,
end,
onChange,
formatLabel = (value) =>
new Date(value).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
}),
minWindow = 5 * 60 * 1000,
}: TimelineZoomProps) {
const STEP = 5 * 60 * 1000
function snap(value: number) {
return Math.round(value / STEP) * STEP
}
const trackRef = useRef<HTMLDivElement | null>(null)
const dragTypeRef = useRef<DragType>(null)
const startXRef = useRef(0)
const initialRangeRef = useRef({
start,
end,
})
const [draftRange, setDraftRange] = useState({
start,
end,
})
const draftRangeRef = useRef(draftRange)
const pendingRangeRef = useRef<{
start: number
end: number
} | null>(null)
const changeTimerRef = useRef<number | null>(null)
const lastChangeAtRef = useRef(0)
useEffect(() => {
if (dragTypeRef.current) return
const nextRange = {
start,
end,
}
draftRangeRef.current = nextRange
setDraftRange(nextRange)
}, [end, start])
function setNextDraftRange(nextRange: {
start: number
end: number
}) {
if (
draftRangeRef.current.start === nextRange.start &&
draftRangeRef.current.end === nextRange.end
) {
return
}
draftRangeRef.current = nextRange
setDraftRange(nextRange)
}
function emitRange(nextRange: {
start: number
end: number
}) {
pendingRangeRef.current = nextRange
const now = Date.now()
const remaining = CHANGE_THROTTLE_MS - (now - lastChangeAtRef.current)
if (remaining <= 0) {
if (changeTimerRef.current !== null) {
window.clearTimeout(changeTimerRef.current)
changeTimerRef.current = null
}
pendingRangeRef.current = null
lastChangeAtRef.current = now
onChange(nextRange)
return
}
if (changeTimerRef.current !== null) return
changeTimerRef.current = window.setTimeout(() => {
changeTimerRef.current = null
const range = pendingRangeRef.current
if (!range) return
pendingRangeRef.current = null
lastChangeAtRef.current = Date.now()
onChange(range)
}, remaining)
}
function changeDraftRange(nextRange: {
start: number
end: number
}) {
setNextDraftRange(nextRange)
emitRange(nextRange)
}
const total = max - min
const leftPercent = ((draftRange.start - min) / total) * 100
const widthPercent = ((draftRange.end - draftRange.start) / total) * 100
const leftLabel = useMemo(
() => formatLabel(draftRange.start),
[draftRange.start, formatLabel],
)
const rightLabel = useMemo(
() => formatLabel(draftRange.end),
[draftRange.end, formatLabel],
)
function beginDrag(
type: DragType,
clientX: number,
) {
dragTypeRef.current = type
startXRef.current = clientX
initialRangeRef.current = {
start,
end,
}
setNextDraftRange({
start,
end,
})
pendingRangeRef.current = null
lastChangeAtRef.current = 0
if (changeTimerRef.current !== null) {
window.clearTimeout(changeTimerRef.current)
changeTimerRef.current = null
}
document.body.style.userSelect = 'none'
document.body.style.cursor =
type === 'move'
? 'grabbing'
: 'ew-resize'
}
useEffect(() => {
function handleMove(event: MouseEvent) {
if (!dragTypeRef.current) return
if (!trackRef.current) return
const rect =
trackRef.current.getBoundingClientRect()
const deltaPx =
event.clientX - startXRef.current
const deltaPercent =
deltaPx / rect.width
const deltaValue =
deltaPercent * total
const initial =
initialRangeRef.current
if (dragTypeRef.current === 'move') {
const windowSize =
initial.end - initial.start
let nextStart =
snap(initial.start + deltaValue)
let nextEnd =
snap(initial.end + deltaValue)
if (nextStart < min) {
nextStart = min
nextEnd = min + windowSize
}
if (nextEnd > max) {
nextEnd = max
nextStart = max - windowSize
}
changeDraftRange({
start: nextStart,
end: nextEnd,
})
return
}
if (dragTypeRef.current === 'left') {
let nextStart =
snap(
initial.start + deltaValue,
)
nextStart = Math.max(
min,
Math.min(
nextStart,
initial.end - minWindow,
),
)
changeDraftRange({
start: nextStart,
end: initial.end,
})
return
}
if (dragTypeRef.current === 'right') {
let nextEnd = snap(
initial.end + deltaValue,
)
nextEnd = Math.min(
max,
Math.max(
nextEnd,
initial.start + minWindow,
),
)
changeDraftRange({
start: initial.start,
end: nextEnd,
})
}
}
function handleUp() {
if (dragTypeRef.current) {
if (changeTimerRef.current !== null) {
window.clearTimeout(changeTimerRef.current)
changeTimerRef.current = null
}
pendingRangeRef.current = null
lastChangeAtRef.current = Date.now()
onChange(draftRangeRef.current)
}
dragTypeRef.current = null
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
window.addEventListener(
'mousemove',
handleMove,
)
window.addEventListener(
'mouseup',
handleUp,
)
return () => {
window.removeEventListener(
'mousemove',
handleMove,
)
window.removeEventListener(
'mouseup',
handleUp,
)
if (changeTimerRef.current !== null) {
window.clearTimeout(changeTimerRef.current)
changeTimerRef.current = null
}
}
}, [
max,
min,
minWindow,
onChange,
total,
])
return (
<div className="timeline-zoom">
<div
ref={trackRef}
className="timeline-zoom__track"
>
<div
className={`timeline-zoom__selection`}
style={{
left: `${leftPercent}%`,
width: `${widthPercent}%`,
}}
>
<button
type="button"
className="timeline-zoom__handle timeline-zoom__handle--left"
onMouseDown={(event) => {
beginDrag(
'left',
event.clientX,
)
}}
/>
<button
type="button"
className="timeline-zoom__window"
onMouseDown={(event) => {
beginDrag(
'move',
event.clientX,
)
}}
/>
<button
type="button"
className="timeline-zoom__handle timeline-zoom__handle--right"
onMouseDown={(event) => {
beginDrag(
'right',
event.clientX,
)
}}
/>
</div>
<div
className={`timeline-zoom__label timeline-zoom__label--left ${leftPercent > 5 ? 'out-place' : ''}`}
style={{
left: `${leftPercent}%`,
}}
>
{leftLabel}
</div>
<div
className={`timeline-zoom__label timeline-zoom__label--right ${(leftPercent + widthPercent) < 94 ? 'out-place' : ''}`}
style={{
left: `${leftPercent + widthPercent}%`,
}}
>
{rightLabel}
</div>
</div>
</div>
)
}

View File

@ -1,3 +1,9 @@
import type {
DevicePolicy,
DeviceTelemetryItem,
GroupUsePolicy,
} from '../../entities/device/model/types'
export type DeviceCondition = 'ok' | 'inspection'
export type DeviceConnection = 'online' | 'offline' | 'offlineDanger'
@ -9,6 +15,9 @@ export type Device = {
imei2: string
serialNumber?: string
organisation?: string
organisationId?: number
policy?: DevicePolicy | null
organisationPolicy?: GroupUsePolicy | null
workTime: string | null
employee: string | null
condition: DeviceCondition
@ -19,6 +28,7 @@ export type Device = {
lastLocationDate?: number
battery?: number
batteryMaxCapacity?: number
telemetry?: DeviceTelemetryItem[]
chargeCycles?: string
totalWorkTime?: number | null
mediumImpacts?: string
@ -40,6 +50,7 @@ export type Device = {
camera: boolean
sim: boolean
speaker: boolean
locked?: boolean
}
statusIcons: {
gps: boolean
@ -61,7 +72,7 @@ export const conditionText: Record<DeviceCondition, string> = {
export const connectionText: Record<DeviceConnection, string> = {
online: 'В сети',
offline: 'Не в сети',
offlineDanger: 'Долго не в сети',
offlineDanger: 'Потерян',
}
export function getStatusClass(status: DeviceCondition | DeviceConnection) {

View File

@ -5,51 +5,252 @@
flex-direction: column;
gap: 10px;
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
padding: 20px 20px 36px;
//overflow: hidden;
}
.devices-table-container {
display: flex;
flex: 1;
flex: 1 1 0;
min-width: 0;
min-height: 0;
flex-direction: column;
//overflow: hidden;
//gap: 20px;
}
.devices-table-container .employees-table-shadow {
flex: 1 1 0;
width: 100%;
min-width: 0;
min-height: 0;
}
.devices-table-filter-container {
--devices-filters-width: 292px;
--devices-filters-gap: 20px;
--devices-filters-duration: 360ms;
--devices-filters-easing: cubic-bezier(0.22, 1, 0.36, 1);
display: flex;
gap: 0;
flex: 1;
min-height: 0;
min-width: 0;
//overflow: hidden;
}
.devices-filters-slot {
flex: 0 0 0;
min-width: 0;
min-height: 0;
overflow: hidden;
pointer-events: none;
will-change: flex-basis;
transition:
flex-basis var(--devices-filters-duration) var(--devices-filters-easing);
}
.devices-filters-slot--open {
flex-basis: calc(var(--devices-filters-width) + var(--devices-filters-gap));
pointer-events: auto;
}
.devices-filters-slot--visible {
overflow: visible;
}
.devices-filters-drawer__overlay {
position: fixed;
inset: 0;
z-index: 820;
background: $color-backdrop-soft;
backdrop-filter: blur(4px);
&[data-state='open'] {
animation: devicesFiltersOverlayShow 0.18s ease both;
}
&[data-state='closed'] {
animation: devicesFiltersOverlayHide 0.18s ease both;
}
}
.devices-filters-drawer {
position: fixed;
inset: 0 0 0 auto;
z-index: 821;
width: min(360px, calc(100vw - 28px));
padding: 18px;
margin: 20px;
border-radius: 20px;
border: none;
outline: none;
background: $color-bg;
box-shadow: $shadow-modal-dark;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 14px;
&[data-state='open'] {
animation: devicesFiltersDrawerShow 0.24s cubic-bezier(0.34, 1.02, 0.64, 1) both;
}
&[data-state='closed'] {
animation: devicesFiltersDrawerHide 0.2s cubic-bezier(0.4, 0, 0.2, 1) both;
}
.devices-filters {
width: 100%;
min-width: 0;
margin-left: 0;
opacity: 1;
visibility: visible;
transform: none;
pointer-events: auto;
}
}
.devices-filters-drawer__scroll {
flex: 1 1 auto;
min-height: 0;
width: 100%;
.simplebar-content {
min-height: 100%;
}
.simplebar-track.simplebar-vertical {
width: 10px;
right: -12px;
}
.simplebar-scrollbar::before {
background: $gray50;
opacity: 0.55;
}
}
.devices-filters-drawer__header {
flex: 0 0 auto;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h2 {
margin: 0;
}
}
.devices-filters-drawer__header [data-radix-dialog-title],
.devices-filters-drawer__header h2 {
color: $color-text-heading;
font-size: $font-size-20;
font-weight: 650;
line-height: 1.1;
}
.devices-filters-drawer__close {
width: 34px;
height: 34px;
border: none;
border-radius: 12px;
background: $color-surface;
display: inline-flex;
align-items: center;
justify-content: center;
color: $color-text-muted;
cursor: pointer;
transition: 0.2s ease;
&:hover {
background: $color-surface-hover;
color: $color-text-heading;
}
}
.devices-table-card {
flex: 1;
overflow: auto;
position: relative;
flex: 1 1 0;
width: 100%;
min-width: 0;
//overflow: hidden;
max-height: 100%;
height: 100%;
border-radius: 20px;
background: #ffffff;
scrollbar-width: thin;
scrollbar-color: $gray50 transparent;
background: $color-surface;
min-height: 0px;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
.simplebar-wrapper,
.simplebar-mask,
.simplebar-offset,
.simplebar-content-wrapper,
.simplebar-content {
min-width: 0;
max-width: 100%;
}
&::-webkit-scrollbar-track {
background: transparent;
.simplebar-content-wrapper{
border-radius: 20px;
}
.simplebar-track.simplebar-vertical {
top: 48px;
bottom: 14px;
width: 14px;
right: -7px;
overflow: visible;
}
&::-webkit-scrollbar-thumb {
background: $gray30;
border-radius: 999px;
.simplebar-scrollbar::before {
background: color-mix(in srgb, var(--gray50) 85%, transparent);
backdrop-filter: blur(10px);
border-radius: 4px;
box-shadow: inset 0 0 0 1px var(--gray30), -2px 4px 12px rgba(0, 0, 0, 0.1);
opacity: 1;
}
&::-webkit-scrollbar-thumb:hover {
background: $gray50;
.simplebar-track.simplebar-horizontal {
left: 12px;
right: 12px;
height: 8px;
}
&.is-updating {
.devices-table {
opacity: 0.55;
pointer-events: none;
}
}
}
.devices-state {
min-height: 260px;
height: 100%;
padding: 32px;
display: flex;
align-items: center;
justify-content: center;
color: $gray50;
font-size: $font-size-16;
font-weight: 600;
text-align: center;
}
.devices-state--error {
color: $red;
}
.devices-table {
@ -62,8 +263,8 @@
cursor: pointer;
&:hover {
background-color: rgba($blue, 0.025);
box-shadow: inset 3px 0 0 rgba($blue, 0.8);
background-color: $color-primary-tint-soft;
box-shadow: inset 3px 0 0 rgba($color-primary-rgb, 0.8);
.device-info__number {
color: $blue;
@ -75,10 +276,10 @@
//height: 36px;
padding: 14px 20px;
line-height: 1;
border-bottom: 1px solid #e3e8f0;
border-bottom: 1px solid $color-border;
color: $gray50;
font-size: 16px;
font-size: $font-size-16;
font-weight: 450;
text-align: left;
}
@ -90,8 +291,8 @@
vertical-align: middle;
text-align: left;
color: #151a24;
font-size: 18px;
color: $color-text-strong;
font-size: $font-size-18;
font-weight: 400;
}
@ -127,7 +328,7 @@
}
.devices-table__id {
color: #1d2533;
color: $color-text-strong;
text-align: left;
}
@ -138,16 +339,16 @@
}
.device-info__number {
color: #111827;
font-size: 18px;
color: $color-text-strong;
font-size: $font-size-18;
font-weight: 600;
transition: .2s ease;
}
.device-info__imei,
.device-info__employee {
color: #738098;
font-size: 18px;
color: $color-text-muted;
font-size: $font-size-18;
font-weight: 400;
}
@ -156,8 +357,8 @@
align-items: center;
gap: 5px;
color: #738098;
font-size: 18px;
color: $color-text-muted;
font-size: $font-size-18;
font-weight: 400;
}
@ -166,14 +367,14 @@
align-items: center;
gap: 8px;
text-transform: uppercase;
color: #111827;
font-size: 14px;
color: $color-text-strong;
font-size: $font-size-14;
padding: 0px 8px;
border-radius: 12px;
font-weight: 550;
&--green {
background-color: hsla(128, 56%, 45%, 0.15);
background-color: $color-success-tint;
color: $green;
}
@ -183,7 +384,7 @@
}
&--red {
background-color: hsla(0, 100%, 43%, 0.15);
background-color: $color-danger-status-bg;
color: $red;
}
}
@ -209,25 +410,30 @@
.device-icons {
display: grid;
grid-template-columns: repeat(4, 16px);
grid-template-columns: repeat(4, 20px);
gap: 4px 8px;
align-items: center;
color: $gray50;
svg {
background-color: $color-bg;
width: 20px;
height: auto;
color: $gray50;
stroke-width: 2;
padding: 2px;
border-radius: 6px;
}
.is-active {
color: $blue;
background-color: $blue20;
}
.is-danger {
color: $red;
background-color: $color-danger-bg;
}
}
@ -241,8 +447,8 @@
align-items: center;
gap: 8px;
color: #151a24;
font-size: 18px;
color: $color-text-strong;
font-size: $font-size-16;
font-weight: 500;
cursor: pointer;
transition: .2s ease;
@ -259,14 +465,16 @@
}
.devices-pagination {
position: relative;
z-index: 100;
padding: 12px 14px 0;
display: flex;
align-items: center;
justify-content: space-between;
color: #738098;
font-size: 16px;
color: $color-text-muted;
font-size: $font-size-16;
}
.devices-pagination__controls {
@ -281,15 +489,15 @@
border: none;
border-radius: 10px;
background: #e9edf5;
background: $color-surface-active;
color: #738098;
font-size: 17px;
color: $color-text-muted;
font-size: $font-size-17;
cursor: pointer;
&.is-active {
background: #031d9a;
color: #ffffff;
background: $color-primary;
color: $color-text-inverse;
}
&:disabled {
@ -298,3 +506,122 @@
}
}
}
[data-theme='dark'] {
.devices-table__row:hover {
background-color: rgba($color-primary-rgb, 0.03);
box-shadow: inset 3px 0 0 rgba($color-primary-rgb, 0.36);
}
.device-icons svg:not(.is-active):not(.is-danger) {
background-color: $color-surface-active;
}
.devices-map-btn:hover {
background-color: $color-surface-active;
color: $color-primary-hover;
}
.devices-pagination__controls button {
background: $color-surface-muted;
&:hover:not(:disabled):not(.is-active) {
background: $color-surface-hover;
color: $color-text-heading;
}
&.is-active {
background: $color-primary;
color: $color-text-inverse;
}
}
}
@include wide-down {
.devices-table-filter-container {
--devices-filters-width: 264px;
--devices-filters-gap: 14px;
}
.devices-filters-slot {
display: none;
}
.devices-table {
min-width: 980px;
th,
td {
padding-inline: 16px;
}
}
}
@include laptop-down {
.devices-table-filter-container {
--devices-filters-width: 264px;
--devices-filters-gap: 14px;
}
.devices-filters-slot {
display: none;
}
.devices-table {
min-width: 980px;
th,
td {
padding-inline: 16px;
}
}
}
@include tablet-down {
.devices-table {
min-width: 980px;
}
}
@keyframes devicesFiltersOverlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes devicesFiltersOverlayHide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes devicesFiltersDrawerShow {
from {
opacity: 0;
transform: translateX(18px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes devicesFiltersDrawerHide {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(18px);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,17 +3,16 @@
.add-device-modal__overlay {
position: fixed;
inset: 0;
z-index: 80;
z-index: 110;
background: rgba(15, 23, 42, 0.42);
background: $color-backdrop;
backdrop-filter: blur(2px);
animation: addDeviceOverlayShow 0.18s ease;
}
.add-device-modal {
position: fixed;
z-index: 90;
z-index: 120;
top: 50%;
left: 50%;
@ -21,11 +20,10 @@
padding: 24px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
background: $color-surface;
box-shadow: $shadow-modal;
transform: translate(-50%, -50%);
animation: addDeviceModalShow 0.2s ease;
}
.add-device-modal__header {
@ -40,8 +38,8 @@
.add-device-modal__title {
margin: 0;
color: #111827;
font-size: 24px;
color: $color-text-strong;
font-size: $font-size-24;
font-weight: 650;
}
@ -49,7 +47,7 @@
margin: 6px 0 0;
color: $gray50;
font-size: 15px;
font-size: $font-size-15;
font-weight: 400;
line-height: 1.4;
}
@ -85,7 +83,7 @@
margin: 0;
color: $gray50;
font-size: 14px;
font-size: $font-size-14;
line-height: 1.45;
text-align: center;
}
@ -94,7 +92,7 @@
.add-device-qr__box {
min-height: 260px;
border-radius: 22px;
border: 1px dashed rgba(3, 29, 154, 0.32);
border: 1px dashed rgba($color-primary-rgb, 0.32);
background: $color-bg;
display: flex;
@ -106,8 +104,8 @@
color: $blue;
span {
color: #111827;
font-size: 18px;
color: $color-text-strong;
font-size: $font-size-18;
font-weight: 600;
}
.add-device-qr__state, .add-device-qr__content{
@ -118,6 +116,73 @@
}
}
.add-device-token-timer {
min-height: 42px;
padding: 0 12px;
border-radius: 14px;
background: rgba($color-primary-rgb, 0.08);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: $blue;
font-size: $font-size-15;
font-weight: 600;
svg {
flex: 0 0 auto;
}
span {
min-width: 0;
}
button {
height: 30px;
margin-left: 4px;
padding: 0 10px;
border: none;
border-radius: 10px;
background: $color-surface;
display: inline-flex;
align-items: center;
gap: 6px;
color: $blue;
font-size: $font-size-13;
font-weight: 650;
cursor: pointer;
transition: 0.2s ease;
&:hover {
background: $blue;
color: $color-text-inverse;
}
&:disabled {
opacity: 0.55;
cursor: default;
}
}
}
.add-device-token-timer.is-expired {
background: rgba($color-danger-rgb, 0.1);
color: $red;
button {
color: $red;
&:hover {
background: $red;
color: $color-text-inverse;
}
}
}
.add-device-modal__footer {
margin-top: 22px;
@ -134,7 +199,7 @@
background: $color-bg;
color: $gray50;
font-size: 15px;
font-size: $font-size-15;
font-weight: 600;
cursor: pointer;
@ -142,28 +207,6 @@
&:hover {
background: $gray20;
color: #111827;
}
}
@keyframes addDeviceOverlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes addDeviceModalShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.98);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
color: $color-text-strong;
}
}

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { QRCodeSVG } from 'qrcode.react'
import { QrCode, RefreshCw, X } from 'lucide-react'
import { Clock3, QrCode, RefreshCw, X } from 'lucide-react'
import { useMutation } from '@apollo/client/react'
import { CREATE_PHONE_REGISTRATION_TOKEN_MUTATION } from '../../../../entities/device/api/device.graphql'
@ -9,6 +9,7 @@ import type {
CreatePhoneRegistrationTokenData,
CreatePhoneRegistrationTokenVariables,
} from '../../../../entities/device/model/types'
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import './AddDeviceModal.scss'
@ -17,24 +18,40 @@ type AddDeviceModalProps = {
onOpenChange: (open: boolean) => void
}
function formatExpiresIn(expiresIn: number) {
if (!expiresIn) return '—'
const SECOND_MS = 1000
const DAY_SECONDS = 24 * 60 * 60
const UNIX_SECONDS_THRESHOLD = 1_000_000_000
const UNIX_MILLISECONDS_THRESHOLD = 1_000_000_000_000
const now = Date.now()
const expiresAt = expiresIn > 10_000_000_000
? expiresIn
: now + expiresIn * 1000
function getExpiresAt(expiresIn: number, receivedAt: number) {
if (!Number.isFinite(expiresIn) || expiresIn <= 0) return 0
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(expiresAt))
if (expiresIn >= UNIX_MILLISECONDS_THRESHOLD) {
return expiresIn
}
if (expiresIn >= UNIX_SECONDS_THRESHOLD) {
return expiresIn * SECOND_MS
}
if (expiresIn > DAY_SECONDS) {
return receivedAt + expiresIn
}
return receivedAt + expiresIn * SECOND_MS
}
function formatTokenTimeLeft(milliseconds: number) {
const totalSeconds = Math.max(0, Math.ceil(milliseconds / SECOND_MS))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
const [tokenReceivedAt, setTokenReceivedAt] = useState(0)
const [now, setNow] = useState(() => Date.now())
const [createToken, { data, loading, error }] = useMutation<
CreatePhoneRegistrationTokenData,
@ -44,6 +61,11 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
})
const registrationData = data?.createPhoneRegistrationToken
const expiresAt = registrationData
? getExpiresAt(registrationData.expiresIn, tokenReceivedAt)
: 0
const timeLeft = expiresAt ? Math.max(0, expiresAt - now) : 0
const isTokenExpired = Boolean(registrationData && timeLeft <= 0)
const qrValue = useMemo(() => {
if (!registrationData) return ''
@ -58,18 +80,51 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
if (!open) return
createToken()
.then(() => {
const receivedAt = Date.now()
setTokenReceivedAt(receivedAt)
setNow(receivedAt)
})
.catch(() => {
setTokenReceivedAt(0)
})
}, [open, createToken])
function handleRefreshToken() {
createToken()
useEffect(() => {
if (!open || !registrationData || isTokenExpired) return
const intervalId = window.setInterval(() => {
setNow(Date.now())
}, SECOND_MS)
return () => {
window.clearInterval(intervalId)
}
}, [open, registrationData, isTokenExpired])
async function handleRefreshToken() {
setNow(Date.now())
try {
await createToken()
const receivedAt = Date.now()
setTokenReceivedAt(receivedAt)
setNow(receivedAt)
} catch {
setTokenReceivedAt(0)
}
}
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="add-device-modal__overlay" />
<Dialog.Content className="add-device-modal">
<MotionDialogContent
open={open}
overlayClassName="add-device-modal__overlay"
contentClassName="add-device-modal"
motionPreset="fade"
>
<div className="add-device-modal__header">
<div>
<Dialog.Title className="add-device-modal__title">
@ -109,13 +164,39 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
size={220}
level="M"
bgColor='transparent'
fgColor="#031D9A"
fgColor="currentColor"
includeMargin
/>
</div>
)}
</div>
{registrationData && !loading && !error && (
<div
className={`add-device-token-timer ${
isTokenExpired ? 'is-expired' : ''
}`}
>
<Clock3 size={18} />
<span>
{isTokenExpired
? 'QR-код истек'
: `QR-код активен еще ${formatTokenTimeLeft(timeLeft)}`}
</span>
{isTokenExpired && (
<button
type="button"
onClick={handleRefreshToken}
disabled={loading}
>
<RefreshCw size={16} />
Обновить
</button>
)}
</div>
)}
<p>
После сканирования устройство появится в списке и будет доступно
для назначения сотруднику.
@ -127,8 +208,7 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
Закрыть
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</MotionDialogContent>
</Dialog.Root>
)
}

View File

@ -5,7 +5,7 @@
inset: 0;
z-index: 90;
background: rgba(15, 23, 42, 0.42);
background: $color-backdrop-soft;
backdrop-filter: blur(2px);
animation: deviceMapModalOverlayShow 0.18s ease;
@ -17,14 +17,15 @@
inset: 24px;
padding: 24px;
border: 1px solid $color-border-muted;
border-radius: 24px;
background: #ffffff;
background: $color-surface;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.24);
box-shadow: $shadow-modal-strong;
animation: deviceMapModalShow 0.2s ease;
}
@ -40,7 +41,7 @@
.device-map-modal__title {
margin: 0;
color: $color-text;
color: $color-text-heading;
font-size: 24px;
font-weight: 650;
}
@ -48,7 +49,7 @@
.device-map-modal__description {
margin: 4px 0 0;
color: $gray50;
color: $color-text-muted;
font-size: 15px;
font-weight: 500;
}
@ -59,19 +60,19 @@
border: none;
border-radius: 12px;
background: $color-bg;
background: $color-surface-muted;
display: inline-flex;
align-items: center;
justify-content: center;
color: $gray50;
color: $color-text-muted;
cursor: pointer;
transition: 0.2s ease;
&:hover {
color: $blue;
background: $gray20;
color: $color-primary;
background: $color-surface-hover;
}
}
@ -82,6 +83,7 @@
.device-map {
height: 100%;
min-height: 0;
background: transparent;
}
.device-map__container {

View File

@ -3,6 +3,7 @@ import { X } from 'lucide-react'
import type { Device } from '../../../DevicePage/types'
import { DeviceMapCard } from '../../../DevicePage/components/DeviceMapCard/DeviceMapCard'
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import './DeviceMapModal.scss'
@ -19,33 +20,34 @@ export function DeviceMapModal({
}: DeviceMapModalProps) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="device-map-modal__overlay" />
<MotionDialogContent
open={open}
overlayClassName="device-map-modal__overlay"
contentClassName="device-map-modal"
placement="inset"
>
<div className="device-map-modal__header">
<div>
<Dialog.Title className="device-map-modal__title">
Устройство на карте
</Dialog.Title>
<Dialog.Content className="device-map-modal">
<div className="device-map-modal__header">
<div>
<Dialog.Title className="device-map-modal__title">
Устройство на карте
</Dialog.Title>
<Dialog.Description className="device-map-modal__description">
{device?.factoryNumber || 'Выбранное устройство'}
</Dialog.Description>
</div>
<Dialog.Close className="device-map-modal__close" type="button">
<X size={22} />
</Dialog.Close>
<Dialog.Description className="device-map-modal__description">
{device?.factoryNumber || 'Выбранное устройство'}
</Dialog.Description>
</div>
{device && (
<div className="device-map-modal__body">
<DeviceMapCard device={device} variant="modal" />
</div>
)}
</Dialog.Content>
</Dialog.Portal>
<Dialog.Close className="device-map-modal__close" type="button">
<X size={22} />
</Dialog.Close>
</div>
{device && (
<div className="device-map-modal__body">
<DeviceMapCard device={device} variant="modal" />
</div>
)}
</MotionDialogContent>
</Dialog.Root>
)
}

View File

@ -12,14 +12,14 @@
border: none;
border-radius: 999px;
background: #eef1f6;
background: $color-surface-hover;
display: flex;
align-items: center;
gap: 8px;
color: #4f5b73;
font-size: 14px;
color: $color-text-soft;
font-size: $font-size-14;
font-weight: 500;
text-align: left;
cursor: pointer;
@ -33,7 +33,7 @@
}
&.is-open {
box-shadow: 0 0 0 2px rgba(3, 29, 154, 0.15);
box-shadow: 0 0 0 2px rgba($color-primary-rgb, 0.15);
}
}
@ -53,13 +53,13 @@
padding: 12px;
border-radius: 18px;
background: #ffffff;
background: $color-surface;
border: 1.5px solid $gray20;
}
.devices-date-range__calendar {
--rdp-accent-color: $blue;
--rdp-accent-background-color: #e8edff;
--rdp-accent-color: #{$blue};
--rdp-accent-background-color: #{$color-primary-soft};
--rdp-day_button-border-radius: 12px;
--rdp-day_button-height: 34px;
--rdp-day_button-width: 34px;
@ -78,9 +78,9 @@
}
.rdp-caption_label {
font-size: 14px;
font-size: $font-size-14;
font-weight: 700;
color: #151a24;
color: $color-text-strong;
}
.rdp-nav {
@ -95,33 +95,33 @@
border-radius: 8px;
&:hover {
background: #eef1f6;
background: $color-surface-hover;
}
}
.rdp-weekday {
color: #8c96aa;
font-size: 12px;
color: $color-text-muted;
font-size: $font-size-12;
font-weight: 600;
}
.rdp-day_button {
color: #30394b;
font-size: 12px;
color: $color-text-heading;
font-size: $font-size-12;
font-weight: 600;
}
.rdp-day_button:hover {
background: #eef1f6;
background: $color-surface-hover;
}
.rdp-selected .rdp-day_button {
background: $blue;
color: #ffffff;
color: $color-text-inverse;
}
.rdp-range_middle .rdp-day_button {
background: #e8edff;
background: $color-primary-soft;
color: $blue;
border-radius: 0;
}
@ -129,7 +129,7 @@
.rdp-range_start .rdp-day_button,
.rdp-range_end .rdp-day_button {
background: $blue;
color: #ffffff;
color: $color-text-inverse;
border-radius: 12px;
}
@ -151,8 +151,8 @@
gap: 5px;
span {
color: #738098;
font-size: 11px;
color: $color-text-muted;
font-size: $font-size-11;
font-weight: 600;
}
@ -163,10 +163,10 @@
border: none;
outline: none;
border-radius: 10px;
background: #eef1f6;
background: $color-surface-hover;
color: #30394b;
font-size: 13px;
color: $color-text-heading;
font-size: $font-size-13;
font-weight: 600;
font-family: inherit;
}
@ -185,15 +185,15 @@
border: none;
border-radius: 999px;
background: #eef1f6;
background: $color-surface-hover;
color: #4f5b73;
font-size: 12px;
color: $color-text-soft;
font-size: $font-size-12;
font-weight: 600;
cursor: pointer;
&:hover {
background: #e8edff;
background: $color-primary-soft;
color: $blue;
}
}
@ -202,7 +202,7 @@
.devices-date-range__footer {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e3e8f0;
border-top: 1px solid $color-border;
display: flex;
flex-direction: column;
@ -210,8 +210,8 @@
p {
margin: 0;
color: #738098;
font-size: 12px;
color: $color-text-muted;
font-size: $font-size-12;
font-weight: 500;
}
}
@ -230,17 +230,17 @@
border: none;
border-radius: 10px;
font-size: 12px;
font-size: $font-size-12;
font-weight: 700;
cursor: pointer;
}
.devices-date-range__reset {
background: #eef1f6;
color: #4f5b73;
background: $color-surface-hover;
color: $color-text-soft;
}
.devices-date-range__apply {
background: $blue;
color: #ffffff;
color: $color-text-inverse;
}

View File

@ -4,9 +4,11 @@ import type { DateRange } from 'react-day-picker'
import { ru } from 'date-fns/locale'
import { format } from 'date-fns'
import { CalendarDays, ChevronDown, X } from 'lucide-react'
import { AnimatePresence, m } from 'framer-motion'
import 'react-day-picker/style.css'
import './DevicesDateRangePicker.scss'
import { popoverVariants, uiFadeTransition } from '../../../../shared/lib/motion'
export type DevicesDateRangePickerValue = {
from: Date | null
@ -138,8 +140,16 @@ export function DevicesDateRangePicker({
)}
</button>
{isOpen && (
<div className="devices-date-range__popover">
<AnimatePresence initial={false}>
{isOpen && (
<m.div
className="devices-date-range__popover"
variants={popoverVariants}
initial="initial"
animate="animate"
exit="exit"
transition={uiFadeTransition}
>
<DayPicker
mode="range"
selected={selectedRange}
@ -216,8 +226,9 @@ export function DevicesDateRangePicker({
</button>
</div>
</div>
</div>
)}
</m.div>
)}
</AnimatePresence>
</div>
)
}

View File

@ -1,20 +1,22 @@
@use '../../../../shared/styles/variables' as *;
.react-datepicker {
border: none;
border-radius: 14px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.15);
box-shadow: $shadow-popover-soft;
font-family: inherit;
overflow: hidden;
}
.react-datepicker__header {
background: #f3f6fa;
border-bottom: 1px solid #e3e8f0;
background: $color-surface-muted;
border-bottom: 1px solid $color-border;
}
.react-datepicker__current-month,
.react-datepicker-time__header,
.react-datepicker-year-header {
color: #30394b;
color: $color-text-heading;
font-weight: 600;
}
@ -25,19 +27,19 @@
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item--selected {
background: #031d9a;
color: #ffffff;
background: $color-primary;
color: $color-text-inverse;
}
.react-datepicker__day--in-range,
.react-datepicker__day--in-selecting-range {
background: #e8edff;
color: #031d9a;
background: $color-primary-soft;
color: $color-primary;
}
.react-datepicker__day:hover,
.react-datepicker__month-text:hover,
.react-datepicker__quarter-text:hover,
.react-datepicker__year-text:hover {
background: #eef1f6;
background: $color-surface-hover;
}

View File

@ -1,32 +1,43 @@
@use '../../../../shared/styles/variables' as *;
.devices-filters {
width: 0;
flex: 0 0 0;
width: var(--devices-filters-width, 292px);
min-width: var(--devices-filters-width, 292px);
margin-left: var(--devices-filters-gap, 20px);
box-sizing: border-box;
opacity: 0;
overflow: hidden;
visibility: hidden;
overflow: visible;
pointer-events: none;
transform: translateX(16px);
transform: translateX(6px);
will-change: opacity, transform;
transition:
width 0.25s ease,
flex-basis 0.25s ease,
opacity 0.2s ease,
transform 0.25s ease;
transform var(
--devices-filters-duration,
360ms
) var(--devices-filters-easing, cubic-bezier(0.22, 1, 0.36, 1)),
visibility 0s linear var(--devices-filters-duration, 360ms);
}
.devices-filters--open {
width: 292px;
flex-basis: 292px;
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateX(0);
padding-left: 20px;
transition:
opacity 0.2s ease,
transform var(
--devices-filters-duration,
360ms
) var(--devices-filters-easing, cubic-bezier(0.22, 1, 0.36, 1)),
visibility 0s;
}
.devices-filters__accordion {
width: 292px;
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
@ -34,8 +45,9 @@
.devices-filter-item {
border-radius: 14px;
background: #ffffff;
background: $color-surface;
overflow: hidden;
box-shadow: 2px 2px 30px -10px rgba($gray50-rgb, .2);
&:nth-child(1)[data-state=open]{
overflow: visible;
}
@ -51,16 +63,16 @@
margin: 0 16px;
border: none;
border-bottom: 1px solid transparent;
background: transparent;
border-bottom: 1px solid $transparent;
background: $transparent;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: black;
font-size: 18px;
color: $color-text-strong;
font-size: $font-size-18;
font-weight: 500;
cursor: pointer;
@ -116,10 +128,10 @@
border: none;
outline: none;
border-radius: 999px;
background: #eef1f6;
background: $color-surface-hover;
color: $gray50;
font-size: 16px;
font-size: $font-size-16;
font-weight: 400;
cursor: pointer;
}
@ -131,10 +143,10 @@
display: block;
border: none;
background: transparent;
background: $transparent;
color: $blue;
font-size: 14px;
font-size: $font-size-14;
font-weight: 400;
text-decoration: underline;
cursor: pointer;
@ -149,7 +161,7 @@
min-height: 28px;
color: $gray50;
font-size: 13px;
font-size: $font-size-13;
& + & {
margin-top: 12px;
@ -158,7 +170,7 @@
.devices-filter-row__label {
color: $gray50;
font-size: 16px;
font-size: $font-size-16;
font-weight: 500;
text-align: left;
}
@ -174,8 +186,8 @@
align-items: center;
gap: 6px;
color: #30394b;
font-size: 16px;
color: $color-text-heading;
font-size: $font-size-16;
font-weight: 500;
cursor: pointer;
user-select: none;
@ -189,15 +201,15 @@
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #dfe5ef;
background: #f3f6fa;
border: 2px solid $color-border-strong;
background: $color-surface-muted;
position: relative;
flex: 0 0 18px;
}
.devices-radio input:checked + .devices-radio__control {
border-color: #031d9a;
background: #031d9a;
border-color: $color-primary;
background: $color-primary;
}
.devices-radio input:checked + .devices-radio__control::after {
@ -205,7 +217,7 @@
position: absolute;
inset: 5px;
border-radius: 50%;
background: #ffffff;
background: $color-surface;
}
.devices-radio__label {
@ -224,15 +236,17 @@
width: 18px;
height: 18px;
border-radius: 5px;
border: 2px solid #dfe5ef;
background: #f3f6fa;
border: 2px solid $color-border-strong;
background: $color-surface-muted;
position: relative;
flex: 0 0 18px;
transition: .2s ease;
}
.devices-checkbox input:checked + .devices-checkbox__control {
border-color: #031d9a;
background: #031d9a;
border-color: $color-primary;
background: $color-primary;
box-shadow: 0 2px 8px -2px rgba(var(--color-primary-rgb), 0.8);
}
.devices-checkbox input:checked + .devices-checkbox__control::after {
@ -242,7 +256,7 @@
top: 2px;
width: 4px;
height: 8px;
border: solid #ffffff;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
@ -266,3 +280,14 @@
height: 0;
}
}
[data-theme='dark'] {
.devices-filter-item {
box-shadow: none;
outline: 1px solid rgba($gray50-rgb, 0.08);
}
.devices-filter-item__trigger:hover {
color: $color-primary-hover;
}
}

View File

@ -1,175 +1,295 @@
import { useState } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import * as Accordion from '@radix-ui/react-accordion'
import { useSearchParams } from 'react-router-dom'
import { ChevronDown } from 'lucide-react'
import './Datepicker.scss'
import { DevicesDateRangePicker, type DevicesDateRangePickerValue } from '../DevicesDateRangePicker/DevicesDateRangePicker'
import type { DeviceNetworkStatus } from '../../../../entities/device/model/types'
import {
DevicesDateRangePicker,
type DevicesDateRangePickerValue,
} from '../DevicesDateRangePicker/DevicesDateRangePicker'
import { DevicesOrganisationFilter } from '../DevicesOrganisationFilter/DevicesOrganisationFilter'
import './Datepicker.scss'
import './DevicesFiltersPanel.scss'
const networkStatusOptions: {
value: DeviceNetworkStatus
label: string
}[] = [
{
value: 'Online',
label: 'В сети',
},
{
value: 'Offline',
label: 'Не в сети',
},
{
value: 'Lost',
label: 'Потерян',
},
]
const maintenanceOptions = [
{
value: false,
label: 'Исправно',
},
{
value: true,
label: 'Требует ТО',
},
]
function isDeviceNetworkStatus(value: string): value is DeviceNetworkStatus {
return networkStatusOptions.some((option) => option.value === value)
}
type DevicesFiltersPanelProps = {
isOpen: boolean
isOpen: boolean
}
export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
isOpen,
}: DevicesFiltersPanelProps) {
const [searchParams, setSearchParams] = useSearchParams()
const [workPeriod, setWorkPeriod] = useState<DevicesDateRangePickerValue>({
from: new Date(2026, 3, 20),
to: new Date(2026, 3, 22),
fromTime: '07:00',
toTime: '16:00',
const selectedOrganisationIds = useMemo(
() => searchParams.get('dOrgs')?.split(',').filter(Boolean) ?? [],
[searchParams],
)
const networkStatusParam = searchParams.get('dNetwork')
const selectedNetworkStatuses = useMemo<DeviceNetworkStatus[]>(
() =>
networkStatusParam === 'none'
? []
: networkStatusParam
?.split(',')
.filter(isDeviceNetworkStatus) ??
networkStatusOptions.map((option) => option.value),
[networkStatusParam],
)
const needMaintenanceParam = searchParams.get('dNeedMaintenance')
const selectedMaintenanceStates = useMemo(
() =>
needMaintenanceParam === 'true'
? [true]
: needMaintenanceParam === 'false'
? [false]
: maintenanceOptions.map((option) => option.value),
[needMaintenanceParam],
)
const [workPeriod, setWorkPeriod] = useState<DevicesDateRangePickerValue>({
from: new Date(2026, 3, 20),
to: new Date(2026, 3, 22),
fromTime: '07:00',
toTime: '16:00',
})
const updateSearchParams = useCallback((updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams)
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === '') {
params.delete(key)
return
}
params.set(key, value)
})
return (
<aside className={`devices-filters ${isOpen ? 'devices-filters--open' : ''}`}>
<Accordion.Root
className="devices-filters__accordion"
type="multiple"
defaultValue={['work-period', 'statuses']}
>
<Accordion.Item className="devices-filter-item" value="work-period">
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Период работы</span>
<ChevronDown className="devices-filter-item__chevron" size={16} />
</Accordion.Trigger>
</Accordion.Header>
setSearchParams(params)
}, [searchParams, setSearchParams])
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<div className="devices-period">
<DevicesDateRangePicker value={workPeriod} onChange={setWorkPeriod} />
</div>
const handleNetworkStatusChange = useCallback((
status: DeviceNetworkStatus,
checked: boolean,
) => {
const currentStatuses = new Set(selectedNetworkStatuses)
</div>
</Accordion.Content>
</Accordion.Item>
if (checked) {
currentStatuses.add(status)
} else {
currentStatuses.delete(status)
}
{/* <Accordion.Item className="devices-filter-item" value="statuses">
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Статусы</span>
<ChevronDown className="devices-filter-item__chevron" size={16} />
</Accordion.Trigger>
</Accordion.Header>
const nextStatuses = networkStatusOptions
.map((option) => option.value)
.filter((optionStatus) => currentStatuses.has(optionStatus))
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<div className="devices-filter-row">
<span className="devices-filter-row__label">GPS</span>
updateSearchParams({
dNetwork:
nextStatuses.length === networkStatusOptions.length
? null
: nextStatuses.length === 0
? 'none'
: nextStatuses.join(','),
dPage: '0',
})
}, [selectedNetworkStatuses, updateSearchParams])
<label className="devices-radio">
<input type="radio" name="gps" />
<span className="devices-radio__control" />
<span className="devices-radio__label">Вкл</span>
</label>
const handleMaintenanceChange = useCallback((value: boolean, checked: boolean) => {
const currentStates = new Set(selectedMaintenanceStates)
<label className="devices-radio">
<input type="radio" name="gps" defaultChecked />
<span className="devices-radio__control" />
<span className="devices-radio__label">Выкл</span>
</label>
</div>
if (checked) {
currentStates.add(value)
} else {
currentStates.delete(value)
}
<div className="devices-filter-row">
<span className="devices-filter-row__label">Wi-Fi</span>
if (currentStates.size === 0) return
<label className="devices-radio">
<input type="radio" name="wifi" defaultChecked />
<span className="devices-radio__control" />
<span className="devices-radio__label">Вкл</span>
</label>
const nextStates = maintenanceOptions
.map((option) => option.value)
.filter((optionValue) => currentStates.has(optionValue))
<label className="devices-radio">
<input type="radio" name="wifi" />
<span className="devices-radio__control" />
<span className="devices-radio__label">Выкл</span>
</label>
</div>
updateSearchParams({
dNeedMaintenance:
nextStates.length === maintenanceOptions.length
? null
: String(nextStates[0]),
dPage: '0',
})
}, [selectedMaintenanceStates, updateSearchParams])
<div className="devices-filter-row">
<span className="devices-filter-row__label">Камера</span>
const handleOrganisationsChange = useCallback((ids: string[]) => {
updateSearchParams({
dOrgs: ids.length > 0 ? ids.join(',') : null,
dPage: '0',
})
}, [updateSearchParams])
<label className="devices-radio">
<input type="radio" name="camera" />
<span className="devices-radio__control" />
<span className="devices-radio__label">Вкл</span>
</label>
return (
<aside
className={`devices-filters ${isOpen ? 'devices-filters--open' : ''}`}
aria-hidden={!isOpen}
>
<Accordion.Root
className="devices-filters__accordion"
type="multiple"
defaultValue={['work-period']}
>
<Accordion.Item className="devices-filter-item" value="work-period">
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Период работы</span>
<ChevronDown
className="devices-filter-item__chevron"
size={16}
/>
</Accordion.Trigger>
</Accordion.Header>
<label className="devices-radio">
<input type="radio" name="camera" />
<span className="devices-radio__control" />
<span className="devices-radio__label">Выкл</span>
</label>
</div>
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<div className="devices-period">
<DevicesDateRangePicker
value={workPeriod}
onChange={setWorkPeriod}
/>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
<button className="devices-filter-reset" type="button">
Сбросить
</button>
</div>
</Accordion.Content>
</Accordion.Item> */}
<Accordion.Item
className="devices-filter-item"
value="organisations"
>
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Организации</span>
<ChevronDown
className="devices-filter-item__chevron"
size={16}
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Item className="devices-filter-item" value="network">
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Сеть</span>
<ChevronDown className="devices-filter-item__chevron" size={16} />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<DevicesOrganisationFilter
selectedOrganisationIds={selectedOrganisationIds}
onChange={handleOrganisationsChange}
/>
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<div className='devices-checkbox-list'>
<label className="devices-checkbox">
<input type="checkbox" defaultChecked />
<span className="devices-checkbox__control" />
<span>В сети</span>
</label>
<Accordion.Item className="devices-filter-item" value="network">
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Сеть</span>
<ChevronDown
className="devices-filter-item__chevron"
size={16}
/>
</Accordion.Trigger>
</Accordion.Header>
<label className="devices-checkbox">
<input type="checkbox" />
<span className="devices-checkbox__control" />
<span>Не в сети</span>
</label>
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<div className="devices-checkbox-list">
{networkStatusOptions.map((option) => (
<label className="devices-checkbox" key={option.value}>
<input
type="checkbox"
checked={selectedNetworkStatuses.includes(option.value)}
onChange={(event) => {
handleNetworkStatusChange(
option.value,
event.target.checked,
)
}}
/>
<span className="devices-checkbox__control" />
<span>{option.label}</span>
</label>
))}
</div>
</div>
</Accordion.Content>
</Accordion.Item>
<label className="devices-checkbox">
<input type="checkbox" />
<span className="devices-checkbox__control" />
<span>Долго не в сети</span>
</label>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item className="devices-filter-item" value="condition">
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Состояние</span>
<ChevronDown
className="devices-filter-item__chevron"
size={16}
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Item className="devices-filter-item" value="condition">
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Состояние</span>
<ChevronDown className="devices-filter-item__chevron" size={16} />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<div className='devices-checkbox-list'>
<label className="devices-checkbox">
<input type="checkbox" defaultChecked />
<span className="devices-checkbox__control" />
<span>Исправно</span>
</label>
<label className="devices-checkbox">
<input type="checkbox" />
<span className="devices-checkbox__control" />
<span>Требует осмотра</span>
</label>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</aside>
)
}
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<div className="devices-checkbox-list">
{maintenanceOptions.map((option) => (
<label className="devices-checkbox" key={String(option.value)}>
<input
type="checkbox"
checked={selectedMaintenanceStates.includes(option.value)}
onChange={(event) => {
handleMaintenanceChange(
option.value,
event.target.checked,
)
}}
/>
<span className="devices-checkbox__control" />
<span>{option.label}</span>
</label>
))}
</div>
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</aside>
)
})

View File

@ -0,0 +1,57 @@
@use '../../../../shared/styles/variables' as *;
.organisation-filter__tags {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.organisation-select__dropdown{
width: 240px;
top: calc(100% + 0px);
}
.organisation-filter__tag {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 5px 5px 12px;
border-radius: 12px;
background: $color-bg;
span {
font-size: $font-size-16;
font-weight: 450;
color: $gray50;
line-height: 1;
}
button {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: $gray50;
padding: 0;
width: 24px;
height: 24px;
border: none;
background: rgba($gray50-rgb, .1);
border-radius: 8px;
cursor: pointer;
transition: .2s ease;
&:hover{
background-color: $blue20;
color: $blue;
}
}
}

View File

@ -0,0 +1,332 @@
import {
memo,
type ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import * as Popover from '@radix-ui/react-popover'
import { useQuery } from '@apollo/client/react'
import {
ChevronDown,
Search,
X,
} from 'lucide-react'
import { GET_ORGANISATIONS_QUERY } from '../../../../entities/employee/api/employee.graphql'
import type {
GetOrganisationsData,
GetOrganisationsVariables,
} from '../../../../entities/employee/model/types'
import './DevicesOrganisationFilter.scss'
type DevicesOrganisationFilterProps = {
selectedOrganisationIds: string[]
onChange: (ids: string[]) => void
}
type OrganisationFilterItem = {
id: string
name: string
}
const SORT_DIRECTION = 'ASC' as const
const SORT_FIELD = 'Name' as const
export const DevicesOrganisationFilter = memo(function DevicesOrganisationFilter({
selectedOrganisationIds = [],
onChange,
}: DevicesOrganisationFilterProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] =
useState('')
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedSearch(search.trim())
}, 300)
return () => {
window.clearTimeout(timeoutId)
}
}, [search])
const {
data,
previousData,
loading,
error,
} = useQuery<
GetOrganisationsData,
GetOrganisationsVariables
>(GET_ORGANISATIONS_QUERY, {
variables: {
page: 0,
query: debouncedSearch || undefined,
sortDirection: SORT_DIRECTION,
sortField: SORT_FIELD,
},
skip: !open && selectedOrganisationIds.length === 0,
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
notifyOnNetworkStatusChange: true,
})
const response =
data?.getOrganisations ??
previousData?.getOrganisations
const organisations = useMemo(
() => response?.page ?? [],
[response?.page],
)
const selectedOrganisationIdSet = useMemo(
() => new Set(selectedOrganisationIds),
[selectedOrganisationIds],
)
const [
selectedOrganisations,
setSelectedOrganisations,
] = useState<
OrganisationFilterItem[]
>([])
useEffect(() => {
if (organisations.length === 0) return
setSelectedOrganisations((prev) => {
const map = new Map(
prev.map((item) => [item.id, item]),
)
organisations.forEach((organisation) => {
const id = String(organisation.id)
if (
selectedOrganisationIdSet.has(id)
) {
map.set(id, {
id,
name: organisation.name,
})
}
})
return Array.from(map.values()).filter(
(item) =>
selectedOrganisationIdSet.has(item.id),
)
})
}, [
organisations,
selectedOrganisationIdSet,
])
const handleSearchChange = useCallback((
event: ChangeEvent<HTMLInputElement>,
) => {
setSearch(event.target.value)
}, [])
const handleSelect = useCallback((
organisation: OrganisationFilterItem,
) => {
const alreadySelected =
selectedOrganisationIdSet.has(organisation.id)
if (alreadySelected) {
onChange(
selectedOrganisationIds.filter(
(item) =>
item !== organisation.id,
),
)
return
}
setSelectedOrganisations((prev) => {
const exists = prev.some(
(item) =>
item.id === organisation.id,
)
if (exists) {
return prev
}
return [...prev, organisation]
})
onChange([
...selectedOrganisationIds,
organisation.id,
])
setSearch('')
setDebouncedSearch('')
}, [
onChange,
selectedOrganisationIds,
selectedOrganisationIdSet,
])
const handleRemove = useCallback((id: string) => {
onChange(
selectedOrganisationIds.filter(
(item) => item !== id,
),
)
}, [onChange, selectedOrganisationIds])
const mergedOrganisations = useMemo(
() => [
...selectedOrganisations,
...organisations
.filter(
(organisation) =>
!selectedOrganisationIdSet.has(
String(organisation.id),
),
)
.map((organisation) => ({
id: String(organisation.id),
name: organisation.name,
})),
],
[
organisations,
selectedOrganisationIdSet,
selectedOrganisations,
],
)
return (
<div className="organisation-filter">
{selectedOrganisations.length > 0 && (
<div className="organisation-filter__tags">
{selectedOrganisations.map(
(organisation) => (
<div
className="organisation-filter__tag"
key={organisation.id}
>
<span>{organisation.name}</span>
<button
type="button"
onClick={() =>
handleRemove(
String(organisation.id),
)
}
>
<X size={14} />
</button>
</div>
),
)}
</div>
)}
<Popover.Root
open={open}
onOpenChange={setOpen}
>
<Popover.Trigger asChild>
<button
className="organisation-select__trigger"
type="button"
>
<span className="is-placeholder">
Выберите организацию
</span>
<ChevronDown size={18} />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="organisation-select__dropdown"
sideOffset={8}
align="start"
collisionPadding={12}
>
<label className="organisation-select__search">
<Search size={16} />
<input
value={search}
onChange={handleSearchChange}
placeholder="Поиск организации"
autoFocus
/>
</label>
<div className="organisation-select__list">
{loading &&
organisations.length === 0 && (
<div className="organisation-select__state">
Загрузка организаций...
</div>
)}
{error && (
<div className="organisation-select__state is-error">
Не удалось загрузить организации
</div>
)}
{!loading &&
!error &&
organisations.length === 0 && (
<div className="organisation-select__state">
Организации не найдены
</div>
)}
{mergedOrganisations.map((organisation) => {
const isSelected =
selectedOrganisationIds.includes(
organisation.id,
)
return (
<button
className={`organisation-select__option ${isSelected
? 'is-selected'
: ''
}`}
type="button"
key={organisation.id}
onClick={() =>
handleSelect(organisation)
}
>
<span>
{organisation.name}
</span>
<small>
ID: {organisation.id}
</small>
</button>
)
},
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
)
})

View File

@ -1,39 +0,0 @@
@use '../../../../shared/styles/variables' as *;
.devices-tabs {
display: flex;
align-items: center;
gap: 8px;
}
.devices-tabs__item {
position: relative;
min-height: 36px;
padding: 12px 20px;
border: none;
border-radius: 12px;
background: #ffffff;
color: #4f5b73;
font-size: 18px;
font-weight: 400;
cursor: pointer;
b {
font-weight: 700;
color: #1d2533;
}
}
.devices-tabs__item--active {
color: #4f5b73;
}
.devices-tabs__alert {
position: absolute;
top: -5px;
right: -5px;
width: 11px;
height: 11px;
border-radius: 50%;
background: $red;
}

View File

@ -1,21 +1,174 @@
import './DevicesTabs.scss'
import { useQuery } from '@apollo/client/react'
import { memo, useCallback, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
export function DevicesTabs() {
return (
<div className="devices-tabs">
<button className="devices-tabs__item devices-tabs__item--active" type="button">
В работе: <b>15 из 77</b>
</button>
import {
GET_PHONES_TABS_STATS_QUERY,
} from '../../../../entities/device/api/device.graphql'
import type {
GetPhonesTabsStatsData,
GetPhonesTabsStatsVariables,
} from '../../../../entities/device/model/types'
import {
PageTabs,
type PageTabItem,
} from '../../../../shared/ui/PageTabs/PageTabs'
<button className="devices-tabs__item" type="button">
Долгое время не в сети: <b>8</b>
<span className="devices-tabs__alert" />
</button>
type DevicesTab = 'working' | 'lost' | 'locked' | 'maintenance'
type DevicesTabsValue = DevicesTab | 'none'
<button className="devices-tabs__item" type="button">
Требуют обслуживания: <b>9</b>
<span className="devices-tabs__alert" />
</button>
</div>
)
function getActiveTab(searchParams: URLSearchParams): DevicesTabsValue {
const locked = searchParams.get('dLocked')
const needMaintenance = searchParams.get('dNeedMaintenance')
const networkStatus = searchParams.get('dNetwork')
if (locked === 'true') return 'locked'
if (needMaintenance === 'true') return 'maintenance'
if (networkStatus === 'Lost') return 'lost'
if (networkStatus === 'Online') return 'working'
return 'none'
}
function formatCount(value?: number) {
if (typeof value !== 'number') return '...'
return value.toLocaleString('ru-RU')
}
export const DevicesTabs = memo(function DevicesTabs() {
const [searchParams, setSearchParams] = useSearchParams()
const activeTab = useMemo(() => getActiveTab(searchParams), [searchParams])
const query = searchParams.get('dQuery')?.trim() || undefined
const selectedOrganisationIds = useMemo(() => {
return searchParams.get('dOrgs')?.split(',').filter(Boolean) ?? []
}, [searchParams])
const queryVariables = useMemo<GetPhonesTabsStatsVariables>(() => {
return {
query,
orgs:
selectedOrganisationIds.length > 0
? selectedOrganisationIds
: undefined,
}
}, [query, selectedOrganisationIds])
const { data, previousData } = useQuery<
GetPhonesTabsStatsData,
GetPhonesTabsStatsVariables
>(GET_PHONES_TABS_STATS_QUERY, {
variables: queryVariables,
fetchPolicy: 'cache-and-network',
notifyOnNetworkStatusChange: true,
})
const stats = data ?? previousData
const onlineCount = stats?.onlinePhones.totalElements
const totalCount = stats?.totalPhones.totalElements
const lockedCount = stats?.lockedPhones.totalElements
const lostCount = stats?.lostPhones.totalElements
const maintenanceCount = stats?.maintenancePhones.totalElements
const tabs = useMemo<Array<PageTabItem<DevicesTabsValue>>>(() => {
const nextTabs: Array<PageTabItem<DevicesTabsValue>> = [
{
value: 'working',
label: (
<>
В сети: <b>{formatCount(onlineCount)} из {formatCount(totalCount)}</b>
</>
),
},
]
if ((lostCount ?? 0) > 0) {
nextTabs.push({
value: 'lost',
label: (
<>
Потеряно: <b>{formatCount(lostCount)}</b>
</>
),
alert: true,
})
}
if ((lockedCount ?? 0) > 0) {
nextTabs.push({
value: 'locked',
label: (
<>
Заблокировано: <b>{formatCount(lockedCount)}</b>
</>
),
alert: true,
})
}
if ((maintenanceCount ?? 0) > 0) {
nextTabs.push({
value: 'maintenance',
label: (
<>
Требуют ТО: <b>{formatCount(maintenanceCount)}</b>
</>
),
alert: true,
})
}
return nextTabs
}, [lockedCount, lostCount, maintenanceCount, onlineCount, totalCount])
const applyQuickFilter = useCallback((tab: DevicesTabsValue) => {
if (tab === 'none') return
const nextParams = new URLSearchParams(searchParams)
nextParams.delete('dPage')
if (activeTab === tab) {
nextParams.delete('dLocked')
nextParams.delete('dNetwork')
nextParams.delete('dNeedMaintenance')
setSearchParams(nextParams, { replace: true })
return
}
if (tab === 'working') {
nextParams.set('dNetwork', 'Online')
nextParams.delete('dLocked')
nextParams.delete('dNeedMaintenance')
}
if (tab === 'locked') {
nextParams.set('dLocked', 'true')
nextParams.delete('dNetwork')
nextParams.delete('dNeedMaintenance')
}
if (tab === 'lost') {
nextParams.set('dNetwork', 'Lost')
nextParams.delete('dLocked')
nextParams.delete('dNeedMaintenance')
}
if (tab === 'maintenance') {
nextParams.delete('dLocked')
nextParams.delete('dNetwork')
nextParams.set('dNeedMaintenance', 'true')
}
setSearchParams(nextParams, { replace: true })
}, [activeTab, searchParams, setSearchParams])
return (
<PageTabs
activeValue={activeTab}
items={tabs}
onChange={applyQuickFilter}
/>
)
})

View File

@ -5,9 +5,10 @@
align-items: center;
justify-content: space-between;
gap: 16px;
background-color: white;
background-color: $color-surface;
border-radius: 100px;
padding: 4px;
box-shadow: $shadow-toolbar;
}
.devices-search {
@ -32,9 +33,9 @@
width: 100%;
border: none;
outline: none;
background: transparent;
color: black;
font-size: 16px;
background: $transparent;
color: $color-text-strong;
font-size: $font-size-16;
&::placeholder {
color: $gray50;
@ -42,7 +43,7 @@
}
&:focus-within{
box-shadow: inset 0 0 0 1.5px rgba($blue, .15);
box-shadow: inset 0 0 0 1.5px rgba($color-primary-rgb, .15);
}
}
@ -53,7 +54,7 @@
button {
padding: 12px 20px;
font-size: 16px;
font-size: $font-size-16;
font-weight: 500;
background-color: $color-bg;
border-radius: 20px;
@ -62,8 +63,12 @@
.add-device {
background-color: $blue;
color: white;
color: $color-text-inverse;
cursor: pointer;
transition: .2s ease;
&:hover{
box-shadow: 0 4px 20px -6px rgba(var(--color-primary-rgb), 0.8);
}
}
}
@ -74,14 +79,14 @@
border: none;
border-radius: 20px;
background: #ffffff;
background: $color-surface;
display: inline-flex;
align-items: center;
gap: 8px;
color: $gray50;
font-size: 14px;
font-size: $font-size-14;
font-weight: 500;
cursor: pointer;
@ -108,9 +113,9 @@
padding: 6px;
border-radius: 16px;
background: #ffffff;
background: $color-surface;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
box-shadow: $shadow-popover;
animation: devicesSortMenuShow 0.16s ease;
}
@ -125,15 +130,15 @@
justify-content: space-between;
gap: 12px;
color: #30394b;
font-size: 14px;
color: $color-text-heading;
font-size: $font-size-14;
font-weight: 500;
outline: none;
cursor: pointer;
&[data-highlighted] {
background: #f1f4f8;
background: $color-surface-subtle;
color: $blue;
}
}
@ -196,3 +201,76 @@
border-radius: 50%;
background: $blue;
}
@include desktop-down{
.devices-toolbar {
gap: 10px;
}
.devices-search {
width: 180px !important;
input{
font-size: var(--font-size-14) !important;
}
}
.devices-toolbar__right {
button {
padding: 10px 14px !important;
font-size: var(--font-size-14) !important;
height: 38px !important;
}
}
.devices-sort {
padding: 10px 12px 10px 14px !important;
}
}
@include laptop-down {
.devices-toolbar {
gap: 10px;
}
.devices-search {
width: 220px;
}
.devices-toolbar__right {
button {
padding: 10px 14px;
}
}
.devices-sort {
padding: 10px 12px 10px 14px !important;
}
}
@include tablet-down {
.devices-toolbar {
flex-wrap: wrap;
align-items: stretch;
gap: 6px;
border-radius: 24px;
padding: 6px;
}
.devices-search {
order: 1;
width: 100%;
height: 42px;
min-height: 42px;
box-sizing: border-box;
}
.devices-toolbar__right {
order: 2;
width: 100%;
justify-content: flex-end;
.add-device {
flex: 1 1 auto;
}
}
}

View File

@ -1,63 +1,114 @@
import { useState } from 'react'
import { memo } from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { ChevronDown, Search, SlidersHorizontal } from 'lucide-react'
import { Search, SlidersHorizontal } from 'lucide-react'
import './DevicesToolbar.scss'
export type DevicesSortField = 'ID' | 'Serial' | 'Date'
export type DevicesSortDirection = 'ASC' | 'DESC'
export type DevicesSortOption = {
value: string
label: string
sortField: DevicesSortField
sortDirection: DevicesSortDirection
}
type DevicesToolbarProps = {
isFiltersOpen: boolean
onToggleFilters: () => void
showFilters?: boolean
onAddDevice: () => void
search: string
onSearchChange: (value: string) => void
sortField: DevicesSortField
sortDirection: DevicesSortDirection
onSortChange: (option: DevicesSortOption) => void
}
const sortOptions = [
export const devicesSortOptions: DevicesSortOption[] = [
{
value: 'default',
label: 'По умолчанию',
value: 'date-desc',
label: 'Сначала новые',
sortField: 'Date',
sortDirection: 'DESC',
},
{
value: 'id-asc',
label: 'ID по возрастанию',
sortField: 'ID',
sortDirection: 'ASC',
},
{
value: 'id-desc',
label: 'ID по убыванию',
sortField: 'ID',
sortDirection: 'DESC',
},
{
value: 'serial-asc',
label: 'Заводской номер А–Я',
sortField: 'Serial',
sortDirection: 'ASC',
},
{
value: 'serial-desc',
label: 'Заводской номер Я–А',
}
sortField: 'Serial',
sortDirection: 'DESC',
},
{
value: 'date-asc',
label: 'Сначала старые',
sortField: 'Date',
sortDirection: 'ASC',
},
]
export function DevicesToolbar({
export const DevicesToolbar = memo(function DevicesToolbar({
isFiltersOpen,
onToggleFilters,
showFilters = true,
onAddDevice,
search,
onSearchChange,
sortField,
sortDirection,
onSortChange,
}: DevicesToolbarProps) {
const [selectedSort, setSelectedSort] = useState(sortOptions[0])
const selectedSort =
devicesSortOptions.find(
(option) =>
option.sortField === sortField &&
option.sortDirection === sortDirection,
) ?? devicesSortOptions[0]
return (
<div className="devices-toolbar">
<label className="devices-search">
<Search size={16} />
<input type="text" placeholder="Поиск" />
<input
placeholder="Поиск"
value={search}
onChange={(e) =>
onSearchChange(e.target.value)
}
/>
</label>
<div className="devices-toolbar__right">
<button className="add-device" type="button" onClick={onAddDevice}>
Добавить устройство
</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="devices-sort" type="button">
{selectedSort.label}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6" />
</svg>
</button>
</DropdownMenu.Trigger>
@ -68,11 +119,11 @@ export function DevicesToolbar({
align="end"
sideOffset={8}
>
{sortOptions.map((option) => (
{devicesSortOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="devices-sort-menu__item"
onSelect={() => setSelectedSort(option)}
onSelect={() => onSortChange(option)}
>
<span>{option.label}</span>
@ -84,17 +135,22 @@ export function DevicesToolbar({
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<button
className={`devices-filter ${isFiltersOpen ? 'devices-filter--active' : ''}`}
type="button"
aria-label="Фильтр"
onClick={onToggleFilters}
>
<SlidersHorizontal size={16} />
<span className="devices-filter__alert" />
<button className="add-device" type="button" onClick={onAddDevice}>
Добавить устройство
</button>
{showFilters && (
<button
className={`devices-filter ${isFiltersOpen ? 'devices-filter--active' : ''}`}
type="button"
aria-label="Фильтр"
onClick={onToggleFilters}
>
<SlidersHorizontal size={16} />
<span className="devices-filter__alert" />
</button>
)}
</div>
</div>
)
}
})

View File

@ -8,37 +8,25 @@
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
max-height: calc(100% - 56px);
overflow: visible;
padding: 20px 20px 36px;
}
.employees-sections {
width: fit-content;
.employees-page__section {
display: flex;
align-items: center;
flex: 1;
gap: 10px;
min-height: 0;
flex-direction: column;
}
.employees-sections__item {
padding: 10px 18px;
border: none;
border-radius: 12px;
background: transparent;
color: $gray50;
font-size: 18px;
font-weight: 500;
cursor: pointer;
transition: 0.2s ease;
&:hover {
background-color: $gray20;
}
&.is-active {
background: #ffffff;
color: $blue;
}
.employees-page__table-motion {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.employees-toolbar--organisations {
@ -51,37 +39,47 @@
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
//overflow: hidden;
border-radius: 20px;
}
.employees-table-card {
position: relative;
flex: 1;
flex: 1 1 0;
height: 100%;
min-height: 0;
overflow: auto;
max-height: 100%;
//overflow: hidden;
//box-shadow: $shadow-card;
border-radius: 20px;
background: #ffffff;
background: $color-surface;
scrollbar-width: thin;
scrollbar-color: $gray50 transparent;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
.simplebar-content-wrapper {
border-radius: 20px;
}
&::-webkit-scrollbar-track {
background: transparent;
.simplebar-track.simplebar-vertical {
top: 48px;
bottom: 14px;
width: 14px;
right: -7px;
}
&::-webkit-scrollbar-thumb {
background: $gray50;
border-radius: 999px;
.simplebar-scrollbar::before {
background: color-mix(in srgb, var(--gray50) 85%, transparent);
backdrop-filter: blur(10px);
box-shadow: inset 0 0 0 1px var(--gray30), -2px 4px 12px rgba(0, 0, 0, 0.1);
border-radius: 4px;
opacity: 1;
}
&::-webkit-scrollbar-thumb:hover {
background: $gray50;
.simplebar-track.simplebar-horizontal {
left: 12px;
right: 12px;
height: 8px;
}
}
@ -90,12 +88,14 @@
min-width: 1180px;
border-collapse: collapse;
table-layout: fixed;
overflow: hidden;
border-radius: 20px;
thead {
position: sticky;
top: 0;
z-index: 2;
background: #ffffff;
background: $color-surface;
}
th {
@ -104,10 +104,10 @@
border-bottom: 1px solid $gray20;
color: $gray50;
font-size: 16px;
font-size: $font-size-16;
font-weight: 450;
text-align: left;
background: #ffffff;
background: $color-surface;
}
td {
@ -118,7 +118,7 @@
text-align: left;
color: $color-text;
font-size: 17px;
font-size: $font-size-17;
font-weight: 400;
}
@ -169,23 +169,74 @@
.employee-person {
display: flex;
flex-direction: column;
gap: 4px;
flex-direction: row;
align-items: center;
gap: 12px;
min-width: 0;
span {
color: $color-text;
font-size: 17px;
font-weight: 500;
line-height: 1.2;
.employee-person__avatar {
width: 48px;
height: 48px;
flex: 0 0 48px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at 30% 20%, rgba($white, 0.1), $transparent 28%),
linear-gradient(135deg, $blue, $color-primary-hover);
color: $color-text-inverse;
font-size: $font-size-14;
font-weight: 750;
letter-spacing: 0.04em;
box-shadow: inset 0 0 0 1px rgba($color-shadow-rgb, 0.06);
&.transparent-backdrop {
background: transparent;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.employee-person__info {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
>span {
color: $color-text;
font-size: $font-size-17;
font-weight: 600;
line-height: 1.2;
}
small {
color: $gray50;
font-size: $font-size-14;
font-weight: 500;
line-height: 1.2;
}
}
}
.employee-organisation,
.employee-organisation-name {
color: $gray50;
font-size: 16px;
font-weight: 400;
font-size: $font-size-16;
font-weight: 500;
}
.employee-organisation-name {
@ -204,13 +255,13 @@
background: $blue20;
color: $blue;
font-size: 15px;
font-size: $font-size-15;
font-weight: 600;
}
.employee-role--admin {
background: $orange;
color: #ffffff;
background: $color-warning-tint !important;
color: $orange !important;
}
.employees-table-actions {
@ -230,7 +281,7 @@
align-items: center;
gap: 6px;
font-size: 14px;
font-size: $font-size-14;
font-weight: 600;
cursor: pointer;
@ -252,12 +303,12 @@
color: $blue;
&:hover {
box-shadow: inset 0 0 0 1px $blue;
box-shadow: inset 0 0 0 1px $blue, var(--shadow-primary);
}
}
.employees-action-btn--delete {
background: rgba(224, 0, 0, 0.12);
background: $color-danger-tint-strong;
color: $red;
&:hover {
@ -268,12 +319,19 @@
.employees-table__row {
position: relative;
transition: 0.2s ease;
box-shadow: inset 0px 0 0 transparent;
box-shadow: inset 0px 0 0 $transparent;
cursor: pointer;
overflow: hidden;
&:hover {
background-color: rgba($blue, 0.025);
box-shadow: inset 3px 0 0 rgba($blue, 0.8);
background-color: $color-primary-tint-soft;
box-shadow: inset 3px 0 0 rgba($color-primary-rgb, 0.8);
}
&:last-child {
td {
border-bottom: none;
}
}
}
@ -285,10 +343,10 @@
padding: 0 12px;
border-radius: 999px;
background: rgba(3, 29, 154, 0.08);
background: $color-primary-tint;
color: $blue;
font-size: 16px;
font-size: $font-size-16;
font-weight: 500;
}
@ -309,13 +367,13 @@
align-items: center;
gap: 6px;
font-size: 14px;
font-size: $font-size-14;
font-weight: 600;
cursor: pointer;
opacity: 0.88;
box-shadow: inset 0px 0 0 transparent;
box-shadow: inset 0px 0 0 $transparent;
transition:
background-color 0.2s ease,
@ -329,105 +387,24 @@
}
.employees-action-btn--edit {
background: $blue20;
background-color: $blue20;
color: $blue;
&:hover {
box-shadow: inset 0 0 0 1px $blue;
background-color: $blue;
color: $white;
box-shadow: inset 0 0 0 1px $blue, 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
}
}
.employees-action-btn--delete {
background: rgba(224, 0, 0, 0.12);
background-color: $color-danger-tint-strong;
color: $red;
&:hover {
box-shadow: inset 0 0 0 1px $red;
}
}
.employees-pagination {
flex: 0 0 auto;
padding: 12px 14px 0;
display: flex;
align-items: center;
justify-content: flex-end;
color: #738098;
font-size: 16px;
}
.employees-pagination__controls {
display: flex;
align-items: center;
gap: 6px;
button {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
min-width: 38px;
height: 38px;
//padding: 0 12px;
font-weight: 500;
border: none;
border-radius: 10px;
background: $gray20;
color: $gray50;
font-size: 16px;
cursor: pointer;
transition: .2s ease;
&:hover{
background-color: rgba($blue, 0.1);
}
&.is-active {
background: $blue;
color: #ffffff;
&:disabled {
opacity: 1;
cursor: default;
}
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
}
.employees-pagination__ellipsis-button {
position: relative;
}
.employees-pagination__ellipsis-dots,
.employees-pagination__ellipsis-arrow {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.employees-pagination__ellipsis-arrow {
position: absolute;
opacity: 0;
transform: translateY(2px);
}
.employees-pagination__ellipsis-button:hover {
.employees-pagination__ellipsis-dots {
opacity: 0;
transform: translateY(-2px);
}
.employees-pagination__ellipsis-arrow {
opacity: 1;
transform: translateY(0);
background-color: $red;
color: $white;
box-shadow: inset 0 0 0 1px $red, 0 8px 20px -8px rgba(var(--color-danger-rgb), 0.8);
}
}
@ -439,11 +416,155 @@
align-items: center;
justify-content: center;
color: #738098;
font-size: 15px;
color: $color-text-muted;
font-size: $font-size-15;
font-weight: 500;
}
.employees-state--error {
color: $red;
}
[data-theme='dark'] {
.employees-table__row:hover {
background-color: rgba($color-primary-rgb, 0.03);
box-shadow: inset 3px 0 0 rgba($color-primary-rgb, 0.36);
}
}
@include laptop-down {
.employee-person .employee-person__info>span {
font-size: var(--font-size-15);
}
.employee-person .employee-person__info small {
font-size: var(--font-size-14);
}
.employee-organisation,
.employee-organisation-name {
font-size: var(--font-size-15);
}
.employee-role {
font-size: var(--font-size-14);
}
.employees-table {
min-width: 1080px;
th,
td {
padding-inline: 16px;
}
td {
font-size: var(--font-size-15);
}
th:nth-child(1),
td:nth-child(1) {
width: 58px;
}
th:nth-child(2),
td:nth-child(2) {
width: 36%;
}
th:nth-child(3),
td:nth-child(3) {
width: 22%;
}
th:nth-child(4),
td:nth-child(4) {
width: 160px;
}
th:nth-child(5),
td:nth-child(5) {
width: 280px;
}
}
.organisations-table {
min-width: 820px;
th:nth-child(1),
td:nth-child(1) {
width: 58px;
}
th:nth-child(2),
td:nth-child(2) {
width: 64%;
}
th:nth-child(3),
td:nth-child(3) {
width: 280px;
}
}
.employee-person {
gap: 10px;
.employee-person__avatar {
width: 44px;
height: 44px;
flex-basis: 44px;
}
}
}
@include tablet-down {
.employees-table {
min-width: 980px;
th,
td {
padding-inline: 14px;
}
th:nth-child(1),
td:nth-child(1) {
width: 72px;
}
th:nth-child(2),
td:nth-child(2) {
width: 28%;
}
th:nth-child(3),
td:nth-child(3) {
width: 24%;
}
th:nth-child(4),
td:nth-child(4) {
width: 140px;
}
th:nth-child(5),
td:nth-child(5) {
width: 240px;
}
}
.organisations-table {
min-width: 760px;
th:nth-child(1),
td:nth-child(1) {
width: 86px;
}
th:nth-child(3),
td:nth-child(3) {
width: 230px;
}
}
}

View File

@ -1,722 +1,54 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useApolloClient, useQuery } from '@apollo/client/react'
import { Pencil, Trash2 } from 'lucide-react'
import { useSearchParams } from 'react-router-dom'
import { useCallback, useMemo } from 'react'
import {
GET_ORGANISATIONS_QUERY,
GET_USERS_PAGE_QUERY,
} from '../../entities/employee/api/employee.graphql'
import type {
Employee,
GetOrganisationsData,
GetOrganisationsVariables,
GetUsersPageData,
GetUsersPageVariables,
Organisation,
} from '../../entities/employee/model/types'
import {
EmployeesToolbar,
employeesSortOptions,
type EmployeesSortOption,
} from './components/EmployeesToolbar/EmployeesToolbar'
import { AddEmployeeModal } from './components/AddEmployeeModal/AddEmployeeModal'
import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
import { AddOrganisationModal } from './components/AddOrganisationModal/AddOrganisationModal'
EmployeesTabs,
type EmployeesSection,
} from './components/EmployeesTabs/EmployeesTabs'
import { UsersSection } from './components/UsersSection/UsersSection'
import { OrganisationsSection } from './components/OrganisationsSection/OrganisationsSection'
import './EmployeesPage.scss'
type EmployeesSection = 'users' | 'organisations'
function getEmployeeFullName(employee: Employee) {
return [employee.lastName, employee.firstName, employee.middleName]
.filter(Boolean)
.join(' ')
}
function getEmployeeRoleLabel(role: string) {
if (role === 'Admin') return 'Администратор'
if (role === 'User') return 'Пользователь'
return role
}
type PaginationItem = number | 'prev-ellipsis' | 'next-ellipsis'
function getPaginationItems(currentPage: number, totalPages: number): PaginationItem[] {
if (totalPages <= 0) return []
const current = currentPage + 1
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1)
}
const pages = new Set<number>()
pages.add(1)
pages.add(totalPages)
pages.add(current)
pages.add(current - 1)
pages.add(current + 1)
if (current <= 4) {
pages.add(2)
pages.add(3)
pages.add(4)
pages.add(5)
}
if (current >= totalPages - 3) {
pages.add(totalPages - 1)
pages.add(totalPages - 2)
pages.add(totalPages - 3)
pages.add(totalPages - 4)
}
const sortedPages = Array.from(pages)
.filter((page) => page >= 1 && page <= totalPages)
.sort((a, b) => a - b)
const result: PaginationItem[] = []
sortedPages.forEach((page, index) => {
const previousPage = sortedPages[index - 1]
if (previousPage && page - previousPage > 1) {
if (page - previousPage === 2) {
result.push(previousPage + 1)
} else {
result.push(page < current ? 'prev-ellipsis' : 'next-ellipsis')
}
}
result.push(page)
})
return result
}
export function EmployeesPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const client = useApolloClient()
const activeSection = useMemo<EmployeesSection>(() => {
return searchParams.get('section') === 'organisations'
? 'organisations'
: 'users'
}, [searchParams])
const [activeSection, setActiveSection] = useState<EmployeesSection>('users')
const handleSectionChange = useCallback((section: EmployeesSection) => {
if (activeSection === section) return
const [usersCurrentPage, setUsersCurrentPage] = useState(1)
const [usersLoadedPages, setUsersLoadedPages] = useState<Employee[][]>([])
const [usersLoadedNextKeys, setUsersLoadedNextKeys] = useState<Array<string | null>>([])
const [isUsersPageLoading, setIsUsersPageLoading] = useState(false)
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.set('section', section)
const [organisationsCurrentPage, setOrganisationsCurrentPage] = useState(0)
const [organisationSearch, setOrganisationSearch] = useState('')
const [debouncedOrganisationSearch, setDebouncedOrganisationSearch] =
useState('')
const [organisationSort, setOrganisationSort] =
useState<EmployeesSortOption>(employeesSortOptions[0])
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedOrganisationSearch(organisationSearch.trim())
}, 350)
return () => {
window.clearTimeout(timeoutId)
}
}, [organisationSearch])
useEffect(() => {
setOrganisationsCurrentPage(0)
}, [debouncedOrganisationSearch, organisationSort])
const [isAddEmployeeOpen, setIsAddEmployeeOpen] = useState(false)
const [isAddOrganisationOpen, setIsAddOrganisationOpen] = useState(false)
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
const [deletingEmployee, setDeletingEmployee] = useState<Employee | null>(null)
const [editingOrganisation, setEditingOrganisation] =
useState<Organisation | null>(null)
const [deletingOrganisation, setDeletingOrganisation] =
useState<Organisation | null>(null)
const {
data: firstUsersPageData,
loading: isFirstUsersPageLoading,
error: usersError,
} = useQuery<GetUsersPageData, GetUsersPageVariables>(GET_USERS_PAGE_QUERY, {
variables: {},
fetchPolicy: 'network-only',
})
const {
data: organisationsData,
previousData: previousOrganisationsData,
loading: organisationsLoading,
error: organisationsError,
refetch: refetchOrganisations,
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
GET_ORGANISATIONS_QUERY,
{
variables: {
page: organisationsCurrentPage,
query: debouncedOrganisationSearch || undefined,
sortDirection: organisationSort.sortDirection,
sortField: organisationSort.sortField,
return next
},
{
replace: true,
},
fetchPolicy: 'network-only',
notifyOnNetworkStatusChange: true,
},
)
const firstPageEmployees = firstUsersPageData?.getUsersPage.page ?? []
const firstUsersPageNextKey = firstUsersPageData?.getUsersPage.nextKey ?? null
const usersPages = [firstPageEmployees, ...usersLoadedPages]
const employees = usersPages[usersCurrentPage - 1] ?? []
const usersNextKey =
usersCurrentPage === 1
? firstUsersPageNextKey
: usersLoadedNextKeys[usersCurrentPage - 2] ?? null
const usersLoading = isFirstUsersPageLoading || isUsersPageLoading
const organisationsResponse =
organisationsData?.getOrganisations ??
previousOrganisationsData?.getOrganisations
const organisations = organisationsResponse?.page ?? []
const organisationsTotalPages = organisationsResponse?.totalPages ?? 0
const isOrganisationsInitialLoading =
organisationsLoading && !organisationsResponse
const isOrganisationsUpdating =
organisationsLoading && Boolean(organisationsResponse)
//const organisationsTotalElements = organisationsData?.getOrganisations.totalElements ?? 0
const hasPrevOrganisationsPage = organisationsCurrentPage > 0
const hasNextOrganisationsPage =
organisationsTotalPages > 0 &&
organisationsCurrentPage < organisationsTotalPages - 1
const organisationsPaginationItems = getPaginationItems(
organisationsCurrentPage,
organisationsTotalPages,
)
async function handleNextUsersPage() {
const alreadyLoadedNextPage = usersPages[usersCurrentPage]
if (alreadyLoadedNextPage) {
setUsersCurrentPage((prev) => prev + 1)
return
}
if (!usersNextKey) return
setIsUsersPageLoading(true)
try {
const result = await client.query<GetUsersPageData, GetUsersPageVariables>({
query: GET_USERS_PAGE_QUERY,
variables: {
key: usersNextKey,
},
fetchPolicy: 'network-only',
})
const nextPageData = result.data?.getUsersPage
if (!nextPageData) return
setUsersLoadedPages((prev) => [...prev, nextPageData.page])
setUsersLoadedNextKeys((prev) => [...prev, nextPageData.nextKey])
setUsersCurrentPage((prev) => prev + 1)
} finally {
setIsUsersPageLoading(false)
}
}
function handlePrevUsersPage() {
setUsersCurrentPage((prev) => Math.max(1, prev - 1))
}
function handleNextOrganisationsPage() {
if (!hasNextOrganisationsPage || organisationsLoading) return
setOrganisationsCurrentPage((prev) => prev + 1)
}
function handlePrevOrganisationsPage() {
if (!hasPrevOrganisationsPage || organisationsLoading) return
setOrganisationsCurrentPage((prev) => Math.max(0, prev - 1))
}
function handleJumpPrevOrganisationsPage() {
if (organisationsLoading) return
setOrganisationsCurrentPage((prev) => Math.max(0, prev - 5))
}
function handleJumpNextOrganisationsPage() {
if (organisationsLoading) return
setOrganisationsCurrentPage((prev) =>
Math.min(organisationsTotalPages - 1, prev + 5),
)
}
function handleEditEmployee(employee: Employee) {
setEditingEmployee(employee)
}
function handleDeleteEmployee(employee: Employee) {
setDeletingEmployee(employee)
}
function handleConfirmDeleteEmployee() {
if (!deletingEmployee) return
console.log('Удаление сотрудника пока без мутации', deletingEmployee)
setDeletingEmployee(null)
}
function handleEditOrganisation(organisation: Organisation) {
setEditingOrganisation(organisation)
}
function handleDeleteOrganisation(organisation: Organisation) {
setDeletingOrganisation(organisation)
}
function handleConfirmDeleteOrganisation() {
if (!deletingOrganisation) return
console.log('Удаление организации пока без мутации', deletingOrganisation)
setDeletingOrganisation(null)
}
}, [activeSection, setSearchParams])
return (
<section className="employees-page">
<div className="employees-sections">
<button
className={
activeSection === 'users'
? 'employees-sections__item is-active'
: 'employees-sections__item'
}
type="button"
onClick={() => setActiveSection('users')}
>
Пользователи
</button>
<button
className={
activeSection === 'organisations'
? 'employees-sections__item is-active'
: 'employees-sections__item'
}
type="button"
onClick={() => setActiveSection('organisations')}
>
Организации
</button>
</div>
<EmployeesToolbar
addButtonText={
activeSection === 'users'
? 'Добавить сотрудника'
: 'Добавить организацию'
}
searchValue={activeSection === 'organisations' ? organisationSearch : ''}
onSearchChange={(value) => {
if (activeSection === 'organisations') {
setOrganisationSearch(value)
}
}}
selectedSort={organisationSort}
onSortChange={setOrganisationSort}
showSort={activeSection === 'organisations'}
onAdd={() => {
if (activeSection === 'users') {
setIsAddEmployeeOpen(true)
return
}
setIsAddOrganisationOpen(true)
}}
<EmployeesTabs
activeSection={activeSection}
onChange={handleSectionChange}
/>
<div className="employees-table-container">
<div
className={`employees-table-card ${activeSection === 'organisations' && isOrganisationsUpdating
? 'is-updating'
: ''
}`}
>
{activeSection === 'users' && (
<>
{usersLoading && employees.length === 0 && (
<div className="employees-state">Загрузка сотрудников...</div>
)}
{usersError && (
<div className="employees-state employees-state--error">
Не удалось загрузить сотрудников
</div>
)}
{!usersLoading && !usersError && employees.length === 0 && (
<div className="employees-state">Сотрудники не найдены</div>
)}
{!usersError && employees.length > 0 && (
<table className="employees-table">
<thead>
<tr>
<th>ID</th>
<th>ФИО</th>
<th>Организация</th>
<th>Роль</th>
<th>Управление</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.id} className="employees-table__row">
<td>{employee.id}</td>
<td>
<div className="employee-person">
<span>
{getEmployeeFullName(employee) || 'ФИО не указано'}
</span>
</div>
</td>
<td>
<span className="employee-organisation">
{employee.org?.name || 'Организация не указана'}
</span>
</td>
<td>
<span
className={
employee.role === 'Admin'
? 'employee-role employee-role--admin'
: 'employee-role'
}
>
{getEmployeeRoleLabel(employee.role)}
</span>
</td>
<td>
<div className="employees-table-actions">
<button
className="employees-action-btn employees-action-btn--edit"
type="button"
onClick={(event) => {
event.stopPropagation()
handleEditEmployee(employee)
}}
>
<Pencil size={16} />
Редактировать
</button>
<button
className="employees-action-btn employees-action-btn--delete"
type="button"
onClick={(event) => {
event.stopPropagation()
handleDeleteEmployee(employee)
}}
>
<Trash2 size={16} />
Удалить
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
{activeSection === 'organisations' && (
<>
{isOrganisationsInitialLoading && (
<div className="employees-state">Загрузка организаций...</div>
)}
{organisationsError && (
<div className="employees-state employees-state--error">
Не удалось загрузить организации
</div>
)}
{!isOrganisationsInitialLoading &&
!organisationsError &&
organisations.length === 0 && (
<div className="employees-state">Организации не найдены</div>
)}
{!organisationsError && organisations.length > 0 && (
<table className="employees-table organisations-table">
<thead>
<tr>
<th>ID</th>
<th>Название организации</th>
<th>Управление</th>
</tr>
</thead>
<tbody>
{organisations.map((organisation) => (
<tr
key={organisation.id}
className="employees-table__row employees-table__row--clickable"
tabIndex={0}
onClick={() => navigate(`/employees/organisations/${organisation.id}`)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
navigate(`/employees/organisations/${organisation.id}`)
}
}}
>
<td>{organisation.id}</td>
<td>
<span className="employee-organisation-name">
{organisation.name}
</span>
</td>
<td>
<div className="employees-table-actions">
<button
className="employees-action-btn employees-action-btn--edit"
type="button"
onClick={(event) => {
event.stopPropagation()
handleEditOrganisation(organisation)
}}
>
<Pencil size={16} />
Редактировать
</button>
<button
className="employees-action-btn employees-action-btn--delete"
type="button"
onClick={(event) => {
event.stopPropagation()
handleDeleteOrganisation(organisation)
}}
>
<Trash2 size={16} />
Удалить
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
{activeSection === 'users' && (
<div className="employees-pagination">
<span>Страница {usersCurrentPage}</span>
<div className="employees-pagination__controls">
<button
type="button"
disabled={usersCurrentPage === 1 || usersLoading}
onClick={handlePrevUsersPage}
>
Назад
</button>
<button className="is-active" type="button">
{usersCurrentPage}
</button>
<button
type="button"
disabled={!usersNextKey || usersLoading}
onClick={handleNextUsersPage}
>
Вперед
</button>
</div>
</div>
)}
{activeSection === 'organisations' && (
<div className="employees-pagination">
<div className="employees-pagination__controls">
<button
type="button"
disabled={!hasPrevOrganisationsPage || organisationsLoading}
onClick={handlePrevOrganisationsPage}
aria-label="Предыдущая страница"
>
</button>
{organisationsPaginationItems.map((item, index) => {
if (item === 'prev-ellipsis' || item === 'next-ellipsis') {
const isPrev = item === 'prev-ellipsis'
return (
<button
className="employees-pagination__ellipsis-button"
type="button"
key={`${item}-${index}`}
disabled={organisationsLoading}
onClick={
isPrev
? handleJumpPrevOrganisationsPage
: handleJumpNextOrganisationsPage
}
aria-label={
isPrev
? 'Перейти на 5 страниц назад'
: 'Перейти на 5 страниц вперед'
}
>
<span className="employees-pagination__ellipsis-dots">...</span>
<span className="employees-pagination__ellipsis-arrow">
{isPrev ? '«' : '»'}
</span>
</button>
)
}
const pageIndex = item - 1
const isActive = pageIndex === organisationsCurrentPage
return (
<button
className={isActive ? 'is-active' : ''}
type="button"
key={item}
disabled={organisationsLoading || isActive}
onClick={() => setOrganisationsCurrentPage(pageIndex)}
>
{item}
</button>
)
})}
<button
type="button"
disabled={!hasNextOrganisationsPage || organisationsLoading}
onClick={handleNextOrganisationsPage}
aria-label="Следующая страница"
>
</button>
</div>
</div>
<div className="employees-page__section">
{activeSection === 'users' ? (
<UsersSection />
) : (
<OrganisationsSection />
)}
</div>
<AddEmployeeModal
open={isAddEmployeeOpen}
mode="create"
onOpenChange={setIsAddEmployeeOpen}
/>
<AddEmployeeModal
open={Boolean(editingEmployee)}
mode="edit"
employee={editingEmployee}
onOpenChange={(open) => {
if (!open) {
setEditingEmployee(null)
}
}}
/>
<AddOrganisationModal
open={isAddOrganisationOpen}
mode="create"
onOpenChange={setIsAddOrganisationOpen}
onSuccess={async () => {
setOrganisationsCurrentPage(0)
await refetchOrganisations({
page: 0,
query: debouncedOrganisationSearch || undefined,
sortDirection: organisationSort.sortDirection,
sortField: organisationSort.sortField,
})
}}
/>
<AddOrganisationModal
open={Boolean(editingOrganisation)}
mode="edit"
organisation={editingOrganisation}
onOpenChange={(open) => {
if (!open) {
setEditingOrganisation(null)
}
}}
/>
<ConfirmDangerDialog
open={Boolean(deletingOrganisation)}
title="Удалить организацию?"
description={
deletingOrganisation
? `Организация «${deletingOrganisation.name}» будет удалена из системы. Это действие нельзя будет отменить.`
: ''
}
confirmText="Удалить"
onOpenChange={(open) => {
if (!open) {
setDeletingOrganisation(null)
}
}}
onConfirm={handleConfirmDeleteOrganisation}
/>
<ConfirmDangerDialog
open={Boolean(deletingEmployee)}
title="Удалить сотрудника?"
description={
deletingEmployee
? `Сотрудник «${getEmployeeFullName(deletingEmployee)}» будет удалён из системы. Это действие нельзя будет отменить.`
: ''
}
confirmText="Удалить"
onOpenChange={(open) => {
if (!open) {
setDeletingEmployee(null)
}
}}
onConfirm={handleConfirmDeleteEmployee}
/>
</section>
)
}

View File

@ -3,31 +3,31 @@
.add-employee-modal__overlay {
position: fixed;
inset: 0;
z-index: 80;
z-index: 110;
background: rgba(15, 23, 42, 0.42);
background: $color-backdrop;
backdrop-filter: blur(2px);
animation: addEmployeeOverlayShow 0.18s ease;
}
.add-employee-modal {
position: fixed;
z-index: 90;
z-index: 120;
top: 50%;
left: 50%;
width: min(520px, calc(100vw - 32px));
width: min(860px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
transform: translate(-50%, -50%);
border-radius: 24px;
background: #ffffff;
border-radius: 26px;
background: $color-surface;
padding: 24px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
box-shadow: $shadow-modal;
animation: addEmployeeModalShow 0.2s ease;
}
.add-employee-modal__header {
@ -42,8 +42,8 @@
.add-employee-modal__title {
margin: 0;
color: #111827;
font-size: 24px;
color: $color-text-strong;
font-size: $font-size-24;
font-weight: 650;
}
@ -51,7 +51,7 @@
margin: 6px 0 0;
color: $gray50;
font-size: 15px;
font-size: $font-size-15;
font-weight: 400;
}
@ -76,17 +76,83 @@
color: $blue;
background: $gray20;
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
.add-employee-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.add-employee-layout {
//display: grid;
//grid-template-columns: 260px minmax(0, 1fr);
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: start;
}
.add-employee-fields__section {
border: 1px solid $color-border-accent;
border-radius: 22px;
background: $color-surface-muted;
flex: 1;
}
.avatar-fio-container{
display: flex;
flex: 1;
gap: 20px;
}
.add-employee-fields__title {
span {
color: $color-text-strong;
font-size: $font-size-16;
font-weight: 650;
}
p {
margin: 5px 0 0;
color: $gray50;
font-size: $font-size-13;
font-weight: 500;
line-height: 1.35;
}
}
.add-employee-fields {
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
&__grid {
}
.add-employee-fields__section {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.add-employee-form__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.add-employee-form__grid--credentials {
align-items: end;
}
.add-employee-field__spacer {
visibility: hidden;
}
.add-employee-field {
@ -94,50 +160,286 @@
flex-direction: column;
gap: 8px;
span {
color: #30394b;
font-size: 14px;
font-weight: 500;
> span {
color: $color-text-heading;
font-size: $font-size-14;
font-weight: 600;
}
input, select {
input,
select {
height: 44px;
border: 1px solid #dfe5ef;
border: 1px solid $color-border-strong;
border-radius: 14px;
padding: 0 14px;
background: #f8fafc;
background: $color-surface;
outline: none;
color: #111827;
font-size: 15px;
color: $color-text-strong;
font-size: $font-size-15;
font-weight: 400;
transition: 0.2s ease;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:focus {
border-color: $blue;
background: #ffffff;
background: $color-surface;
box-shadow: 0 0 0 3px rgba($color-primary-rgb, 0.12);
}
&:disabled {
opacity: 0.65;
cursor: not-allowed;
}
&::placeholder {
color: $gray50;
}
}
select {
cursor: pointer;
}
}
.employee-role-select {
width: 100%;
.organisation-select__trigger {
border-radius: 14px;
}
}
.employee-role-select__dropdown {
z-index: 140;
}
.employee-role-select__list {
margin-top: 0;
max-height: none;
}
.employee-role-select__option {
align-items: flex-start;
flex-direction: column;
gap: 4px;
small {
flex: 0 1 auto;
white-space: normal;
line-height: 1.3;
}
}
.employee-password-picker {
--height: 44px;
--radius: 14px;
--motion: 320ms cubic-bezier(0.34, 1.42, 0.64, 1);
width: 100%;
height: var(--height);
display: flex;
align-items: center;
gap: 0;
color: $color-text-strong;
transition:
gap var(--motion),
opacity 180ms ease;
}
.employee-password-picker__button,
.employee-password-picker__status,
.employee-password-picker__input,
.employee-password-picker__action {
height: var(--height);
min-width: 0;
border: 0;
outline: 0;
border-radius: var(--radius);
background: $color-surface;
color: inherit;
font: inherit;
height: 46px;
transition:
flex-basis var(--motion),
width var(--motion),
padding var(--motion),
opacity 180ms ease,
transform var(--motion),
background-color 220ms ease,
color 220ms ease,
box-shadow 220ms ease;
}
.employee-password-picker__button,
.employee-password-picker__status {
width: 100%;
flex: 1 1 auto;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid $color-border-strong;
cursor: pointer;
color: $gray50;
font-size: $font-size-15;
font-weight: 600;
white-space: nowrap;
svg {
flex: 0 0 auto;
}
}
.employee-password-picker__button {
border: 1px solid $color-border-strong;
cursor: pointer;
&:hover:not(:disabled) {
border-color: rgba($color-primary-rgb, 0.24);
background: $blue20;
color: $blue;
}
&:disabled {
opacity: 0.65;
cursor: not-allowed;
}
}
.employee-password-picker__status {
border: 1px solid rgba($color-primary-rgb, 0.18);
background: $blue20;
color: $blue;
}
.employee-password-picker__input {
width: auto !important;
flex: 1 1 auto;
padding: 0 14px !important;
border: 1px solid $color-border-strong !important;
opacity: 1;
pointer-events: auto;
transform: scaleX(1);
background: $color-surface !important;
font-size: $font-size-15 !important;
-webkit-text-fill-color: $color-text-strong;
&:focus {
border-color: $blue !important;
box-shadow: 0 0 0 3px rgba($color-primary-rgb, 0.12);
}
&:disabled {
opacity: 0.65;
pointer-events: none;
}
}
.employee-password-picker__action {
width: var(--height);
flex: 0 0 var(--height);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 1;
transform: scale(1) rotate(0deg);
background: $color-surface;
color: $gray50;
cursor: pointer;
&:hover:not(:disabled) {
background: rgba($color-danger-rgb, 0.08);
color: $red;
}
&:disabled {
opacity: 0.45;
cursor: default;
transform: scale(0.92);
}
}
.employee-password-picker--open {
gap: 8px;
}
.employee-password-picker--confirmed {
gap: 8px;
}
.employee-password-picker__action--confirm {
background: $blue20;
color: $blue;
&:hover:not(:disabled) {
background: $blue;
color: $color-text-inverse;
box-shadow: 0 8px 20px rgba($color-primary-rgb, 0.18);
}
}
.employee-password-confirm {
--height: 46px;
--radius: 14px;
width: 100%;
height: var(--height);
display: flex;
align-items: center;
gap: 8px;
}
.employee-password-confirm__input {
--motion: 320ms cubic-bezier(0.34, 1.42, 0.64, 1);
flex: 1 1 auto;
min-width: 0;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform var(--motion);
}
.add-employee-field--password-confirm-error {
.employee-password-confirm__input {
border-color: rgba($color-danger-rgb, 0.62) !important;
box-shadow: 0 0 0 3px rgba($color-danger-rgb, 0.1) !important;
}
}
.add-employee-error {
border-radius: 14px;
background: rgba(224, 0, 0, 0.08);
background: $color-danger-tint;
padding: 12px 14px;
color: $red;
font-size: 14px;
font-size: $font-size-14;
font-weight: 500;
}
.add-employee-modal__footer {
margin-top: 8px;
padding-top: 4px;
display: flex;
justify-content: flex-end;
@ -152,7 +454,7 @@
border: none;
border-radius: 14px;
font-size: 15px;
font-size: $font-size-15;
font-weight: 600;
cursor: pointer;
@ -165,16 +467,7 @@
&:hover {
background: $gray20;
color: #111827;
}
}
.add-employee-submit {
background: $blue;
color: #ffffff;
&:hover {
opacity: 0.9;
color: $color-text-strong;
}
&:disabled {
@ -183,24 +476,37 @@
}
}
@keyframes addEmployeeOverlayShow {
from {
opacity: 0;
.add-employee-submit {
background: $blue;
color: $color-text-inverse;
&:hover {
box-shadow: inset 0 0 0 1px $blue, 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
}
to {
opacity: 1;
&:disabled {
opacity: 0.65;
cursor: default;
}
&:disabled:hover {
box-shadow: none;
}
}
@keyframes addEmployeeModalShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.98);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
@media (max-width: 820px) {
.add-employee-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.add-employee-modal {
width: min(100vw - 20px, 520px);
padding: 18px;
border-radius: 22px;
}
.add-employee-form__grid {
grid-template-columns: 1fr;
}
}

View File

@ -3,29 +3,31 @@
.add-organisation-modal__overlay {
position: fixed;
inset: 0;
z-index: 80;
z-index: 110;
background: rgba(15, 23, 42, 0.42);
background: $color-backdrop;
backdrop-filter: blur(2px);
animation: addOrganisationOverlayShow 0.18s ease;
}
.add-organisation-modal {
position: fixed;
z-index: 90;
z-index: 120;
top: 50%;
left: 50%;
width: min(520px, calc(100vw - 32px));
max-height: calc(100vh - 40px);
padding: 24px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
background: $color-surface;
box-shadow: $shadow-modal;
transform: translate(-50%, -50%);
animation: addOrganisationModalShow 0.2s ease;
}
.add-organisation-modal--with-policy {
width: min(980px, calc(100vw - 32px));
}
.add-organisation-modal__header {
@ -41,7 +43,7 @@
margin: 0;
color: $color-text;
font-size: 24px;
font-size: $font-size-24;
font-weight: 650;
}
@ -49,7 +51,7 @@
margin: 6px 0 0;
color: $gray50;
font-size: 15px;
font-size: $font-size-15;
font-weight: 400;
line-height: 1.4;
}
@ -82,6 +84,44 @@
gap: 14px;
}
.add-organisation-form--with-policy {
display: grid;
grid-template-columns: minmax(260px, 0.82fr) minmax(460px, 1.18fr);
align-items: stretch;
gap: 16px;
.add-organisation-error,
.add-organisation-modal__footer {
grid-column: 1 / -1;
}
.avatar-upload{
flex: 1;
width: inherit;
.avatar-upload__actions{
//display: none;
}
&__preview{
width: 200px;
height: 200px;
}
&__camera{
right: 6px;
bottom: 6px;
}
}
.add-organisation-field{
span{
display: none;
}
}
}
.add-organisation-form__main {
display: flex;
flex-direction: column;
gap: 14px;
}
.add-organisation-field {
display: flex;
flex-direction: column;
@ -89,28 +129,28 @@
span {
color: $color-text;
font-size: 14px;
font-size: $font-size-14;
font-weight: 500;
}
input {
height: 44px;
border: 1px solid #dfe5ef;
border: 1px solid $color-border-strong;
border-radius: 14px;
padding: 0 14px;
background: #f8fafc;
background: $color-surface-muted;
outline: none;
color: $color-text;
font-size: 15px;
font-size: $font-size-15;
font-weight: 400;
transition: 0.2s ease;
&:focus {
border-color: $blue;
background: #ffffff;
background: $color-surface;
}
&::placeholder {
@ -121,11 +161,11 @@
.add-organisation-error {
border-radius: 14px;
background: rgba(224, 0, 0, 0.08);
background: $color-danger-tint;
padding: 12px 14px;
color: $red;
font-size: 14px;
font-size: $font-size-14;
font-weight: 500;
}
@ -145,7 +185,7 @@
border: none;
border-radius: 14px;
font-size: 15px;
font-size: $font-size-15;
font-weight: 600;
cursor: pointer;
@ -169,198 +209,23 @@
.add-organisation-submit {
background: $blue;
color: #ffffff;
color: $color-text-inverse;
&:hover {
opacity: 0.9;
box-shadow: 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
}
&:disabled:hover {
box-shadow: none;
}
}
.add-organisation-policy {
margin-top: 2px;
padding: 14px;
border: 1px solid #dfe5ef;
border-radius: 18px;
background: #f8fafc;
}
.add-organisation-policy__header {
margin-bottom: 8px;
h3 {
margin: 0;
color: $color-text;
font-size: 16px;
font-weight: 650;
@media (max-width: 900px) {
.add-organisation-modal--with-policy {
width: min(560px, calc(100vw - 32px));
}
p {
margin: 4px 0 0;
color: $gray50;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
}
}
.add-organisation-policy__list {
display: flex;
flex-direction: column;
}
.add-organisation-policy-item {
display: flex;
align-items: center;
gap: 10px;
svg {
width: 22px;
height: 22px;
padding: 4px;
border-radius: 8px;
color: $blue;
background: #eef1f6;
}
&:first-child {
.add-organisation-policy-item__content {
border-top: none;
}
}
&.is-disabled {
opacity: 0.45;
cursor: not-allowed;
svg,
span,
button {
pointer-events: none;
}
}
}
.add-organisation-policy-item__content {
min-height: 44px;
flex: 1;
border-top: 1px solid $gray20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
> span {
color: $color-text;
font-size: 15px;
font-weight: 500;
}
}
.add-organisation-policy-switch {
position: relative;
width: 48px;
height: 24px;
flex: 0 0 48px;
padding: 0;
border: none;
outline: none;
display: inline-flex;
align-items: center;
border-radius: 999px;
background: $gray20;
box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
cursor: pointer;
user-select: none;
transition:
background-color 0.28s ease,
box-shadow 0.28s ease,
transform 0.18s ease;
> span {
position: absolute;
top: 3px;
left: 3px;
width: 24px;
height: 18px;
border-radius: 100px;
background: #ffffff;
box-shadow:
0 2px 5px rgba(15, 23, 42, 0.18),
0 1px 1px rgba(15, 23, 42, 0.08);
transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.28s ease,
width 0.18s ease;
}
&.is-enabled {
background: $blue;
box-shadow: inset 0 0 0 1px rgba($blue, 0.12);
> span {
transform: translateX(18px);
box-shadow:
0 3px 7px rgba(3, 29, 154, 0.24),
0 1px 1px rgba(15, 23, 42, 0.08);
}
}
&:hover {
box-shadow:
inset 0 0 0 1px rgba($gray50, 0.12),
0 4px 10px rgba(15, 23, 42, 0.06);
}
&:active {
transform: scale(0.96);
> span {
width: 23px;
}
}
&.is-enabled:active > span {
transform: translateX(17px);
}
&:focus-visible {
box-shadow:
0 0 0 3px rgba($blue, 0.18),
inset 0 0 0 1px rgba($blue, 0.18);
}
&:disabled {
cursor: not-allowed;
}
}
@keyframes addOrganisationOverlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes addOrganisationModalShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.98);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
.add-organisation-form--with-policy {
display: flex;
flex-direction: column;
}
}

View File

@ -1,30 +1,45 @@
import { useEffect, useState, type FormEvent, type ReactNode } from 'react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
} from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { useApolloClient, useMutation } from '@apollo/client/react'
import { WifiIcon } from '../../../../assets/icons/Wifi'
import { BluetoothIcon } from '../../../../assets/icons/Bluetooth'
import { GpsIcon } from '../../../../assets/icons/Gps'
import { CameraIcon } from '../../../../assets/icons/Camera'
import { SimIcon } from '../../../../assets/icons/Sim'
import { VolumeIcon } from '../../../../assets/icons/Volume'
import {
CHANGE_ORGANISATION_MUTATION,
CREATE_ORGANISATION_MUTATION,
CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION,
GET_ORGANISATION_QUERY,
GET_ORGANISATIONS_QUERY,
} from '../../../../entities/employee/api/employee.graphql'
import type {
ChangeOrganisationData,
ChangeOrganisationVariables,
CreateOrganisationData,
CreateOrganisationVariables,
CreateUploadOrganisationLogoUrlData,
CreateUploadOrganisationLogoUrlVariables,
GetOrganisationData,
GetOrganisationVariables,
Organisation,
OrganisationPolicy,
} from '../../../../entities/employee/model/types'
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import {
OrganisationPolicyCard,
defaultOrganisationPolicyDraft,
mapOrganisationPolicyDraftToVariables,
mapOrganisationPolicyToDraft,
type OrganisationPolicyDraft,
} from '../../../OrganisationPage/components/OrganisationPolicyCard/OrganisationPolicyCard'
import { AvatarUpload } from '../AvatarUpload/AvatarUpload'
import { uploadMultipartFile } from '../../lib/uploadMultipartFile'
import './AddOrganisationModal.scss'
@ -36,82 +51,16 @@ type AddOrganisationModalProps = {
onSuccess?: () => void | Promise<void>
}
type ModalPolicyKey =
| keyof OrganisationPolicy
| 'wifi'
| 'speaker'
type OrganisationPolicyVariables = Omit<
CreateOrganisationVariables,
'name'
>
type ModalPolicyItem = {
key: ModalPolicyKey
label: string
icon: ReactNode
disabled?: boolean
}
const defaultOrganisationPolicy: OrganisationPolicy = {
canUseBluetooth: true,
canUseCamera: true,
canUseGPS: true,
canUseSim: true,
}
const policyItems: ModalPolicyItem[] = [
{
key: 'wifi',
label: 'Wi-Fi',
icon: <WifiIcon />,
disabled: true,
},
{
key: 'canUseBluetooth',
label: 'Bluetooth',
icon: <BluetoothIcon />,
},
{
key: 'canUseGPS',
label: 'GPS',
icon: <GpsIcon />,
},
{
key: 'canUseCamera',
label: 'Камера',
icon: <CameraIcon />,
},
{
key: 'canUseSim',
label: 'SIM-карта',
icon: <SimIcon />,
},
{
key: 'speaker',
label: 'Динамик',
icon: <VolumeIcon />,
disabled: true,
},
]
const defaultOrganisationsQueryVariables = {
page: 0,
query: undefined,
sortDirection: 'ASC' as const,
sortField: 'ID' as const,
}
function getPolicyVariables(policy?: OrganisationPolicy | null): OrganisationPolicy {
return {
canUseBluetooth: policy?.canUseBluetooth ?? true,
canUseCamera: policy?.canUseCamera ?? true,
canUseGPS: policy?.canUseGPS ?? true,
canUseSim: policy?.canUseSim ?? true,
}
}
function isBackendPolicyKey(key: ModalPolicyKey): key is keyof OrganisationPolicy {
return (
key === 'canUseBluetooth' ||
key === 'canUseCamera' ||
key === 'canUseGPS' ||
key === 'canUseSim'
function getPolicyVariables(
policy?: OrganisationPolicy | OrganisationPolicyDraft | null,
): OrganisationPolicyVariables {
return mapOrganisationPolicyDraftToVariables(
mapOrganisationPolicyToDraft(policy),
)
}
@ -125,10 +74,19 @@ export function AddOrganisationModal({
const client = useApolloClient()
const [name, setName] = useState('')
const [policy, setPolicy] = useState<OrganisationPolicy>(
defaultOrganisationPolicy,
const [policy, setPolicy] = useState<OrganisationPolicyDraft>(
defaultOrganisationPolicyDraft,
)
const [logoFile, setLogoFile] = useState<File | null>(null)
const [logoPreviewUrl, setLogoPreviewUrl] = useState('')
const logoPreviewUrlRef = useRef('')
const [createUploadOrganisationLogoUrl] = useMutation<
CreateUploadOrganisationLogoUrlData,
CreateUploadOrganisationLogoUrlVariables
>(CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION)
const isEditMode = mode === 'edit'
const [createOrganisation, { loading: isCreating, error: createError }] =
@ -140,37 +98,121 @@ export function AddOrganisationModal({
useMutation<ChangeOrganisationData, ChangeOrganisationVariables>(
CHANGE_ORGANISATION_MUTATION,
)
const isLoading = isCreating || isChanging
const [isLogoUploading, setIsLogoUploading] = useState(false)
const isLoading = isCreating || isChanging || isLogoUploading
const mutationError = createError || changeError
const modalClassName = useMemo(
() =>
`add-organisation-modal ${
!isEditMode ? 'add-organisation-modal--with-policy' : ''
}`,
[isEditMode],
)
const formClassName = useMemo(
() =>
`add-organisation-form ${
!isEditMode ? 'add-organisation-form--with-policy' : ''
}`,
[isEditMode],
)
const logoAvatarUrl = useMemo(
() => logoPreviewUrl || organisation?.logoUrl || '',
[logoPreviewUrl, organisation?.logoUrl],
)
const organisationInitials = useMemo(
() => getOrganisationInitials(name),
[name],
)
const handleNameChange = useCallback((
event: ChangeEvent<HTMLInputElement>,
) => {
setName(event.target.value)
}, [])
useEffect(() => {
logoPreviewUrlRef.current = logoPreviewUrl
}, [logoPreviewUrl])
useEffect(() => {
return () => {
if (logoPreviewUrlRef.current) {
URL.revokeObjectURL(logoPreviewUrlRef.current)
}
}
}, [])
function getOrganisationInitials(nameValue: string) {
const words = nameValue.trim().split(/\s+/).filter(Boolean)
if (words.length >= 2) {
return `${words[0][0] ?? ''}${words[1][0] ?? ''}`.toUpperCase()
}
return nameValue.trim().slice(0, 2).toUpperCase() || 'ОР'
}
function handleLogoChange(file: File, previewUrl: string) {
if (logoPreviewUrlRef.current) {
URL.revokeObjectURL(logoPreviewUrlRef.current)
logoPreviewUrlRef.current = ''
}
setLogoFile(file)
setLogoPreviewUrl(previewUrl)
}
function handleLogoReset() {
resetLogoDraft()
}
function resetLogoDraft() {
if (logoPreviewUrlRef.current) {
URL.revokeObjectURL(logoPreviewUrlRef.current)
logoPreviewUrlRef.current = ''
}
setLogoFile(null)
setLogoPreviewUrl('')
}
async function uploadLogoForOrganisation(orgId: string) {
if (!logoFile) return
setIsLogoUploading(true)
try {
const uploadUrlResult = await createUploadOrganisationLogoUrl({
variables: {
orgId,
},
})
const uploadUrl = uploadUrlResult.data?.createUploadOrganisationLogoUrl
if (!uploadUrl) {
throw new Error('Не удалось получить ссылку для загрузки логотипа')
}
await uploadMultipartFile(uploadUrl, 'logo', logoFile)
} finally {
setIsLogoUploading(false)
}
}
useEffect(() => {
if (!open) return
resetLogoDraft()
if (isEditMode && organisation) {
setName(organisation.name ?? '')
return
}
setName('')
setPolicy(defaultOrganisationPolicy)
setPolicy(defaultOrganisationPolicyDraft)
}, [open, isEditMode, organisation])
function handlePolicyToggle(key: ModalPolicyKey, disabled?: boolean) {
if (disabled || !isBackendPolicyKey(key)) return
setPolicy((prev) => ({
...prev,
[key]: !prev[key],
}))
}
function getPolicyValue(key: ModalPolicyKey) {
if (!isBackendPolicyKey(key)) return true
return policy[key]
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
@ -200,170 +242,201 @@ export function AddOrganisationModal({
const policyVariables = getPolicyVariables(currentPolicy)
await changeOrganisation({
variables: {
id: String(organisation.id),
name: trimmedName,
...policyVariables,
},
refetchQueries: [
{
query: GET_ORGANISATION_QUERY,
variables: {
id: String(organisation.id),
try {
await changeOrganisation({
variables: {
id: String(organisation.id),
name: trimmedName,
...policyVariables,
},
refetchQueries: [
{
query: GET_ORGANISATION_QUERY,
variables: {
id: String(organisation.id),
},
},
},
{
query: GET_ORGANISATIONS_QUERY,
variables: defaultOrganisationsQueryVariables,
},
],
})
],
})
await onSuccess?.()
if (logoFile) {
try {
await uploadLogoForOrganisation(String(organisation.id))
} catch (logoError) {
console.error(
'Организация изменена, но логотип не загрузился',
logoError,
)
}
}
try {
await onSuccess?.()
} catch (successError) {
console.error(
'Организация изменена, но refetch не сработал',
successError,
)
}
onOpenChange(false)
} catch (changeError) {
console.error('Не удалось изменить организацию', changeError)
}
onOpenChange(false)
return
}
console.log('createOrganisation variables', {
name: trimmedName,
...policy,
})
const policyVariables = getPolicyVariables(policy)
await createOrganisation({
variables: {
name: trimmedName,
...policy,
},
})
try {
const result = await createOrganisation({
variables: {
name: trimmedName,
...policyVariables,
},
})
await onSuccess?.()
const createdOrganisationId = result.data?.createOrganisation.id
setName('')
setPolicy(defaultOrganisationPolicy)
onOpenChange(false)
if (!createdOrganisationId) {
throw new Error('Организация создана, но id не вернулся')
}
if (logoFile) {
try {
await uploadLogoForOrganisation(String(createdOrganisationId))
} catch (logoError) {
console.error(
'Организация создана, но логотип не загрузился',
logoError,
)
}
}
try {
await onSuccess?.()
} catch (successError) {
console.error(
'Организация создана, но refetch не сработал',
successError,
)
}
onOpenChange(false)
} catch (createError) {
console.error('Не удалось создать организацию', createError)
}
}
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="add-organisation-modal__overlay" />
<MotionDialogContent
open={open}
overlayClassName="add-organisation-modal__overlay"
contentClassName={modalClassName}
motionPreset="fade"
>
<div className="add-organisation-modal__header">
<div>
<Dialog.Title className="add-organisation-modal__title">
{isEditMode
? 'Редактировать организацию'
: 'Добавить организацию'}
</Dialog.Title>
<Dialog.Content className="add-organisation-modal">
<div className="add-organisation-modal__header">
<div>
<Dialog.Title className="add-organisation-modal__title">
{isEditMode ? 'Редактировать организацию' : 'Добавить организацию'}
</Dialog.Title>
<Dialog.Description className="add-organisation-modal__description">
{isEditMode
? 'Изменение данных организации'
: 'Создание организации для привязки пользователей и устройств'}
</Dialog.Description>
</div>
<Dialog.Close
className="add-organisation-modal__close"
type="button"
disabled={isLoading}
>
<X size={20} />
</Dialog.Close>
<Dialog.Description className="add-organisation-modal__description">
{isEditMode
? 'Изменение данных организации'
: 'Создание организации для привязки пользователей и устройств'}
</Dialog.Description>
</div>
<form className="add-organisation-form" onSubmit={handleSubmit}>
<Dialog.Close
className="add-organisation-modal__close"
type="button"
disabled={isLoading}
>
<X size={20} />
</Dialog.Close>
</div>
<form
className={formClassName}
onSubmit={handleSubmit}
>
<div className="add-organisation-form__main">
<AvatarUpload
avatarUrl={logoAvatarUrl}
allowCamera={false}
initials={organisationInitials}
disabled={isLoading}
hasSelectedFile={Boolean(logoFile)}
title="Логотип организации"
description="PNG, JPG или GIF"
chooseText="Выбрать логотип"
replaceText="Заменить логотип"
alt="Логотип организации"
onChange={handleLogoChange}
onReset={handleLogoReset}
/>
<label className="add-organisation-field">
<span>Название организации</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Например: Organisation 1"
onChange={handleNameChange}
placeholder="Название организации"
required
/>
</label>
</div>
{!isEditMode && (
<div className="add-organisation-policy">
<div className="add-organisation-policy__header">
<h3>Начальная политика</h3>
<p>Будет применяться к сотрудникам этой организации</p>
</div>
{!isEditMode && (
<OrganisationPolicyCard
variant="form"
title="Начальная политика"
description="Будет применяться к устройствам этой организации"
value={policy}
disabled={isLoading}
onChange={setPolicy}
/>
)}
<div className="add-organisation-policy__list">
{policyItems.map((item) => {
const enabled = getPolicyValue(item.key)
return (
<div
className={`add-organisation-policy-item ${item.disabled ? 'is-disabled' : ''
}`}
key={item.key}
aria-disabled={item.disabled}
>
{item.icon}
<div className="add-organisation-policy-item__content">
<span>{item.label}</span>
<button
type="button"
className={`add-organisation-policy-switch ${enabled ? 'is-enabled' : ''
}`}
onClick={() =>
handlePolicyToggle(item.key, item.disabled)
}
disabled={item.disabled || isLoading}
aria-pressed={enabled}
aria-label={`${enabled ? 'Выключить' : 'Включить'
} ${item.label}`}
>
<span />
</button>
</div>
</div>
)
})}
</div>
</div>
)}
{mutationError && (
<div className="add-organisation-error">
{isEditMode
? 'Не удалось сохранить организацию. Проверьте данные.'
: 'Не удалось добавить организацию. Проверьте данные.'}
</div>
)}
<div className="add-organisation-modal__footer">
<Dialog.Close
className="add-organisation-cancel"
type="button"
disabled={isLoading}
>
Отмена
</Dialog.Close>
<button
className="add-organisation-submit"
type="submit"
disabled={isLoading || !name.trim()}
>
{isLoading
? isEditMode
? 'Сохранение...'
: 'Добавление...'
: isEditMode
? 'Сохранить'
: 'Добавить'}
</button>
{mutationError && (
<div className="add-organisation-error">
{isEditMode
? 'Не удалось сохранить организацию. Проверьте данные.'
: 'Не удалось добавить организацию. Проверьте данные.'}
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
)}
<div className="add-organisation-modal__footer">
<Dialog.Close
className="add-organisation-cancel"
type="button"
disabled={isLoading}
>
Отмена
</Dialog.Close>
<button
className="add-organisation-submit"
type="submit"
disabled={isLoading || !name.trim()}
>
{isLoading
? isEditMode
? 'Сохранение...'
: 'Добавление...'
: isEditMode
? 'Сохранить'
: 'Добавить'}
</button>
</div>
</form>
</MotionDialogContent>
</Dialog.Root>
)
}

View File

@ -0,0 +1,152 @@
@use '../../../../shared/styles/variables' as *;
.avatar-upload__menu {
min-width: 220px;
padding: 6px;
border-radius: 16px;
background: $color-media-bg;
border: 1px solid $color-media-border;
z-index: 1000;
}
.avatar-upload__menu-item {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 12px;
border-radius: 10px;
cursor: pointer;
outline: none;
&:hover {
background: $color-media-hover;
}
}
.camera-modal__overlay {
position: fixed;
inset: 0;
background: $color-media-overlay;
z-index: 9998;
}
.camera-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 751px;
background: $color-media-bg;
border-radius: 24px;
//padding: 24px;
z-index: 9999;
.camera-modal__header{
position: absolute;
top: 8px;
right: 8px;
button{
display: flex;
align-items: center;
justify-content: center;
background-color: $color-media-control-bg;
border: none;
backdrop-filter: blur(15px);
padding: 8px;
height: 44px;
width: 44px;
color: $color-media-control-text-muted;
border-radius: 1000px;
cursor: pointer;
transition: .2s ease;
&:hover{
color: $color-media-control-text;
background-color: $color-media-control-bg-hover;
}
}
}
}
.camera-modal__body {
margin-top: 0px;
position: relative;
width: 100%;
height: 80vh;
}
.camera-modal__not-found{
color: $color-media-control-text;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
//background-color: $blue;
border-radius: 24px;
svg{
width: 32px;
height: auto;
margin-right: 8px;
margin-bottom: 5px;
}
}
.camera-modal__body video, .camera-modal__body img {
position: absolute;
width: 100%;
height: 100%;
border-radius: 24px;
object-fit: contain;
display: block;
}
.camera-modal__footer {
position: absolute;
display: flex;
justify-content: center;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
button{
display: flex;
align-items: center;
justify-content: center;
background-color: rgba($black, 0.1);
border: none;
backdrop-filter: blur(15px);
padding: 8px;
height: 44px;
width: 44px;
color: $color-media-control-text;
border-radius: 1000px;
box-shadow: inset 0 0 5px rgba($white, 0.4);
transition: .2s ease;
cursor: pointer;
&:hover{
box-shadow: inset 0 0 5px $blue20;
color: $blue20;
}
}
}

View File

@ -0,0 +1,194 @@
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import {
Camera,
X,
} from 'lucide-react'
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import './AvatarCameraModal.scss'
type AvatarCameraModalProps = {
open: boolean
onClose: () => void
onCapture: (file: File) => void
}
export function AvatarCameraModal({
open,
onClose,
onCapture,
}: AvatarCameraModalProps) {
const videoRef =
useRef<HTMLVideoElement | null>(null)
const streamRef =
useRef<MediaStream | null>(null)
const [error, setError] = useState('')
const stopStream = useCallback(() => {
streamRef.current
?.getTracks()
.forEach((track) => track.stop())
streamRef.current = null
}, [])
useEffect(() => {
if (!open) {
stopStream()
return
}
async function initCamera() {
try {
setError('')
const stream =
await navigator.mediaDevices.getUserMedia(
{
video: {
facingMode: 'user',
},
audio: false,
},
)
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
}
} catch {
setError(
'Не удалось получить доступ к камере',
)
}
}
initCamera()
return () => {
stopStream()
}
}, [open, stopStream])
async function handleCapture() {
if (!videoRef.current) return
const video = videoRef.current
const canvas =
document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const context =
canvas.getContext('2d')
if (!context) return
context.drawImage(
video,
0,
0,
canvas.width,
canvas.height,
)
canvas.toBlob(
(blob) => {
if (!blob) return
const file = new File(
[blob],
`camera-${Date.now()}.jpg`,
{
type: 'image/jpeg',
},
)
onCapture(file)
},
'image/jpeg',
0.95,
)
}
return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next) {
onClose()
}
}}
>
<MotionDialogContent
open={open}
overlayClassName="camera-modal__overlay"
contentClassName="camera-modal"
>
<div className="camera-modal__header">
<button
type="button"
onClick={onClose}
>
<X size={18} />
</button>
</div>
<div className="camera-modal__body">
{error ? (
<div className='camera-modal__not-found'>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<g fill="currentColor">
<path d="m7.762 7.5l.41-1.268A2.5 2.5 0 0 1 10.55 4.5h2.898a2.5 2.5 0 0 1 2.379 1.732l.41 1.268H17.5A2.5 2.5 0 0 1 20 10v6a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 16v-6a2.5 2.5 0 0 1 2.5-2.5z" opacity=".2"></path>
<path fillRule="evenodd" d="M6.172 5.232L5.762 6.5H4.5A2.5 2.5 0 0 0 2 9v6a2.5 2.5 0 0 0 2.5 2.5h11A2.5 2.5 0 0 0 18 15V9a2.5 2.5 0 0 0-2.5-2.5h-1.263l-.409-1.268a2.5 2.5 0 0 0-2.38-1.732H8.552a2.5 2.5 0 0 0-2.38 1.732M4.5 7.5h1.99l.633-1.96A1.5 1.5 0 0 1 8.551 4.5h2.898a1.5 1.5 0 0 1 1.427 1.04l.633 1.96H15.5A1.5 1.5 0 0 1 17 9v6a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 15V9a1.5 1.5 0 0 1 1.5-1.5" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M7 11.5a3 3 0 1 0 6 0a3 3 0 0 0-6 0m5 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" clipRule="evenodd"></path>
<path d="M1.15 1.878a.514.514 0 0 1 .728-.727l16.971 16.971a.514.514 0 0 1-.727.727z"></path>
</g>
</svg>
{error}
</div>
) : (
<video
ref={videoRef}
autoPlay
muted
playsInline
/>
)}
<div className="camera-modal__header">
<button
type="button"
onClick={onClose}
>
<X size={18} />
</button>
</div>
<div className="camera-modal__footer">
<button
type="button"
onClick={handleCapture}
disabled={Boolean(error)}
>
<Camera size={22} />
</button>
</div>
</div>
</MotionDialogContent>
</Dialog.Root>
)
}

View File

@ -0,0 +1,156 @@
@use '../../../../shared/styles/variables' as *;
.avatar-crop-modal__overlay {
position: fixed;
inset: 0;
z-index: 120;
background: $color-backdrop-strong;
backdrop-filter: blur(3px);
}
.avatar-crop-modal {
position: fixed;
z-index: 130;
top: 50%;
left: 50%;
width: min(720px, calc(100vw - 28px));
height: min(680px, calc(100vh - 28px));
padding: 18px;
transform: translate(-50%, -50%);
border-radius: 24px;
background: $color-surface;
color: $color-text;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: $shadow-modal-dark;
}
.avatar-crop-modal__header {
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h2 {
margin: 0;
font-size: $font-size-18;
font-weight: 650;
}
}
.avatar-crop-modal__close {
width: 36px;
height: 36px;
border: none;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: $gray20;
color: $gray50;
cursor: pointer;
transition: 0.2s ease;
&:hover {
background: $blue20;
color: $blue;
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.avatar-crop-modal__cropper {
position: relative;
flex: 1;
min-height: 360px;
border-radius: 20px;
overflow: hidden;
background: $color-bg
}
.avatar-crop-modal__controls {
padding: 0 4px;
label {
display: flex;
align-items: center;
gap: 14px;
}
span {
width: 82px;
color: $gray50;
font-size: $font-size-14;
font-weight: 600;
}
input {
width: 100%;
accent-color: $blue;
}
}
.avatar-crop-modal__footer {
height: 44px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.avatar-crop-modal__cancel,
.avatar-crop-modal__submit {
height: 42px;
border: none;
border-radius: 14px;
padding: 0 18px;
font-size: $font-size-15;
font-weight: 650;
cursor: pointer;
transition: 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: default;
}
}
.avatar-crop-modal__cancel {
background: $gray20;
color: $gray50;
opacity: .8;
&:hover {
opacity: 1;
}
}
.avatar-crop-modal__submit {
background: $blue;
color: $color-text-inverse;
&:hover {
opacity: 0.9;
}
}

View File

@ -0,0 +1,186 @@
import { useCallback, useEffect, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import Cropper, { type Area } from 'react-easy-crop'
import { X } from 'lucide-react'
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import './AvatarCropModal.scss'
type AvatarCropModalProps = {
open: boolean
imageSrc: string
onCancel: () => void
onConfirm: (file: File, previewUrl: string) => void
}
function createImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = reject
image.src = src
})
}
async function createCroppedAvatarFile(imageSrc: string, crop: Area) {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
const outputSize = 512
canvas.width = outputSize
canvas.height = outputSize
if (!context) {
throw new Error('Не удалось подготовить изображение')
}
context.drawImage(
image,
crop.x,
crop.y,
crop.width,
crop.height,
0,
0,
outputSize,
outputSize,
)
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png', 0.92)
})
if (!blob) {
throw new Error('Не удалось обрезать изображение')
}
return new File([blob], 'avatar.png', {
type: 'image/png',
})
}
export function AvatarCropModal({
open,
imageSrc,
onCancel,
onConfirm,
}: AvatarCropModalProps) {
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
useEffect(() => {
if (!open) return
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
setIsProcessing(false)
}, [open, imageSrc])
const handleCropComplete = useCallback(
(_croppedArea: Area, nextCroppedAreaPixels: Area) => {
setCroppedAreaPixels(nextCroppedAreaPixels)
},
[],
)
async function handleConfirm() {
if (!croppedAreaPixels || isProcessing) return
setIsProcessing(true)
try {
const file = await createCroppedAvatarFile(imageSrc, croppedAreaPixels)
const previewUrl = URL.createObjectURL(file)
onConfirm(file, previewUrl)
} finally {
setIsProcessing(false)
}
}
return (
<Dialog.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onCancel()
}
}}
>
<MotionDialogContent
open={open}
overlayClassName="avatar-crop-modal__overlay"
contentClassName="avatar-crop-modal"
>
<div className="avatar-crop-modal__header">
<Dialog.Title>Настройка фото</Dialog.Title>
<button
className="avatar-crop-modal__close"
type="button"
onClick={onCancel}
disabled={isProcessing}
>
<X size={20} />
</button>
</div>
<div className="avatar-crop-modal__cropper">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={handleCropComplete}
/>
</div>
<div className="avatar-crop-modal__controls">
<label>
<span>Масштаб</span>
<input
type="range"
min="1"
max="3"
step="0.05"
value={zoom}
onChange={(event) => setZoom(Number(event.target.value))}
disabled={isProcessing}
/>
</label>
</div>
<div className="avatar-crop-modal__footer">
<button
className="avatar-crop-modal__cancel"
type="button"
onClick={onCancel}
disabled={isProcessing}
>
Отмена
</button>
<button
className="avatar-crop-modal__submit"
type="button"
onClick={handleConfirm}
disabled={isProcessing}
>
{isProcessing ? 'Подготовка...' : 'Установить фотографию'}
</button>
</div>
</MotionDialogContent>
</Dialog.Root>
)
}

View File

@ -0,0 +1,168 @@
@use '../../../../shared/styles/variables' as *;
.avatar-upload {
padding: 16px;
border: 1px solid $color-border-accent;
border-radius: 22px;
background: $color-surface-muted;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 180px;
}
.avatar-upload__preview {
position: relative;
width: 148px;
height: 148px;
border-radius: 50%;
background:
radial-gradient(circle at 30% 20%, rgba($white, 0.1), $transparent 28%),
linear-gradient(135deg, $blue, $color-primary-hover);
display: flex;
align-items: center;
justify-content: center;
color: $color-text-inverse;
font-size: $font-size-34;
font-weight: 750;
letter-spacing: 0.04em;
box-shadow:
$shadow-avatar-preview;
overflow: visible;
&.transparent-backdrop{
background: transparent;
}
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
}
.avatar-upload__camera {
position: absolute;
right: 0px;
bottom: 0px;
width: 42px;
height: 42px;
border: 2px solid $color-surface-muted;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: $blue;
//backdrop-filter: blur(16px);
color: $color-text-inverse;
cursor: pointer;
transition:
transform 0.18s ease,
opacity 0.18s ease,
background-color 0.18s ease;
//box-shadow: 0 8px 18px rgba($color-shadow-rgb, 0.18);
&:hover {
transform: scale(1.05);
//opacity: 0.92;
}
&:disabled {
cursor: default;
opacity: 0.6;
transform: none;
}
}
.avatar-upload__text {
text-align: center;
b {
display: block;
color: $color-text-strong;
font-size: $font-size-16;
font-weight: 650;
}
span {
display: block;
margin-top: 4px;
color: $gray50;
font-size: $font-size-13;
font-weight: 500;
}
}
.avatar-upload__actions {
width: 100%;
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.avatar-upload__choose,
.avatar-upload__reset {
height: 40px;
border: none;
border-radius: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: $font-size-14;
font-weight: 650;
cursor: pointer;
transition: 0.2s ease;
&:disabled {
cursor: default;
opacity: 0.6;
}
}
.avatar-upload__choose {
background: $blue;
color: $color-text-inverse;
&:hover {
box-shadow: inset 0 0 0 1px $blue, 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
}
}
.avatar-upload__reset {
width: 40px;
background: rgba($color-danger-rgb, 0.1);
color: $red;
&:hover {
background: rgba($color-danger-rgb, 0.16);
}
}
.avatar-upload__hint {
margin: -2px 0 0;
color: $gray50;
font-size: $font-size-12;
font-weight: 500;
text-align: center;
line-height: 1.35;
}

View File

@ -0,0 +1,229 @@
import { useEffect, useRef, useState } from 'react'
import { AvatarCropModal } from '../AvatarCropModal/AvatarCropModal'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { Camera, ImagePlus, RotateCcw } from 'lucide-react'
import { AvatarCameraModal } from '../AvatarCameraModal/AvatarCameraModal'
import {
MODAL_EXIT_DURATION_MS,
useDeferredModalPayload,
} from '../../../../shared/lib/lazyMount'
import './AvatarUpload.scss'
type AvatarUploadProps = {
avatarUrl?: string
allowCamera?: boolean
initials: string
disabled?: boolean
hasSelectedFile?: boolean
title?: string
description?: string
chooseText?: string
replaceText?: string
alt?: string
onChange: (file: File, previewUrl: string) => void
onReset: () => void
}
export function AvatarUpload({
avatarUrl = '',
allowCamera = true,
initials,
disabled = false,
hasSelectedFile = false,
chooseText = 'Выбрать фото',
replaceText = 'Заменить фото',
alt = 'Изображение',
onChange,
onReset,
}: AvatarUploadProps) {
const inputRef = useRef<HTMLInputElement | null>(null)
const cropImageSrcRef = useRef('')
const [cropImageSrc, setCropImageSrc] = useState('')
const renderCropImageSrc = useDeferredModalPayload(cropImageSrc || null)
const [isCameraOpen, setIsCameraOpen] =
useState(false)
const [isMenuOpen, setIsMenuOpen] =
useState(false)
useEffect(() => {
cropImageSrcRef.current = cropImageSrc
}, [cropImageSrc])
useEffect(() => {
return () => {
if (cropImageSrcRef.current) {
URL.revokeObjectURL(cropImageSrcRef.current)
}
}
}, [])
function revokeCropImageLater(src: string) {
window.setTimeout(() => {
URL.revokeObjectURL(src)
}, MODAL_EXIT_DURATION_MS)
}
function handleCameraCapture(file: File) {
handleFileChange(file)
setIsCameraOpen(false)
}
function handleFileChange(file?: File) {
if (!file || disabled) return
if (cropImageSrc) {
URL.revokeObjectURL(cropImageSrc)
}
setCropImageSrc(URL.createObjectURL(file))
}
function handleCancelCrop() {
if (cropImageSrc) {
revokeCropImageLater(cropImageSrc)
}
setCropImageSrc('')
}
function handleConfirmCrop(file: File, previewUrl: string) {
if (cropImageSrc) {
revokeCropImageLater(cropImageSrc)
}
setCropImageSrc('')
onChange(file, previewUrl)
}
return (
<div className="avatar-upload">
<div className={`avatar-upload__preview ${avatarUrl ? 'transparent-backdrop' : ''}`}>
{avatarUrl ? (
<img src={avatarUrl} alt={alt} />
) : (
<span>{initials}</span>
)}
<DropdownMenu.Root
open={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<DropdownMenu.Trigger asChild>
<button
className="avatar-upload__camera"
type="button"
disabled={disabled}
aria-label="Выбрать фото"
>
<Camera size={18} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="avatar-upload__menu"
side="right"
align="start"
sideOffset={8}
>
<DropdownMenu.Item
className="avatar-upload__menu-item"
onSelect={(event) => {
event.preventDefault()
setIsMenuOpen(false)
setTimeout(() => {
inputRef.current?.click()
}, 0)
}}
>
<ImagePlus size={16} />
<span>Выбрать изображение</span>
</DropdownMenu.Item>
{allowCamera && (
<DropdownMenu.Item
className="avatar-upload__menu-item"
onSelect={(event) => {
event.preventDefault()
setIsMenuOpen(false)
setTimeout(() => {
setIsCameraOpen(true)
}, 0)
}}
>
<Camera size={16} />
<span>Сделать фото</span>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/gif"
hidden
disabled={disabled}
onChange={(event) => {
handleFileChange(event.target.files?.[0])
event.target.value = ''
}}
/>
{/* <div className="avatar-upload__text">
<span>PNG, JPG или GIF</span>
</div> */}
<div className="avatar-upload__actions">
<button
className="avatar-upload__choose"
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
{avatarUrl ? replaceText : chooseText}
</button>
{hasSelectedFile && (
<button
className="avatar-upload__reset"
type="button"
disabled={disabled}
onClick={onReset}
aria-label="Сбросить выбранное фото"
>
<RotateCcw size={16} />
</button>
)}
</div>
{/* {hasSelectedFile && (
<p className="avatar-upload__hint">
Новое фото будет загружено после сохранения.
</p>
)} */}
<AvatarCropModal
open={Boolean(cropImageSrc)}
imageSrc={cropImageSrc || renderCropImageSrc || ''}
onCancel={handleCancelCrop}
onConfirm={handleConfirmCrop}
/>
<AvatarCameraModal
open={isCameraOpen}
onClose={() => setIsCameraOpen(false)}
onCapture={handleCameraCapture}
/>
</div>
)
}

View File

@ -0,0 +1,138 @@
@use '../../../../shared/styles/variables' as *;
.employees-pagination {
position: relative;
z-index: 100;
flex: 0 0 auto;
padding: 12px 0 0;
display: flex;
align-items: center;
justify-content: flex-end;
color: $color-text-muted;
font-size: $font-size-16;
}
.employees-pagination__controls {
display: flex;
align-items: center;
gap: 6px;
button {
min-width: 38px;
height: 38px;
border: none;
border-radius: 10px;
background: $gray20;
display: flex;
align-items: center;
justify-content: center;
color: $gray50;
font-size: $font-size-16;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: 0.2s ease;
&[aria-label='Предыдущая страница'],
&[aria-label='Следующая страница'] {
svg {
width: 20px;
height: 20px;
fill: $transparent;
}
}
&:hover {
background-color: rgba($color-primary-rgb, 0.1);
}
&.is-active {
background: $blue;
color: $color-text-inverse;
box-shadow: var(--shadow-primary);
&:disabled {
opacity: 1;
cursor: default;
}
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
}
.employees-pagination__ellipsis-button {
position: relative;
}
.employees-pagination__ellipsis-dots,
.employees-pagination__ellipsis-arrow {
line-height: 0.5;
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.employees-pagination__ellipsis-dots {
svg {
width: 16px;
height: 16px;
}
}
.employees-pagination__ellipsis-arrow {
position: absolute;
opacity: 0;
transform: translateY(2px);
svg {
width: 22px;
height: 22px;
&.prev {
transform: rotate(-180deg);
}
}
}
.employees-pagination__ellipsis-button:hover {
.employees-pagination__ellipsis-dots {
opacity: 0;
transform: translateY(-2px);
}
.employees-pagination__ellipsis-arrow {
opacity: 1;
transform: translateY(0);
}
}
[data-theme='dark'] {
.employees-pagination__controls button {
background: $color-surface-muted;
&:hover:not(:disabled):not(.is-active) {
background: $color-surface-hover;
color: $color-text-heading;
}
&.is-active {
background: $color-primary;
color: $color-text-inverse;
}
}
}
@include tablet-down {
.employees-pagination {
justify-content: center;
}
}

View File

@ -0,0 +1,183 @@
import { memo } from 'react'
import './EmployeesPagination.scss'
export type PaginationItem = number | 'prev-ellipsis' | 'next-ellipsis'
type EmployeesPaginationProps = {
currentPage: number
loading: boolean
items: PaginationItem[]
hasPrevPage: boolean
hasNextPage: boolean
onPrev: () => void
onNext: () => void
onJumpPrev: () => void
onJumpNext: () => void
onPageChange: (page: number) => void
}
function ChevronLeftIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M15 18L9 12L15 6"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
)
}
function ChevronRightIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M9 6L15 12L9 18"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
)
}
function DoubleChevronLeftIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M11 17L6 12L11 7"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path
d="M18 17L13 12L18 7"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
)
}
function DoubleChevronRightIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M13 7L18 12L13 17"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path
d="M6 7L11 12L6 17"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
)
}
export const EmployeesPagination = memo(function EmployeesPagination({
currentPage,
loading,
items,
hasPrevPage,
hasNextPage,
onPrev,
onNext,
onJumpPrev,
onJumpNext,
onPageChange,
}: EmployeesPaginationProps) {
return (
<div className="employees-pagination">
<div className="employees-pagination__controls">
<button
className="employees-pagination__button"
type="button"
disabled={!hasPrevPage || loading}
onClick={onPrev}
aria-label="Предыдущая страница"
>
<ChevronLeftIcon />
</button>
{items.map((item, index) => {
if (item === 'prev-ellipsis' || item === 'next-ellipsis') {
const isPrev = item === 'prev-ellipsis'
return (
<button
className="employees-pagination__button employees-pagination__ellipsis-button"
type="button"
key={`${item}-${index}`}
disabled={loading}
onClick={isPrev ? onJumpPrev : onJumpNext}
aria-label={
isPrev
? 'Перейти на 5 страниц назад'
: 'Перейти на 5 страниц вперед'
}
>
<span className="employees-pagination__ellipsis-dots">
...
</span>
<span className="employees-pagination__ellipsis-arrow">
{isPrev ? (
<DoubleChevronLeftIcon />
) : (
<DoubleChevronRightIcon />
)}
</span>
</button>
)
}
const pageIndex = item - 1
const isActive = pageIndex === currentPage
return (
<button
className={`employees-pagination__button ${
isActive ? 'is-active' : ''
}`}
type="button"
key={item}
disabled={loading || isActive}
onClick={() => onPageChange(pageIndex)}
>
{item}
</button>
)
})}
<button
className="employees-pagination__button"
type="button"
disabled={!hasNextPage || loading}
onClick={onNext}
aria-label="Следующая страница"
>
<ChevronRightIcon />
</button>
</div>
</div>
)
})

View File

@ -0,0 +1,37 @@
import { memo } from 'react'
import {
PageTabs,
type PageTabItem,
} from '../../../../shared/ui/PageTabs/PageTabs'
export type EmployeesSection = 'users' | 'organisations'
type EmployeesTabsProps = {
activeSection: EmployeesSection
onChange: (section: EmployeesSection) => void
}
const tabs: Array<PageTabItem<EmployeesSection>> = [
{
value: 'users',
label: 'Пользователи',
},
{
value: 'organisations',
label: 'Организации',
},
]
export const EmployeesTabs = memo(function EmployeesTabs({
activeSection,
onChange,
}: EmployeesTabsProps) {
return (
<PageTabs
activeValue={activeSection}
items={tabs}
onChange={onChange}
/>
)
})

View File

@ -1,5 +1,70 @@
@use '../../../../shared/styles/variables' as *;
.devices-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background-color: $color-surface;
border-radius: 100px;
padding: 4px;
box-shadow: $shadow-toolbar;
}
.devices-search {
width: 250px;
height: calc(100% - 20px);
padding: 10px 12px;
border-radius: 24px;
background: $color-bg;
display: flex;
align-items: center;
gap: 8px;
color: $gray50;
transition: .2s ease;
input {
width: 100%;
border: none;
outline: none;
background: $transparent;
color: $color-text-strong;
font-size: $font-size-16;
&::placeholder {
color: $gray50;
}
}
&:focus-within {
box-shadow: inset 0 0 0 1.5px rgba($color-primary-rgb, .15);
}
}
.devices-toolbar__right {
display: flex;
align-items: center;
gap: 6px;
button {
padding: 12px 20px;
font-size: $font-size-16;
font-weight: 500;
background-color: $color-bg;
border-radius: 20px;
border: none;
height: 44px;
}
.add-device {
background-color: $blue;
color: $color-text-inverse;
cursor: pointer;
}
}
.add-device.add-employees{
display: flex;
align-items: center;
@ -10,7 +75,7 @@
width: auto;
}
&:hover{
opacity: .85;
//box-shadow: 0 8px 20px -8px rgba(var(--color-primary-rgb), 0.8);
}
}
@ -19,14 +84,14 @@
padding: 11px 14px 11px 20px !important;
border: none;
border-radius: 20px;
background: #ffffff;
background: $color-surface;
display: inline-flex;
align-items: center;
gap: 8px;
color: $gray50;
font-size: 14px;
font-size: $font-size-14;
font-weight: 550 !important;
cursor: pointer;
@ -56,13 +121,85 @@
}
.employees-filter-button {
max-width: 220px;
padding: 11px 12px 11px 16px !important;
border: none;
border-radius: 20px;
background: $color-surface;
display: inline-flex;
align-items: center;
gap: 8px;
color: $gray50;
font-size: $font-size-16 !important;
font-weight: 550 !important;
cursor: pointer;
transition: 0.2s ease;
> span:not(.employees-filter-button__reset) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
svg {
flex: 0 0 auto;
height: 17px;
width: 17px;
}
&:focus-visible {
outline: none;
}
&:hover {
background-color: $gray20;
}
&[data-state='open'],
&.is-active {
color: $blue;
background-color: $blue20;
}
}
.employees-filter-button--organisation {
max-width: 260px;
}
.employees-filter-button__reset {
width: 24px;
height: 24px;
margin-right: -5px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
color: $blue;
//background: rgba($gray30-rgb, 0.5);
transition:
background-color 0.18s ease,
color 0.18s ease;
&:hover {
color: $red;
background: rgba($gray30-rgb, 0.4);
}
}
.employees-sort-menu {
z-index: 100;
min-width: 210px;
padding: 6px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
background: $color-surface;
box-shadow: $shadow-popover;
animation: employeesSortMenuShow 0.16s ease;
}
@ -77,7 +214,7 @@
gap: 12px;
color: $gray50;
font-size: 14px;
font-size: $font-size-14;
font-weight: 500;
outline: none;
@ -98,6 +235,118 @@
flex: 0 0 7px;
}
.employees-organisation-filter {
z-index: 100;
width: 300px;
padding: 8px;
border-radius: 16px;
background: $color-surface;
box-shadow: $shadow-popover;
animation: employeesSortMenuShow 0.16s ease;
}
.employees-organisation-filter__search {
height: 40px;
padding: 0 10px;
border-radius: 12px;
background: $color-bg;
display: flex;
align-items: center;
gap: 8px;
color: $gray50;
input {
width: 100%;
height: 100%;
border: none;
outline: none;
background: $transparent;
color: $color-text;
font-size: $font-size-14;
font-weight: 500;
&::placeholder {
color: $gray50;
}
}
}
.employees-organisation-filter__list {
max-height: 260px;
margin-top: 8px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 4px;
scrollbar-width: thin;
scrollbar-color: $gray50 $transparent;
}
.employees-organisation-filter__option {
width: 100%;
min-height: 42px;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: $transparent;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: $color-text;
text-align: left;
cursor: pointer;
transition:
background-color 0.18s ease,
color 0.18s ease;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: $font-size-14;
font-weight: 550;
}
small {
flex: 0 0 auto;
color: $gray50;
font-size: $font-size-12;
font-weight: 500;
}
&:hover {
background: $color-bg;
color: $blue;
}
&.is-selected {
background: $blue20;
color: $blue;
}
}
.employees-organisation-filter__state {
padding: 14px 10px;
color: $gray50;
font-size: $font-size-14;
font-weight: 500;
text-align: center;
&.is-error {
color: $red;
}
}
@keyframes employeesSortMenuShow {
from {
opacity: 0;
@ -109,3 +358,61 @@
transform: translateY(0) scale(1);
}
}
@include laptop-down {
.devices-toolbar {
gap: 10px;
}
.devices-search {
width: 220px;
}
.devices-toolbar__right {
button {
padding: 10px 14px;
}
}
.employees-filter-button--organisation {
max-width: 220px;
}
.employees-sort {
padding: 10px 12px 10px 14px !important;
}
}
@include tablet-down {
.devices-toolbar {
flex-wrap: wrap;
align-items: stretch;
gap: 6px;
border-radius: 24px;
padding: 6px;
}
.devices-search {
order: 1;
width: 100%;
height: 42px;
min-height: 42px;
box-sizing: border-box;
}
.devices-toolbar__right {
order: 2;
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
.add-device {
flex: 1 1 auto;
}
}
.employees-filter-button {
flex: 1 1 150px;
max-width: none;
}
}

View File

@ -1,9 +1,29 @@
import {
memo,
type ChangeEvent,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { Search } from 'lucide-react'
import * as Popover from '@radix-ui/react-popover'
import { Building2, ChevronDown, Search, ShieldCheck, X } from 'lucide-react'
import { useQuery } from '@apollo/client/react'
import {
GET_ORGANISATION_QUERY,
GET_ORGANISATIONS_QUERY,
} from '../../../../entities/employee/api/employee.graphql'
import type {
OrganisationSortDirection,
OrganisationSortField,
EmployeeRole,
GetOrganisationData,
GetOrganisationVariables,
GetOrganisationsData,
GetOrganisationsVariables,
} from '../../../../entities/employee/model/types'
import './EmployeesToolbar.scss'
@ -11,11 +31,56 @@ import './EmployeesToolbar.scss'
export type EmployeesSortOption = {
value: string
label: string
sortField: OrganisationSortField
sortDirection: OrganisationSortDirection
sortField: 'ID' | 'Name' | 'Date'
sortDirection: 'ASC' | 'DESC'
}
export const employeesSortOptions: EmployeesSortOption[] = [
export const usersSortOptions: EmployeesSortOption[] = [
{
value: 'date-desc',
label: 'Сначала новые',
sortField: 'Date',
sortDirection: 'DESC',
},
{
value: 'id-asc',
label: 'ID по возрастанию',
sortField: 'ID',
sortDirection: 'ASC',
},
{
value: 'id-desc',
label: 'ID по убыванию',
sortField: 'ID',
sortDirection: 'DESC',
},
{
value: 'name-asc',
label: 'ФИО А–Я',
sortField: 'Name',
sortDirection: 'ASC',
},
{
value: 'name-desc',
label: 'ФИО Я–А',
sortField: 'Name',
sortDirection: 'DESC',
},
{
value: 'date-asc',
label: 'Сначала старые',
sortField: 'Date',
sortDirection: 'ASC',
},
]
export const organisationsSortOptions: EmployeesSortOption[] = [
{
value: 'date-desc',
label: 'Сначала новые',
sortField: 'Date',
sortDirection: 'DESC',
},
{
value: 'id-asc',
label: 'ID по возрастанию',
@ -40,12 +105,6 @@ export const employeesSortOptions: EmployeesSortOption[] = [
sortField: 'Name',
sortDirection: 'DESC',
},
{
value: 'date-desc',
label: 'Сначала новые',
sortField: 'Date',
sortDirection: 'DESC',
},
{
value: 'date-asc',
label: 'Сначала старые',
@ -54,6 +113,22 @@ export const employeesSortOptions: EmployeesSortOption[] = [
},
]
export type EmployeesRoleFilterOption = {
value: 'Admin' | 'User'
label: string
}
const roleFilterOptions: EmployeesRoleFilterOption[] = [
{
value: 'Admin',
label: 'Администратор',
},
{
value: 'User',
label: 'Пользователь',
},
]
type EmployeesToolbarProps = {
addButtonText: string
onAdd: () => void
@ -61,21 +136,198 @@ type EmployeesToolbarProps = {
searchValue?: string
onSearchChange?: (value: string) => void
selectedSort?: EmployeesSortOption
onSortChange?: (option: EmployeesSortOption) => void
sortOptions: EmployeesSortOption[]
selectedSort: EmployeesSortOption
onSortChange: (option: EmployeesSortOption) => void
showSort?: boolean
roleFilter?: EmployeeRole | null
onRoleFilterChange?: (role: EmployeeRole | null) => void
organisationFilterId?: string | null
onOrganisationFilterChange?: (
organisation: { id: string; name: string } | null,
) => void
showUserFilters?: boolean
}
export function EmployeesToolbar({
export const EmployeesToolbar = memo(function EmployeesToolbar({
addButtonText,
onAdd,
searchValue = '',
onSearchChange,
selectedSort = employeesSortOptions[0],
sortOptions,
selectedSort,
onSortChange,
showSort = true,
roleFilter = null,
onRoleFilterChange,
organisationFilterId = null,
onOrganisationFilterChange,
showUserFilters = false,
}: EmployeesToolbarProps) {
const [isOrganisationOpen, setIsOrganisationOpen] = useState(false)
const [organisationSearch, setOrganisationSearch] = useState('')
const [debouncedOrganisationSearch, setDebouncedOrganisationSearch] =
useState('')
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedOrganisationSearch(organisationSearch.trim())
}, 300)
return () => {
window.clearTimeout(timeoutId)
}
}, [organisationSearch])
const selectedRoleOption = useMemo(
() =>
roleFilterOptions.find((option) => option.value === roleFilter) ?? null,
[roleFilter],
)
const selectedOrganisationVariables = useMemo<GetOrganisationVariables>(
() => ({
id: organisationFilterId ?? '',
}),
[organisationFilterId],
)
const organisationsVariables = useMemo<GetOrganisationsVariables>(
() => ({
page: 0,
query: debouncedOrganisationSearch || undefined,
sortDirection: 'ASC',
sortField: 'Name',
}),
[debouncedOrganisationSearch],
)
const {
data: selectedOrganisationData,
} = useQuery<GetOrganisationData, GetOrganisationVariables>(
GET_ORGANISATION_QUERY,
{
variables: selectedOrganisationVariables,
skip: !showUserFilters || !organisationFilterId,
fetchPolicy: 'cache-first',
},
)
const {
data: organisationsData,
previousData: previousOrganisationsData,
loading: organisationsLoading,
error: organisationsError,
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
GET_ORGANISATIONS_QUERY,
{
variables: organisationsVariables,
skip: !showUserFilters || !isOrganisationOpen,
fetchPolicy: 'network-only',
notifyOnNetworkStatusChange: true,
},
)
const organisationsResponse =
organisationsData?.getOrganisations ??
previousOrganisationsData?.getOrganisations
const organisations = useMemo(
() => organisationsResponse?.page ?? [],
[organisationsResponse?.page],
)
const selectedOrganisationName =
selectedOrganisationData?.getOrganisation?.name ??
(organisationFilterId ? `Организация #${organisationFilterId}` : null)
const organisationOptions = useMemo(() => {
const map = new Map<string, { id: string; name: string }>()
if (organisationFilterId && selectedOrganisationName) {
map.set(organisationFilterId, {
id: organisationFilterId,
name: selectedOrganisationName,
})
}
organisations.forEach((organisation) => {
map.set(String(organisation.id), {
id: String(organisation.id),
name: organisation.name,
})
})
return Array.from(map.values())
}, [organisationFilterId, selectedOrganisationName, organisations])
const handleSearchChange = useCallback((
event: ChangeEvent<HTMLInputElement>,
) => {
onSearchChange?.(event.target.value)
}, [onSearchChange])
const handleOrganisationSearchChange = useCallback((
event: ChangeEvent<HTMLInputElement>,
) => {
setOrganisationSearch(event.target.value)
}, [])
const handleResetClick = useCallback((
event: MouseEvent<HTMLSpanElement>,
) => {
event.preventDefault()
event.stopPropagation()
}, [])
const handleRoleResetPointerDown = useCallback((
event: PointerEvent<HTMLSpanElement>,
) => {
event.preventDefault()
event.stopPropagation()
onRoleFilterChange?.(null)
}, [onRoleFilterChange])
const handleOrganisationResetPointerDown = useCallback((
event: PointerEvent<HTMLSpanElement>,
) => {
event.preventDefault()
event.stopPropagation()
onOrganisationFilterChange?.(null)
}, [onOrganisationFilterChange])
const handleRoleResetKeyDown = useCallback((
event: KeyboardEvent<HTMLSpanElement>,
) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
event.stopPropagation()
onRoleFilterChange?.(null)
}, [onRoleFilterChange])
const handleOrganisationResetKeyDown = useCallback((
event: KeyboardEvent<HTMLSpanElement>,
) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
event.stopPropagation()
onOrganisationFilterChange?.(null)
}, [onOrganisationFilterChange])
const handleOrganisationSelect = useCallback((
organisation: { id: string; name: string },
) => {
onOrganisationFilterChange?.(organisation)
setIsOrganisationOpen(false)
setOrganisationSearch('')
setDebouncedOrganisationSearch('')
}, [onOrganisationFilterChange])
return (
<div className="devices-toolbar">
<label className="devices-search">
@ -85,11 +337,196 @@ export function EmployeesToolbar({
type="text"
placeholder="Поиск"
value={searchValue}
onChange={(event) => onSearchChange?.(event.target.value)}
onChange={handleSearchChange}
/>
</label>
<div className="devices-toolbar__right">
{showUserFilters && (
<>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className={`employees-filter-button ${
selectedRoleOption ? 'is-active' : ''
}`}
type="button"
>
<ShieldCheck size={17} />
<span>{selectedRoleOption?.label ?? 'Роль'}</span>
{selectedRoleOption ? (
<span
className="employees-filter-button__reset"
role="button"
tabIndex={0}
onPointerDown={handleRoleResetPointerDown}
onClick={handleResetClick}
onKeyDown={handleRoleResetKeyDown}
aria-label="Сбросить фильтр роли"
>
<X size={14} />
</span>
) : (
<ChevronDown size={16} />
)}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="employees-sort-menu"
align="end"
sideOffset={8}
>
{roleFilterOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="employees-sort-menu__item"
onSelect={() => onRoleFilterChange?.(option.value)}
>
<span>{option.label}</span>
{roleFilter === option.value && (
<span className="employees-sort-menu__check" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<Popover.Root
open={isOrganisationOpen}
onOpenChange={setIsOrganisationOpen}
>
<Popover.Trigger asChild>
<button
className={`employees-filter-button employees-filter-button--organisation ${
organisationFilterId ? 'is-active' : ''
}`}
type="button"
>
<Building2 size={17} />
<span>{selectedOrganisationName ?? 'Организация'}</span>
{organisationFilterId ? (
<span
className="employees-filter-button__reset"
role="button"
tabIndex={0}
onPointerDown={handleOrganisationResetPointerDown}
onClick={handleResetClick}
onKeyDown={handleOrganisationResetKeyDown}
aria-label="Сбросить фильтр организации"
>
<X size={14} />
</span>
) : (
<ChevronDown size={16} />
)}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="employees-organisation-filter"
align="end"
sideOffset={8}
collisionPadding={12}
>
<label className="employees-organisation-filter__search">
<Search size={16} />
<input
value={organisationSearch}
onChange={handleOrganisationSearchChange}
placeholder="Название организации"
autoFocus
/>
</label>
<div className="employees-organisation-filter__list">
{organisationsLoading && organisationOptions.length === 0 && (
<div className="employees-organisation-filter__state">
Загрузка организаций...
</div>
)}
{organisationsError && (
<div className="employees-organisation-filter__state is-error">
Не удалось загрузить организации
</div>
)}
{!organisationsLoading &&
!organisationsError &&
organisationOptions.length === 0 && (
<div className="employees-organisation-filter__state">
Организации не найдены
</div>
)}
{organisationOptions.map((organisation) => {
const isSelected =
organisation.id === organisationFilterId
return (
<button
className={`employees-organisation-filter__option ${
isSelected ? 'is-selected' : ''
}`}
type="button"
key={organisation.id}
onClick={() => handleOrganisationSelect(organisation)}
>
<span>{organisation.name}</span>
<small>ID: {organisation.id}</small>
</button>
)
})}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</>
)}
{showSort && (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="employees-sort" type="button">
{selectedSort.label}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6" />
</svg>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="employees-sort-menu"
align="end"
sideOffset={8}
>
{sortOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="employees-sort-menu__item"
onSelect={() => onSortChange?.(option)}
>
<span>{option.label}</span>
{selectedSort.value === option.value && (
<span className="employees-sort-menu__check" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
<button
className="add-device add-employees"
type="button"
@ -139,43 +576,7 @@ export function EmployeesToolbar({
{addButtonText}
</button>
{showSort && (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="employees-sort" type="button">
{selectedSort.label}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6" />
</svg>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="employees-sort-menu"
align="end"
sideOffset={8}
>
{employeesSortOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="employees-sort-menu__item"
onSelect={() => onSortChange?.(option)}
>
<span>{option.label}</span>
{selectedSort.value === option.value && (
<span className="employees-sort-menu__check" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</div>
</div>
)
}
})

View File

@ -0,0 +1,208 @@
@use '../../../../shared/styles/variables' as *;
.organisation-select {
position: relative;
}
.organisation-select__trigger {
width: 100%;
height: 44px;
padding: 0 12px;
border: 1px solid $color-border-strong;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: $color-surface;
color: $color-text;
font-size: $font-size-15;
font-weight: 500;
text-align: left;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.is-placeholder {
color: $gray50;
}
svg {
flex: 0 0 auto;
color: $gray50;
transition: transform 0.2s ease;
}
&:hover {
border-color: rgba($color-primary-rgb, 0.45);
}
&:disabled {
opacity: 0.65;
cursor: not-allowed;
}
}
.organisation-select.is-open {
.organisation-select__trigger {
border-color: rgba($color-primary-rgb, 0.7);
box-shadow: 0 0 0 3px rgba($color-primary-rgb, 0.12);
svg {
transform: rotate(180deg);
}
}
}
.organisation-select__dropdown {
position: absolute;
z-index: 30;
top: calc(100% + 8px);
left: 0;
right: 0;
padding: 10px;
border: 1px solid $color-border-strong;
border-radius: 16px;
background: $color-surface;
box-shadow:
0 18px 45px rgba($color-shadow-rgb, 0.12),
0 4px 12px rgba($color-shadow-rgb, 0.08);
}
.organisation-select__search {
height: 40px;
padding: 0 10px;
border-radius: 11px;
background: $color-surface-muted;
display: flex;
align-items: center;
gap: 8px;
color: $gray50;
input {
width: 100% !important;
border: none !important;
outline: none !important;
background: $transparent !important;
color: $color-text;
font-size: $font-size-15;
height: 100% !important;
&:focus{
background-color: $transparent;
box-shadow: none !important;
}
&::placeholder {
color: $gray50;
}
}
}
.organisation-select__list {
max-height: 250px;
margin-top: 8px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 4px;
scrollbar-width: thin;
scrollbar-color: $gray50 $transparent;
}
.organisation-select__option {
width: 100%;
padding: 10px;
border: none;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: $transparent;
color: $color-text;
cursor: pointer;
text-align: left;
transition:
background-color 0.2s ease,
color 0.2s ease;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: $font-size-15;
font-weight: 500;
}
small {
flex: 0 0 auto;
color: $gray50;
font-size: $font-size-12;
font-weight: 500;
}
&:hover {
background: $gray20;
color: $blue;
}
&.is-selected {
background: $blue20;
span{
color: $blue;
}
small {
//color: rgba($white, 0.75);
}
}
}
.organisation-select__state {
padding: 14px 10px;
color: $gray50;
font-size: $font-size-14;
font-weight: 500;
text-align: center;
&.is-error {
color: $red;
}
}
.organisation-select__hint {
margin-top: 8px;
padding: 8px 10px;
border-radius: 10px;
background: $color-surface-muted;
color: $gray50;
font-size: $font-size-12;
font-weight: 500;
line-height: 1.35;
}

View File

@ -0,0 +1,236 @@
import {
memo,
type ChangeEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useQuery } from '@apollo/client/react'
import { ChevronDown, Search } from 'lucide-react'
import { AnimatePresence, m } from 'framer-motion'
import { GET_ORGANISATIONS_QUERY } from '../../../../entities/employee/api/employee.graphql'
import type {
GetOrganisationsData,
GetOrganisationsVariables,
} from '../../../../entities/employee/model/types'
import './OrganisationSelect.scss'
import { popoverVariants, uiFadeTransition } from '../../../../shared/lib/motion'
type OrganisationSelectProps = {
value: string
onChange: (value: string) => void
enabled: boolean
disabled?: boolean
selectedLabel?: string | null
}
export const OrganisationSelect = memo(function OrganisationSelect({
value,
onChange,
enabled,
disabled = false,
selectedLabel = null,
}: OrganisationSelectProps) {
const rootRef = useRef<HTMLDivElement | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [selectedName, setSelectedName] = useState<string | null>(selectedLabel)
useEffect(() => {
if (!value) {
setSelectedName(null)
return
}
if (selectedLabel) {
setSelectedName(selectedLabel)
}
}, [selectedLabel, value])
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedSearch(search.trim())
}, 300)
return () => {
window.clearTimeout(timeoutId)
}
}, [search])
useEffect(() => {
if (!enabled) {
setIsOpen(false)
setSearch('')
}
}, [enabled])
useEffect(() => {
if (!isOpen) return
function handleDocumentMouseDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleDocumentMouseDown)
return () => {
document.removeEventListener('mousedown', handleDocumentMouseDown)
}
}, [isOpen])
const queryVariables = useMemo<GetOrganisationsVariables>(() => ({
page: 0,
query: debouncedSearch || undefined,
sortDirection: 'ASC',
sortField: 'Name',
}), [debouncedSearch])
const {
data,
previousData,
loading,
error,
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
GET_ORGANISATIONS_QUERY,
{
variables: queryVariables,
skip: !enabled || !isOpen,
fetchPolicy: 'network-only',
notifyOnNetworkStatusChange: true,
},
)
const organisationsResponse =
data?.getOrganisations ?? previousData?.getOrganisations
const organisations = useMemo(
() => organisationsResponse?.page ?? [],
[organisationsResponse?.page],
)
const totalElements = organisationsResponse?.totalElements ?? 0
const selectedOrganisation = useMemo(
() => organisations.find(
(organisation) => String(organisation.id) === value,
),
[organisations, value],
)
const buttonText =
selectedOrganisation?.name ||
selectedName ||
selectedLabel ||
'Выберите организацию'
const handleToggleOpen = useCallback(() => {
setIsOpen((prev) => !prev)
}, [])
const handleSearchChange = useCallback((
event: ChangeEvent<HTMLInputElement>,
) => {
setSearch(event.target.value)
}, [])
const handleSelect = useCallback((organisation: { id: number; name: string }) => {
onChange(String(organisation.id))
setSelectedName(organisation.name)
setIsOpen(false)
setSearch('')
}, [onChange])
return (
<div
className={`organisation-select ${isOpen ? 'is-open' : ''}`}
ref={rootRef}
>
<button
className="organisation-select__trigger"
type="button"
disabled={disabled}
onClick={handleToggleOpen}
>
<span className={value ? '' : 'is-placeholder'}>{buttonText}</span>
<ChevronDown size={18} />
</button>
<AnimatePresence initial={false}>
{isOpen && (
<m.div
className="organisation-select__dropdown"
variants={popoverVariants}
initial="initial"
animate="animate"
exit="exit"
transition={uiFadeTransition}
>
<label className="organisation-select__search">
<Search size={16} />
<input
value={search}
onChange={handleSearchChange}
placeholder="Поиск организации"
autoFocus
/>
</label>
<div className="organisation-select__list">
{loading && organisations.length === 0 && (
<div className="organisation-select__state">
Загрузка организаций...
</div>
)}
{error && (
<div className="organisation-select__state is-error">
Не удалось загрузить организации
</div>
)}
{!loading && !error && organisations.length === 0 && (
<div className="organisation-select__state">
Организации не найдены
</div>
)}
{organisations.map((organisation) => {
const organisationId = String(organisation.id)
const isSelected = organisationId === value
return (
<button
className={`organisation-select__option ${isSelected ? 'is-selected' : ''
}`}
type="button"
key={organisation.id}
onClick={() => handleSelect(organisation)}
>
<span>{organisation.name}</span>
<small>ID: {organisation.id}</small>
</button>
)
})}
</div>
{totalElements > organisations.length && (
<div className="organisation-select__hint">
Показаны первые {organisations.length} из {totalElements}.
Используйте поиск, чтобы найти нужную организацию.
</div>
)}
</m.div>
)}
</AnimatePresence>
</div>
)
})

View File

@ -0,0 +1,518 @@
import {
useCallback,
useEffect,
useMemo,
useState,
useRef,
} from 'react'
import {
useLocation,
useNavigate,
useSearchParams,
} from 'react-router-dom'
import { useMutation, useQuery } from '@apollo/client/react'
import { m } from 'framer-motion'
import { DELETE_ORGANISATION_MUTATION, GET_ORGANISATIONS_QUERY } from '../../../../entities/employee/api/employee.graphql'
import type {
GetOrganisationsData,
GetOrganisationsVariables,
Organisation,
DeleteOrganisationData,
DeleteOrganisationVariables,
} from '../../../../entities/employee/model/types'
import {
EmployeesToolbar,
organisationsSortOptions,
} from '../EmployeesToolbar/EmployeesToolbar'
import { EmployeesPagination } from '../EmployeesPagination/EmployeesPagination'
import { OrganisationsTable } from '../OrganisationsTable/OrganisationsTable'
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
import { getPaginationItems } from '../../lib/getPaginationItems'
import {
TableSkeleton,
type TableSkeletonColumn,
} from '../../../../shared/ui/TableSkeleton/TableSkeleton'
import { RevealContent } from '../../../../shared/ui/RevealContent/RevealContent'
import { pageSwapVariants, uiFadeTransition } from '../../../../shared/lib/motion'
import { EmptyState } from '../../../../shared/ui/EmptyState/EmptyState'
import {
useDeferredModalPayload,
} from '../../../../shared/lib/lazyMount'
import { AddOrganisationModal } from '../AddOrganisationModal/AddOrganisationModal'
import SimpleBar from "simplebar-react";
import "simplebar-react/dist/simplebar.min.css";
const ORGANISATIONS_SCROLL_KEY = 'employees.organisations.scrollTop'
const organisationsTableSkeletonColumns: TableSkeletonColumn[] = [
{ width: '50px', headerWidth: '34px' },
{ variant: 'person', width: '72%', headerWidth: '180px' },
{ variant: 'actions', headerWidth: '132px' },
]
export function OrganisationsSection() {
const navigate = useNavigate()
const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const tableCardRef = useRef<HTMLDivElement | null>(null)
const organisationsCurrentPage = Number(searchParams.get('oPage') ?? 0)
const organisationSearch = searchParams.get('oQuery') ?? ''
const organisationSort = useMemo(() => {
return organisationsSortOptions.find(
(option) => option.value === searchParams.get('oSort'),
) ?? organisationsSortOptions[0]
}, [searchParams])
const [debouncedOrganisationSearch, setDebouncedOrganisationSearch] =
useState('')
const [isAddOrganisationOpen, setIsAddOrganisationOpen] = useState(false)
const [editingOrganisation, setEditingOrganisation] =
useState<Organisation | null>(null)
const [deletingOrganisation, setDeletingOrganisation] =
useState<Organisation | null>(null)
const renderEditingOrganisation =
useDeferredModalPayload(editingOrganisation)
const renderDeletingOrganisation =
useDeferredModalPayload(deletingOrganisation)
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedOrganisationSearch(organisationSearch.trim())
}, 350)
return () => {
window.clearTimeout(timeoutId)
}
}, [organisationSearch])
const organisationsQueryVariables = useMemo<GetOrganisationsVariables>(
() => ({
page: organisationsCurrentPage,
query: debouncedOrganisationSearch || undefined,
sortDirection: organisationSort.sortDirection,
sortField: organisationSort.sortField,
}),
[
debouncedOrganisationSearch,
organisationSort.sortDirection,
organisationSort.sortField,
organisationsCurrentPage,
],
)
const {
data: organisationsData,
previousData: previousOrganisationsData,
loading: organisationsLoading,
error: organisationsError,
refetch: refetchOrganisations,
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
GET_ORGANISATIONS_QUERY,
{
variables: organisationsQueryVariables,
fetchPolicy: 'cache-first',
notifyOnNetworkStatusChange: true,
},
)
const [deleteOrganisation, { loading: isDeletingOrganisation }] =
useMutation<DeleteOrganisationData, DeleteOrganisationVariables>(
DELETE_ORGANISATION_MUTATION,
)
const organisationsResponse =
organisationsData?.getOrganisations ??
previousOrganisationsData?.getOrganisations
const organisations = organisationsResponse?.page ?? []
const organisationsTotalPages = organisationsResponse?.totalPages ?? 0
const isOrganisationsInitialLoading =
organisationsLoading && !organisationsResponse
const isOrganisationsUpdating =
organisationsLoading && Boolean(organisationsResponse)
const shouldShowOrganisationsError =
Boolean(organisationsError) && organisations.length === 0
const hasAppliedOrganisationFilters = Boolean(
debouncedOrganisationSearch,
)
const hasPrevOrganisationsPage = organisationsCurrentPage > 0
const hasNextOrganisationsPage =
organisationsTotalPages > 0 &&
organisationsCurrentPage < organisationsTotalPages - 1
const organisationsPaginationItems = useMemo(
() => getPaginationItems(
organisationsCurrentPage,
organisationsTotalPages,
),
[organisationsCurrentPage, organisationsTotalPages],
)
useEffect(() => {
if (isOrganisationsInitialLoading) return
const element = tableCardRef.current
if (!element) return
const savedScrollTop = Number(
sessionStorage.getItem(ORGANISATIONS_SCROLL_KEY) ?? 0,
)
const frameId = window.requestAnimationFrame(() => {
element.scrollTop = savedScrollTop
})
return () => {
window.cancelAnimationFrame(frameId)
}
}, [isOrganisationsInitialLoading, organisationsCurrentPage])
const updateOrganisationsSearchParams = useCallback((
updates: Record<string, string | null>,
) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === '') {
next.delete(key)
return
}
next.set(key, value)
})
return next
},
{
replace: true,
},
)
}, [setSearchParams])
const resetOrganisationsScroll = useCallback(() => {
sessionStorage.setItem(ORGANISATIONS_SCROLL_KEY, '0')
}, [])
const handleOrganisationsScroll = useCallback(() => {
const element = tableCardRef.current
if (!element) return
sessionStorage.setItem(
ORGANISATIONS_SCROLL_KEY,
String(element.scrollTop),
)
}, [])
const tableScrollProps = useMemo(
() => ({
ref: tableCardRef,
onScroll: handleOrganisationsScroll,
}),
[handleOrganisationsScroll],
)
const setOrganisationsPage = useCallback((page: number) => {
resetOrganisationsScroll()
updateOrganisationsSearchParams({
oPage: page > 0 ? String(page) : null,
})
}, [resetOrganisationsScroll, updateOrganisationsSearchParams])
const handleNextOrganisationsPage = useCallback(() => {
if (!hasNextOrganisationsPage || organisationsLoading) return
setOrganisationsPage(organisationsCurrentPage + 1)
}, [
hasNextOrganisationsPage,
organisationsCurrentPage,
organisationsLoading,
setOrganisationsPage,
])
const handlePrevOrganisationsPage = useCallback(() => {
if (!hasPrevOrganisationsPage || organisationsLoading) return
setOrganisationsPage(Math.max(0, organisationsCurrentPage - 1))
}, [
hasPrevOrganisationsPage,
organisationsCurrentPage,
organisationsLoading,
setOrganisationsPage,
])
const handleJumpPrevOrganisationsPage = useCallback(() => {
if (organisationsLoading) return
setOrganisationsPage(Math.max(0, organisationsCurrentPage - 5))
}, [organisationsCurrentPage, organisationsLoading, setOrganisationsPage])
const handleJumpNextOrganisationsPage = useCallback(() => {
if (organisationsLoading) return
setOrganisationsPage(
Math.min(organisationsTotalPages - 1, organisationsCurrentPage + 5),
)
}, [
organisationsCurrentPage,
organisationsLoading,
organisationsTotalPages,
setOrganisationsPage,
])
const refetchFirstOrganisationsPage = useCallback(async () => {
resetOrganisationsScroll()
updateOrganisationsSearchParams({
oPage: null,
})
await refetchOrganisations({
...organisationsQueryVariables,
page: 0,
})
}, [
organisationsQueryVariables,
refetchOrganisations,
resetOrganisationsScroll,
updateOrganisationsSearchParams,
])
const handleOpenOrganisation = useCallback((organisation: Organisation) => {
navigate(`/employees/organisations/${organisation.id}`, {
state: {
fromEmployeesPath: `${location.pathname}${location.search}`,
},
})
}, [location.pathname, location.search, navigate])
const handleEditOrganisation = useCallback((organisation: Organisation) => {
setEditingOrganisation(organisation)
}, [])
const handleDeleteOrganisation = useCallback((organisation: Organisation) => {
setDeletingOrganisation(organisation)
}, [])
const handleConfirmDeleteOrganisation = useCallback(async () => {
if (!deletingOrganisation) return
try {
await deleteOrganisation({
variables: {
id: String(deletingOrganisation.id),
},
})
const nextPage =
organisations.length === 1 && organisationsCurrentPage > 0
? organisationsCurrentPage - 1
: organisationsCurrentPage
setDeletingOrganisation(null)
if (nextPage !== organisationsCurrentPage) {
updateOrganisationsSearchParams({
oPage: nextPage > 0 ? String(nextPage) : null,
})
}
await refetchOrganisations({
page: nextPage,
query: debouncedOrganisationSearch || undefined,
sortDirection: organisationSort.sortDirection,
sortField: organisationSort.sortField,
})
} catch (error) {
console.error('Не удалось удалить организацию', error)
}
}, [
debouncedOrganisationSearch,
deleteOrganisation,
deletingOrganisation,
organisationSort.sortDirection,
organisationSort.sortField,
organisations.length,
organisationsCurrentPage,
refetchOrganisations,
updateOrganisationsSearchParams,
])
const handleOrganisationSearchChange = useCallback((value: string) => {
resetOrganisationsScroll()
updateOrganisationsSearchParams({
oQuery: value || null,
oPage: null,
})
}, [resetOrganisationsScroll, updateOrganisationsSearchParams])
const handleOrganisationSortChange = useCallback((
option: typeof organisationsSortOptions[number],
) => {
resetOrganisationsScroll()
updateOrganisationsSearchParams({
oSort:
option.value === organisationsSortOptions[0].value
? null
: option.value,
oPage: null,
})
}, [resetOrganisationsScroll, updateOrganisationsSearchParams])
const handleOpenAddOrganisation = useCallback(() => {
setIsAddOrganisationOpen(true)
}, [])
const handleEditOrganisationOpenChange = useCallback((open: boolean) => {
if (!open) {
setEditingOrganisation(null)
}
}, [])
const handleDeleteOrganisationOpenChange = useCallback((open: boolean) => {
if (!open) {
setDeletingOrganisation(null)
}
}, [])
return (
<>
<EmployeesToolbar
addButtonText="Добавить организацию"
searchValue={organisationSearch}
onSearchChange={handleOrganisationSearchChange}
sortOptions={organisationsSortOptions}
selectedSort={organisationSort}
onSortChange={handleOrganisationSortChange}
showSort
onAdd={handleOpenAddOrganisation}
/>
<m.div
key="organisations-table"
className="employees-page__table-motion"
variants={pageSwapVariants}
initial="initial"
animate="animate"
transition={uiFadeTransition}
>
<div className="employees-table-container">
<div className="employees-table-shadow">
<SimpleBar
scrollableNodeProps={tableScrollProps}
className={`employees-table-card ${isOrganisationsUpdating ? 'is-updating' : ''
}`}
>
<RevealContent
loading={isOrganisationsInitialLoading}
fallback={
<TableSkeleton
className="employees-table organisations-table"
columns={organisationsTableSkeletonColumns}
/>
}
>
{shouldShowOrganisationsError && (
<div className="employees-state employees-state--error">
Не удалось загрузить организации
</div>
)}
{!organisationsError && organisations.length === 0 && (
<EmptyState
className="employees-state"
variant={
hasAppliedOrganisationFilters
? 'not-found'
: 'no-items'
}
title={
hasAppliedOrganisationFilters
? 'Не найдено результатов'
: 'Организаций пока нет'
}
description={
hasAppliedOrganisationFilters
? 'По заданным параметрам не найдено ни одной организации. Попробуйте изменить поиск.'
: 'Добавьте организацию, чтобы привязать сотрудников и устройства.'
}
/>
)}
{organisations.length > 0 && (
<OrganisationsTable
organisations={organisations}
onOpen={handleOpenOrganisation}
onEdit={handleEditOrganisation}
onDelete={handleDeleteOrganisation}
/>
)}
</RevealContent>
</SimpleBar>
</div>
{organisationsTotalPages > 1 && (
<EmployeesPagination
currentPage={organisationsCurrentPage}
loading={organisationsLoading}
items={organisationsPaginationItems}
hasPrevPage={hasPrevOrganisationsPage}
hasNextPage={hasNextOrganisationsPage}
onPrev={handlePrevOrganisationsPage}
onNext={handleNextOrganisationsPage}
onJumpPrev={handleJumpPrevOrganisationsPage}
onJumpNext={handleJumpNextOrganisationsPage}
onPageChange={setOrganisationsPage}
/>
)}
</div>
</m.div>
<AddOrganisationModal
open={isAddOrganisationOpen}
mode="create"
onOpenChange={setIsAddOrganisationOpen}
onSuccess={refetchFirstOrganisationsPage}
/>
<AddOrganisationModal
open={Boolean(editingOrganisation)}
mode="edit"
organisation={
editingOrganisation ?? renderEditingOrganisation
}
onOpenChange={handleEditOrganisationOpenChange}
onSuccess={refetchFirstOrganisationsPage}
/>
<ConfirmDangerDialog
open={Boolean(deletingOrganisation)}
title="Удалить организацию?"
description={
renderDeletingOrganisation
? `Организация «${renderDeletingOrganisation.name}» будет удалена из системы. Это действие нельзя будет отменить.`
: ''
}
confirmText="Удалить"
isLoading={isDeletingOrganisation}
onOpenChange={handleDeleteOrganisationOpenChange}
onConfirm={handleConfirmDeleteOrganisation}
/>
</>
)
}

View File

@ -0,0 +1,138 @@
import { memo } from 'react'
import { Pencil, Trash2 } from 'lucide-react'
import type { Organisation } from '../../../../entities/employee/model/types'
function getOrganisationInitials(name: string) {
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return 'ОР'
return words
.slice(0, 2)
.map((word) => word[0])
.join('')
.toUpperCase()
}
function formatCreationDate(timestamp?: number) {
if (!timestamp) return 'Дата создания не указана'
return `Создана ${new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(timestamp))}`
}
type OrganisationsTableProps = {
organisations: Organisation[]
onOpen: (organisation: Organisation) => void
onEdit: (organisation: Organisation) => void
onDelete: (organisation: Organisation) => void
}
type OrganisationTableRowProps = {
organisation: Organisation
onOpen: (organisation: Organisation) => void
onEdit: (organisation: Organisation) => void
onDelete: (organisation: Organisation) => void
}
const OrganisationTableRow = memo(function OrganisationTableRow({
organisation,
onOpen,
onEdit,
onDelete,
}: OrganisationTableRowProps) {
return (
<tr
className="employees-table__row employees-table__row--clickable"
tabIndex={0}
onClick={() => onOpen(organisation)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onOpen(organisation)
}
}}
>
<td>{organisation.id}</td>
<td>
<div className="employee-person">
<div className={`employee-person__avatar ${organisation.logoUrl ? 'transparent-backdrop' : ''}`}>
{organisation.logoUrl ? (
<img src={organisation.logoUrl} alt={organisation.name} />
) : (
<span>{getOrganisationInitials(organisation.name)}</span>
)}
</div>
<div className="employee-person__info">
<span>{organisation.name}</span>
<small>{formatCreationDate(organisation.creationDate)}</small>
</div>
</div>
</td>
<td>
<div className="employees-table-actions">
<button
className="employees-action-btn employees-action-btn--edit"
type="button"
onClick={(event) => {
event.stopPropagation()
onEdit(organisation)
}}
>
<Pencil size={16} />
Редактировать
</button>
<button
className="employees-action-btn employees-action-btn--delete"
type="button"
onClick={(event) => {
event.stopPropagation()
onDelete(organisation)
}}
>
<Trash2 size={16} />
Удалить
</button>
</div>
</td>
</tr>
)
})
export const OrganisationsTable = memo(function OrganisationsTable({
organisations,
onOpen,
onEdit,
onDelete,
}: OrganisationsTableProps) {
return (
<table className="employees-table organisations-table">
<thead>
<tr>
<th>ID</th>
<th>Название организации</th>
<th>Управление</th>
</tr>
</thead>
<tbody>
{organisations.map((organisation) => (
<OrganisationTableRow
key={organisation.id}
organisation={organisation}
onOpen={onOpen}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</tbody>
</table>
)
})

View File

@ -0,0 +1,443 @@
import {
useCallback,
useEffect,
useMemo,
useState,
useRef,
} from 'react'
import { useSearchParams } from 'react-router-dom'
import { useQuery } from '@apollo/client/react'
import { m } from 'framer-motion'
import { GET_USERS_QUERY } from '../../../../entities/employee/api/employee.graphql'
import type {
Employee,
EmployeeRole,
GetUsersData,
GetUsersVariables,
} from '../../../../entities/employee/model/types'
import {
EmployeesToolbar,
usersSortOptions,
} from '../EmployeesToolbar/EmployeesToolbar'
import { EmployeesPagination } from '../EmployeesPagination/EmployeesPagination'
import { UsersTable } from '../UsersTable/UsersTable'
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
import { getPaginationItems } from '../../lib/getPaginationItems'
import { getEmployeeFullName } from '../../lib/employeeFormatters'
import {
TableSkeleton,
type TableSkeletonColumn,
} from '../../../../shared/ui/TableSkeleton/TableSkeleton'
import { RevealContent } from '../../../../shared/ui/RevealContent/RevealContent'
import { pageSwapVariants, uiFadeTransition } from '../../../../shared/lib/motion'
import { EmptyState } from '../../../../shared/ui/EmptyState/EmptyState'
import {
useDeferredModalPayload,
} from '../../../../shared/lib/lazyMount'
import { AddEmployeeModal } from '../AddEmployeeModal/AddEmployeeModal'
import SimpleBar from "simplebar-react";
import "simplebar-react/dist/simplebar.min.css";
const USERS_SCROLL_KEY = 'employees.users.scrollTop'
const usersTableSkeletonColumns: TableSkeletonColumn[] = [
{ width: '42px', headerWidth: '34px' },
{ variant: 'person', width: '70%', headerWidth: '54px' },
{ width: '72%', headerWidth: '118px' },
{ variant: 'pill', headerWidth: '54px' },
{ variant: 'actions', headerWidth: '132px' },
]
function getRoleFilter(value: string | null): EmployeeRole | null {
if (value === 'Admin' || value === 'User') return value
return null
}
export function UsersSection() {
const [searchParams, setSearchParams] = useSearchParams()
const tableCardRef = useRef<HTMLDivElement | null>(null)
const usersCurrentPage = Number(searchParams.get('uPage') ?? 0)
const userSearch = searchParams.get('uQuery') ?? ''
const roleFilter = getRoleFilter(searchParams.get('uRole'))
const organisationFilterId = searchParams.get('uOrg')
const userSort = useMemo(() => {
return usersSortOptions.find(
(option) => option.value === searchParams.get('uSort'),
) ?? usersSortOptions[0]
}, [searchParams])
const [debouncedUserSearch, setDebouncedUserSearch] = useState('')
const [isAddEmployeeOpen, setIsAddEmployeeOpen] = useState(false)
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
const [deletingEmployee, setDeletingEmployee] = useState<Employee | null>(null)
const renderEditingEmployee = useDeferredModalPayload(editingEmployee)
const renderDeletingEmployee = useDeferredModalPayload(deletingEmployee)
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedUserSearch(userSearch.trim())
}, 350)
return () => {
window.clearTimeout(timeoutId)
}
}, [userSearch])
const usersQueryVariables = useMemo<GetUsersVariables>(() => ({
page: usersCurrentPage,
query: debouncedUserSearch || undefined,
orgs: organisationFilterId ? [organisationFilterId] : undefined,
roles: roleFilter ? [roleFilter] : undefined,
sortDirection: userSort.sortDirection,
sortField: userSort.sortField,
}), [
debouncedUserSearch,
organisationFilterId,
roleFilter,
userSort.sortDirection,
userSort.sortField,
usersCurrentPage,
])
const {
data: usersData,
previousData: previousUsersData,
loading: usersLoading,
error: usersError,
refetch: refetchUsers,
} = useQuery<GetUsersData, GetUsersVariables>(GET_USERS_QUERY, {
variables: usersQueryVariables,
fetchPolicy: 'cache-first',
notifyOnNetworkStatusChange: true,
})
const usersResponse = usersData?.getUsers ?? previousUsersData?.getUsers
const employees = usersResponse?.page ?? []
const usersTotalPages = usersResponse?.totalPages ?? 0
const isUsersInitialLoading = usersLoading && !usersResponse
const isUsersUpdating = usersLoading && Boolean(usersResponse)
const shouldShowUsersError = Boolean(usersError) && employees.length === 0
const hasAppliedUsersFilters = Boolean(
debouncedUserSearch || roleFilter || organisationFilterId,
)
const hasPrevUsersPage = usersCurrentPage > 0
const hasNextUsersPage =
usersTotalPages > 0 && usersCurrentPage < usersTotalPages - 1
const usersPaginationItems = useMemo(
() => getPaginationItems(usersCurrentPage, usersTotalPages),
[usersCurrentPage, usersTotalPages],
)
useEffect(() => {
if (isUsersInitialLoading) return
const element = tableCardRef.current
if (!element) return
const savedScrollTop = Number(
sessionStorage.getItem(USERS_SCROLL_KEY) ?? 0,
)
const frameId = window.requestAnimationFrame(() => {
element.scrollTop = savedScrollTop
})
return () => {
window.cancelAnimationFrame(frameId)
}
}, [isUsersInitialLoading, usersCurrentPage])
const updateUsersSearchParams = useCallback((
updates: Record<string, string | null>,
) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === '') {
next.delete(key)
return
}
next.set(key, value)
})
return next
},
{
replace: true,
},
)
}, [setSearchParams])
const resetUsersScroll = useCallback(() => {
sessionStorage.setItem(USERS_SCROLL_KEY, '0')
}, [])
const handleUsersScroll = useCallback(() => {
const element = tableCardRef.current
if (!element) return
sessionStorage.setItem(USERS_SCROLL_KEY, String(element.scrollTop))
}, [])
const tableScrollProps = useMemo(
() => ({
ref: tableCardRef,
onScroll: handleUsersScroll,
}),
[handleUsersScroll],
)
const setUsersPage = useCallback((page: number) => {
resetUsersScroll()
updateUsersSearchParams({
uPage: page > 0 ? String(page) : null,
})
}, [resetUsersScroll, updateUsersSearchParams])
const handleNextUsersPage = useCallback(() => {
if (!hasNextUsersPage || usersLoading) return
setUsersPage(usersCurrentPage + 1)
}, [hasNextUsersPage, setUsersPage, usersCurrentPage, usersLoading])
const handlePrevUsersPage = useCallback(() => {
if (!hasPrevUsersPage || usersLoading) return
setUsersPage(Math.max(0, usersCurrentPage - 1))
}, [hasPrevUsersPage, setUsersPage, usersCurrentPage, usersLoading])
const handleJumpPrevUsersPage = useCallback(() => {
if (usersLoading) return
setUsersPage(Math.max(0, usersCurrentPage - 5))
}, [setUsersPage, usersCurrentPage, usersLoading])
const handleJumpNextUsersPage = useCallback(() => {
if (usersLoading) return
setUsersPage(Math.min(usersTotalPages - 1, usersCurrentPage + 5))
}, [setUsersPage, usersCurrentPage, usersLoading, usersTotalPages])
const refetchFirstUsersPage = useCallback(async () => {
setUsersPage(0)
await refetchUsers({
...usersQueryVariables,
page: 0,
})
}, [
refetchUsers,
setUsersPage,
usersQueryVariables,
])
const handleEditEmployee = useCallback((employee: Employee) => {
setEditingEmployee(employee)
}, [])
const handleDeleteEmployee = useCallback((employee: Employee) => {
setDeletingEmployee(employee)
}, [])
const handleConfirmDeleteEmployee = useCallback(() => {
if (!deletingEmployee) return
setDeletingEmployee(null)
}, [deletingEmployee])
const handleUserSearchChange = useCallback((value: string) => {
resetUsersScroll()
updateUsersSearchParams({
uQuery: value || null,
uPage: null,
})
}, [resetUsersScroll, updateUsersSearchParams])
const handleRoleFilterChange = useCallback((role: EmployeeRole | null) => {
resetUsersScroll()
updateUsersSearchParams({
uRole: role,
uPage: null,
})
}, [resetUsersScroll, updateUsersSearchParams])
const handleOrganisationFilterChange = useCallback((
organisation: { id: string; name: string } | null,
) => {
resetUsersScroll()
updateUsersSearchParams({
uOrg: organisation?.id ?? null,
uPage: null,
})
}, [resetUsersScroll, updateUsersSearchParams])
const handleUserSortChange = useCallback((
option: typeof usersSortOptions[number],
) => {
resetUsersScroll()
updateUsersSearchParams({
uSort: option.value === usersSortOptions[0].value ? null : option.value,
uPage: null,
})
}, [resetUsersScroll, updateUsersSearchParams])
const handleOpenAddEmployee = useCallback(() => {
setIsAddEmployeeOpen(true)
}, [])
const handleEditEmployeeOpenChange = useCallback((open: boolean) => {
if (!open) {
setEditingEmployee(null)
}
}, [])
const handleDeleteEmployeeOpenChange = useCallback((open: boolean) => {
if (!open) {
setDeletingEmployee(null)
}
}, [])
return (
<>
<EmployeesToolbar
addButtonText="Добавить сотрудника"
searchValue={userSearch}
onSearchChange={handleUserSearchChange}
sortOptions={usersSortOptions}
selectedSort={userSort}
roleFilter={roleFilter}
onRoleFilterChange={handleRoleFilterChange}
organisationFilterId={organisationFilterId}
onOrganisationFilterChange={handleOrganisationFilterChange}
onSortChange={handleUserSortChange}
showSort
showUserFilters
onAdd={handleOpenAddEmployee}
/>
<m.div
key="users-table"
className="employees-page__table-motion"
variants={pageSwapVariants}
initial="initial"
animate="animate"
transition={uiFadeTransition}
>
<div className="employees-table-container">
<div className="employees-table-shadow">
<SimpleBar
scrollableNodeProps={tableScrollProps}
className={`employees-table-card ${isUsersUpdating ? 'is-updating' : ''
}`}
>
<RevealContent
loading={isUsersInitialLoading}
fallback={
<TableSkeleton
className="employees-table"
columns={usersTableSkeletonColumns}
/>
}
>
{shouldShowUsersError && (
<div className="employees-state employees-state--error">
Не удалось загрузить сотрудников
</div>
)}
{!usersError && employees.length === 0 && (
<EmptyState
className="employees-state"
variant={hasAppliedUsersFilters ? 'not-found' : 'no-items'}
title={
hasAppliedUsersFilters
? 'Не найдено результатов'
: 'Сотрудников пока нет'
}
description={
hasAppliedUsersFilters
? 'По заданным параметрам не найдено ни одного сотрудника. Попробуйте изменить поиск или фильтры.'
: 'Добавьте сотрудников, чтобы они появились в системе.'
}
/>
)}
{employees.length > 0 && (
<UsersTable
employees={employees}
onEdit={handleEditEmployee}
onDelete={handleDeleteEmployee}
/>
)}
</RevealContent>
</SimpleBar>
</div>
{usersTotalPages > 1 && (
<EmployeesPagination
currentPage={usersCurrentPage}
loading={usersLoading}
items={usersPaginationItems}
hasPrevPage={hasPrevUsersPage}
hasNextPage={hasNextUsersPage}
onPrev={handlePrevUsersPage}
onNext={handleNextUsersPage}
onJumpPrev={handleJumpPrevUsersPage}
onJumpNext={handleJumpNextUsersPage}
onPageChange={setUsersPage}
/>
)}
</div>
</m.div>
<AddEmployeeModal
open={isAddEmployeeOpen}
mode="create"
onOpenChange={setIsAddEmployeeOpen}
onSuccess={refetchFirstUsersPage}
/>
<AddEmployeeModal
open={Boolean(editingEmployee)}
mode="edit"
employee={editingEmployee ?? renderEditingEmployee}
onOpenChange={handleEditEmployeeOpenChange}
onSuccess={refetchFirstUsersPage}
/>
<ConfirmDangerDialog
open={Boolean(deletingEmployee)}
title="Удалить сотрудника?"
description={
renderDeletingEmployee
? `Сотрудник «${getEmployeeFullName(
renderDeletingEmployee,
)}» будет удалён из системы. Это действие нельзя будет отменить.`
: ''
}
confirmText="Удалить"
onOpenChange={handleDeleteEmployeeOpenChange}
onConfirm={handleConfirmDeleteEmployee}
/>
</>
)
}

View File

@ -0,0 +1,136 @@
import { memo } from 'react'
import { Pencil, Trash2 } from 'lucide-react'
import type { Employee } from '../../../../entities/employee/model/types'
import {
getEmployeeFullName,
getEmployeeRoleLabel,
} from '../../lib/employeeFormatters'
function getUserInitials(employee: Employee) {
const first = employee.lastName?.trim()[0] ?? ''
const second = employee.firstName?.trim()[0] ?? ''
return `${first}${second}`.toUpperCase() || '??'
}
type UsersTableProps = {
employees: Employee[]
onEdit: (employee: Employee) => void
onDelete: (employee: Employee) => void
}
type UserTableRowProps = {
employee: Employee
onEdit: (employee: Employee) => void
onDelete: (employee: Employee) => void
}
const UserTableRow = memo(function UserTableRow({
employee,
onEdit,
onDelete,
}: UserTableRowProps) {
return (
<tr className="employees-table__row">
<td>{employee.id}</td>
<td>
<div className="employee-person">
<div className={`employee-person__avatar ${employee.avatarUrl ? 'transparent-backdrop' : ''}`}>
{employee.avatarUrl ? (
<img src={employee.avatarUrl} alt={getEmployeeFullName(employee)} />
) : (
<span>{getUserInitials(employee)}</span>
)}
</div>
<div className="employee-person__info">
<span>{getEmployeeFullName(employee) || 'ФИО не указано'}</span>
{employee.username && (
<small>@{employee.username}</small>
)}
</div>
</div>
</td>
<td>
<span className="employee-organisation">
{employee.org?.name || 'Организация не указана'}
</span>
</td>
<td>
<span
className={
employee.role === 'Admin'
? 'employee-role employee-role--admin'
: 'employee-role'
}
>
{getEmployeeRoleLabel(employee.role)}
</span>
</td>
<td>
<div className="employees-table-actions">
<button
className="employees-action-btn employees-action-btn--edit"
type="button"
onClick={(event) => {
event.stopPropagation()
onEdit(employee)
}}
>
<Pencil size={16} />
Редактировать
</button>
<button
className="employees-action-btn employees-action-btn--delete"
type="button"
onClick={(event) => {
event.stopPropagation()
onDelete(employee)
}}
>
<Trash2 size={16} />
Удалить
</button>
</div>
</td>
</tr>
)
})
export const UsersTable = memo(function UsersTable({
employees,
onEdit,
onDelete,
}: UsersTableProps) {
return (
<table className="employees-table">
<thead>
<tr>
<th>ID</th>
<th>ФИО</th>
<th>Организация</th>
<th>Роль</th>
<th>Управление</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<UserTableRow
key={employee.id}
employee={employee}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</tbody>
</table>
)
})

View File

@ -0,0 +1,14 @@
import type { Employee } from '../../../entities/employee/model/types'
export function getEmployeeFullName(employee: Employee) {
return [employee.lastName, employee.firstName, employee.middleName]
.filter(Boolean)
.join(' ')
}
export function getEmployeeRoleLabel(role: string) {
if (role === 'Admin') return 'Администратор'
if (role === 'User') return 'Пользователь'
return role
}

View File

@ -0,0 +1,58 @@
import type { PaginationItem } from '../components/EmployeesPagination/EmployeesPagination'
export function getPaginationItems(
currentPage: number,
totalPages: number,
): PaginationItem[] {
if (totalPages <= 0) return []
const current = currentPage + 1
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1)
}
const pages = new Set<number>()
pages.add(1)
pages.add(totalPages)
pages.add(current)
pages.add(current - 1)
pages.add(current + 1)
if (current <= 4) {
pages.add(2)
pages.add(3)
pages.add(4)
pages.add(5)
}
if (current >= totalPages - 3) {
pages.add(totalPages - 1)
pages.add(totalPages - 2)
pages.add(totalPages - 3)
pages.add(totalPages - 4)
}
const sortedPages = Array.from(pages)
.filter((page) => page >= 1 && page <= totalPages)
.sort((a, b) => a - b)
const result: PaginationItem[] = []
sortedPages.forEach((page, index) => {
const previousPage = sortedPages[index - 1]
if (previousPage && page - previousPage > 1) {
if (page - previousPage === 2) {
result.push(previousPage + 1)
} else {
result.push(page < current ? 'prev-ellipsis' : 'next-ellipsis')
}
}
result.push(page)
})
return result
}

View File

@ -0,0 +1,35 @@
function resolveUploadUrl(uploadUrl: string) {
const trimmedUploadUrl = uploadUrl.trim()
if (/^https?:\/\//i.test(trimmedUploadUrl)) {
const url = new URL(trimmedUploadUrl)
return `${url.pathname}${url.search}`
}
return trimmedUploadUrl
}
export async function uploadMultipartFile(
uploadUrl: string,
fieldName: string,
file: File,
) {
const formData = new FormData()
formData.append(fieldName, file)
const response = await fetch(resolveUploadUrl(uploadUrl), {
method: 'POST',
body: formData,
credentials: 'include',
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(
`Не удалось загрузить файл: ${response.status} ${text}`,
)
}
}

View File

@ -1,23 +1,223 @@
@use '../../shared/styles/variables' as *;
.login-page {
min-height: 100vh;
background: $color-bg;
position: relative;
isolation: isolate;
width: 100%;
height: 100dvh;
min-height: 0;
padding: 24px;
box-sizing: border-box;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(
95% 70% at 50% 118%,
rgba(3, 29, 154, 0.18) 0%,
rgba(66, 93, 232, 0.14) 32%,
rgba(47, 128, 237, 0.1) 52%,
rgba(239, 242, 247, 0.45) 72%,
transparent 86%
),
radial-gradient(
70% 48% at 18% 100%,
rgba(24, 184, 155, 0.1) 0%,
transparent 68%
),
linear-gradient(
180deg,
#fbfcff 0%,
#f4f7fc 45%,
#eff2f7 100%
);
[data-theme='dark'] & {
background:
radial-gradient(
95% 70% at 50% 125%,
rgba($color-primary-rgb, 0.34) 0%,
rgba(72, 96, 180, 0.22) 36%,
rgba($color-bg-rgb, 0.55) 62%,
transparent 82%
),
linear-gradient(
180deg,
#242a33 0%,
#242a33 12%,
#222832 18%,
#1f252e 30%,
#1d232c 55%,
#171b22 100%
);
}
}
.login-theme-switch {
position: absolute;
top: 24px;
right: 24px;
z-index: 2;
width: 72px;
height: 32px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.login-theme-switch__track {
position: relative;
width: 62px;
height: 26px;
padding: 4px;
border-radius: 999px;
background: $color-surface-glass;
box-shadow:
inset 0 0 1px 1px rgba($gray50-rgb, 0.24),
0 10px 28px rgba($color-shadow-rgb, 0.12);
overflow: hidden;
transition:
background 0.25s ease,
box-shadow 0.25s ease;
}
.login-theme-switch__track::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background:
radial-gradient(circle at 25% 35%, rgba($orange, 0.22), transparent 34%),
radial-gradient(circle at 78% 65%, rgba($blue, 0.2), transparent 38%);
opacity: 0.92;
transition: opacity 0.25s ease;
}
.login-theme-switch__thumb {
position: relative;
z-index: 2;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: $white;
color: $orange;
box-shadow: 0 1px 8px -2px rgba($gray50-rgb, 0.8);
transform: translateX(0);
transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
background 0.25s ease,
color 0.25s ease,
box-shadow 0.25s ease;
}
.login-theme-switch__icon {
position: absolute;
z-index: 1;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
color: $color-text-muted;
opacity: 0.55;
transform: translateY(-50%);
transition:
opacity 0.25s ease,
color 0.25s ease;
}
.login-theme-switch__icon--sun {
left: 10px;
}
.login-theme-switch__icon--moon {
right: 10px;
}
.login-theme-switch.is-light {
.login-theme-switch__icon--sun {
opacity: 0;
}
.login-theme-switch__icon--moon {
opacity: 0.45;
}
}
.login-theme-switch.is-dark {
.login-theme-switch__track {
background: $color-surface-glass;
box-shadow:
inset 0 0 0 1px rgba($white, 0.08),
0 12px 30px rgba($black, 0.24);
}
.login-theme-switch__track::before {
opacity: 0.65;
}
.login-theme-switch__thumb {
transform: translateX(36px);
background: $color-surface;
color: $blue;
box-shadow: 0 1px 6px -2px $gray50;
}
.login-theme-switch__icon--sun {
opacity: 0.38;
}
.login-theme-switch__icon--moon {
opacity: 0;
}
}
.login-theme-switch:active {
.login-theme-switch__thumb {
transform: scale(0.94);
}
&.is-dark .login-theme-switch__thumb {
transform: translateX(36px) scale(0.94);
}
}
.login-card {
width: 100%;
max-width: 420px;
position: relative;
z-index: 1;
border-radius: 24px;
background: #ffffff;
padding: 32px;
width: min(100%, 430px);
padding: 30px;
border: 1px solid rgba($color-border-rgb, 0.74);
border-radius: 22px;
background: $color-surface-glass;
backdrop-filter: blur(16px);
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
@ -25,69 +225,202 @@
}
.login-card__header {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 8px;
text-align: center;
p {
margin: 8px 0 0;
color: $gray50;
font-size: 16px;
font-weight: 450;
line-height: 1.4;
margin: 6px 0 0;
color: $color-text-muted;
font-size: $font-size-14;
font-weight: 500;
line-height: 1.35;
}
}
.login-card__logo {
flex: 0 0 auto;
width: 60px;
height: 60px;
display: inline-flex;
align-items: center;
justify-content: center;
img {
display: block;
width: 90%;
height: 90%;
}
}
.login-field {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
display: block;
text-align: left;
}
span {
color: #30394b;
font-size: 14px;
font-weight: 500;
}
.login-field__label {
position: absolute;
z-index: 2;
top: -8px;
left: 44px;
padding: 0 7px;
border-radius: 999px;
background: $color-surface-glass;
color: $color-text-muted;
font-size: $font-size-11;
font-weight: 500;
line-height: 16px;
//text-transform: uppercase;
letter-spacing: 0;
transition:
color 0.18s ease,
background 0.18s ease;
}
.login-field__control {
height: 52px;
padding: 0 12px 0 14px;
border: 1px solid $color-border-strong;
border-radius: 15px;
background: $color-surface-muted;
display: flex;
align-items: center;
gap: 10px;
color: $color-text-muted;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease,
color 0.18s ease;
input {
height: 44px;
border: 1px solid #dfe5ef;
border-radius: 14px;
padding: 0 14px;
background: #f8fafc;
color: #111827;
font-size: 16px;
min-width: 0;
flex: 1;
height: 100%;
border: none;
outline: none;
background: transparent;
&:focus {
border-color: $blue;
background: #ffffff;
color: $color-text-strong;
font-size: $font-size-15;
font-weight: 600;
font-family: inherit;
&::placeholder {
color: $color-text-muted;
font-weight: 500;
}
}
}
.login-field:focus-within {
.login-field__label {
color: $blue;
background: $color-surface;
}
.login-field__control {
border-color: $blue;
background: $color-surface;
color: $blue;
box-shadow: 0 0 0 3px rgba($color-primary-rgb, 0.12);
}
}
.login-field__password-toggle {
flex: 0 0 auto;
width: 34px;
height: 34px;
border: none;
border-radius: 10px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: $color-text-muted;
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease;
&:hover,
&[aria-pressed='true'] {
background: $color-surface-hover;
color: $blue;
}
}
.login-error {
border-radius: 12px;
background: #ffecec;
color: $red;
padding: 10px 12px;
font-weight: 450;
font-size: 14px;
border-radius: 12px;
background: $color-danger-bg;
color: $red;
font-size: $font-size-13;
font-weight: 600;
text-align: left;
}
.login-button {
height: 44px;
height: 54px;
border: none;
border-radius: 14px;
background: $blue;
color: #ffffff;
font-size: 15px;
font-weight: 600;
color: $color-text-inverse;
font-size: $font-size-16;
font-weight: 550;
cursor: pointer;
transition:
transform 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease,
opacity 0.18s ease;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-primary);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&:disabled {
opacity: 0.65;
cursor: default;
}
}
@media (max-width: 520px) {
.login-page {
padding: 16px;
}
.login-theme-switch {
top: 16px;
right: 16px;
}
.login-card {
padding: 24px;
}
.login-card__header {
align-items: flex-start;
flex-direction: column;
}
}

View File

@ -1,75 +1,142 @@
import { useState } from 'react'
import type { SubmitEvent } from 'react'
import type { FormEvent } from 'react'
import { useMutation } from '@apollo/client/react'
import { SIGN_IN_MUTATION, CURRENT_USER_QUERY } from '../../features/auth/api/auth.graphql'
import {
Eye,
EyeOff,
LockKeyhole,
Moon,
Sun,
UserRound,
} from 'lucide-react'
import {
CURRENT_USER_QUERY,
SIGN_IN_MUTATION,
} from '../../features/auth/api/auth.graphql'
import { useTheme } from '../../shared/theme/useTheme'
import LogoIcon from '../../assets/LogoIcon.svg'
import LogoIconWhite from '../../assets/LogoIconWhite.svg'
import './LoginPage.scss'
type LoginPageProps = {
onSuccess: () => void
onSuccess: () => void
}
export function LoginPage({ onSuccess }: LoginPageProps) {
const [username, setUsername] = useState('Admin1')
const [password, setPassword] = useState('123456')
const [username, setUsername] = useState('Admin2')
const [password, setPassword] = useState('123456')
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
const { isDark, toggleTheme } = useTheme()
const logo = isDark ? LogoIconWhite : LogoIcon
const canSubmit = Boolean(username.trim()) && Boolean(password.trim())
const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, {
refetchQueries: [CURRENT_USER_QUERY],
onCompleted: () => {
onSuccess()
},
const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, {
refetchQueries: [CURRENT_USER_QUERY],
onCompleted: () => {
onSuccess()
},
})
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!canSubmit || loading) return
signIn({
variables: {
username: username.trim(),
password,
},
})
}
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault()
return (
<main className="login-page">
<button
className={`login-theme-switch ${isDark ? 'is-dark' : 'is-light'}`}
type="button"
aria-label={isDark ? 'Включить светлую тему' : 'Включить тёмную тему'}
aria-pressed={isDark}
onClick={toggleTheme}
>
<span className="login-theme-switch__track">
<span className="login-theme-switch__icon login-theme-switch__icon--sun">
<Sun size={14} />
</span>
signIn({
variables: {
username,
password,
},
})
}
<span className="login-theme-switch__icon login-theme-switch__icon--moon">
<Moon size={14} />
</span>
return (
<main className="login-page">
<form className="login-card" onSubmit={handleSubmit}>
<div className="login-card__header">
<img src='/favicon.svg' height={56} width={56} alt='logo'></img>
<p>Авторизуйтесь для доступа к панели управления устройствами</p>
</div>
<span className="login-theme-switch__thumb">
{isDark ? <Moon size={15} /> : <Sun size={15} />}
</span>
</span>
</button>
<label className="login-field">
<span>Логин</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Введите логин"
autoComplete="username"
/>
</label>
<form className="login-card" onSubmit={handleSubmit}>
<div className="login-card__header">
<span className="login-card__logo">
<img src={logo} height={40} width={40} alt="АРМА" />
</span>
<label className="login-field">
<span>Пароль</span>
<input
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Введите пароль"
type="password"
autoComplete="current-password"
/>
</label>
<div>
<p>Авторизуйтесь для доступа <br></br>к панели управления устройствами</p>
</div>
</div>
{error && (
<div className="login-error">
Не удалось войти. Проверьте логин и пароль.
</div>
)}
<label className="login-field">
<span className="login-field__label">Логин</span>
<button className="login-button" type="submit" disabled={loading}>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
</main>
)
<span className="login-field__control">
<UserRound size={18} />
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Введите логин"
autoComplete="username"
/>
</span>
</label>
<label className="login-field">
<span className="login-field__label">Пароль</span>
<span className="login-field__control">
<LockKeyhole size={18} />
<input
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Введите пароль"
type={isPasswordVisible ? 'text' : 'password'}
autoComplete="current-password"
/>
<button
className="login-field__password-toggle"
type="button"
aria-label={isPasswordVisible ? 'Скрыть пароль' : 'Показать пароль'}
aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((value) => !value)}
>
{isPasswordVisible ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</span>
</label>
{error && (
<div className="login-error">
Не удалось войти. Проверьте логин и пароль.
</div>
)}
<button className="login-button" type="submit" disabled={loading || !canSubmit}>
{loading ? 'Входим...' : 'Войти'}
</button>
</form>
</main>
)
}

View File

@ -9,6 +9,7 @@
height: 100%;
gap: 12px;
overflow: hidden;
padding: 20px 20px 36px 20px;
}
.map-page__sidebar {
@ -20,7 +21,7 @@
.map-page__panel {
height: 100%;
border-radius: 20px;
background: #ffffff;
background: $color-surface;
padding: 18px;
display: flex;
@ -29,8 +30,8 @@
h2 {
margin: 0 0 14px;
color: #111827;
font-size: 20px;
color: $color-text-strong;
font-size: $font-size-20;
font-weight: 600;
}
}
@ -38,7 +39,7 @@
.map-page__state {
margin: 0;
color: $gray50;
font-size: 15px;
font-size: $font-size-15;
}
.map-page__state--error {
@ -48,10 +49,10 @@
.map-page__count {
margin: 0 0 14px;
color: $gray50;
font-size: 15px;
font-size: $font-size-15;
b {
color: #111827;
color: $color-text-strong;
}
}
@ -64,18 +65,18 @@
min-height: 0;
scrollbar-width: thin;
scrollbar-color: rgba(3, 29, 154, 0.45) transparent;
scrollbar-color: rgba($color-primary-rgb, 0.45) $transparent;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
background: $transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(3, 29, 154, 0.35);
background: rgba($color-primary-rgb, 0.35);
border-radius: 999px;
}
}
@ -93,14 +94,14 @@
transition: 0.2s ease;
span {
color: #111827;
font-size: 15px;
color: $color-text-strong;
font-size: $font-size-15;
font-weight: 600;
}
small {
color: $gray50;
font-size: 13px;
font-size: $font-size-13;
}
&:hover {
@ -113,12 +114,20 @@
}
.map-page__map {
position: relative;
flex: 1;
min-width: 0;
min-height: 0;
border-radius: 20px;
overflow: hidden;
background: #ffffff;
background: $color-surface;
}
.map-page__map:fullscreen {
width: 100vw;
height: 100vh;
border-radius: 0;
background: $color-surface;
}
.map-page__leaflet {
@ -126,16 +135,141 @@
height: 100%;
}
.map-page__load-state {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 600;
min-height: 36px;
padding: 0 14px;
border-radius: 999px;
background: $color-surface-glass;
backdrop-filter: blur(10px);
box-shadow: 0 12px 28px rgba($color-shadow-rgb, 0.16);
display: inline-flex;
align-items: center;
color: $color-text-heading;
font-size: $font-size-14;
font-weight: 650;
&.is-error {
color: $red;
}
}
.device-map-marker {
background: $transparent;
border: none;
}
.device-map-marker__pin {
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba($color-primary-rgb, 0.18);
display: flex;
align-items: center;
justify-content: center;
}
.device-map-marker__inner {
width: 16px;
height: 16px;
border-radius: 50%;
background: $blue;
border: 3px solid $color-surface;
box-shadow: 0 8px 18px rgba($color-primary-rgb, 0.35);
}
.device-map-marker.is-online {
.device-map-marker__pin {
background: rgba($color-success-rgb, 0.18);
}
.device-map-marker__inner {
background: $green;
box-shadow: 0 8px 18px rgba($color-success-rgb, 0.35);
}
}
.device-map-marker.is-offline {
.device-map-marker__pin {
background: rgba($gray50-rgb, 0.18);
}
.device-map-marker__inner {
background: $gray50;
box-shadow: 0 8px 18px rgba($gray50-rgb, 0.28);
}
}
.device-map-marker.is-lost {
.device-map-marker__pin {
background: rgba($color-danger-rgb, 0.16);
}
.device-map-marker__inner {
background: $red;
box-shadow: 0 8px 18px rgba($color-danger-rgb, 0.28);
}
}
.leaflet-tooltip.map-marker-label {
padding: 6px 9px;
border: none;
border-radius: 10px;
background: $color-surface-glass;
color: $color-text-heading;
font-size: $font-size-12;
font-weight: 650;
line-height: 1.2;
pointer-events: none;
box-shadow: 0 10px 24px rgba($color-shadow-rgb, 0.16);
}
.leaflet-tooltip-right.map-marker-label::before {
border-right-color: $color-surface-glass;
}
.leaflet-tooltip-left.map-marker-label::before {
border-left-color: $color-surface-glass;
}
.map-marker-label.is-online {
color: $green;
}
.map-marker-label.is-offline {
color: $gray50;
}
.map-marker-label.is-lost {
color: $red;
}
.map-popup {
min-width: 210px;
min-width: 230px;
display: flex;
flex-direction: column;
gap: 10px;
}
.map-popup__header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 7px;
}
.map-popup__title {
display: inline-block;
margin-bottom: 8px;
color: $blue !important;
font-size: 15px;
font-size: $font-size-15;
font-weight: 700;
text-decoration: none;
@ -144,72 +278,247 @@
}
}
.map-popup__status {
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 6px;
color: $color-text-heading;
font-size: $font-size-11;
font-weight: 700;
text-transform: uppercase;
&::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
&.is-online {
background: rgba($color-success-rgb, 0.14);
color: $green;
}
&.is-offline {
background: $color-bg;
color: $gray50;
}
&.is-lost {
background: rgba($color-danger-rgb, 0.14);
color: $red;
}
}
.map-popup__row {
display: flex;
flex-direction: column;
gap: 3px;
span {
color: #738098;
font-size: 12px;
color: $color-text-muted;
font-size: $font-size-12;
}
b {
color: #111827;
font-size: 13px;
color: $color-text-strong;
font-size: $font-size-13;
font-weight: 600;
}
}
.map-popup__coords {
margin-top: 8px;
color: #738098;
font-size: 12px;
padding-top: 2px;
color: $color-text-muted;
font-size: $font-size-12;
}
.leaflet-touch .leaflet-bar {
border: none !important;
background-clip: padding-box;
.map-selection-panel {
position: absolute;
top: 12px;
left: 12px;
z-index: 600;
width: min(340px, calc(100% - 24px));
display: flex;
flex-direction: column;
gap: 4px;
}
align-items: stretch;
gap: 12px;
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
--sans: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
;
font: 28px/100% var(--sans) !important;
width: 36px !important;
height: 36px !important;
display: flex !important;
align-items: center;
justify-content: center;
font-weight: 500 !important;
text-indent: 0px !important;
border-radius: 8px !important;
border: none !important;
transition: .2s ease;
pointer-events: none;
&:hover {
background-color: white !important;
color: $blue !important;
> * {
pointer-events: auto;
}
.map-track-filter {
position: static;
z-index: auto;
}
}
.leaflet-top.leaflet-left {
top: 50%;
left: auto;
right: 12px;
transform: translateY(-50%);
.device-map-info-card {
position: absolute;
left: 12px;
top: 70px;
z-index: 430;
width: min(180px, calc(100% - 24px));
padding: 14px;
border: 1px solid rgba($color-border-rgb, 0.72);
border-radius: 16px;
background: rgba($color-surface-rgb, 0.94);
backdrop-filter: blur(14px);
box-shadow: $shadow-popover;
&--map-page{
top: 120px !important;
}
}
.leaflet-left .leaflet-control {
margin-left: 0px !important;
.device-map-info-card__close {
position: absolute;
top: 8px;
right: 8px;
width: 26px;
height: 26px;
border: none;
border-radius: 8px;
background: $color-surface-muted;
display: inline-flex;
align-items: center;
justify-content: center;
color: $color-text-muted;
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease;
&:hover {
background: $color-surface-hover;
color: $color-primary;
}
}
.leaflet-top .leaflet-control {
margin-top: 0px !important;
.device-map-info-card__header {
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 28px;
margin-bottom: 12px;
line-height: 1;
text-align: left;
b,
span,
.device-map-info-card__title {
display: block;
}
b,
.device-map-info-card__title {
overflow: hidden;
color: $color-text-heading;
font-size: $font-size-15;
font-weight: 600;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: $blue;
}
}
span {
margin-top: 3px;
overflow: hidden;
color: $color-text-muted;
font-size: $font-size-12;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.device-map-info-card__rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-map-info-card__row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
justify-content: flex-start;
gap: 8px;
color: $color-text;
font-size: $font-size-12;
font-weight: 500;
svg {
color: $color-primary;
}
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
}
.device-map-info-card__status {
grid-column: 1 / -1;
min-height: 22px;
width: fit-content;
padding: 0 8px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 6px;
color: $color-text-heading;
font-size: $font-size-11;
font-weight: 700;
text-transform: uppercase;
&::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
&.is-online {
background: rgba($color-success-rgb, 0.14);
color: $green;
}
&.is-offline {
background: $color-bg;
color: $gray50;
}
&.is-lost {
background: rgba($color-danger-rgb, 0.14);
color: $red;
}
}
.map-track-filter {
@ -221,7 +530,101 @@
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.16);
pointer-events: none;
> * {
pointer-events: auto;
}
}
.map-track-filter__reset {
height: 36px;
padding: 0 14px;
border: none;
border-radius: 999px;
background: $color-surface-glass;
backdrop-filter: blur(10px);
box-shadow: 0 12px 28px rgba($color-shadow-rgb, 0.16);
display: inline-flex;
align-items: center;
gap: 8px;
color: $color-text-heading;
font-size: $font-size-14;
font-weight: 650;
cursor: pointer;
transition: 0.2s ease;
svg {
color: $blue;
}
&:hover {
color: $blue;
box-shadow:
0 14px 32px rgba($color-shadow-rgb, 0.18),
inset 0 0 0 1px rgba($color-primary-rgb, 0.14);
}
}
.map-track-filter__state {
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: $color-surface-glass;
backdrop-filter: blur(10px);
box-shadow: 0 12px 28px rgba($color-shadow-rgb, 0.16);
display: inline-flex;
align-items: center;
color: $color-text-heading;
font-size: $font-size-13;
font-weight: 650;
&.is-error {
color: $red;
}
}
@include laptop-down {
.map-page__sidebar {
width: 280px;
flex-basis: 280px;
}
.map-page__panel {
padding: 16px;
}
.map-device-link {
padding: 10px;
}
}
@include tablet-down {
.map-page {
gap: 10px;
}
.map-page__sidebar {
width: 260px;
flex-basis: 260px;
}
.map-selection-panel,
.map-track-filter {
max-width: calc(100% - 24px);
}
.map-selection-panel > *,
.map-track-filter > * {
max-width: 100%;
}
}

View File

@ -1,29 +1,31 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@apollo/client/react'
import { AnimatePresence, m } from 'framer-motion'
import {
CircleMarker,
MapContainer,
Marker,
Polyline,
Popup,
TileLayer,
useMap,
useMapEvents,
} from 'react-leaflet'
import L from 'leaflet'
import { Battery, Clock3, Layers, MapPin, Thermometer, X } from 'lucide-react'
import {
GET_PHONE_GPS_TRACK_QUERY,
GET_PHONES_PAGE_QUERY,
GET_PHONES_QUERY,
} from '../../entities/device/api/device.graphql'
import type {
Device,
DeviceGpsTrackPoint,
DeviceNetworkStatus,
GetPhoneGpsTrackData,
GetPhoneGpsTrackVariables,
GetPhonesPageData,
GetPhonesPageVariables,
GetPhonesData,
GetPhonesVariables,
} from '../../entities/device/model/types'
import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
@ -31,22 +33,68 @@ import {
MapTrackPeriodControl,
type MapTrackPeriodValue,
} from './components/MapTrackPeriodControl/MapTrackPeriodControl'
import { popoverVariants, uiFadeTransition } from '../../shared/lib/motion'
import 'leaflet/dist/leaflet.css'
import './MapPage.scss'
const markerIcon = L.divIcon({
className: 'device-map-marker',
html: `
<div class="device-map-marker__pin">
<div class="device-map-marker__inner"></div>
</div>
`,
iconSize: [34, 34],
iconAnchor: [17, 17],
popupAnchor: [0, -18],
})
const MAP_PHONES_SORT_DIRECTION = 'DESC'
const MAP_PHONES_SORT_FIELD = 'Date'
const routeColor = 'var(--color-primary)'
function formatLocationDate(timestamp: number) {
type MapInfoPoint = {
key: string
title: string
subtitle?: string
date?: number
lat: number
lng: number
}
function getNetworkStatusClass(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'is-online'
if (status === 'Lost') return 'is-lost'
return 'is-offline'
}
function getNetworkStatusLabel(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'В сети'
if (status === 'Lost') return 'Потерян'
return 'Не в сети'
}
function createMapInfoPointKey(lat: number, lng: number, date?: number) {
return `${date ?? 'no-date'}-${lat}-${lng}`
}
const markerIconCache = new Map<string, L.DivIcon>()
function createMarkerIcon(status?: DeviceNetworkStatus) {
const iconKey = status ?? 'default'
const cachedIcon = markerIconCache.get(iconKey)
if (cachedIcon) return cachedIcon
const icon = L.divIcon({
className: `device-map-marker ${getNetworkStatusClass(status)}`,
html: `
<div class="device-map-marker__pin">
<div class="device-map-marker__inner"></div>
</div>
`,
iconSize: [34, 34],
iconAnchor: [17, 17],
popupAnchor: [0, -18],
})
markerIconCache.set(iconKey, icon)
return icon
}
function formatLocationDate(timestamp?: number) {
if (!timestamp) return 'Нет данных'
return new Intl.DateTimeFormat('ru-RU', {
@ -58,6 +106,16 @@ function formatLocationDate(timestamp: number) {
}).format(new Date(timestamp))
}
function formatCoordinate(value: number) {
return value.toFixed(6)
}
function formatMetric(value: number | null | undefined, suffix: string) {
if (typeof value !== 'number') return '-'
return `${value}${suffix}`
}
function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
return [...track].sort((a, b) => a.date - b.date)
}
@ -85,18 +143,47 @@ function MapResetHandler({ onReset }: MapResetHandlerProps) {
return null
}
type MapFitBoundsProps = {
positions: [number, number][]
}
function MapFitBounds({ positions }: MapFitBoundsProps) {
function MapResizeWatcher() {
const map = useMap()
useEffect(() => {
function invalidateMapSize() {
map.invalidateSize()
}
const animationFrameId = window.requestAnimationFrame(invalidateMapSize)
const timeoutId = window.setTimeout(invalidateMapSize, 220)
window.addEventListener('resize', invalidateMapSize)
document.addEventListener('fullscreenchange', invalidateMapSize)
return () => {
window.cancelAnimationFrame(animationFrameId)
window.clearTimeout(timeoutId)
window.removeEventListener('resize', invalidateMapSize)
document.removeEventListener('fullscreenchange', invalidateMapSize)
}
}, [map])
return null
}
type MapFitBoundsProps = {
fitKey: string
positions: [number, number][]
}
function MapFitBounds({ fitKey, positions }: MapFitBoundsProps) {
const map = useMap()
const lastFitKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!positions.length) return
if (lastFitKeyRef.current === fitKey) return
if (positions.length === 1) {
map.setView(positions[0], 14)
lastFitKeyRef.current = fitKey
return
}
@ -107,22 +194,40 @@ function MapFitBounds({ positions }: MapFitBoundsProps) {
maxZoom: 15,
animate: true,
})
}, [map, positions])
lastFitKeyRef.current = fitKey
}, [fitKey, map, positions])
return null
}
export function MapPage() {
const fullscreenTargetId = useId()
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
const [selectedPeriod, setSelectedPeriod] =
useState<MapTrackPeriodValue | null>(null)
const [phonesPaginationError, setPhonesPaginationError] =
useState<Error | null>(null)
const [isFetchingNextPhonesPage, setIsFetchingNextPhonesPage] =
useState(false)
const [gpsFitDataKey, setGpsFitDataKey] = useState('no-period')
const [selectedPointKey, setSelectedPointKey] = useState<string | null>(null)
const [isInfoCardVisible, setIsInfoCardVisible] = useState(false)
const isFetchingPhonesPageRef = useRef(false)
const requestedPhonesPagesRef = useRef(new Set<number>([0]))
const fittedGpsPeriodRef = useRef<string | null>(null)
const { data, loading, error } = useQuery<
GetPhonesPageData,
GetPhonesPageVariables
>(GET_PHONES_PAGE_QUERY, {
variables: {},
const { data, loading, error, fetchMore } = useQuery<
GetPhonesData,
GetPhonesVariables
>(GET_PHONES_QUERY, {
variables: {
page: 0,
sortDirection: MAP_PHONES_SORT_DIRECTION,
sortField: MAP_PHONES_SORT_FIELD,
},
fetchPolicy: 'network-only',
notifyOnNetworkStatusChange: true,
})
const {
@ -142,24 +247,131 @@ export function MapPage() {
},
)
const devices = data?.getPhonesPage.page ?? []
const phonesTotalPages = data?.getPhones.totalPages ?? 0
const devicesWithLocation = devices.filter((device) =>
Boolean(device.lastLocation),
)
useEffect(() => {
if (
phonesPaginationError ||
phonesTotalPages <= 1 ||
isFetchingPhonesPageRef.current
) {
return
}
const nextPhonesPage = Array.from(
{ length: phonesTotalPages - 1 },
(_, index) => index + 1,
).find((page) => !requestedPhonesPagesRef.current.has(page))
if (nextPhonesPage === undefined) return
isFetchingPhonesPageRef.current = true
requestedPhonesPagesRef.current.add(nextPhonesPage)
setIsFetchingNextPhonesPage(true)
setPhonesPaginationError(null)
fetchMore({
variables: {
page: nextPhonesPage,
sortDirection: MAP_PHONES_SORT_DIRECTION,
sortField: MAP_PHONES_SORT_FIELD,
},
updateQuery: (previousData, { fetchMoreResult }) => {
const nextPageData = fetchMoreResult?.getPhones
if (!nextPageData) return previousData
const previousDevices = previousData.getPhones.page
const previousDeviceIds = new Set(
previousDevices.map((device) => device.id),
)
const nextDevices = nextPageData.page.filter((device) => {
return !previousDeviceIds.has(device.id)
})
return {
getPhones: {
...nextPageData,
page: [...previousDevices, ...nextDevices],
},
}
},
})
.catch((requestError: unknown) => {
setPhonesPaginationError(
requestError instanceof Error
? requestError
: new Error(
'Не удалось загрузить следующую страницу устройств',
),
)
})
.finally(() => {
isFetchingPhonesPageRef.current = false
setIsFetchingNextPhonesPage(false)
})
}, [
fetchMore,
isFetchingNextPhonesPage,
phonesPaginationError,
phonesTotalPages,
])
const phonesError = error ?? phonesPaginationError
const isPhonesLoading = loading || isFetchingNextPhonesPage
const selectedPeriodKey = selectedPeriod
? `${selectedPeriod.periodStart.getTime()}-${selectedPeriod.periodEnd.getTime()}`
: 'no-period'
useEffect(() => {
if (!selectedPeriod) {
fittedGpsPeriodRef.current = null
setGpsFitDataKey('no-period')
return
}
if (
isGpsTrackLoading ||
!gpsTrackData ||
fittedGpsPeriodRef.current === selectedPeriodKey
) {
return
}
fittedGpsPeriodRef.current = selectedPeriodKey
setGpsFitDataKey(`ready:${selectedPeriodKey}`)
}, [gpsTrackData, isGpsTrackLoading, selectedPeriod, selectedPeriodKey])
const devices = useMemo(() => {
return data?.getPhones.page ?? []
}, [data?.getPhones.page])
const devicesWithLocation = useMemo(() => {
return devices.filter(
(device): device is Device & {
lastLocation: NonNullable<Device['lastLocation']>
} => Boolean(device.lastLocation),
)
}, [devices])
const shouldShowPhonesLoadState =
!selectedDevice &&
(Boolean(phonesError) ||
(isPhonesLoading && devicesWithLocation.length === 0))
const sortedGpsTrack = useMemo(() => {
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
}, [gpsTrackData])
const shouldShowCurrentLocation = Boolean(
selectedDevice?.lastLocation &&
isDateInPeriod(selectedDevice.lastLocation.date, selectedPeriod),
const selectedLastLocation = selectedDevice?.lastLocation ?? null
const shouldAddLastLocationToRoute = Boolean(
selectedLastLocation &&
isDateInPeriod(selectedLastLocation.date, selectedPeriod),
)
const selectedCurrentLocation =
shouldShowCurrentLocation && selectedDevice?.lastLocation
? selectedDevice.lastLocation
shouldAddLastLocationToRoute && selectedLastLocation
? selectedLastLocation
: null
const selectedRoutePoints = useMemo(() => {
@ -187,21 +399,81 @@ export function MapPage() {
]
}, [selectedRoutePoints, selectedCurrentLocation])
const selectedMapPositions = useMemo<[number, number][]>(() => {
const positions = [...selectedRoutePositions]
if (!selectedLastLocation) return positions
const hasLastLocation = positions.some(([lat, lng]) => {
return lat === selectedLastLocation.lat && lng === selectedLastLocation.lng
})
if (hasLastLocation) return positions
return [...positions, [selectedLastLocation.lat, selectedLastLocation.lng]]
}, [selectedLastLocation, selectedRoutePositions])
const isSelectedMode = Boolean(selectedDevice)
const routeMapPoints = useMemo<MapInfoPoint[]>(() => {
return selectedRoutePoints.map((point) => ({
key: createMapInfoPointKey(point.lat, point.lng, point.date),
title: 'Точка маршрута',
subtitle: 'Точка маршрута',
date: point.date,
lat: point.lat,
lng: point.lng,
}))
}, [selectedDevice, selectedRoutePoints])
const currentMapPoint = useMemo<MapInfoPoint | null>(() => {
if (!selectedDevice || !selectedLastLocation) return null
return {
key: createMapInfoPointKey(
selectedLastLocation.lat,
selectedLastLocation.lng,
selectedLastLocation.date,
),
title: selectedDevice.serial || `Устройство #${selectedDevice.id}`,
subtitle: selectedDevice.org?.name,
date: selectedLastLocation.date,
lat: selectedLastLocation.lat,
lng: selectedLastLocation.lng,
}
}, [selectedDevice, selectedLastLocation])
const selectedInfoPoint = useMemo(() => {
if (!selectedPointKey) return currentMapPoint
return (
routeMapPoints.find((point) => point.key === selectedPointKey) ??
currentMapPoint
)
}, [currentMapPoint, routeMapPoints, selectedPointKey])
useEffect(() => {
if (selectedPointKey && selectedInfoPoint?.key !== selectedPointKey) {
setSelectedPointKey(null)
}
}, [selectedInfoPoint, selectedPointKey])
const visibleMapPositions = useMemo<[number, number][]>(() => {
if (isSelectedMode && selectedRoutePositions.length > 0) {
return selectedRoutePositions
if (isSelectedMode) {
return selectedMapPositions
}
return devicesWithLocation.map(
(device) =>
[
device.lastLocation!.lat,
device.lastLocation!.lng,
device.lastLocation.lat,
device.lastLocation.lng,
] as [number, number],
)
}, [isSelectedMode, selectedRoutePositions, devicesWithLocation])
}, [isSelectedMode, selectedMapPositions, devicesWithLocation])
const visibleMapFitKey = isSelectedMode
? `selected:${selectedDevice?.id ?? 'none'}:${selectedPeriodKey}:${gpsFitDataKey}`
: 'all-devices'
const firstLocation = devicesWithLocation[0]?.lastLocation
@ -212,33 +484,160 @@ export function MapPage() {
function handleSelectDevice(device: Device) {
setSelectedDevice(device)
setSelectedPeriod(null)
setSelectedPointKey(null)
setIsInfoCardVisible(true)
}
function handleResetSelection() {
setSelectedDevice(null)
setSelectedPeriod(null)
setSelectedPointKey(null)
setIsInfoCardVisible(false)
}
return (
<section className="map-page">
<div className="map-page__map">
{selectedDevice && (
<div className="map-track-filter">
<MapTrackPeriodControl onChange={setSelectedPeriod} />
<div className="map-page__map" id={fullscreenTargetId}>
{shouldShowPhonesLoadState && (
<div
className={`map-page__load-state${
phonesError ? ' is-error' : ''
}`}
>
{phonesError
? 'Не удалось загрузить устройства'
: 'Загружаем устройства'}
</div>
)}
<AnimatePresence initial={false}>
{selectedDevice && selectedInfoPoint && isInfoCardVisible && (
<m.div
className="device-map-info-card device-map-info-card--map-page"
variants={popoverVariants}
initial="initial"
animate="animate"
exit="exit"
transition={uiFadeTransition}
>
<button
className="device-map-info-card__close"
type="button"
aria-label="Закрыть информацию о точке"
onClick={handleResetSelection}
>
<X size={15} />
</button>
<div className="device-map-info-card__header">
<Link
className="device-map-info-card__title"
to={`/devices/${selectedDevice.id}`}
>
{selectedInfoPoint.title}
</Link>
<span>
{selectedInfoPoint.subtitle ??
selectedDevice.org?.name ??
selectedDevice.serial}
</span>
</div>
<div className="device-map-info-card__rows">
<div className="device-map-info-card__row">
<span
className={`device-map-info-card__status ${getNetworkStatusClass(
selectedDevice.networkStatus,
)}`}
>
{getNetworkStatusLabel(selectedDevice.networkStatus)}
</span>
</div>
<div className="device-map-info-card__row">
<Clock3 size={15} />
<span>{formatLocationDate(selectedInfoPoint.date)}</span>
</div>
<div className="device-map-info-card__row">
<Battery size={15} />
<span>
Заряд АКБ:{' '}
{formatMetric(selectedDevice.techState?.batteryLevel, '%')}
</span>
</div>
<div className="device-map-info-card__row">
<Thermometer size={15} />
<span>
Температура:{' '}
{formatMetric(selectedDevice.techState?.temperature, '°C')}
</span>
</div>
<div className="device-map-info-card__row">
<MapPin size={15} />
<span>
{formatCoordinate(selectedInfoPoint.lat)},{' '}
{formatCoordinate(selectedInfoPoint.lng)}
</span>
</div>
</div>
</m.div>
)}
</AnimatePresence>
<AnimatePresence initial={false}>
{selectedDevice && (
<m.div
className="map-selection-panel"
variants={popoverVariants}
initial="initial"
animate="animate"
exit="exit"
transition={uiFadeTransition}
>
<div className="map-track-filter">
<MapTrackPeriodControl onChange={setSelectedPeriod} />
<button
className="map-track-filter__reset"
type="button"
onClick={handleResetSelection}
>
<Layers size={17} />
Все устройства
</button>
{(isGpsTrackLoading || gpsTrackError) && (
<div
className={`map-track-filter__state${
gpsTrackError ? ' is-error' : ''
}`}
>
{gpsTrackError
? 'Не удалось загрузить маршрут'
: 'Загружаем маршрут'}
</div>
)}
</div>
</m.div>
)}
</AnimatePresence>
<MapContainer
center={mapCenter}
zoom={12}
scrollWheelZoom
attributionControl={false}
className="map-page__leaflet"
id="devices-map"
>
<MapResizeWatcher />
<MapResetHandler onReset={handleResetSelection} />
<MapFitBounds positions={visibleMapPositions} />
<MapFitBounds
fitKey={visibleMapFitKey}
positions={visibleMapPositions}
/>
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
@ -250,33 +649,14 @@ export function MapPage() {
<Marker
key={device.id}
position={[device.lastLocation.lat, device.lastLocation.lng]}
icon={markerIcon}
icon={createMarkerIcon(device.networkStatus)}
eventHandlers={{
click: (event) => {
event.originalEvent.stopPropagation()
handleSelectDevice(device)
},
}}
>
<Popup>
<div className="map-popup">
<Link
className="map-popup__title"
to={`/devices/${device.id}`}
>
{device.serial || `Устройство #${device.id}`}
</Link>
<div className="map-popup__row">
<b>{formatLocationDate(device.lastLocation.date)}</b>
</div>
<div className="map-popup__coords">
{device.lastLocation.lat}, {device.lastLocation.lng}
</div>
</div>
</Popup>
</Marker>
/>
)
})}
@ -286,81 +666,54 @@ export function MapPage() {
<Polyline
positions={selectedRoutePositions}
pathOptions={{
color: '#031d9a',
color: routeColor,
weight: 4,
opacity: 0.85,
}}
/>
)}
{selectedRoutePoints.map((point, index) => (
{routeMapPoints.map((point) => (
<CircleMarker
key={`${point.lat}-${point.lng}-${point.date}-${index}`}
key={point.key}
center={[point.lat, point.lng]}
radius={4}
pathOptions={{
color: '#031d9a',
fillColor: '#031d9a',
color: routeColor,
fillColor: routeColor,
fillOpacity: 1,
weight: 2,
}}
>
<Popup>
<div className="map-popup">
<div className="map-popup__row">
<b>{formatLocationDate(point.date)}</b>
</div>
<div className="map-popup__coords">
{point.lat}, {point.lng}
</div>
</div>
</Popup>
</CircleMarker>
))}
{selectedCurrentLocation && (
<Marker
position={[
selectedCurrentLocation.lat,
selectedCurrentLocation.lng,
]}
icon={markerIcon}
eventHandlers={{
click: (event) => {
event.originalEvent.stopPropagation()
setSelectedPointKey(point.key)
setIsInfoCardVisible(true)
},
}}
>
<Popup>
<div className="map-popup">
<Link
className="map-popup__title"
to={`/devices/${selectedDevice?.id}`}
>
{selectedDevice?.serial ||
`Устройство #${selectedDevice?.id}`}
</Link>
/>
))}
<div className="map-popup__row">
<b>
Текущее местоположение:{' '}
{formatLocationDate(selectedCurrentLocation.date)}
</b>
</div>
<div className="map-popup__coords">
{selectedCurrentLocation.lat},{' '}
{selectedCurrentLocation.lng}
</div>
</div>
</Popup>
</Marker>
{selectedLastLocation && currentMapPoint && (
<Marker
position={[
selectedLastLocation.lat,
selectedLastLocation.lng,
]}
icon={createMarkerIcon(selectedDevice?.networkStatus)}
eventHandlers={{
click: (event) => {
event.originalEvent.stopPropagation()
setSelectedPointKey(currentMapPoint.key)
setIsInfoCardVisible(true)
},
}}
/>
)}
</>
)}
<FullscreenControl targetId="devices-map" />
<FullscreenControl targetId={fullscreenTargetId} />
</MapContainer>
</div>
</section>

View File

@ -15,7 +15,7 @@
.history-period-tabs {
padding: 2px;
border-radius: 999px;
background: rgba($color-bg, .8);
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
@ -39,10 +39,11 @@
&:hover {
color: $blue;
background: $gray30;
}
&.is-active {
background: #ffffff;
background: $color-bg;
color: $blue;
}
}
@ -54,17 +55,18 @@
.history-date-picker__trigger {
height: 36px;
padding: 0 14px;
text-wrap: nowrap;
border: none;
border-radius: 999px;
background: rgba($color-bg, .8);
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
gap: 8px;
color: #30394b;
color: var(--gray50);
font-size: 14px;
font-weight: 500;
@ -78,9 +80,9 @@
&:hover,
&.is-active {
background: rgba($color-bg, 1);
background: $color-bg;
color: $blue;
box-shadow: inset 0 0 0 1px rgba(3, 29, 154, 0.16);
box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.16);
svg {
color: $blue;
@ -94,94 +96,99 @@
top: calc(100% + 8px);
z-index: 120;
min-width: 100%;
width: max-content;
border-radius: 18px;
background: #ffffff;
padding: 14px;
background: $color-surface;
padding: 12px;
border: 1.5px solid $gray20;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
box-shadow: none;
button{
transition: .18s ease;
}
.rdp-root {
--rdp-accent-color: $blue;
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
--rdp-day_button-border-radius: 10px;
--rdp-accent-color: #{$blue};
--rdp-accent-background-color: #{$color-primary-soft};
--rdp-day_button-border-radius: 12px;
--rdp-day_button-height: 34px;
--rdp-day_button-width: 34px;
--rdp-day-height: 36px;
--rdp-day-width: 36px;
--rdp-selected-border: 0;
margin: 0;
font-family: inherit;
}
.rdp-months {
max-width: 100%;
gap: 18px;
}
.rdp-month {
width: 100%;
}
.rdp-month_caption {
margin-bottom: 8px;
}
.rdp-caption_label {
color: #30394b;
font-size: 15px;
font-weight: 600;
color: $color-text-strong;
font-size: $font-size-14;
font-weight: 700;
}
.rdp-nav {
button {
width: 30px;
height: 30px;
border-radius: 10px;
gap: 4px;
}
color: $gray50;
.rdp-button_previous,
.rdp-button_next,
.rdp-nav button {
width: 28px;
height: 28px;
border-radius: 8px;
&:hover {
background: $color-bg;
color: $blue;
}
color: $blue;
&:hover {
background: $color-surface-hover;
}
}
.rdp-weekday {
color: $gray50;
font-size: 12px;
font-weight: 500;
}
.rdp-day {
width: 38px;
height: 38px;
padding: 0;
color: $color-text-muted;
font-size: $font-size-12;
font-weight: 600;
}
.rdp-day_button {
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
color: #30394b;
font-size: 14px;
font-weight: 500;
color: $color-text-heading;
font-size: $font-size-12;
font-weight: 600;
&:hover {
background: $color-bg;
background: $color-surface-hover;
}
}
.rdp-selected {
.rdp-day_button {
background: $blue;
color: #ffffff;
color: $color-text-inverse;
}
}
.rdp-range_middle {
background-color: transparent;
.rdp-day_button {
background: #e8edff;
background: $color-primary-soft;
color: $blue;
border-radius: 0;
}
@ -189,19 +196,23 @@
.rdp-range_start {
.rdp-day_button {
border-radius: 10px;
background: $blue;
color: $color-text-inverse;
border-radius: 12px;
}
}
.rdp-range_end {
.rdp-day_button {
border-radius: 10px;
background: $blue;
color: $color-text-inverse;
border-radius: 12px;
}
}
.rdp-range_start.rdp-range_end {
.rdp-day_button {
border-radius: 10px;
border-radius: 12px;
}
.rdp-today {
@ -244,7 +255,7 @@
height: 4px;
border-radius: 999px;
background: rgba(3, 29, 154, 0.16);
background: rgba($color-primary-rgb, 0.16);
}
.history-range__active {
@ -295,52 +306,3 @@
cursor: pointer;
}
/* react-day-picker */
.history-date-picker__dropdown .rdp-root {
--rdp-accent-color: $blue;
--rdp-accent-background-color: rgba(3, 29, 154, 0.1);
--rdp-day_button-border-radius: 10px;
--rdp-selected-border: 0;
margin: 0;
}
.history-date-picker__dropdown .rdp-months {
gap: 18px;
}
.history-date-picker__dropdown .rdp-caption_label {
color: #30394b;
font-size: 15px;
font-weight: 600;
}
.history-date-picker__dropdown .rdp-weekday {
color: $gray50;
font-size: 12px;
font-weight: 500;
}
.history-date-picker__dropdown .rdp-day_button {
width: 36px;
height: 36px;
color: #30394b;
font-size: 14px;
}
.history-date-picker__dropdown .rdp-selected .rdp-day_button {
background: $blue;
color: #ffffff;
}
.history-date-picker__dropdown .rdp-range_middle .rdp-day_button {
background: #e8edff;
color: $blue;
}
.history-date-picker__dropdown .rdp-today .rdp-day_button {
font-weight: 700;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,173 @@
@use '../../../../shared/styles/variables' as *;
.duration-picker {
--height: 44px;
--radius: 12px;
--gap-open: 7px;
--text: #{$duration-picker-text};
--muted: #{$duration-picker-muted};
@include desktop-down{
--height: 36px;
--gap-open: 6px;
}
width: fit-content;
height: var(--height);
display: flex;
align-items: center;
gap: 0;
border-radius: var(--radius);
overflow: visible;
color: var(--text);
cursor: pointer;
user-select: none;
transition:
gap 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
opacity 180ms ease;
&.is-disabled {
cursor: default;
opacity: 1;
}
}
.duration-picker--open {
gap: var(--gap-open);
cursor: default;
}
.duration-picker__segment {
position: relative;
height: var(--height);
display: flex;
align-items: center;
justify-content: center;
gap: 0;
border: 0;
outline: 0;
background: $color-bg;
color: var(--text);
font: inherit;
transition:
width 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
gap 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
padding 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
border-radius 320ms cubic-bezier(0.22, 1, 0.36, 1),
background-color 220ms ease,
box-shadow 220ms ease;
}
.duration-picker__segment--days,
.duration-picker__segment--hours {
width: 44px;
padding: 0 4px;
}
.duration-picker__segment--days {
border-radius: var(--radius) 0 0 var(--radius);
padding-left: 8px;
}
.duration-picker__segment--hours,
.duration-picker__segment--minutes {
border-radius: 0;
}
.duration-picker__segment--minutes {
width: 64px;
padding: 0 3px;
padding-right: 8px;
border-radius: 0 var(--radius) var(--radius) 0;
}
.duration-picker--open .duration-picker__segment {
border-radius: var(--radius);
gap: 8px;
}
.duration-picker--open .duration-picker__segment--days,
.duration-picker--open .duration-picker__segment--hours,
.duration-picker--open .duration-picker__segment--minutes {
width: 52px;
padding: 0 12px;
}
.duration-picker--open .duration-picker__segment--minutes {
width: 70px;
}
.duration-picker--open .duration-picker__segment--days:hover,
.duration-picker--open .duration-picker__segment--hours:hover,
.duration-picker--open .duration-picker__segment--minutes:hover {
background: $duration-picker-hover-bg;
}
.duration-picker--open .duration-picker__segment--days:focus-within,
.duration-picker--open .duration-picker__segment--hours:focus-within,
.duration-picker--open .duration-picker__segment--minutes:focus-within {
background: $color-surface;
box-shadow: $shadow-duration-focus;
}
.duration-picker__input {
width: 22px;
padding: 0;
border: 0;
outline: 0;
background: $transparent;
color: var(--text);
font: inherit;
font-size: $font-size-16;
font-weight: 600;
line-height: 1;
letter-spacing: 0;
text-align: right;
pointer-events: none;
transition:
width 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
transform 320ms cubic-bezier(0.34, 1.42, 0.64, 1);
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
&:disabled {
color: var(--text);
opacity: 1;
-webkit-text-fill-color: var(--text);
}
}
.duration-picker--open .duration-picker__input {
width: 28px;
pointer-events: auto;
}
.duration-picker__unit {
margin-left: 6px;
color: var(--muted);
font-size: $font-size-15;
font-weight: 600;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
transition:
margin-left 320ms cubic-bezier(0.34, 1.42, 0.64, 1),
color 220ms ease;
}
.duration-picker--open .duration-picker__unit {
margin-left: 7px;
}

Some files were not shown because too many files have changed in this diff Show More