From 9064a99373b5c69e3afde433285bd3ee5fe7f3d0 Mon Sep 17 00:00:00 2001 From: neizbejnoezlo <137374284+neizbejnoezlo@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:00:34 +0700 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=B2=D0=BE=D0=B4=20=D1=81=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9,=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0,=20ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mdm-front/.env | 3 +- mdm-front/index.html | 2 +- mdm-front/package-lock.json | 536 ++++++++++++++++++ mdm-front/package.json | 2 + .../entities/employee/api/employee.graphql.ts | 34 ++ .../src/entities/employee/model/types.ts | 29 + mdm-front/src/features/auth/ui/AuthGate.tsx | 24 +- mdm-front/src/main.tsx | 2 + .../DeviceMapCard/DeviceMapCard.tsx | 5 +- .../src/pages/DevicesPage/DevicesPage.scss | 1 - .../src/pages/DevicesPage/DevicesPage.tsx | 18 +- .../AddDeviceModal/AddDeviceModal.scss | 163 ++++++ .../AddDeviceModal/AddDeviceModal.tsx | 55 ++ .../components/DevicesTabs/DevicesTabs.scss | 8 +- .../DevicesToolbar/DevicesToolbar.scss | 118 +++- .../DevicesToolbar/DevicesToolbar.tsx | 118 +++- .../pages/EmployeesPage/EmployeesPage.scss | 181 ++++++ .../src/pages/EmployeesPage/EmployeesPage.tsx | 161 +++++- .../AddEmployeeModal/AddEmployeeModal.scss | 201 +++++++ .../AddEmployeeModal/AddEmployeeModal.tsx | 146 +++++ .../EmployeesToolbar/EmployeesToolbar.scss | 104 ++++ .../EmployeesToolbar/EmployeesToolbar.tsx | 85 +++ mdm-front/src/pages/MapPage/MapPage.scss | 166 ++++++ mdm-front/src/pages/MapPage/MapPage.tsx | 135 ++++- mdm-front/src/shared/styles/_variables.scss | 1 + .../FullscreenControl.scss | 28 + .../FullscreenControl.tsx | 33 ++ mdm-front/src/widgets/Navbar/Navbar.scss | 153 ++++- mdm-front/src/widgets/Navbar/Navbar.tsx | 77 ++- 29 files changed, 2523 insertions(+), 66 deletions(-) create mode 100644 mdm-front/src/entities/employee/api/employee.graphql.ts create mode 100644 mdm-front/src/entities/employee/model/types.ts create mode 100644 mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.scss create mode 100644 mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx create mode 100644 mdm-front/src/pages/EmployeesPage/EmployeesPage.scss create mode 100644 mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.scss create mode 100644 mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx create mode 100644 mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss create mode 100644 mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx create mode 100644 mdm-front/src/pages/MapPage/MapPage.scss create mode 100644 mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.scss create mode 100644 mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.tsx diff --git a/mdm-front/.env b/mdm-front/.env index 8209e8b..797265c 100644 --- a/mdm-front/.env +++ b/mdm-front/.env @@ -1 +1,2 @@ -VITE_GRAPHQL_API_URL=/graphql \ No newline at end of file +VITE_GRAPHQL_API_URL=/graphql +VITE_MAPTILER_KEY=IorSzMRqcNUCYzcXZhi6 \ No newline at end of file diff --git a/mdm-front/index.html b/mdm-front/index.html index b293216..f46cc11 100644 --- a/mdm-front/index.html +++ b/mdm-front/index.html @@ -4,7 +4,7 @@ - MDM + MDM ARMA
diff --git a/mdm-front/package-lock.json b/mdm-front/package-lock.json index e795067..f65f4fc 100644 --- a/mdm-front/package-lock.json +++ b/mdm-front/package-lock.json @@ -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", diff --git a/mdm-front/package.json b/mdm-front/package.json index bea0de5..54895d3 100644 --- a/mdm-front/package.json +++ b/mdm-front/package.json @@ -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", diff --git a/mdm-front/src/entities/employee/api/employee.graphql.ts b/mdm-front/src/entities/employee/api/employee.graphql.ts new file mode 100644 index 0000000..5d1ecfb --- /dev/null +++ b/mdm-front/src/entities/employee/api/employee.graphql.ts @@ -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 + } + } +` \ No newline at end of file diff --git a/mdm-front/src/entities/employee/model/types.ts b/mdm-front/src/entities/employee/model/types.ts new file mode 100644 index 0000000..dcd4d3a --- /dev/null +++ b/mdm-front/src/entities/employee/model/types.ts @@ -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 +} \ No newline at end of file diff --git a/mdm-front/src/features/auth/ui/AuthGate.tsx b/mdm-front/src/features/auth/ui/AuthGate.tsx index 0136299..cc749f4 100644 --- a/mdm-front/src/features/auth/ui/AuthGate.tsx +++ b/mdm-front/src/features/auth/ui/AuthGate.tsx @@ -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( 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 ( + { + setIsForcedLogout(false) + refetch() + }} + /> + ) + } + if (loading) { - return
Проверка авторизации...
+ return ( +
+ Проверка авторизации... +
+ ) } if (error || !data?.currentUser) { return ( { + setIsForcedLogout(false) refetch() }} /> diff --git a/mdm-front/src/main.tsx b/mdm-front/src/main.tsx index 7db3476..e6edff4 100644 --- a/mdm-front/src/main.tsx +++ b/mdm-front/src/main.tsx @@ -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( diff --git a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx index 44bdb20..8af2d6f 100644 --- a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx +++ b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx @@ -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' > - + {routePositions.length > 1 && ( + diff --git a/mdm-front/src/pages/DevicesPage/DevicesPage.scss b/mdm-front/src/pages/DevicesPage/DevicesPage.scss index 5aeb31a..c9e5371 100644 --- a/mdm-front/src/pages/DevicesPage/DevicesPage.scss +++ b/mdm-front/src/pages/DevicesPage/DevicesPage.scss @@ -22,7 +22,6 @@ flex: 1; min-height: 0; min-width: 0; - gap: 12px; overflow: hidden; } diff --git a/mdm-front/src/pages/DevicesPage/DevicesPage.tsx b/mdm-front/src/pages/DevicesPage/DevicesPage.tsx index 37905e4..b2a111d 100644 --- a/mdm-front/src/pages/DevicesPage/DevicesPage.tsx +++ b/mdm-front/src/pages/DevicesPage/DevicesPage.tsx @@ -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>([]) const [isPageLoading, setIsPageLoading] = useState(false) + const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false) + const { data: firstPageData, loading: isFirstPageLoading, @@ -126,6 +129,7 @@ export function DevicesPage() { setIsFiltersOpen((prev) => !prev)} + onAddDevice={() => setIsAddDeviceOpen(true)} />
@@ -182,15 +186,15 @@ export function DevicesPage() { -
- - - {device.lastLocation? 'Исправно' : 'Требует ТО'} +
+ + + {device.lastLocation ? 'Исправно' : 'Требует ТО'}
-
+
+ ) } \ No newline at end of file diff --git a/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.scss b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.scss new file mode 100644 index 0000000..c936bbc --- /dev/null +++ b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.scss @@ -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); + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx new file mode 100644 index 0000000..752a130 --- /dev/null +++ b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx @@ -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 ( + + + + + +
+
+ + Добавить устройство + + + + Отсканируйте QR-код на устройстве для привязки к системе + +
+ + + + +
+ +
+
+ + Здесь будет QR-код +
+ +

+ После сканирования устройство появится в списке и будет доступно + для назначения сотруднику. +

+
+ +
+ + Закрыть + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesTabs/DevicesTabs.scss b/mdm-front/src/pages/DevicesPage/components/DevicesTabs/DevicesTabs.scss index 36f9a90..2552105 100644 --- a/mdm-front/src/pages/DevicesPage/components/DevicesTabs/DevicesTabs.scss +++ b/mdm-front/src/pages/DevicesPage/components/DevicesTabs/DevicesTabs.scss @@ -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; diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss index 833c984..ee00a4d 100644 --- a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss +++ b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss @@ -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; - background: #ffffff; - color: $gray50; - font-size: 16px; - cursor: pointer; + //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; + + &: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; } \ No newline at end of file diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx index 0596ed6..6ab2f59 100644 --- a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx +++ b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx @@ -1,38 +1,98 @@ -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 + isFiltersOpen: boolean + onToggleFilters: () => void + onAddDevice: () => void } -export function DevicesToolbar({ isFiltersOpen, onToggleFilters }: DevicesToolbarProps) { - return ( -
- +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]) - + + + + + + + + + {sortOptions.map((option) => ( + setSelectedSort(option)} > - - - -
-
- ) + {option.label} + + {selectedSort.value === option.value && ( + + )} + + ))} + + + + + +
+
+ ) } \ No newline at end of file diff --git a/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss b/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss new file mode 100644 index 0000000..afd300f --- /dev/null +++ b/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss @@ -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; +} \ No newline at end of file diff --git a/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx b/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx index 1bc9891..9f0531f 100644 --- a/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx +++ b/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx @@ -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

Сотрудники

+ const client = useApolloClient() + + const [currentPage, setCurrentPage] = useState(1) + const [loadedPages, setLoadedPages] = useState([]) + const [loadedNextKeys, setLoadedNextKeys] = useState>([]) + const [isPageLoading, setIsPageLoading] = useState(false) + + const [isAddEmployeeOpen, setIsAddEmployeeOpen] = useState(false) + + const { + data: firstPageData, + loading: isFirstPageLoading, + error, + } = useQuery(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({ + 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 ( +
+ setIsAddEmployeeOpen(true)} /> + +
+
+ {loading && employees.length === 0 && ( +
Загрузка сотрудников...
+ )} + + {error && ( +
+ Не удалось загрузить сотрудников +
+ )} + + {!loading && !error && employees.length === 0 && ( +
Сотрудники не найдены
+ )} + + {!error && employees.length > 0 && ( + + + + + + + + + + {employees.map((employee) => ( + + + + + ))} + +
IDРоль
{employee.id} + {employee.role} +
+ )} +
+ +
+ Страница {currentPage} + +
+ + + + + +
+
+
+ +
+ ) } \ No newline at end of file diff --git a/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.scss b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.scss new file mode 100644 index 0000000..8ba19ad --- /dev/null +++ b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.scss @@ -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); + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx new file mode 100644 index 0000000..233a1e6 --- /dev/null +++ b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx @@ -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( + SIGN_UP_MUTATION, + { + refetchQueries: [ + { + query: GET_USERS_PAGE_QUERY, + variables: {}, + }, + ], + onCompleted: () => { + setUsername('') + setPassword('') + setGroupId('') + onOpenChange(false) + }, + }, + ) + + function handleSubmit(event: React.SubmitEvent) { + event.preventDefault() + + signUp({ + variables: { + organisationId, + username, + password, + groupId: groupId.trim() ? groupId : null, + }, + }) + } + + return ( + + + + + +
+
+ + Добавить сотрудника + + + + Создание пользователя для доступа к смартфонам + +
+ + + + +
+ +
+ + + + + + + + + {error && ( +
+ Не удалось добавить сотрудника. Проверьте данные. +
+ )} + +
+ + Отмена + + + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss new file mode 100644 index 0000000..80fdda8 --- /dev/null +++ b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss @@ -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); + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx new file mode 100644 index 0000000..158c34f --- /dev/null +++ b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx @@ -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 ( +
+ + +
+ + + + + + + + + {sortOptions.map((option) => ( + setSelectedSort(option)} + > + {option.label} + + {selectedSort.value === option.value && ( + + )} + + ))} + + + +
+
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/MapPage/MapPage.scss b/mdm-front/src/pages/MapPage/MapPage.scss new file mode 100644 index 0000000..c9569a9 --- /dev/null +++ b/mdm-front/src/pages/MapPage/MapPage.scss @@ -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; +} \ No newline at end of file diff --git a/mdm-front/src/pages/MapPage/MapPage.tsx b/mdm-front/src/pages/MapPage/MapPage.tsx index bc2c9ef..ee182da 100644 --- a/mdm-front/src/pages/MapPage/MapPage.tsx +++ b/mdm-front/src/pages/MapPage/MapPage.tsx @@ -1,3 +1,136 @@ +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: ` +
+
+
+ `, + 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() { - return

Карта

+ const { data, loading, error } = useQuery( + 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 ( +
+ {/*
+
+ + {loading &&

Загрузка устройств...

} + + {error && ( +

+ Не удалось загрузить устройства +

+ )} + + {!loading && !error && ( + <> + +
+ {devicesWithLocation.map((device) => ( + + {device.serial || `Устройство #${device.id}`} + + {device.lastLocation + ? formatLocationDate(device.lastLocation.date) + : 'Нет данных'} + + + ))} +
+ + )} +
+
*/} + +
+ + + + {devicesWithLocation.map((device) => { + if (!device.lastLocation) return null + + return ( + + +
+ + {device.serial || `Устройство #${device.id}`} + + +
+ {formatLocationDate(device.lastLocation.date)} +
+ +
+ {device.lastLocation.lat}, {device.lastLocation.lng} +
+
+
+
+ ) + })} + +
+
+
+ ) } \ No newline at end of file diff --git a/mdm-front/src/shared/styles/_variables.scss b/mdm-front/src/shared/styles/_variables.scss index e45e8a0..e09b235 100644 --- a/mdm-front/src/shared/styles/_variables.scss +++ b/mdm-front/src/shared/styles/_variables.scss @@ -9,3 +9,4 @@ $red20: hsla(0, 100%, 43%, 0.2); $green: #33B343; $blue: #031D9A; $blue20: hsla(230, 96%, 31%, 0.2); +$orange: #FF6B16; diff --git a/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.scss b/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.scss new file mode 100644 index 0000000..e4890be --- /dev/null +++ b/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.scss @@ -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; + } +} \ No newline at end of file diff --git a/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.tsx b/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.tsx new file mode 100644 index 0000000..d77566b --- /dev/null +++ b/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/mdm-front/src/widgets/Navbar/Navbar.scss b/mdm-front/src/widgets/Navbar/Navbar.scss index b18adc8..56f37d1 100644 --- a/mdm-front/src/widgets/Navbar/Navbar.scss +++ b/mdm-front/src/widgets/Navbar/Navbar.scss @@ -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); + } } \ No newline at end of file diff --git a/mdm-front/src/widgets/Navbar/Navbar.tsx b/mdm-front/src/widgets/Navbar/Navbar.tsx index 9a4783c..66671da 100644 --- a/mdm-front/src/widgets/Navbar/Navbar.tsx +++ b/mdm-front/src/widgets/Navbar/Navbar.tsx @@ -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(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() {

{title}

-
- Администратор +
+ + + + + + + + + + + +
+ Текущая роль + {userRole} +
+ + + + + + Выйти + +
+
+
)