Вывод сотрудников, добавление пользователей, карта, ui

This commit is contained in:
neizbejnoezlo 2026-04-30 17:00:34 +07:00
parent 9a07d006d7
commit 9064a99373
29 changed files with 2523 additions and 66 deletions

View File

@ -1 +1,2 @@
VITE_GRAPHQL_API_URL=/graphql
VITE_MAPTILER_KEY=IorSzMRqcNUCYzcXZhi6

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MDM</title>
<title>MDM ARMA</title>
</head>
<body>
<div id="root"></div>

View File

@ -12,6 +12,8 @@
"@hookform/resolvers": "^5.2.2",
"@internationalized/date": "^3.12.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@tanstack/react-table": "^8.21.3",
"date-fns": "^4.1.0",
"echarts": "^6.0.0",
@ -491,6 +493,44 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
@ -1017,6 +1057,29 @@
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.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-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
@ -1103,6 +1166,42 @@
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"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-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-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@ -1118,6 +1217,102 @@
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"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-dropdown-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
"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-id": "1.1.1",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"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-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"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-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
@ -1136,6 +1331,102 @@
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@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-roving-focus": "1.1.11",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"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",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"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-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
@ -1183,6 +1474,37 @@
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"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-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@ -1201,6 +1523,21 @@
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
@ -1238,6 +1575,24 @@
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
@ -1253,6 +1608,48 @@
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
@ -2039,6 +2436,18 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -2238,6 +2647,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
@ -2621,6 +3036,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -3438,6 +3862,53 @@
"react-dom": "^19.0.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
@ -3476,6 +3947,28 @@
"react-dom": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -3755,6 +4248,49 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",

View File

@ -14,6 +14,8 @@
"@hookform/resolvers": "^5.2.2",
"@internationalized/date": "^3.12.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@tanstack/react-table": "^8.21.3",
"date-fns": "^4.1.0",
"echarts": "^6.0.0",

View File

@ -0,0 +1,34 @@
import { gql } from '@apollo/client'
export const GET_USERS_PAGE_QUERY = gql`
query GetUsersPage($key: String) {
getUsersPage(key: $key) {
page {
id
role
}
nextKey
}
}
`
export const SIGN_UP_MUTATION = gql`
mutation SignUp(
$organisationId: ID!
$username: String!
$password: String!
$groupId: ID
) {
signUp(
payload: {
organisationId: $organisationId
username: $username
password: $password
groupId: $groupId
}
) {
id
role
}
}
`

View File

@ -0,0 +1,29 @@
export type Employee = {
id: number
role: string
}
export type GetUsersPageData = {
getUsersPage: {
page: Employee[]
nextKey: string | null
}
}
export type GetUsersPageVariables = {
key?: string
}
export type SignUpData = {
signUp: {
id: number
role: string
}
}
export type SignUpVariables = {
organisationId: string
username: string
password: string
groupId?: string | null
}

View File

@ -19,18 +19,19 @@ type AuthGateProps = {
}
export function AuthGate({ children }: AuthGateProps) {
const [authVersion, setAuthVersion] = useState(0)
const [isForcedLogout, setIsForcedLogout] = useState(false)
const { data, loading, error, refetch } = useQuery<CurrentUserQueryData>(
CURRENT_USER_QUERY,
{
fetchPolicy: 'network-only',
skip: isForcedLogout,
},
)
useEffect(() => {
function handleLogout() {
setAuthVersion((prev) => prev + 1)
setIsForcedLogout(true)
}
window.addEventListener('auth:logout', handleLogout)
@ -40,15 +41,30 @@ export function AuthGate({ children }: AuthGateProps) {
}
}, [])
if (isForcedLogout) {
return (
<LoginPage
onSuccess={() => {
setIsForcedLogout(false)
refetch()
}}
/>
)
}
if (loading) {
return <div style={{ padding: 24 }}>Проверка авторизации...</div>
return (
<div style={{ padding: 24 }}>
Проверка авторизации...
</div>
)
}
if (error || !data?.currentUser) {
return (
<LoginPage
key={authVersion}
onSuccess={() => {
setIsForcedLogout(false)
refetch()
}}
/>

View File

@ -8,6 +8,8 @@ import { router } from './app/router/router.tsx'
import 'react-day-picker/style.css'
import 'leaflet/dist/leaflet.css'
import { AuthGate } from './features/auth/ui/AuthGate'
//import 'leaflet.fullscreen/Control.FullScreen.css'
//import 'leaflet.fullscreen'
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@ -13,6 +13,7 @@ import { Map } from 'lucide-react'
import './DeviceMapCard.scss'
import type { Device } from '../../types'
import { FullscreenControl } from '../../../../widgets/FullscreenControlLeaflet/FullscreenControl'
type DeviceMapCardProps = {
device: Device
@ -81,10 +82,11 @@ export function DeviceMapCard({ device }: DeviceMapCardProps) {
scrollWheelZoom={true}
attributionControl={false}
className="device-map__leaflet"
id='devices-map'
>
<MapResizeWatcher />
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<TileLayer url={`https://api.maptiler.com/maps/hybrid/{z}/{x}/{y}.jpg?key=${import.meta.env.VITE_MAPTILER_KEY}`} />
{routePositions.length > 1 && (
<Polyline
@ -128,6 +130,7 @@ export function DeviceMapCard({ device }: DeviceMapCardProps) {
</div>
</Popup>
</Marker>
<FullscreenControl targetId="devices-map" />
</MapContainer>
</div>
</div>

View File

@ -22,7 +22,6 @@
flex: 1;
min-height: 0;
min-width: 0;
gap: 12px;
overflow: hidden;
}

View File

@ -18,6 +18,7 @@ import './DevicesPage.scss'
import { DevicesTabs } from './components/DevicesTabs/DevicesTabs'
import { DevicesToolbar } from './components/DevicesToolbar/DevicesToolbar'
import { DevicesFiltersPanel } from './components/DevicesFiltersPanel/DevicesFiltersPanel'
import { AddDeviceModal } from './components/AddDeviceModal/AddDeviceModal'
import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql'
import type { Device, GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types'
@ -46,6 +47,8 @@ export function DevicesPage() {
const [loadedNextKeys, setLoadedNextKeys] = useState<Array<string | null>>([])
const [isPageLoading, setIsPageLoading] = useState(false)
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false)
const {
data: firstPageData,
loading: isFirstPageLoading,
@ -126,6 +129,7 @@ export function DevicesPage() {
<DevicesToolbar
isFiltersOpen={isFiltersOpen}
onToggleFilters={() => setIsFiltersOpen((prev) => !prev)}
onAddDevice={() => setIsAddDeviceOpen(true)}
/>
<div className="devices-table-filter-container">
@ -182,15 +186,15 @@ export function DevicesPage() {
</td>
<td>
<div className={`devices-status ${device.lastLocation? 'devices-status--green' : 'devices-status--red'}`}>
<span className={`devices-dot ${device.lastLocation? 'devices-dot--green' : 'devices-dot--red'}`} />
<div className={`devices-status ${device.lastLocation ? 'devices-status--green' : 'devices-status--red'}`}>
<span className={`devices-dot ${device.lastLocation ? 'devices-dot--green' : 'devices-dot--red'}`} />
{device.lastLocation? 'Исправно' : 'Требует ТО'}
{device.lastLocation ? 'Исправно' : 'Требует ТО'}
</div>
</td>
<td>
<div className={`devices-status ${device.lastLocation? 'devices-status--green' : 'devices-status--gray'}`}>
<div className={`devices-status ${device.lastLocation ? 'devices-status--green' : 'devices-status--gray'}`}>
<span
className={
device.lastLocation
@ -269,6 +273,10 @@ export function DevicesPage() {
<DevicesFiltersPanel isOpen={isFiltersOpen} />
</div>
<AddDeviceModal
open={isAddDeviceOpen}
onOpenChange={setIsAddDeviceOpen}
/>
</section>
)
}

View File

@ -0,0 +1,163 @@
@use '../../../../shared/styles/variables' as *;
.add-device-modal__overlay {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(2px);
animation: addDeviceOverlayShow 0.18s ease;
}
.add-device-modal {
position: fixed;
z-index: 90;
top: 50%;
left: 50%;
width: min(520px, calc(100vw - 32px));
padding: 24px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
transform: translate(-50%, -50%);
animation: addDeviceModalShow 0.2s ease;
}
.add-device-modal__header {
margin-bottom: 22px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.add-device-modal__title {
margin: 0;
color: #111827;
font-size: 24px;
font-weight: 650;
}
.add-device-modal__description {
margin: 6px 0 0;
color: $gray50;
font-size: 15px;
font-weight: 400;
line-height: 1.4;
}
.add-device-modal__close {
width: 40px;
height: 40px;
border: none;
border-radius: 12px;
background: $color-bg;
display: inline-flex;
align-items: center;
justify-content: center;
color: $gray50;
cursor: pointer;
transition: 0.2s ease;
&:hover {
color: $blue;
background: $gray20;
}
}
.add-device-qr {
display: flex;
flex-direction: column;
gap: 14px;
p {
margin: 0;
color: $gray50;
font-size: 14px;
line-height: 1.45;
text-align: center;
}
}
.add-device-qr__box {
min-height: 260px;
border-radius: 22px;
border: 1px dashed rgba(3, 29, 154, 0.32);
background: $color-bg;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: $blue;
span {
color: #111827;
font-size: 18px;
font-weight: 600;
}
}
.add-device-modal__footer {
margin-top: 22px;
display: flex;
justify-content: flex-end;
}
.add-device-cancel {
height: 42px;
padding: 0 18px;
border: none;
border-radius: 14px;
background: $color-bg;
color: $gray50;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: 0.2s ease;
&: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);
}
}

View File

@ -0,0 +1,55 @@
import * as Dialog from '@radix-ui/react-dialog'
import { QrCode, X } from 'lucide-react'
import './AddDeviceModal.scss'
type AddDeviceModalProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="add-device-modal__overlay" />
<Dialog.Content className="add-device-modal">
<div className="add-device-modal__header">
<div>
<Dialog.Title className="add-device-modal__title">
Добавить устройство
</Dialog.Title>
<Dialog.Description className="add-device-modal__description">
Отсканируйте QR-код на устройстве для привязки к системе
</Dialog.Description>
</div>
<Dialog.Close className="add-device-modal__close" type="button">
<X size={20} />
</Dialog.Close>
</div>
<div className="add-device-qr">
<div className="add-device-qr__box">
<QrCode size={92} />
<span>Здесь будет QR-код</span>
</div>
<p>
После сканирования устройство появится в списке и будет доступно
для назначения сотруднику.
</p>
</div>
<div className="add-device-modal__footer">
<Dialog.Close className="add-device-cancel" type="button">
Закрыть
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}

View File

@ -3,18 +3,18 @@
.devices-tabs {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
}
.devices-tabs__item {
position: relative;
min-height: 36px;
padding: 8px 18px;
padding: 12px 20px;
border: none;
border-radius: 10px;
border-radius: 12px;
background: #ffffff;
color: #4f5b73;
font-size: 16px;
font-size: 18px;
font-weight: 400;
cursor: pointer;

View File

@ -45,8 +45,9 @@
gap: 6px;
button {
padding: 12px;
padding: 12px 20px;
font-size: 16px;
font-weight: 450;
background-color: $color-bg;
border-radius: 20px;
border: none;
@ -55,35 +56,124 @@
.add-device {
background-color: $blue;
color: white;
cursor: pointer;
}
}
.devices-sort {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
//height: 32px;
padding: 12px 14px 12px 20px !important;
border: none;
border-radius: 20px;
background: #ffffff;
display: inline-flex;
align-items: center;
gap: 8px;
color: $gray50;
font-size: 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: 0.2s ease;
&:hover {
color: $blue;
}
}
.devices-sort__chevron {
transition: transform 0.2s ease;
}
.devices-sort[data-state='open'] {
color: $blue;
.devices-sort__chevron {
transform: rotate(180deg);
}
}
.devices-sort-menu {
z-index: 100;
min-width: 230px;
padding: 6px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
animation: devicesSortMenuShow 0.16s ease;
}
.devices-sort-menu__item {
min-height: 38px;
padding: 0 10px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #30394b;
font-size: 14px;
font-weight: 500;
outline: none;
cursor: pointer;
&[data-highlighted] {
background: #f1f4f8;
color: $blue;
}
}
.devices-sort-menu__check {
width: 7px;
height: 7px;
border-radius: 50%;
background: $blue;
flex: 0 0 7px;
}
@keyframes devicesSortMenuShow {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.devices-filter {
position: relative;
border: none;
border-radius: 20px;
color: $blue;
color: $gray50;
padding: 12px !important;
display: inline-flex;
align-items: center;
justify-content: center;
transition: .2s ease;
cursor: pointer;
&--active{
color: $blue;
}
svg {
height: 16px;
height: 19px;
width: auto;
}
}
@ -92,8 +182,8 @@
position: absolute;
top: -2px;
right: -1px;
width: 8px;
height: 8px;
width: 12px;
height: 12px;
border-radius: 50%;
background: $blue;
}

View File

@ -1,12 +1,45 @@
import { Search, SlidersHorizontal } from 'lucide-react'
import { useState } from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { ChevronDown, Search, SlidersHorizontal } from 'lucide-react'
import './DevicesToolbar.scss'
type DevicesToolbarProps = {
isFiltersOpen: boolean
onToggleFilters: () => void
onAddDevice: () => void
}
export function DevicesToolbar({ isFiltersOpen, onToggleFilters }: DevicesToolbarProps) {
const sortOptions = [
{
value: 'default',
label: 'По умолчанию',
},
{
value: 'id-asc',
label: 'ID по возрастанию',
},
{
value: 'id-desc',
label: 'ID по убыванию',
},
{
value: 'serial-asc',
label: 'Заводской номер А–Я',
},
{
value: 'serial-desc',
label: 'Заводской номер Я–А',
}
]
export function DevicesToolbar({
isFiltersOpen,
onToggleFilters,
onAddDevice,
}: DevicesToolbarProps) {
const [selectedSort, setSelectedSort] = useState(sortOptions[0])
return (
<div className="devices-toolbar">
<label className="devices-search">
@ -15,14 +48,41 @@ export function DevicesToolbar({ isFiltersOpen, onToggleFilters }: DevicesToolba
</label>
<div className="devices-toolbar__right">
<button className='add-device'>Добавить устройство</button>
<button className="devices-sort" type="button">
По умолчанию
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.4249 4.0835L7.17063 8.33775L2.91638 4.0835L2.09212 4.90862L7.17063 9.98712L12.25 4.90862L11.4249 4.0835Z" fill="currentColor" />
</svg>
<button className="add-device" type="button" onClick={onAddDevice}>
Добавить устройство
</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="devices-sort" type="button">
{selectedSort.label}
<ChevronDown className="devices-sort__chevron" size={16} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="devices-sort-menu"
align="end"
sideOffset={8}
>
{sortOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="devices-sort-menu__item"
onSelect={() => setSelectedSort(option)}
>
<span>{option.label}</span>
{selectedSort.value === option.value && (
<span className="devices-sort-menu__check" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<button
className={`devices-filter ${isFiltersOpen ? 'devices-filter--active' : ''}`}
type="button"

View File

@ -0,0 +1,181 @@
@use '../../shared/styles/variables' as *;
.employees-page {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
}
.employees-table-container {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.employees-table-card {
flex: 1;
min-height: 0;
overflow: auto;
border-radius: 20px;
background: #ffffff;
scrollbar-width: thin;
scrollbar-color: $gray50 transparent;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: $gray50;
border-radius: 999px;
}
&::-webkit-scrollbar-thumb:hover {
background: $gray50;
}
}
.employees-table {
width: 100%;
min-width: 700px;
border-collapse: collapse;
table-layout: fixed;
thead {
position: sticky;
top: 0;
z-index: 2;
background: #ffffff;
}
th {
padding: 14px 20px;
line-height: 1;
border-bottom: 1px solid #e3e8f0;
color: $gray50;
font-size: 16px;
font-weight: 450;
text-align: left;
background: #ffffff;
}
td {
height: 68px;
padding: 14px 20px;
border-bottom: 1px solid $gray20;
vertical-align: middle;
text-align: left;
color: #151a24;
font-size: 18px;
font-weight: 400;
}
th:nth-child(1),
td:nth-child(1) {
width: 140px;
}
th:nth-child(2),
td:nth-child(2) {
width: auto;
}
}
.employees-table__row {
transition: 0.2s ease;
&:hover {
background-color: $gray20;
}
}
.employee-role {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(3, 29, 154, 0.08);
color: $blue;
font-size: 16px;
font-weight: 500;
}
.employees-pagination {
flex: 0 0 auto;
padding: 12px 14px 0;
display: flex;
align-items: center;
justify-content: space-between;
color: #738098;
font-size: 13px;
}
.employees-pagination__controls {
display: flex;
align-items: center;
gap: 6px;
button {
min-width: 30px;
height: 30px;
padding: 0 12px;
border: none;
border-radius: 10px;
background: #e9edf5;
color: #738098;
font-size: 14px;
cursor: pointer;
&.is-active {
background: #031d9a;
color: #ffffff;
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
}
.employees-state {
min-height: 220px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #738098;
font-size: 15px;
font-weight: 500;
}
.employees-state--error {
color: $red;
}

View File

@ -1,3 +1,162 @@
import { useState } from 'react'
import { useApolloClient, useQuery } from '@apollo/client/react'
import { GET_USERS_PAGE_QUERY } from '../../entities/employee/api/employee.graphql'
import type {
Employee,
GetUsersPageData,
GetUsersPageVariables,
} from '../../entities/employee/model/types'
import { EmployeesToolbar } from './components/EmployeesToolbar/EmployeesToolbar'
import { AddEmployeeModal } from './components/AddEmployeeModal/AddEmployeeModal'
import './EmployeesPage.scss'
export function EmployeesPage() {
return <h1>Сотрудники</h1>
const client = useApolloClient()
const [currentPage, setCurrentPage] = useState(1)
const [loadedPages, setLoadedPages] = useState<Employee[][]>([])
const [loadedNextKeys, setLoadedNextKeys] = useState<Array<string | null>>([])
const [isPageLoading, setIsPageLoading] = useState(false)
const [isAddEmployeeOpen, setIsAddEmployeeOpen] = useState(false)
const {
data: firstPageData,
loading: isFirstPageLoading,
error,
} = useQuery<GetUsersPageData, GetUsersPageVariables>(GET_USERS_PAGE_QUERY, {
variables: {},
fetchPolicy: 'network-only',
})
const firstPageEmployees = firstPageData?.getUsersPage.page ?? []
const firstPageNextKey = firstPageData?.getUsersPage.nextKey ?? null
const pages = [firstPageEmployees, ...loadedPages]
const employees = pages[currentPage - 1] ?? []
const nextKey =
currentPage === 1
? firstPageNextKey
: loadedNextKeys[currentPage - 2] ?? null
const loading = isFirstPageLoading || isPageLoading
async function handleNextPage() {
const alreadyLoadedNextPage = pages[currentPage]
if (alreadyLoadedNextPage) {
setCurrentPage((prev) => prev + 1)
return
}
if (!nextKey) return
setIsPageLoading(true)
try {
const result = await client.query<GetUsersPageData, GetUsersPageVariables>({
query: GET_USERS_PAGE_QUERY,
variables: {
key: nextKey,
},
fetchPolicy: 'network-only',
})
const nextPageData = result.data?.getUsersPage
if (!nextPageData) {
return
}
setLoadedPages((prev) => [...prev, nextPageData.page])
setLoadedNextKeys((prev) => [...prev, nextPageData.nextKey])
setCurrentPage((prev) => prev + 1)
} finally {
setIsPageLoading(false)
}
}
function handlePrevPage() {
setCurrentPage((prev) => Math.max(1, prev - 1))
}
return (
<section className="employees-page">
<EmployeesToolbar onAddEmployee={() => setIsAddEmployeeOpen(true)} />
<div className="employees-table-container">
<div className="employees-table-card">
{loading && employees.length === 0 && (
<div className="employees-state">Загрузка сотрудников...</div>
)}
{error && (
<div className="employees-state employees-state--error">
Не удалось загрузить сотрудников
</div>
)}
{!loading && !error && employees.length === 0 && (
<div className="employees-state">Сотрудники не найдены</div>
)}
{!error && employees.length > 0 && (
<table className="employees-table">
<thead>
<tr>
<th>ID</th>
<th>Роль</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.id} className="employees-table__row">
<td>{employee.id}</td>
<td>
<span className="employee-role">{employee.role}</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="employees-pagination">
<span>Страница {currentPage}</span>
<div className="employees-pagination__controls">
<button
type="button"
disabled={currentPage === 1 || loading}
onClick={handlePrevPage}
>
Назад
</button>
<button className="is-active" type="button">
{currentPage}
</button>
<button
type="button"
disabled={!nextKey || loading}
onClick={handleNextPage}
>
Вперед
</button>
</div>
</div>
</div>
<AddEmployeeModal
open={isAddEmployeeOpen}
onOpenChange={setIsAddEmployeeOpen}
/>
</section>
)
}

View File

@ -0,0 +1,201 @@
@use '../../../../shared/styles/variables' as *;
.add-employee-modal__overlay {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(2px);
animation: addEmployeeOverlayShow 0.18s ease;
}
.add-employee-modal {
position: fixed;
z-index: 90;
top: 50%;
left: 50%;
width: min(520px, calc(100vw - 32px));
transform: translate(-50%, -50%);
border-radius: 24px;
background: #ffffff;
padding: 24px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
animation: addEmployeeModalShow 0.2s ease;
}
.add-employee-modal__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 22px;
}
.add-employee-modal__title {
margin: 0;
color: #111827;
font-size: 24px;
font-weight: 650;
}
.add-employee-modal__description {
margin: 6px 0 0;
color: $gray50;
font-size: 15px;
font-weight: 400;
}
.add-employee-modal__close {
width: 40px;
height: 40px;
border: none;
border-radius: 12px;
background: $color-bg;
display: inline-flex;
align-items: center;
justify-content: center;
color: $gray50;
cursor: pointer;
transition: 0.2s ease;
&:hover {
color: $blue;
background: $gray20;
}
}
.add-employee-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.add-employee-field {
display: flex;
flex-direction: column;
gap: 8px;
span {
color: #30394b;
font-size: 14px;
font-weight: 500;
}
input {
height: 44px;
border: 1px solid #dfe5ef;
border-radius: 14px;
padding: 0 14px;
background: #f8fafc;
outline: none;
color: #111827;
font-size: 15px;
font-weight: 400;
transition: 0.2s ease;
&:focus {
border-color: $blue;
background: #ffffff;
}
&::placeholder {
color: $gray50;
}
}
}
.add-employee-error {
border-radius: 14px;
background: rgba(224, 0, 0, 0.08);
padding: 12px 14px;
color: $red;
font-size: 14px;
font-weight: 500;
}
.add-employee-modal__footer {
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.add-employee-cancel,
.add-employee-submit {
height: 42px;
padding: 0 18px;
border: none;
border-radius: 14px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: 0.2s ease;
}
.add-employee-cancel {
background: $color-bg;
color: $gray50;
&:hover {
background: $gray20;
color: #111827;
}
}
.add-employee-submit {
background: $blue;
color: #ffffff;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.65;
cursor: default;
}
}
@keyframes addEmployeeOverlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes addEmployeeModalShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.98);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}

View File

@ -0,0 +1,146 @@
import { useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { useMutation } from '@apollo/client/react'
import {
GET_USERS_PAGE_QUERY,
SIGN_UP_MUTATION,
} from '../../../../entities/employee/api/employee.graphql'
import type {
SignUpData,
SignUpVariables,
} from '../../../../entities/employee/model/types'
import './AddEmployeeModal.scss'
type AddEmployeeModalProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps) {
const [organisationId, setOrganisationId] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [groupId, setGroupId] = useState('')
const [signUp, { loading, error }] = useMutation<SignUpData, SignUpVariables>(
SIGN_UP_MUTATION,
{
refetchQueries: [
{
query: GET_USERS_PAGE_QUERY,
variables: {},
},
],
onCompleted: () => {
setUsername('')
setPassword('')
setGroupId('')
onOpenChange(false)
},
},
)
function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
event.preventDefault()
signUp({
variables: {
organisationId,
username,
password,
groupId: groupId.trim() ? groupId : null,
},
})
}
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="add-employee-modal__overlay" />
<Dialog.Content className="add-employee-modal">
<div className="add-employee-modal__header">
<div>
<Dialog.Title className="add-employee-modal__title">
Добавить сотрудника
</Dialog.Title>
<Dialog.Description className="add-employee-modal__description">
Создание пользователя для доступа к смартфонам
</Dialog.Description>
</div>
<Dialog.Close className="add-employee-modal__close" type="button">
<X size={20} />
</Dialog.Close>
</div>
<form className="add-employee-form" onSubmit={handleSubmit}>
<label className="add-employee-field">
{/* <span>Логин</span> */}
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Логин*"
required
/>
</label>
<label className="add-employee-field">
{/* <span>Пароль</span> */}
<input
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Пароль*"
type="password"
required
/>
</label>
<label className="add-employee-field">
{/* <span>ID организации</span> */}
<input
value={organisationId}
onChange={(event) => setOrganisationId(event.target.value)}
placeholder="ID организации*"
required
/>
</label>
<label className="add-employee-field">
{/* <span>ID группы</span> */}
<input
value={groupId}
onChange={(event) => setGroupId(event.target.value)}
placeholder="ID группы"
/>
</label>
{error && (
<div className="add-employee-error">
Не удалось добавить сотрудника. Проверьте данные.
</div>
)}
<div className="add-employee-modal__footer">
<Dialog.Close className="add-employee-cancel" type="button">
Отмена
</Dialog.Close>
<button
className="add-employee-submit"
type="submit"
disabled={loading}
>
{loading ? 'Добавление...' : 'Добавить'}
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}

View File

@ -0,0 +1,104 @@
@use '../../../../shared/styles/variables' as *;
.add-device.add-employees{
display: flex;
align-items: center;
gap: 8px;
svg{
height: 18px;
width: auto;
}
}
.employees-sort {
//height: 32px;
padding: 12px 14px 12px 20px !important;
border: none;
border-radius: 20px;
background: #ffffff;
display: inline-flex;
align-items: center;
gap: 8px;
color: $gray50;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: 0.2s ease;
&:focus-visible{
outline: none;
}
&:hover {
color: $blue;
}
}
.employees-sort__chevron {
transition: transform 0.2s ease;
}
.employees-sort[data-state='open'] {
color: $blue;
.employees-sort__chevron {
transform: rotate(180deg);
}
}
.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);
animation: employeesSortMenuShow 0.16s ease;
}
.employees-sort-menu__item {
min-height: 38px;
padding: 0 10px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: $gray50;
font-size: 14px;
font-weight: 500;
outline: none;
cursor: pointer;
&[data-highlighted] {
background: $color-bg;
color: $blue;
}
}
.employees-sort-menu__check {
width: 7px;
height: 7px;
border-radius: 50%;
background: $blue;
flex: 0 0 7px;
}
@keyframes employeesSortMenuShow {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

View File

@ -0,0 +1,85 @@
import { useState } from 'react'
import { ChevronDown, Search } from 'lucide-react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import '../../../DevicesPage/components/DevicesToolbar/DevicesToolbar.scss'
import './EmployeesToolbar.scss'
type EmployeesToolbarProps = {
onAddEmployee: () => void
}
const sortOptions = [
{
value: 'default',
label: 'По умолчанию',
},
{
value: 'id-asc',
label: 'ID по возрастанию',
},
{
value: 'id-desc',
label: 'ID по убыванию',
},
{
value: 'role-asc',
label: 'Роль А–Я',
},
{
value: 'role-desc',
label: 'Роль Я–А',
},
]
export function EmployeesToolbar({ onAddEmployee }: EmployeesToolbarProps) {
const [selectedSort, setSelectedSort] = useState(sortOptions[0])
return (
<div className="devices-toolbar">
<label className="devices-search">
<Search size={16} />
<input type="text" placeholder="Поиск" />
</label>
<div className="devices-toolbar__right">
<button className='add-device add-employees' type='button' onClick={onAddEmployee}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.00004 4.66683C4.00004 3.78277 4.35123 2.93493 4.97635 2.30981C5.60147 1.68469 6.44932 1.3335 7.33337 1.3335C8.21743 1.3335 9.06527 1.68469 9.6904 2.30981C10.3155 2.93493 10.6667 3.78277 10.6667 4.66683C10.6667 5.55088 10.3155 6.39873 9.6904 7.02385C9.06527 7.64897 8.21743 8.00016 7.33337 8.00016C6.44932 8.00016 5.60147 7.64897 4.97635 7.02385C4.35123 6.39873 4.00004 5.55088 4.00004 4.66683ZM3.21471 9.7815C4.28337 9.1295 5.73671 8.66683 7.33337 8.66683C7.6316 8.66683 7.92404 8.68238 8.21071 8.7135C8.32522 8.72578 8.4346 8.76752 8.52818 8.83465C8.62177 8.90178 8.69636 8.99201 8.7447 9.09654C8.79304 9.20108 8.81348 9.31635 8.80401 9.43114C8.79455 9.54592 8.75551 9.65629 8.69071 9.7515C8.23917 10.4143 7.99844 11.1981 8.00004 12.0002C8.00004 12.6135 8.13804 13.1935 8.38337 13.7115C8.43118 13.8124 8.45286 13.9237 8.44645 14.0352C8.44004 14.1467 8.40572 14.2547 8.34666 14.3495C8.2876 14.4443 8.20568 14.5227 8.10843 14.5775C8.01117 14.6324 7.90169 14.662 7.79004 14.6635L7.33337 14.6668C5.84737 14.6668 4.44337 14.5735 3.39137 14.2948C2.86804 14.1562 2.37537 13.9575 2.00204 13.6575C1.60671 13.3402 1.33337 12.8968 1.33337 12.3335C1.33337 11.8088 1.57204 11.3182 1.89604 10.9075C2.22537 10.4908 2.68071 10.1075 3.21471 9.78083V9.7815ZM12 9.3335C12.1769 9.3335 12.3464 9.40373 12.4714 9.52876C12.5965 9.65378 12.6667 9.82335 12.6667 10.0002V11.3335H14C14.1769 11.3335 14.3464 11.4037 14.4714 11.5288C14.5965 11.6538 14.6667 11.8234 14.6667 12.0002C14.6667 12.177 14.5965 12.3465 14.4714 12.4716C14.3464 12.5966 14.1769 12.6668 14 12.6668H12.6667V14.0002C12.6667 14.177 12.5965 14.3465 12.4714 14.4716C12.3464 14.5966 12.1769 14.6668 12 14.6668C11.8232 14.6668 11.6537 14.5966 11.5286 14.4716C11.4036 14.3465 11.3334 14.177 11.3334 14.0002V12.6668H10C9.82323 12.6668 9.65366 12.5966 9.52864 12.4716C9.40361 12.3465 9.33337 12.177 9.33337 12.0002C9.33337 11.8234 9.40361 11.6538 9.52864 11.5288C9.65366 11.4037 9.82323 11.3335 10 11.3335H11.3334V10.0002C11.3334 9.82335 11.4036 9.65378 11.5286 9.52876C11.6537 9.40373 11.8232 9.3335 12 9.3335Z" fill="white" />
</svg>
Добавить
</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="employees-sort" type="button">
{selectedSort.label}
<ChevronDown className="employees-sort__chevron" size={16} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="employees-sort-menu"
align="end"
sideOffset={8}
>
{sortOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="employees-sort-menu__item"
onSelect={() => setSelectedSort(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,166 @@
@use '../../shared/styles/variables' as *;
.map-page {
display: flex;
flex: 1;
min-height: 0;
height: 100%;
gap: 12px;
overflow: hidden;
}
.map-page__sidebar {
width: 320px;
flex: 0 0 320px;
min-height: 0;
}
.map-page__panel {
height: 100%;
border-radius: 20px;
background: #ffffff;
padding: 18px;
display: flex;
flex-direction: column;
min-height: 0;
h2 {
margin: 0 0 14px;
color: #111827;
font-size: 20px;
font-weight: 600;
}
}
.map-page__state {
margin: 0;
color: $gray50;
font-size: 15px;
}
.map-page__state--error {
color: $red;
}
.map-page__count {
margin: 0 0 14px;
color: $gray50;
font-size: 15px;
b {
color: #111827;
}
}
.map-page__list {
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: rgba(3, 29, 154, 0.45) transparent;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(3, 29, 154, 0.35);
border-radius: 999px;
}
}
.map-device-link {
padding: 12px;
border-radius: 14px;
background: $color-bg;
display: flex;
flex-direction: column;
gap: 5px;
text-decoration: none;
transition: 0.2s ease;
span {
color: #111827;
font-size: 15px;
font-weight: 600;
}
small {
color: $gray50;
font-size: 13px;
}
&:hover {
background: $gray20;
span {
color: $blue;
}
}
}
.map-page__map {
flex: 1;
min-width: 0;
min-height: 0;
border-radius: 20px;
overflow: hidden;
background: #ffffff;
}
.map-page__leaflet {
width: 100%;
height: 100%;
}
.map-popup {
min-width: 210px;
}
.map-popup__title {
display: inline-block;
margin-bottom: 8px;
color: $blue !important;
font-size: 15px;
font-weight: 700;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.map-popup__row {
display: flex;
flex-direction: column;
gap: 3px;
span {
color: #738098;
font-size: 12px;
}
b {
color: #111827;
font-size: 13px;
font-weight: 600;
}
}
.map-popup__coords {
margin-top: 8px;
color: #738098;
font-size: 12px;
}

View File

@ -1,3 +1,136 @@
export function MapPage() {
return <h1>Карта</h1>
import { Link } from 'react-router-dom'
import { useQuery } from '@apollo/client/react'
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
import L from 'leaflet'
import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql'
import type { GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types'
import './MapPage.scss'
import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
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],
})
function formatLocationDate(timestamp: number) {
if (!timestamp) return 'Нет данных'
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(timestamp * 1000))
}
export function MapPage() {
const { data, loading, error } = useQuery<GetPhonesPageData, GetPhonesPageVariables>(
GET_PHONES_PAGE_QUERY,
{
variables: {},
fetchPolicy: 'network-only',
},
)
const devices = data?.getPhonesPage.page ?? []
const devicesWithLocation = devices.filter((device) => device.lastLocation)
const firstLocation = devicesWithLocation[0]?.lastLocation
const mapCenter: [number, number] = firstLocation
? [firstLocation.lat, firstLocation.lng]
: [55.397243, 86.117034]
return (
<section className="map-page">
{/* <div className="map-page__sidebar">
<div className="map-page__panel">
{loading && <p className="map-page__state">Загрузка устройств...</p>}
{error && (
<p className="map-page__state map-page__state--error">
Не удалось загрузить устройства
</p>
)}
{!loading && !error && (
<>
<div className="map-page__list">
{devicesWithLocation.map((device) => (
<Link
key={device.id}
className="map-device-link"
to={`/devices/${device.id}`}
>
<span>{device.serial || `Устройство #${device.id}`}</span>
<small>
{device.lastLocation
? formatLocationDate(device.lastLocation.date)
: 'Нет данных'}
</small>
</Link>
))}
</div>
</>
)}
</div>
</div> */}
<div className="map-page__map">
<MapContainer
center={mapCenter}
zoom={12}
scrollWheelZoom
attributionControl={false}
className="map-page__leaflet"
id="devices-map"
>
<TileLayer
url={`https://api.maptiler.com/maps/hybrid/{z}/{x}/{y}.jpg?key=${import.meta.env.VITE_MAPTILER_KEY}`}
/>
{devicesWithLocation.map((device) => {
if (!device.lastLocation) return null
return (
<Marker
key={device.id}
position={[device.lastLocation.lat, device.lastLocation.lng]}
icon={markerIcon}
>
<Popup>
<div className="map-popup">
<Link className="map-popup__title" to={`/devices/${device.id}`}>
{device.serial || `Устройство #${device.id}`}
</Link>
<div className="map-popup__row">
<b>{formatLocationDate(device.lastLocation.date)}</b>
</div>
<div className="map-popup__coords">
{device.lastLocation.lat}, {device.lastLocation.lng}
</div>
</div>
</Popup>
</Marker>
)
})}
<FullscreenControl targetId="devices-map" />
</MapContainer>
</div>
</section>
)
}

View File

@ -9,3 +9,4 @@ $red20: hsla(0, 100%, 43%, 0.2);
$green: #33B343;
$blue: #031D9A;
$blue20: hsla(230, 96%, 31%, 0.2);
$orange: #FF6B16;

View File

@ -0,0 +1,28 @@
.map-fullscreen-button {
position: absolute;
top: 12px;
right: 12px;
z-index: 500;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
color: #30394b;
cursor: pointer;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14);
transition: 0.2s ease;
&:hover {
color: #031d9a;
background: #f1f4f8;
}
}

View File

@ -0,0 +1,33 @@
import { useState } from 'react'
import { Maximize2, Minimize2 } from 'lucide-react'
import './FullscreenControl.scss'
type FullscreenControlProps = {
targetId: string
}
export function FullscreenControl({ targetId }: FullscreenControlProps) {
const [isFullscreen, setIsFullscreen] = useState(false)
async function handleToggleFullscreen() {
const element = document.getElementById(targetId)
if (!element) return
if (document.fullscreenElement) {
await document.exitFullscreen()
setIsFullscreen(false)
return
}
await element.requestFullscreen()
setIsFullscreen(true)
}
return (
<button className="map-fullscreen-button" type="button" onClick={handleToggleFullscreen} aria-label={isFullscreen ? 'Выйти из полноэкранного режима' : 'Открыть карту на весь экран'} title={isFullscreen ? 'Выйти из полноэкранного режима' : 'Открыть карту на весь экран'}>
{isFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
)
}

View File

@ -1,9 +1,158 @@
@use '../../shared/styles/variables' as *;
.navbar{
.navbar {
display: flex;
flex-direction: row;
align-items: center;
gap: 100px;
justify-content: space-between;
padding-bottom: 26px;
padding-bottom: 20px;
}
.navbar__actions {
display: flex;
align-items: center;
gap: 12px;
}
.navbar__icon-btn {
width: 34px;
height: 34px;
border: none;
border-radius: 12px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: #738098;
cursor: pointer;
transition: 0.2s ease;
&:hover {
background: #ffffff;
color: $blue;
}
}
.navbar-user {
border: none;
background: transparent;
display: inline-flex;
align-items: center;
gap: 8px;
color: #151a24;
font-size: 16px;
font-weight: 500;
cursor: pointer;
&:focus-visible{
outline: none;
}
}
.navbar-user__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: $blue;
display: inline-flex;
align-items: center;
justify-content: center;
color: #ffffff;
svg{
height: 16px;
width: auto;
}
}
.navbar-user__chevron {
color: #738098;
}
.navbar-dropdown {
z-index: 100;
width: 220px;
border-radius: 16px;
background: #ffffff;
padding: 8px;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
animation: navbarDropdownShow 0.16s ease;
}
.navbar-dropdown__user {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
span {
color: #738098;
font-size: 13px;
}
b {
color: #111827;
font-size: 15px;
font-weight: 600;
}
}
.navbar-dropdown__separator {
height: 1px;
margin: 6px 0;
background: #e3e8f0;
}
.navbar-dropdown__item {
min-height: 38px;
padding: 0 10px;
border-radius: 10px;
display: flex;
align-items: center;
gap: 8px;
color: #30394b;
font-size: 14px;
font-weight: 500;
outline: none;
cursor: pointer;
&[data-highlighted] {
background: #f1f4f8;
}
}
.navbar-dropdown__item--danger {
color: $red;
&[data-highlighted] {
background: rgba(224, 0, 0, 0.08);
}
}
@keyframes navbarDropdownShow {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

View File

@ -1,5 +1,28 @@
import { useLocation } from 'react-router-dom'
import './Navbar.scss'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { ChevronDown, LogOut, Settings, Bell, UserRound } from 'lucide-react'
import { useQuery } from '@apollo/client/react'
import { CURRENT_USER_QUERY } from '../../features/auth/api/auth.graphql'
import { apolloClient } from '../../shared/api/apolloClient'
function clearAuthCookies() {
document.cookie = 'Access-token=; Max-Age=0; path=/'
document.cookie = 'Refresh-token=; Max-Age=0; path=/'
}
async function handleLogout() {
clearAuthCookies()
await apolloClient.clearStore()
window.dispatchEvent(new Event('auth:logout'))
}
type CurrentUserData = {
currentUser: {
id: string
role: string
} | null
}
function getPageTitle(pathname: string) {
if (pathname === '/devices') return 'Устройства'
@ -12,6 +35,13 @@ function getPageTitle(pathname: string) {
}
export function Navbar() {
const { data } = useQuery<CurrentUserData>(CURRENT_USER_QUERY, {
fetchPolicy: 'cache-first',
})
const userRole = data?.currentUser?.role ?? 'Администратор'
const location = useLocation()
const title = getPageTitle(location.pathname)
@ -21,8 +51,51 @@ export function Navbar() {
<div>
<h1>{title}</h1>
</div>
<div className='profile'>
<span>Администратор</span>
<div className="navbar__actions">
<button className="navbar__icon-btn" type="button" aria-label="Настройки">
<Settings size={18} />
</button>
<button className="navbar__icon-btn" type="button" aria-label="Уведомления">
<Bell size={18} />
</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="navbar-user" type="button">
<span className="navbar-user__avatar">
<UserRound size={22} />
</span>
<span className="navbar-user__role">{userRole}</span>
<ChevronDown className="navbar-user__chevron" size={16} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="navbar-dropdown"
align="end"
sideOffset={8}
>
<div className="navbar-dropdown__user">
<span>Текущая роль</span>
<b>{userRole}</b>
</div>
<DropdownMenu.Separator className="navbar-dropdown__separator" />
<DropdownMenu.Item
className="navbar-dropdown__item navbar-dropdown__item--danger"
onSelect={handleLogout}
>
<LogOut size={16} />
Выйти
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</header>
)