diff --git a/mdm-front/eslint.config.js b/mdm-front/eslint.config.js index ef614d2..2a0aeac 100644 --- a/mdm-front/eslint.config.js +++ b/mdm-front/eslint.config.js @@ -18,5 +18,9 @@ export default defineConfig([ languageOptions: { globals: globals.browser, }, + rules: { + 'react-hooks/set-state-in-effect': 'warn', + 'react-refresh/only-export-components': 'warn', + }, }, ]) diff --git a/mdm-front/index.html b/mdm-front/index.html index f46cc11..64f51fc 100644 --- a/mdm-front/index.html +++ b/mdm-front/index.html @@ -1,5 +1,5 @@ - + diff --git a/mdm-front/package-lock.json b/mdm-front/package-lock.json index 25da266..e6c447d 100644 --- a/mdm-front/package-lock.json +++ b/mdm-front/package-lock.json @@ -14,11 +14,12 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@tanstack/react-table": "^8.21.3", "date-fns": "^4.1.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", - "framer-motion": "^12.38.0", + "framer-motion": "^12.40.0", "graphql": "^16.13.2", "leaflet": "^1.9.4", "lucide-react": "^1.9.0", @@ -26,10 +27,13 @@ "react": "^19.2.5", "react-day-picker": "^9.14.0", "react-dom": "^19.2.5", + "react-easy-crop": "^5.5.7", "react-hook-form": "^7.73.1", "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.2", "sass": "^1.99.0", + "simplebar": "^6.3.3", + "simplebar-react": "^3.3.2", "zod": "^4.3.6", "zustand": "^5.0.12" }, @@ -39,6 +43,7 @@ "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-basic-ssl": "^2.3.0", "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", @@ -1372,6 +1377,43 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -2323,6 +2365,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2986,13 +3041,13 @@ "license": "ISC" }, "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -3542,6 +3597,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3578,18 +3645,18 @@ } }, "node_modules/motion-dom": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", - "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", "license": "MIT", "dependencies": { - "motion-utils": "^12.36.0" + "motion-utils": "^12.39.0" } }, "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "license": "MIT" }, "node_modules/ms": { @@ -3639,6 +3706,12 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/optimism": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", @@ -3842,6 +3915,20 @@ "react": "^19.2.5" } }, + "node_modules/react-easy-crop": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz", + "integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-hook-form": { "version": "7.73.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz", @@ -4108,6 +4195,37 @@ "node": ">=8" } }, + "node_modules/simplebar": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/simplebar/-/simplebar-6.3.3.tgz", + "integrity": "sha512-ni9cIiA8GRitiaenV7A/gh8nTStiVqO3gLxrfCMmq6CSy/pB+fz+QxqvJLo142QHb2Tvgvr8ILwZxf3qzAq7pg==", + "license": "MIT", + "dependencies": { + "simplebar-core": "^1.3.2" + } + }, + "node_modules/simplebar-core": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/simplebar-core/-/simplebar-core-1.3.2.tgz", + "integrity": "sha512-qKgTTuTqapjsFGkNhCjyPhysnbZGpQqNmjk0nOYjFN5ordC/Wjvg+RbYCyMSnW60l/Z0ZS82GbNltly6PMUH1w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "lodash-es": "^4.17.21" + } + }, + "node_modules/simplebar-react": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/simplebar-react/-/simplebar-react-3.3.2.tgz", + "integrity": "sha512-ZsgcQhKLtt5ra0BRIJeApfkTBQCa1vUPA/WXI4HcYReFt+oCEOvdVz6rR/XsGJcKxTlCRPmdGx1uJIUChupo+A==", + "license": "MIT", + "dependencies": { + "simplebar-core": "^1.3.2" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/size-sensor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", diff --git a/mdm-front/package.json b/mdm-front/package.json index 1137ac7..6793b65 100644 --- a/mdm-front/package.json +++ b/mdm-front/package.json @@ -16,11 +16,12 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@tanstack/react-table": "^8.21.3", "date-fns": "^4.1.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", - "framer-motion": "^12.38.0", + "framer-motion": "^12.40.0", "graphql": "^16.13.2", "leaflet": "^1.9.4", "lucide-react": "^1.9.0", @@ -28,10 +29,13 @@ "react": "^19.2.5", "react-day-picker": "^9.14.0", "react-dom": "^19.2.5", + "react-easy-crop": "^5.5.7", "react-hook-form": "^7.73.1", "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.2", "sass": "^1.99.0", + "simplebar": "^6.3.3", + "simplebar-react": "^3.3.2", "zod": "^4.3.6", "zustand": "^5.0.12" }, @@ -41,6 +45,7 @@ "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-basic-ssl": "^2.3.0", "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/mdm-front/src/App.scss b/mdm-front/src/App.scss index f90339d..1114bd7 100644 --- a/mdm-front/src/App.scss +++ b/mdm-front/src/App.scss @@ -1,10 +1,12 @@ +@use './shared/styles/variables' as *; + .counter { - font-size: 16px; + font-size: $font-size-16; padding: 5px 10px; border-radius: 5px; color: var(--accent); background: var(--accent-bg); - border: 2px solid transparent; + border: 2px solid $transparent; transition: border-color 0.3s; margin-bottom: 24px; @@ -117,7 +119,7 @@ a { color: var(--text-h); - font-size: 16px; + font-size: $font-size-16; border-radius: 6px; background: var(--social-bg); display: flex; @@ -170,7 +172,7 @@ content: ''; position: absolute; top: -4.5px; - border: 5px solid transparent; + border: 5px solid $transparent; } &::before { diff --git a/mdm-front/src/App.tsx b/mdm-front/src/App.tsx index 1d01b9e..30b7f0a 100644 --- a/mdm-front/src/App.tsx +++ b/mdm-front/src/App.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react' //import reactLogo from './assets/react.svg' //import viteLogo from './assets/vite.svg' //import heroImg from './assets/hero.png' diff --git a/mdm-front/src/app/layouts/AppLayout.scss b/mdm-front/src/app/layouts/AppLayout.scss index 16ae059..cadcbce 100644 --- a/mdm-front/src/app/layouts/AppLayout.scss +++ b/mdm-front/src/app/layouts/AppLayout.scss @@ -1,13 +1,288 @@ +@use '../../shared/styles/variables' as *; + .app-layout { display: flex; + width: 100%; + min-width: 0; + height: 100vh; min-height: 100vh; - background: #EFF2F7; + overflow: hidden; + background: + radial-gradient( + 52% 42% at 16% 18%, + rgba(45, 140, 255, 0.055) 0%, + transparent 72% + ), + radial-gradient( + 50% 42% at 88% 16%, + rgba(3, 29, 154, 0.055) 0%, + transparent 74% + ), + radial-gradient( + 54% 44% at 14% 80%, + rgba(51, 179, 67, 0.06) 0%, + transparent 72% + ), + radial-gradient( + 56% 46% at 86% 78%, + rgba(66, 93, 232, 0.075) 0%, + transparent 74% + ), + radial-gradient( + 74% 44% at 50% 106%, + rgba(3, 29, 154, 0.075) 0%, + rgba(66, 93, 232, 0.04) 42%, + transparent 78% + ), + linear-gradient( + 180deg, + #eff2f7 0%, + #f8fafc 40%, + #f4f7fb 70%, + #eff2f7 100% + ); + + [data-theme='dark'] & { + --color-bg: #242a33; + + background: + radial-gradient( + 54% 46% at 16% 18%, + rgba(45, 140, 255, 0.085) 0%, + transparent 72% + ), + radial-gradient( + 52% 44% at 86% 16%, + rgba(99, 102, 241, 0.085) 0%, + transparent 72% + ), + radial-gradient( + 54% 44% at 14% 78%, + rgba(24, 184, 155, 0.07) 0%, + transparent 72% + ), + radial-gradient( + 58% 48% at 86% 78%, + rgba(45, 140, 255, 0.095) 0%, + transparent 74% + ), + radial-gradient( + 76% 46% at 50% 106%, + rgba(45, 140, 255, 0.16) 0%, + rgba(72, 96, 180, 0.085) 42%, + transparent 78% + ), + linear-gradient( + 180deg, + #242a33 0%, + #242a33 12%, + #222832 18%, + #1f252e 30%, + #1d232c 55%, + #171b22 100% + ); + } +} + +.app-layout__sidebar { + flex: 0 0 auto; } .app-layout__content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + height: 100vh; + max-height: 100vh; + //padding: 0px 20px 20px 20px; +} + +.app-layout__scroll { + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + + .simplebar-content { + min-height: 100%; + box-sizing: border-box; + padding: + $layout-page-padding-top + $layout-page-padding-right + $layout-page-padding-bottom + $layout-page-padding-left; + display: flex; flex-direction: column; - flex: 1; - padding: 20px 36px 36px 0; - max-height: calc(100vh - 56px); -} \ No newline at end of file + } + + .simplebar-track.simplebar-vertical { + top: 74px; + right: -8px; + bottom: 8px; + width: 16px; + } + + .simplebar-scrollbar::before { + background: $gray50; + border-radius: 8px; + opacity: 0.65; + } +} + +.app-layout__route-content { + display: flex; + flex: 1 0 auto; + min-width: 0; + min-height: 0; + flex-direction: column; +} + +.app-layout__unsupported { + display: none !important; +} + +.app-layout__drawer-overlay { + position: fixed; + inset: 0; + z-index: 800; + background: $color-backdrop-soft; + backdrop-filter: blur(4px); + + &[data-state='open'] { + animation: appLayoutDrawerOverlayShow 0.18s ease both; + } + + &[data-state='closed'] { + animation: appLayoutDrawerOverlayHide 0.18s ease both; + } +} + +.app-layout__drawer { + position: fixed; + inset: 0 auto 0 0; + z-index: 801; + margin: 10px; + width: min(320px, calc(100vw - 32px)); + padding: 0; + border: none; + border-radius: 20px; + //background: $color-bg; + box-shadow: $shadow-modal-dark; + overflow: hidden; + + &[data-state='open'] { + animation: appLayoutDrawerShow 0.24s cubic-bezier(0.34, 1.02, 0.64, 1) both; + } + + &[data-state='closed'] { + animation: appLayoutDrawerHide 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; + } +} + +.app-layout__drawer-scroll { + width: 100%; + height: 100%; + max-height: 100%; + + .simplebar-track.simplebar-vertical { + width: 10px; + right: 2px; + } + + .simplebar-scrollbar::before { + background: $gray50; + opacity: 0.55; + } +} + +.app-layout__drawer-title { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@include wide-down { + .app-layout__sidebar { + display: none; + } + + .app-layout__content { + height: 100vh; + max-height: 100vh; + box-sizing: border-box; + } +} + +@media (max-width: 1279.98px) { + .app-layout { + min-height: 100vh; + } + + .app-layout__sidebar, + .app-layout__content, + .app-layout__drawer-overlay, + .app-layout__drawer { + display: none; + } + + .app-layout__unsupported { + display: flex !important; + min-height: 100vh !important; + padding: 32px !important; + background: $color-bg; + } +} + +@keyframes appLayoutDrawerOverlayShow { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes appLayoutDrawerOverlayHide { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes appLayoutDrawerShow { + from { + opacity: 0; + transform: translateX(-16px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes appLayoutDrawerHide { + from { + opacity: 1; + transform: translateX(0); + } + + to { + opacity: 0; + transform: translateX(-16px); + } +} diff --git a/mdm-front/src/app/layouts/AppLayout.tsx b/mdm-front/src/app/layouts/AppLayout.tsx index 35dcbb0..d901847 100644 --- a/mdm-front/src/app/layouts/AppLayout.tsx +++ b/mdm-front/src/app/layouts/AppLayout.tsx @@ -1,16 +1,99 @@ +import { + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from 'react' import { Outlet } from 'react-router-dom' +import * as Dialog from '@radix-ui/react-dialog' +import SimpleBar from 'simplebar-react' +import type SimpleBarCore from 'simplebar-core' +import 'simplebar-react/dist/simplebar.min.css' import { Sidebar } from '../../widgets/Sidebar/Sidebar' import './AppLayout.scss' import { Navbar } from '../../widgets/Navbar/Navbar' +import { EmptyState } from '../../shared/ui/EmptyState/EmptyState' +import type { NavbarBreadcrumb } from '../../widgets/Navbar/Navbar' + +export type AppLayoutOutletContext = { + setNavbarBreadcrumbs: Dispatch> +} export function AppLayout() { + const [isSidebarDrawerOpen, setIsSidebarDrawerOpen] = useState(false) + const contentScrollRef = useRef(null) + const [isContentScrolled, setIsContentScrolled] = useState(false) + const [navbarBreadcrumbs, setNavbarBreadcrumbs] = useState< + NavbarBreadcrumb[] + >([]) + + useEffect(() => { + const scrollElement = contentScrollRef.current?.getScrollElement() + + if (!scrollElement) return undefined + const scrollContainer = scrollElement + + function handleScroll() { + setIsContentScrolled(scrollContainer.scrollTop > 0) + } + + handleScroll() + scrollContainer.addEventListener('scroll', handleScroll, { + passive: true, + }) + + return () => { + scrollContainer.removeEventListener('scroll', handleScroll) + } + }, []) + return (
- +
+ +
+
- - + + setIsSidebarDrawerOpen(true)} + /> +
+ +
+
+ + + + + + + Навигация + + + + setIsSidebarDrawerOpen(false)} + /> + + + + + +
) -} \ No newline at end of file +} diff --git a/mdm-front/src/app/router/router.tsx b/mdm-front/src/app/router/router.tsx index f1ba201..5d8a099 100644 --- a/mdm-front/src/app/router/router.tsx +++ b/mdm-front/src/app/router/router.tsx @@ -1,12 +1,42 @@ // src/app/router/router.tsx +import { lazy, Suspense, type ReactNode } from 'react' import { createBrowserRouter, Navigate } from 'react-router-dom' import { AppLayout } from '../layouts/AppLayout' -import { DevicesPage } from '../../pages/DevicesPage/DevicesPage' -import { DevicePage } from '../../pages/DevicePage/DevicePage' -import { MapPage } from '../../pages/MapPage/MapPage' -import { EmployeesPage } from '../../pages/EmployeesPage/EmployeesPage' -import { OrganisationPage } from '../../pages/OrganisationPage/OrganisationPage' + +const DevicesPage = lazy(() => + import('../../pages/DevicesPage/DevicesPage').then((module) => ({ + default: module.DevicesPage, + })), +) + +const DevicePage = lazy(() => + import('../../pages/DevicePage/DevicePage').then((module) => ({ + default: module.DevicePage, + })), +) + +const MapPage = lazy(() => + import('../../pages/MapPage/MapPage').then((module) => ({ + default: module.MapPage, + })), +) + +const EmployeesPage = lazy(() => + import('../../pages/EmployeesPage/EmployeesPage').then((module) => ({ + default: module.EmployeesPage, + })), +) + +const OrganisationPage = lazy(() => + import('../../pages/OrganisationPage/OrganisationPage').then((module) => ({ + default: module.OrganisationPage, + })), +) + +function lazyRoute(element: ReactNode) { + return {element} +} export const router = createBrowserRouter([ { @@ -19,24 +49,24 @@ export const router = createBrowserRouter([ }, { path: 'devices', - element: , + element: lazyRoute(), }, { path: 'devices/:deviceId', - element: , + element: lazyRoute(), }, { path: 'map', - element: , + element: lazyRoute(), }, { path: 'employees', - element: , + element: lazyRoute(), }, { path: 'employees/organisations/:organisationId', - element: , + element: lazyRoute(), }, ], }, -]) \ No newline at end of file +]) diff --git a/mdm-front/src/assets/LogoIconWhite.svg b/mdm-front/src/assets/LogoIconWhite.svg new file mode 100644 index 0000000..a8611c7 --- /dev/null +++ b/mdm-front/src/assets/LogoIconWhite.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/mdm-front/src/assets/LogoWhite.svg b/mdm-front/src/assets/LogoWhite.svg new file mode 100644 index 0000000..5e584aa --- /dev/null +++ b/mdm-front/src/assets/LogoWhite.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mdm-front/src/assets/empty_state.svg b/mdm-front/src/assets/empty_state.svg new file mode 100644 index 0000000..f753506 --- /dev/null +++ b/mdm-front/src/assets/empty_state.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/mdm-front/src/assets/icons/Apps.tsx b/mdm-front/src/assets/icons/Apps.tsx new file mode 100644 index 0000000..fc1bdd8 --- /dev/null +++ b/mdm-front/src/assets/icons/Apps.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function Apps({ className, ...props }: IconProps) { + return ( + + + + + + + ) +} diff --git a/mdm-front/src/assets/icons/LoadingTwotoneLoop.tsx b/mdm-front/src/assets/icons/LoadingTwotoneLoop.tsx new file mode 100644 index 0000000..c6eb68f --- /dev/null +++ b/mdm-front/src/assets/icons/LoadingTwotoneLoop.tsx @@ -0,0 +1,32 @@ +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function LoadingTwotoneLoopIcon({ className, ...props }: IconProps) { + return ( + + + + + + + ) +} diff --git a/mdm-front/src/assets/no_items.svg b/mdm-front/src/assets/no_items.svg new file mode 100644 index 0000000..ab5b250 --- /dev/null +++ b/mdm-front/src/assets/no_items.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/mdm-front/src/entities/device/api/device.graphql.ts b/mdm-front/src/entities/device/api/device.graphql.ts index 19fe3a1..31d641e 100644 --- a/mdm-front/src/entities/device/api/device.graphql.ts +++ b/mdm-front/src/entities/device/api/device.graphql.ts @@ -1,38 +1,32 @@ import { gql } from '@apollo/client' -export const GET_PHONES_PAGE_QUERY = gql` - query GetPhonesPage($key: String) { - getPhonesPage(key: $key) { +export const GET_PHONES_QUERY = gql` + query GetPhones( + $page: Int! + $query: String + $locked: Boolean + $needMaintenance: Boolean + $networkStatus: [PhoneNetworkStatus!] + $orgs: [ID!] + $sortDirection: SortDirection! + $sortField: PhoneSortField! + ) { + getPhones( + page: $page + query: $query + locked: $locked + needMaintenance: $needMaintenance + networkStatus: $networkStatus + orgs: $orgs + sortDirection: $sortDirection + sortField: $sortField + ) { page { id imei imei2 serial - orgId - registerDate - - org { - id - name - creationDate - policy { - canUseBluetooth - canUseGPS - canUseCamera - canUseSim - } - } - - policy { - canUseBluetooth - canUseCamera - canUseGPS - canUseSim - } - - techState { - needMaintenance - } + networkStatus lastLocation { alt @@ -40,9 +34,102 @@ export const GET_PHONES_PAGE_QUERY = gql` lat lng } + + org { + id + logoUrl + name + + policy { + bluetooth + bluetoothEditable + camera + cameraEditable + GPS + gpsEditable + sim + simEditable + offlineTimeThreshold + } + } + + orgId + + policy { + bluetooth + camera + GPS + sim + locked + } + + techState { + batteryLevel + needMaintenance + temperature + } } - nextKey + totalElements + totalPages + } + } +` + +export const GET_PHONES_TABS_STATS_QUERY = gql` + query GetPhonesTabsStats($query: String, $orgs: [ID!]) { + totalPhones: getPhones( + page: 0 + query: $query + orgs: $orgs + sortDirection: DESC + sortField: ID + ) { + totalElements + } + + onlinePhones: getPhones( + page: 0 + query: $query + networkStatus: [Online] + orgs: $orgs + sortDirection: DESC + sortField: ID + ) { + totalElements + } + + lockedPhones: getPhones( + page: 0 + query: $query + locked: true + orgs: $orgs + sortDirection: DESC + sortField: ID + ) { + totalElements + } + + lostPhones: getPhones( + page: 0 + query: $query + networkStatus: [Lost] + orgs: $orgs + sortDirection: DESC + sortField: ID + ) { + totalElements + } + + maintenancePhones: getPhones( + page: 0 + query: $query + needMaintenance: true + orgs: $orgs + sortDirection: DESC + sortField: ID + ) { + totalElements } } ` @@ -53,6 +140,7 @@ export const GET_PHONE_QUERY = gql` id imei imei2 + networkStatus lastLocation { alt @@ -66,20 +154,26 @@ export const GET_PHONE_QUERY = gql` id name policy { - canUseBluetooth - canUseCamera - canUseGPS - canUseSim + bluetooth + bluetoothEditable + camera + cameraEditable + GPS + gpsEditable + sim + simEditable + offlineTimeThreshold } } orgId policy { - canUseBluetooth - canUseCamera - canUseGPS - canUseSim + bluetooth + camera + GPS + sim + locked } registerDate @@ -109,15 +203,62 @@ export const GET_PHONE_GPS_TRACK_QUERY = gql` } ` +export const GET_PHONE_PACKAGES_QUERY = gql` + query GetPhonePackages($phoneId: ID!) { + getPhonePackages(phoneId: $phoneId) { + category + iconUrl + installDate + name + packageName + version + versionName + } + } +` + export const GET_TELEMETRY_QUERY = gql` - query GetTelemetry($phoneId: Int!, $startDate: Float!, $endDate: Float) { + query GetTelemetry( + $phoneId: Int! + $packagesPhoneId: ID! + $startDate: Float! + $endDate: Float + ) { getTelemetry(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) { + batteryCapacity + batteryCycles batteryLevel - chargeType date - screenOn temperature } + + getPhoneStateEvents(phoneId: $packagesPhoneId, startDate: $startDate, endDate: $endDate) { + date + phoneId + type + } + + getPhonePackagesUseEvents( + phoneId: $packagesPhoneId + startDate: $startDate + endDate: $endDate + ) { + events { + type + packageName + date + } + + phonePackage { + category + iconUrl + installDate + name + packageName + version + versionName + } + } } ` @@ -134,6 +275,7 @@ export const GET_DEVICE_PAGE_QUERY = gql` id imei imei2 + networkStatus lastLocation { alt @@ -147,33 +289,40 @@ export const GET_DEVICE_PAGE_QUERY = gql` id name policy { - canUseBluetooth - canUseCamera - canUseGPS - canUseSim + bluetooth + bluetoothEditable + camera + cameraEditable + GPS + gpsEditable + sim + simEditable + offlineTimeThreshold } } orgId policy { - canUseBluetooth - canUseCamera - canUseGPS - canUseSim + bluetooth + camera + GPS + sim + locked } registerDate serial techState { - batteryCycles - batteryLevel - batteryRemainingCapacity - hits - needMaintenance - overheats - worktime + batteryCycles + batteryLevel + batteryRemainingCapacity + hits + needMaintenance + overheats + temperature + worktime } } @@ -182,10 +331,10 @@ export const GET_DEVICE_PAGE_QUERY = gql` startDate: $telemetryStartDate endDate: $telemetryEndDate ) { + batteryCapacity + batteryCycles batteryLevel - chargeType date - screenOn temperature } @@ -215,19 +364,27 @@ export const CREATE_PHONE_REGISTRATION_TOKEN_MUTATION = gql` export const CHANGE_PHONE_POLICY_MUTATION = gql` mutation ChangePhonePolicy( $id: ID! - $canUseBluetooth: Boolean! - $canUseCamera: Boolean! - $canUseGPS: Boolean! - $canUseSim: Boolean! + $bluetooth: ServiceUseOption! + $camera: ComponentUseOption! + $GPS: ServiceUseOption! + $sim: ServiceUseOption! + $locked: Boolean! ) { changePhonePolicy( id: $id policy: { - canUseBluetooth: $canUseBluetooth - canUseCamera: $canUseCamera - canUseGPS: $canUseGPS - canUseSim: $canUseSim + bluetooth: $bluetooth + camera: $camera + GPS: $GPS + sim: $sim + locked: $locked } - ) + ) { + bluetooth + camera + GPS + sim + locked + } } -` \ No newline at end of file +` diff --git a/mdm-front/src/entities/device/lib/phonePolicy.ts b/mdm-front/src/entities/device/lib/phonePolicy.ts new file mode 100644 index 0000000..565e07c --- /dev/null +++ b/mdm-front/src/entities/device/lib/phonePolicy.ts @@ -0,0 +1,85 @@ +import type { + ComponentUseOption, + LegacyPhoneUsePolicyOption, + PhoneUsePolicyOption, + ServiceUseOption, +} from '../model/types' + +type PhonePolicyValue = + | PhoneUsePolicyOption + | LegacyPhoneUsePolicyOption + | boolean + | null + | undefined + +export function isPhonePolicyOptionEnabled(value: PhonePolicyValue) { + if (typeof value === 'boolean') return value + + return value === 'Enabled' || value === 'Allowed' +} + +export function booleanToPhonePolicyOption( + enabled: boolean, +): ServiceUseOption { + return enabled ? 'Enabled' : 'Disabled' +} + +export function booleanToServiceUseOption(enabled: boolean): ServiceUseOption { + return enabled ? 'Enabled' : 'Disabled' +} + +export function booleanToComponentUseOption( + enabled: boolean, +): ComponentUseOption { + return enabled ? 'Allowed' : 'Disallowed' +} + +export function normalizeServiceUseOption( + value?: ServiceUseOption | LegacyPhoneUsePolicyOption | boolean | null, + fallbackBoolean?: boolean | null, +): ServiceUseOption { + if (value === 'Disabled' || value === 'Enabled' || value === 'Unspecified') { + return value + } + + if (value === 'Allowed') return 'Enabled' + if (value === 'Disallowed') return 'Disabled' + if (value === 'Changeable') return 'Unspecified' + + if (typeof value === 'boolean') { + return booleanToServiceUseOption(value) + } + + if (typeof fallbackBoolean === 'boolean') { + return booleanToServiceUseOption(fallbackBoolean) + } + + return 'Unspecified' +} + +export function normalizeComponentUseOption( + value?: + | ComponentUseOption + | ServiceUseOption + | LegacyPhoneUsePolicyOption + | boolean + | null, + fallbackBoolean?: boolean | null, +): ComponentUseOption { + if (value === 'Allowed' || value === 'Disallowed') { + return value + } + + if (value === 'Enabled') return 'Allowed' + if (value === 'Disabled') return 'Disallowed' + + if (typeof value === 'boolean') { + return booleanToComponentUseOption(value) + } + + if (typeof fallbackBoolean === 'boolean') { + return booleanToComponentUseOption(fallbackBoolean) + } + + return 'Disallowed' +} diff --git a/mdm-front/src/entities/device/model/types.ts b/mdm-front/src/entities/device/model/types.ts index c7ba97a..ea39e04 100644 --- a/mdm-front/src/entities/device/model/types.ts +++ b/mdm-front/src/entities/device/model/types.ts @@ -5,11 +5,45 @@ export type DeviceLocation = { lng: number } +export type ServiceUseOption = + | 'Disabled' + | 'Enabled' + | 'Unspecified' + +export type ComponentUseOption = + | 'Allowed' + | 'Disallowed' + +export type PhoneUsePolicyOption = ServiceUseOption | ComponentUseOption + +export type LegacyPhoneUsePolicyOption = + | 'Allowed' + | 'Disallowed' + | 'Changeable' + +export type GroupUsePolicy = { + bluetooth?: ServiceUseOption | LegacyPhoneUsePolicyOption + bluetoothEditable?: boolean + camera?: ComponentUseOption | LegacyPhoneUsePolicyOption + cameraEditable?: boolean + GPS?: ServiceUseOption | LegacyPhoneUsePolicyOption + gpsEditable?: boolean + sim?: ServiceUseOption | LegacyPhoneUsePolicyOption + simEditable?: boolean + offlineTimeThreshold?: number + date?: number +} + export type DevicePolicy = { - canUseBluetooth: boolean - canUseCamera: boolean - canUseGPS: boolean - canUseSim: boolean + bluetooth?: ServiceUseOption | LegacyPhoneUsePolicyOption + camera?: ComponentUseOption | LegacyPhoneUsePolicyOption + GPS?: ServiceUseOption | LegacyPhoneUsePolicyOption + sim?: ServiceUseOption | LegacyPhoneUsePolicyOption + canUseBluetooth?: boolean + canUseCamera?: boolean + canUseGPS?: boolean + canUseSim?: boolean + locked?: boolean } export type DeviceTechState = { @@ -19,14 +53,18 @@ export type DeviceTechState = { hits: number | null needMaintenance: boolean overheats: number | null + temperature?: number | null worktime: number | null } +export type DeviceNetworkStatus = 'Online' | 'Offline' | 'Lost' + export type DeviceOrganisation = { creationDate?: number id: number + logoUrl?: string | null name: string - policy?: DevicePolicy | null + policy?: GroupUsePolicy | null } export type Device = { @@ -34,6 +72,7 @@ export type Device = { imei: string imei2: string serial: string + networkStatus?: DeviceNetworkStatus orgId: number registerDate: number org: DeviceOrganisation | null @@ -42,15 +81,40 @@ export type Device = { lastLocation: DeviceLocation | null } -export type GetPhonesPageData = { - getPhonesPage: { +export type GetPhonesData = { + getPhones: { page: Device[] - nextKey: string | null + totalElements: number + totalPages: number } } -export type GetPhonesPageVariables = { - key?: string +export type GetPhonesVariables = { + page: number + query?: string + locked?: boolean + needMaintenance?: boolean + networkStatus?: DeviceNetworkStatus[] + orgs?: string[] + sortDirection: 'ASC' | 'DESC' + sortField: 'ID' | 'Serial' | 'Date' +} + +type GetPhonesStatsPage = { + totalElements: number +} + +export type GetPhonesTabsStatsData = { + totalPhones: GetPhonesStatsPage + onlinePhones: GetPhonesStatsPage + lockedPhones: GetPhonesStatsPage + lostPhones: GetPhonesStatsPage + maintenancePhones: GetPhonesStatsPage +} + +export type GetPhonesTabsStatsVariables = { + query?: string + orgs?: string[] } export type GetPhoneData = { @@ -61,27 +125,89 @@ export type GetPhoneVariables = { id: number } -export type DeviceChargeType = - | 'USB' - | 'Wireless' - | 'AC' - | 'Disconnected' +export type DeviceTelemetryItem = { + batteryCapacity: number | null + batteryCycles: number | null + batteryLevel: number | null + date: number + temperature: number | null +} + +export type DeviceTelemetryEventItem = { + date: number; + type: 'event'; + eventType: DevicePhoneStateEventType; + label: string; +}; + +export type DeviceHistoryChartItem = + | DeviceTelemetryItem + | DeviceTelemetryEventItem; + +export type DevicePhoneStateEventType = + | 'ScreenOn' + | 'ScreenOff' + | 'EnterNetwork' + | 'LeaveNetwork' + | 'PowerOn' + | 'PowerOff' + | 'Hit' + | 'Overheat' + | 'PowerAC' + | 'PowerUSB' + | 'PowerWireless' + | 'PowerDisconnected' + +export type DevicePhoneStateEvent = { + date: number + phoneId: string + type: DevicePhoneStateEventType +} + +export type DevicePackage = { + category: string | null + iconUrl: string | null + installDate: number | null + name: string | null + packageName: string + version: number | null + versionName: string | null +} + +export type GetPhonePackagesData = { + getPhonePackages: DevicePackage[] +} + +export type GetPhonePackagesVariables = { + phoneId: string +} + +export type DevicePackageUseEventType = + | 'ResumeActivity' + | 'PauseActivity' + | 'StopActivity' | string -export type DeviceTelemetryItem = { - batteryLevel: number - chargeType: DeviceChargeType +export type DevicePackageUseEvent = { date: number - screenOn: boolean - temperature: number + packageName: string + type: DevicePackageUseEventType +} + +export type DevicePackageUseEventsGroup = { + events: DevicePackageUseEvent[] + phonePackage: DevicePackage | null } export type GetTelemetryData = { getTelemetry: DeviceTelemetryItem[] + getPhoneStateEvents: DevicePhoneStateEvent[] + getPhonePackagesUseEvents: DevicePackageUseEventsGroup[] } export type GetTelemetryVariables = { phoneId: number + packagesPhoneId: string startDate: number endDate?: number } @@ -119,15 +245,16 @@ export type GetDevicePageVariables = { } export type ChangePhonePolicyData = { - changePhonePolicy: boolean + changePhonePolicy: DevicePolicy } export type ChangePhonePolicyVariables = { id: string - canUseBluetooth: boolean - canUseCamera: boolean - canUseGPS: boolean - canUseSim: boolean + bluetooth: ServiceUseOption + camera: ComponentUseOption + GPS: ServiceUseOption + sim: ServiceUseOption + locked: boolean } export type CreatePhoneRegistrationTokenData = { @@ -138,4 +265,4 @@ export type CreatePhoneRegistrationTokenData = { } } -export type CreatePhoneRegistrationTokenVariables = Record \ No newline at end of file +export type CreatePhoneRegistrationTokenVariables = Record diff --git a/mdm-front/src/entities/employee/api/employee.graphql.ts b/mdm-front/src/entities/employee/api/employee.graphql.ts index bf901e3..890ba25 100644 --- a/mdm-front/src/entities/employee/api/employee.graphql.ts +++ b/mdm-front/src/entities/employee/api/employee.graphql.ts @@ -1,21 +1,40 @@ import { gql } from '@apollo/client' -export const GET_USERS_PAGE_QUERY = gql` - query GetUsersPage($key: String) { - getUsersPage(key: $key) { +export const GET_USERS_QUERY = gql` + query GetUsers( + $page: Int! + $query: String + $orgs: [ID!] + $roles: [Role!] + $sortDirection: SortDirection! + $sortField: UserSortField! + ) { + getUsers( + page: $page + query: $query + orgs: $orgs + roles: $roles + sortDirection: $sortDirection + sortField: $sortField + ) { + totalPages + totalElements page { - id + avatarUrl firstName + id lastName middleName org { + creationDate id + logoUrl name } orgId role + username } - nextKey } } ` @@ -26,16 +45,28 @@ export const GET_ORGANISATION_QUERY = gql` creationDate id name + logoUrl policy { - canUseBluetooth - canUseCamera - canUseGPS - canUseSim + bluetooth + bluetoothEditable + camera + cameraEditable + GPS + gpsEditable + sim + simEditable + offlineTimeThreshold } } } ` +export const DELETE_ORGANISATION_MUTATION = gql` + mutation DeleteOrganisation($id: ID!) { + deleteOrganisation(id: $id) + } +` + export const GET_ORGANISATIONS_QUERY = gql` query GetOrganisations( $page: Int! @@ -55,13 +86,7 @@ export const GET_ORGANISATIONS_QUERY = gql` creationDate id name - policy { - canUseBluetooth - canUseCamera - canUseGPS - canUseSim - date - } + logoUrl } } } @@ -70,18 +95,28 @@ export const GET_ORGANISATIONS_QUERY = gql` export const CREATE_ORGANISATION_MUTATION = gql` mutation CreateOrganisation( $name: String! - $canUseBluetooth: Boolean! - $canUseCamera: Boolean! - $canUseGPS: Boolean! - $canUseSim: Boolean! + $bluetooth: ServiceUseOption! + $bluetoothEditable: Boolean! + $camera: ComponentUseOption! + $cameraEditable: Boolean! + $GPS: ServiceUseOption! + $gpsEditable: Boolean! + $sim: ServiceUseOption! + $simEditable: Boolean! + $offlineTimeThreshold: Float! ) { createOrganisation( name: $name policy: { - canUseBluetooth: $canUseBluetooth - canUseCamera: $canUseCamera - canUseGPS: $canUseGPS - canUseSim: $canUseSim + bluetooth: $bluetooth + bluetoothEditable: $bluetoothEditable + camera: $camera + cameraEditable: $cameraEditable + GPS: $GPS + gpsEditable: $gpsEditable + sim: $sim + simEditable: $simEditable + offlineTimeThreshold: $offlineTimeThreshold } ) { id @@ -94,19 +129,29 @@ export const CHANGE_ORGANISATION_MUTATION = gql` mutation ChangeOrganisation( $id: ID! $name: String! - $canUseBluetooth: Boolean! - $canUseCamera: Boolean! - $canUseGPS: Boolean! - $canUseSim: Boolean! + $bluetooth: ServiceUseOption! + $bluetoothEditable: Boolean! + $camera: ComponentUseOption! + $cameraEditable: Boolean! + $GPS: ServiceUseOption! + $gpsEditable: Boolean! + $sim: ServiceUseOption! + $simEditable: Boolean! + $offlineTimeThreshold: Float! ) { changeOrganisation( id: $id name: $name policy: { - canUseBluetooth: $canUseBluetooth - canUseCamera: $canUseCamera - canUseGPS: $canUseGPS - canUseSim: $canUseSim + bluetooth: $bluetooth + bluetoothEditable: $bluetoothEditable + camera: $camera + cameraEditable: $cameraEditable + GPS: $GPS + gpsEditable: $gpsEditable + sim: $sim + simEditable: $simEditable + offlineTimeThreshold: $offlineTimeThreshold } ) { creationDate @@ -116,7 +161,43 @@ export const CHANGE_ORGANISATION_MUTATION = gql` } ` -export const SIGN_UP_MUTATION = gql` +export const CHANGE_ORGANISATION_POLICY_MUTATION = gql` + mutation ChangeOrganisationPolicy( + $id: ID! + $name: String! + $bluetooth: ServiceUseOption! + $bluetoothEditable: Boolean! + $camera: ComponentUseOption! + $cameraEditable: Boolean! + $GPS: ServiceUseOption! + $gpsEditable: Boolean! + $sim: ServiceUseOption! + $simEditable: Boolean! + $offlineTimeThreshold: Float! + ) { + changeOrganisation( + id: $id + name: $name + policy: { + bluetooth: $bluetooth + bluetoothEditable: $bluetoothEditable + camera: $camera + cameraEditable: $cameraEditable + GPS: $GPS + gpsEditable: $gpsEditable + sim: $sim + simEditable: $simEditable + offlineTimeThreshold: $offlineTimeThreshold + } + ) { + creationDate + id + name + } + } +` + +export const CREATE_USER_MUTATION = gql` mutation createUser( $orgId: ID! $firstName: String! @@ -149,4 +230,96 @@ export const SIGN_UP_MUTATION = gql` } } } -` \ No newline at end of file +` + +export const CHANGE_USER_MUTATION = gql` + mutation ChangeUser( + $userId: ID! + $orgId: ID! + $firstName: String! + $lastName: String! + $middleName: String! + $username: String! + $role: Role! + ) { + changeUser( + userId: $userId + form: { + orgId: $orgId + firstName: $firstName + lastName: $lastName + middleName: $middleName + username: $username + role: $role + } + ) { + avatarUrl + firstName + id + lastName + middleName + org { + creationDate + id + logoUrl + name + } + orgId + role + username + } + } +` + +export const CHANGE_USER_WITH_PASSWORD_MUTATION = gql` + mutation ChangeUserWithPassword( + $userId: ID! + $orgId: ID! + $firstName: String! + $lastName: String! + $middleName: String! + $username: String! + $password: String! + $role: Role! + ) { + changeUser( + userId: $userId + form: { + orgId: $orgId + firstName: $firstName + lastName: $lastName + middleName: $middleName + username: $username + password: $password + role: $role + } + ) { + avatarUrl + firstName + id + lastName + middleName + org { + creationDate + id + logoUrl + name + } + orgId + role + username + } + } +` + +export const CREATE_UPLOAD_USER_AVATAR_URL_MUTATION = gql` + mutation CreateUploadUserAvatarUrl($userId: ID!) { + createUploadUserAvatarUrl(userId: $userId) + } +` + +export const CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION = gql` + mutation CreateUploadOrganisationLogoUrl($orgId: ID!) { + createUploadOrganisationLogoUrl(orgId: $orgId) + } +` diff --git a/mdm-front/src/entities/employee/model/types.ts b/mdm-front/src/entities/employee/model/types.ts index a2b6f73..a225e8d 100644 --- a/mdm-front/src/entities/employee/model/types.ts +++ b/mdm-front/src/entities/employee/model/types.ts @@ -1,23 +1,71 @@ export type EmployeeRole = 'User' | 'Admin' | string export type Employee = { + avatarUrl?: string | null id: number firstName: string lastName: string middleName: string + username?: string orgId: number role: EmployeeRole org: { + creationDate?: number id: number + logoUrl?: string | null name: string } | null } +export type UserSortDirection = 'ASC' | 'DESC' + +export type UserSortField = 'ID' | 'Name' | 'Date' + +export type ServiceUseOption = + | 'Disabled' + | 'Enabled' + | 'Unspecified' + +export type ComponentUseOption = + | 'Allowed' + | 'Disallowed' + +export type GroupUsePolicyOption = + | 'Allowed' + | 'Disallowed' + | 'Changeable' + +export type GetUsersData = { + getUsers: { + totalPages: number + totalElements: number + page: Employee[] + } +} + +export type GetUsersVariables = { + page: number + query?: string + orgs?: string[] + roles?: EmployeeRole[] + sortDirection: UserSortDirection + sortField: UserSortField +} + export type OrganisationPolicy = { - canUseBluetooth: boolean - canUseCamera: boolean - canUseGPS: boolean - canUseSim: boolean + bluetooth?: ServiceUseOption | GroupUsePolicyOption + bluetoothEditable?: boolean + camera?: ComponentUseOption | GroupUsePolicyOption + cameraEditable?: boolean + GPS?: ServiceUseOption | GroupUsePolicyOption + gpsEditable?: boolean + sim?: ServiceUseOption | GroupUsePolicyOption + simEditable?: boolean + canUseBluetooth?: boolean + canUseCamera?: boolean + canUseGPS?: boolean + canUseSim?: boolean + offlineTimeThreshold: number date?: number } @@ -26,6 +74,7 @@ export type Organisation = { id: number name: string policy?: OrganisationPolicy | null + logoUrl?: string | null } export type GetOrganisationData = { @@ -46,10 +95,29 @@ export type ChangeOrganisationData = { export type ChangeOrganisationVariables = { id: string name: string - canUseBluetooth: boolean - canUseCamera: boolean - canUseGPS: boolean - canUseSim: boolean + bluetooth: ServiceUseOption + bluetoothEditable: boolean + camera: ComponentUseOption + cameraEditable: boolean + GPS: ServiceUseOption + gpsEditable: boolean + sim: ServiceUseOption + simEditable: boolean + offlineTimeThreshold: number +} + +export type ChangeOrganisationPolicyVariables = { + id: string + name: string + bluetooth: ServiceUseOption + bluetoothEditable: boolean + camera: ComponentUseOption + cameraEditable: boolean + GPS: ServiceUseOption + gpsEditable: boolean + sim: ServiceUseOption + simEditable: boolean + offlineTimeThreshold: number } export type GetUsersPageData = { @@ -78,19 +146,44 @@ export type GetOrganisationsVariables = { sortField: OrganisationSortField } -export type SignUpData = { - signUp: { - id: number - firstName: string - lastName: string - middleName: string - orgId: number - role: EmployeeRole - org: { - id: number - name: string - } | null - } +export type DeleteOrganisationData = { + deleteOrganisation: boolean +} + +export type DeleteOrganisationVariables = { + id: string +} + +export type CreateUserData = { + createUser: Employee +} + +export type CreateUserVariables = { + orgId: string + firstName: string + lastName: string + middleName: string + username: string + password: string + role: EmployeeRole +} + +export type ChangeUserData = { + changeUser: Employee +} + +export type ChangeUserVariables = { + userId: string + orgId: string + firstName: string + lastName: string + middleName: string + username: string + role: EmployeeRole +} + +export type ChangeUserWithPasswordVariables = ChangeUserVariables & { + password: string } export type SignUpVariables = { @@ -112,12 +205,33 @@ export type CreateOrganisationData = { export type CreateOrganisationVariables = { name: string - canUseBluetooth: boolean - canUseCamera: boolean - canUseGPS: boolean - canUseSim: boolean + bluetooth: ServiceUseOption + bluetoothEditable: boolean + camera: ComponentUseOption + cameraEditable: boolean + GPS: ServiceUseOption + gpsEditable: boolean + sim: ServiceUseOption + simEditable: boolean + offlineTimeThreshold: number } export type OrganisationSortDirection = 'ASC' | 'DESC' -export type OrganisationSortField = 'ID' | 'Name' | 'Date' \ No newline at end of file +export type OrganisationSortField = 'ID' | 'Name' | 'Date' + +export type CreateUploadUserAvatarUrlData = { + createUploadUserAvatarUrl: string +} + +export type CreateUploadUserAvatarUrlVariables = { + userId: string +} + +export type CreateUploadOrganisationLogoUrlData = { + createUploadOrganisationLogoUrl: string +} + +export type CreateUploadOrganisationLogoUrlVariables = { + orgId: string +} diff --git a/mdm-front/src/features/auth/api/auth.graphql.ts b/mdm-front/src/features/auth/api/auth.graphql.ts index 9ed7acc..c76f24a 100644 --- a/mdm-front/src/features/auth/api/auth.graphql.ts +++ b/mdm-front/src/features/auth/api/auth.graphql.ts @@ -29,8 +29,17 @@ export const REFRESH_SESSION_MUTATION = gql` export const CURRENT_USER_QUERY = gql` query CurrentUser { currentUser { + avatarUrl id role + firstName + middleName + lastName + username + org { + id + name + } } } ` \ 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 1f037d8..8ac1769 100644 --- a/mdm-front/src/features/auth/ui/AuthGate.tsx +++ b/mdm-front/src/features/auth/ui/AuthGate.tsx @@ -11,6 +11,15 @@ import { LoginPage } from '../../../pages/LoginPage/LoginPage' type CurrentUser = { id: string role: string + avatarUrl: string + firstName: string + middleName: string + lastName: string + username: string + org: { + id: number + name: string + } | null } type CurrentUserQueryData = { diff --git a/mdm-front/src/index.scss b/mdm-front/src/index.scss index 9144a57..c23139f 100644 --- a/mdm-front/src/index.scss +++ b/mdm-front/src/index.scss @@ -1,4 +1,6 @@ @use './shared/styles/variables' as *; +@use './shared/styles/themes'; +@use './shared/styles/table-surfaces'; @font-face { font-family: 'Montserrat'; @@ -9,25 +11,12 @@ } :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - --sans: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;; --heading: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;; --mono: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;; - font: 18px/145% var(--sans); + font: var(--font-size-18) / 145% var(--sans); letter-spacing: 0.18px; - color-scheme: light dark; color: var(--text); background: $color-bg; font-synthesis: none; @@ -36,27 +25,7 @@ -moz-osx-font-smoothing: grayscale; @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); + font-size: $font-size-16; } } @@ -74,7 +43,12 @@ body { margin: 0; + background: $color-bg; } +html{ + background: $color-bg; +} + button{ font-family: inherit; } @@ -82,28 +56,32 @@ a{ text-decoration: none; } +.simplebar-content{ + height: 100%; +} + h1, h2 { font-family: var(--heading); font-weight: 500; - color: black; + color: $color-text-strong; } h1 { - font-size: 32px; + font-size: $font-size-32; margin: 0; @media (max-width: 1024px) { - font-size: 36px; + font-size: $font-size-36; margin: 20px 0; } } h2 { - font-size: 24px; + font-size: $font-size-24; line-height: 118%; letter-spacing: -0.24px; margin: 0 0 8px; @media (max-width: 1024px) { - font-size: 20px; + font-size: $font-size-20; } } p { @@ -119,7 +97,7 @@ code, } code { - font-size: 15px; + font-size: $font-size-15; line-height: 135%; padding: 4px 8px; background: var(--code-bg); diff --git a/mdm-front/src/main.tsx b/mdm-front/src/main.tsx index 165aed1..d132382 100644 --- a/mdm-front/src/main.tsx +++ b/mdm-front/src/main.tsx @@ -1,16 +1,20 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.scss' -import { RouterProvider } from 'react-router-dom' -import { ApolloProvider } from '@apollo/client/react' -import { apolloClient } from './shared/api/apolloClient' -import { router } from './app/router/router.tsx' import 'react-day-picker/style.css' import 'leaflet/dist/leaflet.css' +import { RouterProvider } from 'react-router-dom' +import { ApolloProvider } from '@apollo/client/react' +import { LazyMotion, domAnimation } from 'framer-motion' +import { apolloClient } from './shared/api/apolloClient' +import { router } from './app/router/router.tsx' import { AuthGate } from './features/auth/ui/AuthGate' +import { initTheme } from './shared/theme/theme' const isAuthEnabled = import.meta.env.VITE_AUTH_ENABLED === 'true' +initTheme() + const app = isAuthEnabled ? ( @@ -22,7 +26,9 @@ const app = isAuthEnabled ? ( createRoot(document.getElementById('root')!).render( - {app} + + {app} + , ) diff --git a/mdm-front/src/pages/DevicePage/DevicePage.scss b/mdm-front/src/pages/DevicePage/DevicePage.scss index c481edd..ca0b0b3 100644 --- a/mdm-front/src/pages/DevicePage/DevicePage.scss +++ b/mdm-front/src/pages/DevicePage/DevicePage.scss @@ -2,56 +2,57 @@ .device-page { display: flex; + flex: 1; + min-height: calc(80% - 40px); + max-height: calc(100% - 40px); + height: auto; flex-direction: column; gap: 18px; -} - -.device-breadcrumbs { - display: flex; - align-items: center; - gap: 6px; - - color: $gray50; - font-size: 16px; - font-weight: 500; - padding: 11px 0; - - button, a { - padding: 0; - border: none; - background: transparent; - color: #738098; - font: inherit; - cursor: pointer; - transition: .2s ease; - - &:hover { - color: $blue; - } - } - - span:last-child { - color: #30394b; - } + overflow: visible; + padding: 20px 20px 36px 20px; } .device-page__grid { display: grid; - grid-template-columns: 420px 260px minmax(360px, 1fr); + grid-template-columns: 340px 340px minmax(360px, 1fr); gap: 20px; align-items: start; + width: 100%; + //min-height: 100%; +} + +.device-page__motion-view { + display: flex; + flex: 1 0 auto; + min-height: 0; + flex-direction: column; } .device-card { border-radius: 20px; - background: #ffffff; + background: rgba($color-surface-rgb, .5); + backdrop-filter: blur(22px); padding: 20px; + box-shadow: $shadow-card; } .device-card--main { grid-column: span 2; } +.device-page__details-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; + align-items: start; + + >.device-card, + >.device-card-stats { + min-width: 0; + } +} + .device-main { display: flex; flex-direction: row; @@ -60,10 +61,10 @@ .device-main__image { height: calc(50% - 40px); - width: 30%; + width: 27.1%; padding: 20px; border-radius: 14px; - background: #f1f4f8; + background: $color-surface-subtle; display: flex; align-items: center; @@ -88,8 +89,8 @@ h2 { margin: 0 0 6px; - color: black; - font-size: 30px; + color: $color-text-strong; + font-size: $font-size-30; font-weight: 600; line-height: 1.1; } @@ -97,7 +98,7 @@ p { margin: 0; color: $gray50; - font-size: 17px; + font-size: $font-size-17; font-weight: 500; line-height: 1.3; } @@ -120,8 +121,8 @@ align-items: center; gap: 9px; - color: black; - font-size: 18px; + color: $color-text-strong; + font-size: $font-size-18; font-weight: 500; svg { @@ -134,12 +135,12 @@ &.is-success svg { color: $green; - background: #eaf8ee; + background: $color-success-bg; } &.is-danger svg { color: $red; - background: #ffe8e8; + background: $color-danger-bg; } &.is-muted svg { @@ -159,17 +160,46 @@ align-items: center; gap: 10px; - color: black; - font-size: 18px; + color: $color-text-strong; + font-size: $font-size-18; font-weight: 500; .device-registered-info { display: flex; flex: 1; + align-items: center; padding: 10px 0; height: inherit; justify-content: space-between; border-bottom: 1px solid $gray20; + + a { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + color: $gray50; + font-weight: 500; + text-decoration: none; + transition: color 0.2s ease; + + &:hover { + color: $color-primary-hover; + + svg { + color: $color-primary-hover; + } + } + + svg { + padding: 0; + background-color: transparent; + height: 20px; + width: auto; + margin-bottom: 2px; + transition: .2s ease; + } + } } svg { @@ -185,6 +215,7 @@ color: $gray50; font-weight: 500; } + } .device-card__actions { @@ -202,10 +233,10 @@ border: none; border-radius: 12px; - background: #dfe6ff; + background: $color-primary-soft; color: $blue; - font-size: 18px; + font-size: $font-size-18; font-weight: 500; cursor: pointer; transition: .2s ease; @@ -213,7 +244,8 @@ &:hover { background-color: $blue; - color: white; + color: $color-text-inverse; + box-shadow: var(--shadow-primary); } } @@ -222,7 +254,7 @@ border: none; border-radius: 12px; - background: #ffdede; + background: $color-danger-bg-strong; display: inline-flex; align-items: center; @@ -238,7 +270,8 @@ &:hover { background-color: $red; - color: white; + color: $color-text-inverse; + box-shadow: var(--shadow-danger); } } @@ -252,8 +285,8 @@ p { margin: 0; - color: #738098; - font-size: 15px; + color: $color-text-muted; + font-size: $font-size-15; font-weight: 500; } } @@ -265,8 +298,8 @@ h3 { margin: 0; - color: #30394b; - font-size: 18px; + color: $color-text-heading; + font-size: $font-size-18; font-weight: 600; } @@ -275,8 +308,8 @@ width: 24px; height: 24px; border-radius: 9px; - background: #eef1f6; - color: #30394b; + background: $color-surface-hover; + color: $color-text-heading; } } @@ -288,66 +321,13 @@ height: calc(100% - 40px); } -.device-actions { - display: flex; - flex-direction: column; - justify-content: space-between; - height: calc(100% - 40px); - - h3 { - line-height: 1; - margin: 0 0 12px; - padding-bottom: 12px; - border-bottom: 1px solid #e3e8f0; - text-align: left; - color: #30394b; - font-size: 18px; - font-weight: 600; - } - - .actions-button-container { - display: flex; - flex-direction: column; - } - - button { - width: 100%; - padding: 12px; - border: none; - border-radius: 12px; - background: $color-bg; - - display: flex; - align-items: center; - gap: 8px; - - color: $gray50; - font-size: 18px; - font-weight: 550; - cursor: pointer; - transition: .2s ease; - - svg { - height: 20px; - width: 20px; - } - - &:hover { - background-color: $gray20; - } - - &+button { - margin-top: 8px; - } - } -} .device-permissions { padding: 10px 20px; display: flex; flex: 1; flex-direction: column; - justify-content: center; + justify-content: space-between; height: calc(100% - 20px); } @@ -357,8 +337,8 @@ justify-content: space-between; gap: 10px; - color: #30394b; - font-size: 18px; + color: $color-text-heading; + font-size: $font-size-18; font-weight: 500; &:nth-child(1) { @@ -373,7 +353,7 @@ height: 24px; border-radius: 8px; color: $blue; - background: #eef1f6; + background: $color-surface-hover; } } @@ -394,6 +374,11 @@ } .device-battery { + --device-battery-progress-empty: #cfd5df; + --device-battery-progress-danger: #e00000; + --device-battery-progress-warning: #ff8a34; + --device-battery-progress-strong: #031d9a; + h3 { text-align: left; line-height: 1; @@ -401,8 +386,8 @@ padding-bottom: 12px; border-bottom: 1px solid $gray20; - color: #30394b; - font-size: 18px; + color: $color-text-heading; + font-size: $font-size-18; font-weight: 600; } @@ -420,7 +405,7 @@ } } - svg{ + svg { width: 24px; height: 24px; padding: 4px; @@ -447,9 +432,9 @@ .device-battery__circle { position: relative; - width: 110px; - height: 110px; - flex: 0 0 110px; + width: 120px; + height: 120px; + flex: 0 0 120px; display: flex; align-items: center; @@ -466,15 +451,15 @@ justify-content: center; color: $color-text; - font-size: 26px; + font-size: $font-size-26; font-weight: 650; line-height: 1; } } .device-battery__circle-svg { - width: 110px; - height: 110px; + width: 120px; + height: 120px; display: block; } @@ -506,8 +491,8 @@ align-items: center; gap: 10px; - color: #30394b; - font-size: 16px; + color: $color-text-heading; + font-size: $font-size-16; font-weight: 500; } @@ -516,12 +501,12 @@ width: 30px; height: 30px; border-radius: 9px; - color: #31b24a; - background: #eaf8ee; + color: $color-success-strong; + background: $color-success-bg; } b { - color: #30394b; + color: $color-text-heading; font-weight: 600; } } @@ -532,11 +517,11 @@ align-items: center; gap: 10px; - color: #30394b; - font-size: 18px; + color: $color-text-heading; + font-size: $font-size-18; font-weight: 500; - .device-stats-text{ + .device-stats-text { display: flex; flex: 1; flex-direction: row; @@ -551,7 +536,7 @@ height: 24px; border-radius: 9px; color: $green; - background: #eaf8ee; + background: $color-success-bg; } b { @@ -562,7 +547,7 @@ .device-page__empty { min-height: 260px; border-radius: 18px; - background: #ffffff; + background: $color-surface; display: flex; flex-direction: column; @@ -580,7 +565,278 @@ border: none; border-radius: 12px; background: $blue; - color: #ffffff; + color: $color-text-inverse; cursor: pointer; } -} \ No newline at end of file +} + +[data-theme='dark'] { + .device-battery { + --device-battery-progress-empty: #36414f; + --device-battery-progress-danger: #ff4f5e; + --device-battery-progress-warning: #ff9f43; + --device-battery-progress-strong: #2d8cff; + } +} + + +@include desktop-down { + .device-page__grid { + display: grid; + grid-template-columns: 280px 280px minmax(380px, 1fr); + gap: 14px; + align-items: start; + + .device-page__details-row { + gap: 14px; + grid-template-columns: 1.25fr 1fr 1.25fr; + + .device-card-stats { + gap: 14px; + } + } + + .device-actions { + button { + font-size: var(--font-size-16); + padding: 12px; + gap: 12px; + + svg { + width: 28px; + height: 28px; + } + } + + .future-label { + font-size: var(--font-size-12); + padding: 8px 12px; + } + } + } + + .device-permission { + font-size: var(--font-size-16) !important; + + svg { + width: 20px; + height: 20px; + } + + &__label { + padding: 8px 0; + } + } + + .device-permissions__icon-btn { + width: 32px; + height: 32px; + border-radius: 10px; + } + + .device-battery__content { + grid-template-columns: 100px 1fr; + } + + .device-battery__circle { + width: 100px; + height: 100px; + + span { + font-size: var(--font-size-22); + } + } + + .device-stats { + gap: 8px; + + div { + font-size: var(--font-size-15); + } + + .device-stats-card { + svg { + width: 20px; + height: 20px; + } + } + } + + .device-impacts { + font-size: var(--font-size-16); + + svg { + width: 20px; + height: 20px; + } + } + + .device-main { + &__info { + h2 { + font-size: var(--font-size-28); + } + + p { + font-size: var(--font-size-15); + } + } + } + + .device-main__image { + width: 26.5%; + } + + .device-status { + font-size: var(--font-size-16); + + svg { + width: 20px; + height: 20px; + } + } + + .device-registered { + font-size: var(--font-size-16); + + svg { + height: 20px; + width: 20px; + } + + .device-registered-info { + padding: 8px 0; + + a { + svg { + width: 18px; + height: 18px; + } + } + } + } + + .device-card__actions { + margin-top: 18px; + } + + .device-history-btn { + font-size: var(--font-size-16); + } + + .device-delete-btn { + svg { + width: 16px; + height: 16px; + } + } + + .history-period-tabs__item { + font-size: var(--font-size-12); + height: 30px; + } + +} + +@include laptop-down { + .device-page { + gap: 14px; + } + + .device-page__grid { + grid-template-columns: 540px minmax(360px, 1fr);; + } + + .device-card--main { + grid-column: span 1; + } + + .device-page__grid, + .device-page__details-row { + gap: $layout-card-gap; + } + + .device-card { + padding: 18px; + height: calc(100% - 36px); + border-radius: 16px; + } + .device-card-stats{ + height: calc(100%); + } + .device-impacts{ + flex: 0; + } + .device-battery{ + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + } + .device-permissions{ + height: calc(100% - 12px); + padding: 8px 8px 4px 18px; + } + + .device-main { + gap: 16px; + } + + .device-main__image { + padding: 16px; + } + + .device-map { + min-height: 252px; + } + .history-period-tabs{ + display: none; + } + .device-card__title{ + gap: 8px; + svg{ + width: 22px; + height: 22px; + } + h3{ + font-size: var(--font-size-16); + } + } + .device-card__header{ + p{ + font-size: var(--font-size-14); + } + } +} + +@include tablet-down { + .device-main { + flex-direction: column; + } + + .device-main__image { + width: 100%; + height: 178px; + box-sizing: border-box; + } + + .device-main__info { + h2 { + font-size: $font-size-26; + } + } + + .device-map { + grid-column: span 1; + min-height: 300px; + height: auto; + } + + .device-page__details-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .device-card__actions { + gap: 10px; + } +} diff --git a/mdm-front/src/pages/DevicePage/DevicePage.tsx b/mdm-front/src/pages/DevicePage/DevicePage.tsx index afa29dd..29f851d 100644 --- a/mdm-front/src/pages/DevicePage/DevicePage.tsx +++ b/mdm-front/src/pages/DevicePage/DevicePage.tsx @@ -1,13 +1,26 @@ -import { useMemo, useState } from 'react' -import { Link, useNavigate, useParams } from 'react-router-dom' +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { + useNavigate, + useOutletContext, + useParams, + useSearchParams, +} from 'react-router-dom' +import { AnimatePresence, m } from 'framer-motion' import { useQuery } from '@apollo/client/react' -import { DeviceHistoryModal } from './components/DeviceHistoryModal/DeviceHistoryModal' import type { Device as PageDevice } from './types' import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql' import type { Device as ApiDevice, + DeviceNetworkStatus, GetPhoneGpsTrackData, GetTelemetryData, GetDevicePageData, @@ -19,8 +32,27 @@ import { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard' import { DeviceActionsCard } from './components/DeviceActionsCard/DeviceActionsCard' import { DevicePermissionsCard } from './components/DevicePermissionsCard/DevicePermissionsCard' import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards' +import { DeviceAppsView } from './components/DeviceAppsView/DeviceAppsView' +import { pageSwapVariants, uiFadeTransition } from '../../shared/lib/motion' +import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy' +import { EmptyState } from '../../shared/ui/EmptyState/EmptyState' import './DevicePage.scss' +import { DevicePageSkeleton } from './components/DevicePageSkeleton/DevicePageSkeleton' +import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout' +import { + preloadOnIdle, + useLazyMount, +} from '../../shared/lib/lazyMount' + +const loadDeviceHistoryModal = () => + import('./components/DeviceHistoryModal/DeviceHistoryModal').then( + (module) => ({ + default: module.DeviceHistoryModal, + }), + ) + +const DeviceHistoryModal = lazy(loadDeviceHistoryModal) function formatLocationDate(timestamp: number) { if (!timestamp) return 'Нет данных' @@ -48,15 +80,39 @@ function getSortedGpsTrack(track: GetPhoneGpsTrackData['getPhoneGpsTrack']) { return [...track].sort((a, b) => a.date - b.date) } +function getDeviceNetworkConnection(status?: DeviceNetworkStatus) { + if (status === 'Online') return 'online' + if (status === 'Lost') return 'offlineDanger' + + return 'offline' +} + +function getDeviceNetworkLabel(status?: DeviceNetworkStatus) { + if (status === 'Online') return 'В сети' + if (status === 'Lost') return 'Потерян' + + return 'Не в сети' +} + function mapApiDeviceToPageDevice( device: ApiDevice, batteryLevel?: number, gpsTrack: GetPhoneGpsTrackData['getPhoneGpsTrack'] = [], + telemetry: GetTelemetryData['getTelemetry'] = [], ): PageDevice { const sortedGpsTrack = getSortedGpsTrack(gpsTrack) const policy = device.policy const techState = device.techState const needMaintenance = techState?.needMaintenance ?? false + const bluetoothEnabled = isPhonePolicyOptionEnabled( + policy?.bluetooth ?? policy?.canUseBluetooth, + ) + const gpsEnabled = isPhonePolicyOptionEnabled(policy?.GPS ?? policy?.canUseGPS) + const cameraEnabled = isPhonePolicyOptionEnabled( + policy?.camera ?? policy?.canUseCamera, + ) + const simEnabled = isPhonePolicyOptionEnabled(policy?.sim ?? policy?.canUseSim) + const isLocked = policy?.locked ?? false const currentGpsPoint = sortedGpsTrack.length > 0 @@ -97,10 +153,13 @@ function mapApiDeviceToPageDevice( employee: device.org?.name ?? null, organisation: device.org?.name, + organisationId: device.org?.id ?? device.orgId, + policy, + organisationPolicy: device.org?.policy ?? null, condition: needMaintenance ? 'inspection' : 'ok', - connection: location ? 'online' : 'offline', - connectionText: location ? 'В сети' : 'Не в сети', + connection: getDeviceNetworkConnection(device.networkStatus), + connectionText: getDeviceNetworkLabel(device.networkStatus), workTime: null, registeredAt: device.registerDate @@ -121,26 +180,28 @@ function mapApiDeviceToPageDevice( permissions: { wifi: false, - bluetooth: policy?.canUseBluetooth ?? false, - gps: policy?.canUseGPS ?? false, - camera: policy?.canUseCamera ?? false, - sim: policy?.canUseSim ?? false, + bluetooth: bluetoothEnabled, + gps: gpsEnabled, + camera: cameraEnabled, + sim: simEnabled, speaker: false, + locked: isLocked, }, statusIcons: { - gps: policy?.canUseGPS ?? false, + gps: gpsEnabled, wifi: false, - bluetooth: policy?.canUseBluetooth ?? false, - lock: false, - camera: policy?.canUseCamera ?? false, - sim: policy?.canUseSim ?? false, + bluetooth: bluetoothEnabled, + lock: isLocked, + camera: cameraEnabled, + sim: simEnabled, sound: false, kiosk: false, }, battery: techState?.batteryLevel ?? batteryLevel, batteryMaxCapacity: techState?.batteryRemainingCapacity ?? undefined, + telemetry, chargeCycles: formatTechValue(techState?.batteryCycles), totalWorkTime: techState?.worktime ?? null, mediumImpacts: formatTechValue(techState?.hits), @@ -151,103 +212,227 @@ function mapApiDeviceToPageDevice( export function DevicePage() { const { deviceId } = useParams() const navigate = useNavigate() + const { setNavbarBreadcrumbs } = + useOutletContext() + const [searchParams, setSearchParams] = useSearchParams() const [isHistoryOpen, setIsHistoryOpen] = useState(false) + const shouldMountHistoryModal = useLazyMount(isHistoryOpen) const numericDeviceId = Number(deviceId) + const isAppsView = searchParams.get('view') === 'apps' -const { data, loading, error } = useQuery< - GetDevicePageData, - GetDevicePageVariables ->(GET_DEVICE_PAGE_QUERY, { - variables: { - id: numericDeviceId, - phoneId: String(numericDeviceId), - telemetryStartDate: 0, - gpsStartDate: 0, - }, - skip: !numericDeviceId, - fetchPolicy: 'network-only', - pollInterval: 15000, -}) + useEffect(() => { + return preloadOnIdle([loadDeviceHistoryModal]) + }, []) + + const { + data, + loading, + error, + refetch, + startPolling, + stopPolling, + } = useQuery< + GetDevicePageData, + GetDevicePageVariables + >(GET_DEVICE_PAGE_QUERY, { + variables: { + id: numericDeviceId, + phoneId: String(numericDeviceId), + telemetryStartDate: 0, + gpsStartDate: 0, + }, + skip: !numericDeviceId, + fetchPolicy: 'network-only', + pollInterval: 15000, + }) + + const handlePermissionsEditingChange = useCallback( + (isEditing: boolean) => { + if (isEditing) { + stopPolling() + return + } + + if (numericDeviceId) { + startPolling(15000) + } + }, + [numericDeviceId, startPolling, stopPolling], + ) const latestTelemetryItem = useMemo(() => { - return getLatestTelemetryItem(data?.getTelemetry ?? []) -}, [data]) + return getLatestTelemetryItem(data?.getTelemetry ?? []) + }, [data]) -const device = useMemo(() => { - if (!data?.getPhone) return null + const device = useMemo(() => { + if (!data?.getPhone) return null - return mapApiDeviceToPageDevice( - data.getPhone, - latestTelemetryItem?.batteryLevel, - data.getPhoneGpsTrack ?? [], - ) -}, [data, latestTelemetryItem]) + return mapApiDeviceToPageDevice( + data.getPhone, + latestTelemetryItem?.batteryLevel ?? undefined, + data.getPhoneGpsTrack ?? [], + data.getTelemetry ?? [], + ) + }, [data, latestTelemetryItem]) + + useEffect(() => { + if (!device) { + setNavbarBreadcrumbs([]) + return + } + + setNavbarBreadcrumbs([ + { + label: device.factoryNumber, + to: isAppsView ? `/devices/${device.id}` : undefined, + }, + ...(isAppsView ? [{ label: 'Приложения' }] : []), + ]) + + return () => { + setNavbarBreadcrumbs([]) + } + }, [device, isAppsView, setNavbarBreadcrumbs]) + + const handleOpenApps = useCallback(() => { + const nextParams = new URLSearchParams(searchParams) + nextParams.set('view', 'apps') + + setSearchParams(nextParams) + }, [searchParams, setSearchParams]) + + const handleCloseApps = useCallback(() => { + const nextParams = new URLSearchParams(searchParams) + nextParams.delete('view') + + setSearchParams(nextParams, { + replace: true, + }) + }, [searchParams, setSearchParams]) + + const handleOpenHistory = useCallback(() => { + setIsHistoryOpen(true) + }, []) + + const handleRefetchPolicy = useCallback(async () => { + const result = await refetch() + + return result.data?.getPhone?.policy + }, [refetch]) + + const handleLockStateChange = useCallback(async () => { + const result = await refetch() + + return result.data?.getPhone?.policy?.locked + }, [refetch]) if (!numericDeviceId) { return (
-
-

Некорректный ID устройства

- - -
+
) } if (loading && !device) { - return ( -
-
-

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

-
-
- ) + return } if (error || !device) { return (
-
-

Устройство не найдено

- - -
+
) } return (
-
- - Все устройства - + + {isAppsView ? ( + + + + ) : ( + +
+ + - / - {device.factoryNumber} -
- -
- setIsHistoryOpen(true)} - /> - - - - -
- +
+ + + +
+
+ + )} + + {shouldMountHistoryModal && ( + + + + )}
) -} \ No newline at end of file +} diff --git a/mdm-front/src/pages/DevicePage/components/BatteryCircle/BatteryCircle.scss b/mdm-front/src/pages/DevicePage/components/BatteryCircle/BatteryCircle.scss index 6fa683e..c209ea7 100644 --- a/mdm-front/src/pages/DevicePage/components/BatteryCircle/BatteryCircle.scss +++ b/mdm-front/src/pages/DevicePage/components/BatteryCircle/BatteryCircle.scss @@ -1,3 +1,5 @@ +@use '../../../../shared/styles/variables' as *; + .battery-circle { position: relative; width: 180px; @@ -18,7 +20,7 @@ } .battery-circle__track { - stroke: #cfd5df; + stroke: $color-border-accent; } .battery-circle__progress { @@ -35,8 +37,8 @@ align-items: center; justify-content: center; - font-size: 44px; + font-size: $font-size-44; font-weight: 500; line-height: 1; - color: #000; -} \ No newline at end of file + color: $black; +} diff --git a/mdm-front/src/pages/DevicePage/components/DeviceActionsCard/DeviceActionsCard.scss b/mdm-front/src/pages/DevicePage/components/DeviceActionsCard/DeviceActionsCard.scss new file mode 100644 index 0000000..f268ceb --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/DeviceActionsCard/DeviceActionsCard.scss @@ -0,0 +1,235 @@ +@use '../../../../shared/styles/variables' as *; + + +.device-actions { + display: flex; + flex-direction: column; + justify-content: space-between; + height: calc(100% - 40px); + + h3 { + line-height: 1; + margin: 0 0 12px; + padding-bottom: 12px; + border-bottom: 1px solid $color-border; + text-align: left; + color: $color-text-heading; + font-size: $font-size-18; + font-weight: 600; + } + + .actions-button-container { + display: flex; + flex-direction: column; + } + + button { + display: flex; + flex-direction: row; + gap: 14px; + align-items: center; + justify-content: flex-start; + width: 100%; + padding: 14px; + border: none; + border-radius: 12px; + background: $color-bg; + + display: flex; + align-items: center; + + color: $gray50; + font-size: $font-size-18; + font-weight: 550; + cursor: pointer; + transition: .2s ease; + small{ + font-weight: 500; + opacity: .8; + } + .actions-button-text{ + display: flex; + flex-direction: column; + align-items: flex-start; + } + + svg { + height: 32px; + width: 32px; + } + + &:not(:disabled):hover { + background-color: rgba($gray20-rgb, .9); + //color: $blue; + box-shadow: inset 0 0 0 1.5px rgba($gray50-rgb, .1); + } + + &:disabled { + opacity: .74; + cursor: not-allowed; + + &:hover { + background: $color-bg; + color: $gray50; + } + } + + &+button { + margin-top: 8px; + } + } + + .device-actions__lock-button { + overflow: hidden; + + .device-actions__lock-content { + position: relative; + display: grid; + width: 100%; + min-height: 40px; + min-width: 0; + overflow: hidden; + } + + .device-actions__lock-state { + grid-area: 1 / 1; + display: flex; + align-items: center; + gap: 14px; + width: 100%; + min-height: 40px; + min-width: 0; + } + + .device-actions__lock-icon { + flex: 0 0 32px; + transition: + color 0.2s ease, + transform 0.24s cubic-bezier(0.34, 1.42, 0.64, 1); + } + + &.is-loading { + opacity: 1; + cursor: wait; + + .device-actions__loading-icon { + color: $blue; + transform: none; + transform-box: fill-box; + transform-origin: center; + backface-visibility: hidden; + transition: color 0.2s ease; + will-change: transform; + animation: device-actions-loading-spin 0.85s linear infinite; + } + + &:disabled:hover { + background: $color-bg; + color: $gray50; + } + } + + &.is-lock:not(.is-loading) { + &:not(:disabled):hover { + .device-actions__lock-icon { + color: $red; + transform: translateY(-1px) scale(1.06); + } + } + } + + &.is-unlock:not(.is-loading) { + &:not(:disabled):hover { + .device-actions__lock-icon { + color: $blue; + transform: rotate(-8deg) translateY(-1px) scale(1.06); + } + } + } + } + + .device-actions__error { + margin-top: 10px; + padding: 7px 10px; + border-radius: 10px; + background: $color-danger-tint; + + color: $red; + font-size: $font-size-13; + font-weight: 600; + line-height: 1.25; + } + + .future-label{ + display: flex; + flex-direction: row; + align-items: center; + font-size: $font-size-14; + font-weight: 550; + padding: 10px 14px; + margin-top: 20px; + gap: 14px; + color: $blue; + background-color: $blue20; + border-radius: 14px; + svg{ + height: 20px; + width: auto; + } + } +} + +.device-actions__apps-icon { + transition: color 0.2s ease; + + rect { + transform-box: fill-box; + transform-origin: center; + transition: transform 0.22s ease; + } +} + +.device-actions__apps-button:not(:disabled):hover { + .device-actions__apps-icon { + color: $blue; + + rect:nth-child(2) { + --tile-pop-x: 1.7px; + --tile-pop-y: -1.7px; + --tile-rest-x: 0.8px; + --tile-rest-y: -0.8px; + animation: device-actions-app-tile 0.48s cubic-bezier(0.34, 1.42, 0.64, 1); + transform: translate(var(--tile-rest-x), var(--tile-rest-y)) scale(1); + } + } +} + +@keyframes device-actions-app-tile { + 0% { + transform: translate(0, 0) scale(1); + } + + 45% { + transform: translate(var(--tile-pop-x), var(--tile-pop-y)) scale(0.86); + } + + 100% { + transform: translate(var(--tile-rest-x, 0), var(--tile-rest-y, 0)) scale(1); + } +} + +@keyframes device-actions-loading-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .device-actions { + .device-actions__lock-button.is-loading { + .device-actions__loading-icon { + animation: none; + } + } + } +} diff --git a/mdm-front/src/pages/DevicePage/components/DeviceActionsCard/DeviceActionsCard.tsx b/mdm-front/src/pages/DevicePage/components/DeviceActionsCard/DeviceActionsCard.tsx index ec316d1..a2dc5f4 100644 --- a/mdm-front/src/pages/DevicePage/components/DeviceActionsCard/DeviceActionsCard.tsx +++ b/mdm-front/src/pages/DevicePage/components/DeviceActionsCard/DeviceActionsCard.tsx @@ -1,34 +1,272 @@ -import { BlockIcon } from '../../../../assets/icons/Block' -import { KioskIcon } from '../../../../assets/icons/Kiosk' -import { RebootIcon } from '../../../../assets/icons/Reboot' -import { MessageIcon } from '../../../../assets/icons/Message' +import { memo, useEffect, useState } from 'react' +import { AnimatePresence, m, useReducedMotion } from 'framer-motion' +import { useMutation } from '@apollo/client/react' +import { CircleAlert, Unlock } from 'lucide-react' + +import { Apps } from '../../../../assets/icons/Apps' +import { BlockIcon } from '../../../../assets/icons/Block' +import { LoadingTwotoneLoopIcon } from '../../../../assets/icons/LoadingTwotoneLoop' +import type { Device } from '../../types' +import { + CHANGE_PHONE_POLICY_MUTATION, +} from '../../../../entities/device/api/device.graphql' +import type { + ChangePhonePolicyData, + ChangePhonePolicyVariables, +} from '../../../../entities/device/model/types' +import { + normalizeComponentUseOption, + normalizeServiceUseOption, +} from '../../../../entities/device/lib/phonePolicy' + +import './DeviceActionsCard.scss' + +type DeviceActionsCardProps = { + device: Device + onOpenApps: () => void + onLockStateChange?: () => Promise | boolean | null | void +} + +function getChangeLockVariables( + device: Device, + locked: boolean, +): ChangePhonePolicyVariables { + const policy = device.policy + const permissions = device.permissions + + return { + id: String(device.id), + bluetooth: normalizeServiceUseOption( + policy?.bluetooth ?? policy?.canUseBluetooth, + permissions?.bluetooth, + ), + camera: normalizeComponentUseOption( + policy?.camera ?? policy?.canUseCamera, + permissions?.camera, + ), + GPS: normalizeServiceUseOption( + policy?.GPS ?? policy?.canUseGPS, + permissions?.gps, + ), + sim: normalizeServiceUseOption( + policy?.sim ?? policy?.canUseSim, + permissions?.sim, + ), + locked, + } +} + +function areDeviceActionsCardPropsEqual( + prev: DeviceActionsCardProps, + next: DeviceActionsCardProps, +) { + const prevPolicy = prev.device.policy + const nextPolicy = next.device.policy + const prevPermissions = prev.device.permissions + const nextPermissions = next.device.permissions + + return ( + prev.onOpenApps === next.onOpenApps && + prev.onLockStateChange === next.onLockStateChange && + prev.device.id === next.device.id && + prev.device.connection === next.device.connection && + prevPolicy?.bluetooth === nextPolicy?.bluetooth && + prevPolicy?.camera === nextPolicy?.camera && + prevPolicy?.GPS === nextPolicy?.GPS && + prevPolicy?.sim === nextPolicy?.sim && + prevPolicy?.locked === nextPolicy?.locked && + prevPermissions?.bluetooth === nextPermissions?.bluetooth && + prevPermissions?.camera === nextPermissions?.camera && + prevPermissions?.gps === nextPermissions?.gps && + prevPermissions?.sim === nextPermissions?.sim && + prevPermissions?.locked === nextPermissions?.locked + ) +} + +export const DeviceActionsCard = memo(function DeviceActionsCard({ + device, + onOpenApps, + onLockStateChange, +}: DeviceActionsCardProps) { + const deviceLocked = device.permissions?.locked ?? device.policy?.locked ?? false + const isLost = device.connection === 'offlineDanger' + const [displayLocked, setDisplayLocked] = useState(deviceLocked) + const [pendingLockTarget, setPendingLockTarget] = useState(null) + const [lockError, setLockError] = useState(false) + const [changePhonePolicy] = useMutation< + ChangePhonePolicyData, + ChangePhonePolicyVariables + >(CHANGE_PHONE_POLICY_MUTATION) + const shouldReduceMotion = useReducedMotion() + const isLockLoading = pendingLockTarget !== null + const lockStateTransition = shouldReduceMotion + ? { duration: 0 } + : { + duration: 0.18, + ease: [0.34, 1.42, 0.64, 1] as [number, number, number, number], + } + const lockLoadingTarget = pendingLockTarget ?? !displayLocked + const lockButtonView = isLockLoading + ? { + key: 'loading', + icon: ( +