Вывод сотрудников, добавление пользователей, карта, ui
This commit is contained in:
parent
9a07d006d7
commit
9064a99373
|
|
@ -1 +1,2 @@
|
|||
VITE_GRAPHQL_API_URL=/graphql
|
||||
VITE_MAPTILER_KEY=IorSzMRqcNUCYzcXZhi6
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -269,6 +273,10 @@ export function DevicesPage() {
|
|||
|
||||
<DevicesFiltersPanel isOpen={isFiltersOpen} />
|
||||
</div>
|
||||
<AddDeviceModal
|
||||
open={isAddDeviceOpen}
|
||||
onOpenChange={setIsAddDeviceOpen}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,3 +9,4 @@ $red20: hsla(0, 100%, 43%, 0.2);
|
|||
$green: #33B343;
|
||||
$blue: #031D9A;
|
||||
$blue20: hsla(230, 96%, 31%, 0.2);
|
||||
$orange: #FF6B16;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,156 @@
|
|||
.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue