diff --git a/mdm-front/.env b/mdm-front/.env index 797265c..acd2c7a 100644 --- a/mdm-front/.env +++ b/mdm-front/.env @@ -1,2 +1,3 @@ VITE_GRAPHQL_API_URL=/graphql -VITE_MAPTILER_KEY=IorSzMRqcNUCYzcXZhi6 \ No newline at end of file +VITE_MAPTILER_KEY=IorSzMRqcNUCYzcXZhi6 +VITE_AUTH_ENABLED=true \ No newline at end of file diff --git a/mdm-front/package-lock.json b/mdm-front/package-lock.json index f65f4fc..25da266 100644 --- a/mdm-front/package-lock.json +++ b/mdm-front/package-lock.json @@ -22,6 +22,7 @@ "graphql": "^16.13.2", "leaflet": "^1.9.4", "lucide-react": "^1.9.0", + "qrcode.react": "^4.2.0", "react": "^19.2.5", "react-day-picker": "^9.14.0", "react-dom": "^19.2.5", @@ -3789,6 +3790,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", diff --git a/mdm-front/package.json b/mdm-front/package.json index 54895d3..1137ac7 100644 --- a/mdm-front/package.json +++ b/mdm-front/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host 192.168.1.181", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" @@ -24,6 +24,7 @@ "graphql": "^16.13.2", "leaflet": "^1.9.4", "lucide-react": "^1.9.0", + "qrcode.react": "^4.2.0", "react": "^19.2.5", "react-day-picker": "^9.14.0", "react-dom": "^19.2.5", diff --git a/mdm-front/src/app/router/router.tsx b/mdm-front/src/app/router/router.tsx index 5016963..f1ba201 100644 --- a/mdm-front/src/app/router/router.tsx +++ b/mdm-front/src/app/router/router.tsx @@ -6,6 +6,7 @@ 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' export const router = createBrowserRouter([ { @@ -32,6 +33,10 @@ export const router = createBrowserRouter([ path: 'employees', element: , }, + { + path: 'employees/organisations/:organisationId', + element: , + }, ], }, ]) \ No newline at end of file diff --git a/mdm-front/src/assets/icons/Block.tsx b/mdm-front/src/assets/icons/Block.tsx index 6391b7a..adeda89 100644 --- a/mdm-front/src/assets/icons/Block.tsx +++ b/mdm-front/src/assets/icons/Block.tsx @@ -1,6 +1,10 @@ -export function BlockIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function BlockIcon({ className, ...props }: IconProps) { return ( - + ) diff --git a/mdm-front/src/assets/icons/Bluetooth.tsx b/mdm-front/src/assets/icons/Bluetooth.tsx index ae023e0..98f222a 100644 --- a/mdm-front/src/assets/icons/Bluetooth.tsx +++ b/mdm-front/src/assets/icons/Bluetooth.tsx @@ -1,6 +1,10 @@ -export function BluetoothIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function BluetoothIcon({ className, ...props }: IconProps) { return ( - + ) diff --git a/mdm-front/src/assets/icons/Camera.tsx b/mdm-front/src/assets/icons/Camera.tsx index 0a8b8b5..9d5b1f9 100644 --- a/mdm-front/src/assets/icons/Camera.tsx +++ b/mdm-front/src/assets/icons/Camera.tsx @@ -1,6 +1,10 @@ -export function CameraIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function CameraIcon({ className, ...props }: IconProps) { return ( - + ) diff --git a/mdm-front/src/assets/icons/Gps.tsx b/mdm-front/src/assets/icons/Gps.tsx index af5b0fd..0537a18 100644 --- a/mdm-front/src/assets/icons/Gps.tsx +++ b/mdm-front/src/assets/icons/Gps.tsx @@ -1,6 +1,10 @@ -export function GpsIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function GpsIcon({ className, ...props }: IconProps) { return ( - + diff --git a/mdm-front/src/assets/icons/Kiosk.tsx b/mdm-front/src/assets/icons/Kiosk.tsx index 69ce92b..b92361f 100644 --- a/mdm-front/src/assets/icons/Kiosk.tsx +++ b/mdm-front/src/assets/icons/Kiosk.tsx @@ -1,6 +1,10 @@ -export function KioskIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function KioskIcon({ className, ...props }: IconProps) { return ( - + ) diff --git a/mdm-front/src/assets/icons/Message.tsx b/mdm-front/src/assets/icons/Message.tsx index 2ee397f..e774849 100644 --- a/mdm-front/src/assets/icons/Message.tsx +++ b/mdm-front/src/assets/icons/Message.tsx @@ -1,6 +1,10 @@ -export function MessageIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function MessageIcon({ className, ...props }: IconProps) { return ( - + ) diff --git a/mdm-front/src/assets/icons/Reboot.tsx b/mdm-front/src/assets/icons/Reboot.tsx index 58a06fb..1fc624a 100644 --- a/mdm-front/src/assets/icons/Reboot.tsx +++ b/mdm-front/src/assets/icons/Reboot.tsx @@ -1,6 +1,10 @@ -export function RebootIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function RebootIcon({ className, ...props }: IconProps) { return ( - + ) diff --git a/mdm-front/src/assets/icons/Sim.tsx b/mdm-front/src/assets/icons/Sim.tsx index a731aa1..5c36140 100644 --- a/mdm-front/src/assets/icons/Sim.tsx +++ b/mdm-front/src/assets/icons/Sim.tsx @@ -1,8 +1,12 @@ -export function SimIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function SimIcon({ className, ...props }: IconProps) { return ( - - - - + + + + ) } \ No newline at end of file diff --git a/mdm-front/src/assets/icons/Volume.tsx b/mdm-front/src/assets/icons/Volume.tsx index e724e74..86c0e0d 100644 --- a/mdm-front/src/assets/icons/Volume.tsx +++ b/mdm-front/src/assets/icons/Volume.tsx @@ -1,6 +1,10 @@ -export function VolumeIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function VolumeIcon({ className, ...props }: IconProps) { return ( - + diff --git a/mdm-front/src/assets/icons/Wifi.tsx b/mdm-front/src/assets/icons/Wifi.tsx index 04eee34..ac4a75f 100644 --- a/mdm-front/src/assets/icons/Wifi.tsx +++ b/mdm-front/src/assets/icons/Wifi.tsx @@ -1,6 +1,10 @@ -export function WifiIcon() { +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +export function WifiIcon({ className, ...props }: IconProps) { return ( - + ) diff --git a/mdm-front/src/entities/device/api/device.graphql.ts b/mdm-front/src/entities/device/api/device.graphql.ts index 0e2bde5..19fe3a1 100644 --- a/mdm-front/src/entities/device/api/device.graphql.ts +++ b/mdm-front/src/entities/device/api/device.graphql.ts @@ -8,6 +8,32 @@ export const GET_PHONES_PAGE_QUERY = gql` imei imei2 serial + orgId + registerDate + + org { + id + name + creationDate + policy { + canUseBluetooth + canUseGPS + canUseCamera + canUseSim + } + } + + policy { + canUseBluetooth + canUseCamera + canUseGPS + canUseSim + } + + techState { + needMaintenance + } + lastLocation { alt date @@ -15,6 +41,7 @@ export const GET_PHONES_PAGE_QUERY = gql` lng } } + nextKey } } @@ -26,13 +53,181 @@ export const GET_PHONE_QUERY = gql` id imei imei2 - serial + lastLocation { alt date lat lng } + + org { + creationDate + id + name + policy { + canUseBluetooth + canUseCamera + canUseGPS + canUseSim + } + } + + orgId + + policy { + canUseBluetooth + canUseCamera + canUseGPS + canUseSim + } + + registerDate + serial + + techState { + batteryCycles + batteryLevel + batteryRemainingCapacity + hits + needMaintenance + overheats + worktime + } } } +` + +export const GET_PHONE_GPS_TRACK_QUERY = gql` + query GetPhoneGpsTrack($phoneId: ID!, $startDate: Float!, $endDate: Float) { + getPhoneGpsTrack(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) { + alt + date + lat + lng + } + } +` + +export const GET_TELEMETRY_QUERY = gql` + query GetTelemetry($phoneId: Int!, $startDate: Float!, $endDate: Float) { + getTelemetry(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) { + batteryLevel + chargeType + date + screenOn + temperature + } + } +` + +export const GET_DEVICE_PAGE_QUERY = gql` + query GetDevicePage( + $id: Int! + $phoneId: ID! + $telemetryStartDate: Float! + $telemetryEndDate: Float + $gpsStartDate: Float! + $gpsEndDate: Float + ) { + getPhone(id: $id) { + id + imei + imei2 + + lastLocation { + alt + date + lat + lng + } + + org { + creationDate + id + name + policy { + canUseBluetooth + canUseCamera + canUseGPS + canUseSim + } + } + + orgId + + policy { + canUseBluetooth + canUseCamera + canUseGPS + canUseSim + } + + registerDate + serial + + techState { + batteryCycles + batteryLevel + batteryRemainingCapacity + hits + needMaintenance + overheats + worktime + } +} + + getTelemetry( + phoneId: $id + startDate: $telemetryStartDate + endDate: $telemetryEndDate + ) { + batteryLevel + chargeType + date + screenOn + temperature + } + + getPhoneGpsTrack( + phoneId: $phoneId + startDate: $gpsStartDate + endDate: $gpsEndDate + ) { + alt + date + lat + lng + } + } +` + +export const CREATE_PHONE_REGISTRATION_TOKEN_MUTATION = gql` + mutation CreatePhoneRegistrationToken { + createPhoneRegistrationToken { + address + expiresIn + token + } + } +` + +export const CHANGE_PHONE_POLICY_MUTATION = gql` + mutation ChangePhonePolicy( + $id: ID! + $canUseBluetooth: Boolean! + $canUseCamera: Boolean! + $canUseGPS: Boolean! + $canUseSim: Boolean! + ) { + changePhonePolicy( + id: $id + policy: { + canUseBluetooth: $canUseBluetooth + canUseCamera: $canUseCamera + canUseGPS: $canUseGPS + canUseSim: $canUseSim + } + ) + } ` \ No newline at end of file diff --git a/mdm-front/src/entities/device/model/types.ts b/mdm-front/src/entities/device/model/types.ts index 9f715cb..c7ba97a 100644 --- a/mdm-front/src/entities/device/model/types.ts +++ b/mdm-front/src/entities/device/model/types.ts @@ -5,11 +5,40 @@ export type DeviceLocation = { lng: number } +export type DevicePolicy = { + canUseBluetooth: boolean + canUseCamera: boolean + canUseGPS: boolean + canUseSim: boolean +} + +export type DeviceTechState = { + batteryCycles: number | null + batteryLevel: number | null + batteryRemainingCapacity: number | null + hits: number | null + needMaintenance: boolean + overheats: number | null + worktime: number | null +} + +export type DeviceOrganisation = { + creationDate?: number + id: number + name: string + policy?: DevicePolicy | null +} + export type Device = { id: number imei: string imei2: string serial: string + orgId: number + registerDate: number + org: DeviceOrganisation | null + policy: DevicePolicy | null + techState: DeviceTechState | null lastLocation: DeviceLocation | null } @@ -30,4 +59,83 @@ export type GetPhoneData = { export type GetPhoneVariables = { id: number -} \ No newline at end of file +} + +export type DeviceChargeType = + | 'USB' + | 'Wireless' + | 'AC' + | 'Disconnected' + | string + +export type DeviceTelemetryItem = { + batteryLevel: number + chargeType: DeviceChargeType + date: number + screenOn: boolean + temperature: number +} + +export type GetTelemetryData = { + getTelemetry: DeviceTelemetryItem[] +} + +export type GetTelemetryVariables = { + phoneId: number + startDate: number + endDate?: number +} + +export type DeviceGpsTrackPoint = { + alt: number + date: number + lat: number + lng: number +} + +export type GetPhoneGpsTrackData = { + getPhoneGpsTrack: DeviceGpsTrackPoint[] +} + +export type GetPhoneGpsTrackVariables = { + phoneId: string + startDate: number + endDate?: number +} + +export type GetDevicePageData = { + getPhone: Device | null + getTelemetry: DeviceTelemetryItem[] + getPhoneGpsTrack: DeviceGpsTrackPoint[] +} + +export type GetDevicePageVariables = { + id: number + phoneId: string + telemetryStartDate: number + telemetryEndDate?: number + gpsStartDate: number + gpsEndDate?: number +} + +export type ChangePhonePolicyData = { + changePhonePolicy: boolean +} + +export type ChangePhonePolicyVariables = { + id: string + canUseBluetooth: boolean + canUseCamera: boolean + canUseGPS: boolean + canUseSim: boolean +} + +export type CreatePhoneRegistrationTokenData = { + createPhoneRegistrationToken: { + address: string + expiresIn: number + token: string + } +} + +export type CreatePhoneRegistrationTokenVariables = Record \ No newline at end of file diff --git a/mdm-front/src/entities/employee/api/employee.graphql.ts b/mdm-front/src/entities/employee/api/employee.graphql.ts index 5d1ecfb..bf901e3 100644 --- a/mdm-front/src/entities/employee/api/employee.graphql.ts +++ b/mdm-front/src/entities/employee/api/employee.graphql.ts @@ -5,6 +5,14 @@ export const GET_USERS_PAGE_QUERY = gql` getUsersPage(key: $key) { page { id + firstName + lastName + middleName + org { + id + name + } + orgId role } nextKey @@ -12,23 +20,133 @@ export const GET_USERS_PAGE_QUERY = gql` } ` -export const SIGN_UP_MUTATION = gql` - mutation SignUp( - $organisationId: ID! - $username: String! - $password: String! - $groupId: ID +export const GET_ORGANISATION_QUERY = gql` + query GetOrganisation($id: ID!) { + getOrganisation(id: $id) { + creationDate + id + name + policy { + canUseBluetooth + canUseCamera + canUseGPS + canUseSim + } + } + } +` + +export const GET_ORGANISATIONS_QUERY = gql` + query GetOrganisations( + $page: Int! + $query: String + $sortDirection: SortDirection! + $sortField: OrganisationSortField! ) { - signUp( - payload: { - organisationId: $organisationId - username: $username - password: $password - groupId: $groupId + getOrganisations( + page: $page + query: $query + sortDirection: $sortDirection + sortField: $sortField + ) { + totalPages + totalElements + page { + creationDate + id + name + policy { + canUseBluetooth + canUseCamera + canUseGPS + canUseSim + date + } + } + } + } +` + +export const CREATE_ORGANISATION_MUTATION = gql` + mutation CreateOrganisation( + $name: String! + $canUseBluetooth: Boolean! + $canUseCamera: Boolean! + $canUseGPS: Boolean! + $canUseSim: Boolean! + ) { + createOrganisation( + name: $name + policy: { + canUseBluetooth: $canUseBluetooth + canUseCamera: $canUseCamera + canUseGPS: $canUseGPS + canUseSim: $canUseSim } ) { id + name + } + } +` + +export const CHANGE_ORGANISATION_MUTATION = gql` + mutation ChangeOrganisation( + $id: ID! + $name: String! + $canUseBluetooth: Boolean! + $canUseCamera: Boolean! + $canUseGPS: Boolean! + $canUseSim: Boolean! + ) { + changeOrganisation( + id: $id + name: $name + policy: { + canUseBluetooth: $canUseBluetooth + canUseCamera: $canUseCamera + canUseGPS: $canUseGPS + canUseSim: $canUseSim + } + ) { + creationDate + id + name + } + } +` + +export const SIGN_UP_MUTATION = gql` + mutation createUser( + $orgId: ID! + $firstName: String! + $lastName: String! + $middleName: String! + $username: String! + $password: String! + $role: Role! + ) { + createUser( + payload: { + orgId: $orgId + firstName: $firstName + lastName: $lastName + middleName: $middleName + username: $username + password: $password + role: $role + } + ) { + id + firstName + lastName + middleName + orgId role + org { + id + name + } } } ` \ 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 index dcd4d3a..a2b6f73 100644 --- a/mdm-front/src/entities/employee/model/types.ts +++ b/mdm-front/src/entities/employee/model/types.ts @@ -1,29 +1,123 @@ +export type EmployeeRole = 'User' | 'Admin' | string + export type Employee = { + id: number + firstName: string + lastName: string + middleName: string + orgId: number + role: EmployeeRole + org: { id: number - role: string + name: string + } | null +} + +export type OrganisationPolicy = { + canUseBluetooth: boolean + canUseCamera: boolean + canUseGPS: boolean + canUseSim: boolean + date?: number +} + +export type Organisation = { + creationDate?: number + id: number + name: string + policy?: OrganisationPolicy | null +} + +export type GetOrganisationData = { + getOrganisation: Organisation | null +} + +export type GetOrganisationVariables = { + id: string +} + +export type ChangeOrganisationData = { + changeOrganisation: { + id: number + name: string + } +} + +export type ChangeOrganisationVariables = { + id: string + name: string + canUseBluetooth: boolean + canUseCamera: boolean + canUseGPS: boolean + canUseSim: boolean } export type GetUsersPageData = { - getUsersPage: { - page: Employee[] - nextKey: string | null - } + getUsersPage: { + page: Employee[] + nextKey: string | null + } } export type GetUsersPageVariables = { - key?: string + key?: string +} + +export type GetOrganisationsData = { + getOrganisations: { + totalPages: number + totalElements: number + page: Organisation[] + } +} + +export type GetOrganisationsVariables = { + page: number + query?: string + sortDirection: OrganisationSortDirection + sortField: OrganisationSortField } export type SignUpData = { - signUp: { - id: number - role: string - } + signUp: { + id: number + firstName: string + lastName: string + middleName: string + orgId: number + role: EmployeeRole + org: { + id: number + name: string + } | null + } } export type SignUpVariables = { - organisationId: string + orgId: string + firstName: string + lastName: string + middleName: string username: string password: string - groupId?: string | null -} \ No newline at end of file + role: EmployeeRole +} + +export type CreateOrganisationData = { + createOrganisation: { + id: number + name: string + } +} + +export type CreateOrganisationVariables = { + name: string + canUseBluetooth: boolean + canUseCamera: boolean + canUseGPS: boolean + canUseSim: boolean +} + +export type OrganisationSortDirection = 'ASC' | 'DESC' + +export type OrganisationSortField = 'ID' | 'Name' | 'Date' \ No newline at end of file diff --git a/mdm-front/src/features/auth/api/auth.graphql.ts b/mdm-front/src/features/auth/api/auth.graphql.ts index 1a7f1ca..9ed7acc 100644 --- a/mdm-front/src/features/auth/api/auth.graphql.ts +++ b/mdm-front/src/features/auth/api/auth.graphql.ts @@ -13,7 +13,15 @@ export const REFRESH_SESSION_MUTATION = gql` mutation RefreshSession { refreshSession { id + firstName + lastName + middleName + orgId role + org { + id + name + } } } ` diff --git a/mdm-front/src/features/auth/ui/AuthGate.tsx b/mdm-front/src/features/auth/ui/AuthGate.tsx index cc749f4..1f037d8 100644 --- a/mdm-front/src/features/auth/ui/AuthGate.tsx +++ b/mdm-front/src/features/auth/ui/AuthGate.tsx @@ -1,8 +1,11 @@ import { useEffect, useState } from 'react' import type { ReactNode } from 'react' -import { useQuery } from '@apollo/client/react' +import { useMutation, useQuery } from '@apollo/client/react' -import { CURRENT_USER_QUERY } from '../api/auth.graphql' +import { + CURRENT_USER_QUERY, + REFRESH_SESSION_MUTATION, +} from '../api/auth.graphql' import { LoginPage } from '../../../pages/LoginPage/LoginPage' type CurrentUser = { @@ -14,12 +17,17 @@ type CurrentUserQueryData = { currentUser: CurrentUser | null } +type RefreshSessionData = { + refreshSession: CurrentUser | null +} + type AuthGateProps = { children: ReactNode } export function AuthGate({ children }: AuthGateProps) { const [isForcedLogout, setIsForcedLogout] = useState(false) + const [isRefreshFailed, setIsRefreshFailed] = useState(false) const { data, loading, error, refetch } = useQuery( CURRENT_USER_QUERY, @@ -29,9 +37,26 @@ export function AuthGate({ children }: AuthGateProps) { }, ) + const [refreshSession, { loading: isRefreshing }] = + useMutation(REFRESH_SESSION_MUTATION, { + onCompleted: async (result) => { + if (!result.refreshSession) { + setIsRefreshFailed(true) + return + } + + setIsRefreshFailed(false) + await refetch() + }, + onError: () => { + setIsRefreshFailed(true) + }, + }) + useEffect(() => { function handleLogout() { setIsForcedLogout(true) + setIsRefreshFailed(true) } window.addEventListener('auth:logout', handleLogout) @@ -41,30 +66,36 @@ export function AuthGate({ children }: AuthGateProps) { } }, []) - if (isForcedLogout) { + useEffect(() => { + if (isForcedLogout) return + if (!error) return + if (isRefreshFailed) return + + refreshSession() + }, [error, isForcedLogout, isRefreshFailed, refreshSession]) + + if (isForcedLogout || isRefreshFailed) { return ( { setIsForcedLogout(false) + setIsRefreshFailed(false) refetch() }} /> ) } - if (loading) { - return ( -
- Проверка авторизации... -
- ) + if (loading || isRefreshing) { + return
Проверка авторизации...
} - if (error || !data?.currentUser) { + if (!data?.currentUser) { return ( { setIsForcedLogout(false) + setIsRefreshFailed(false) refetch() }} /> diff --git a/mdm-front/src/main.tsx b/mdm-front/src/main.tsx index e6edff4..165aed1 100644 --- a/mdm-front/src/main.tsx +++ b/mdm-front/src/main.tsx @@ -8,15 +8,21 @@ 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' + +const isAuthEnabled = import.meta.env.VITE_AUTH_ENABLED === 'true' + +const app = isAuthEnabled ? ( + + + +) : ( + +) createRoot(document.getElementById('root')!).render( - - - + {app} , ) diff --git a/mdm-front/src/pages/DevicePage/DevicePage.scss b/mdm-front/src/pages/DevicePage/DevicePage.scss index 0600307..c481edd 100644 --- a/mdm-front/src/pages/DevicePage/DevicePage.scss +++ b/mdm-front/src/pages/DevicePage/DevicePage.scss @@ -271,9 +271,9 @@ } svg { - padding: 5px; - width: 30px; - height: 30px; + padding: 4px; + width: 24px; + height: 24px; border-radius: 9px; background: #eef1f6; color: #30394b; @@ -387,33 +387,6 @@ border-top: 1px solid $gray20; } -.device-permission__switch { - width: 36px; - height: 20px; - padding: 2px; - border-radius: 999px; - background: #d6dce8; - - display: inline-flex; - align-items: center; - - span { - width: 16px; - height: 16px; - border-radius: 50%; - background: #ffffff; - transition: transform 0.2s ease; - } - - &.is-enabled { - background: $blue; - - span { - transform: translateX(16px); - } - } -} - .device-card-stats { display: flex; flex-direction: column; @@ -472,24 +445,56 @@ } .device-battery__circle { - width: 96px; - height: 96px; - border-radius: 50%; - background: - radial-gradient(circle at center, #ffffff 58%, transparent 60%), - conic-gradient($blue 0 65%, #d6dce8 65% 100%); + position: relative; + + width: 110px; + height: 110px; + flex: 0 0 110px; display: flex; align-items: center; justify-content: center; + overflow: visible; + span { - color: #111827; - font-size: 24px; - font-weight: 600; + position: absolute; + inset: 0; + + display: flex; + align-items: center; + justify-content: center; + + color: $color-text; + font-size: 26px; + font-weight: 650; + line-height: 1; } } +.device-battery__circle-svg { + width: 110px; + height: 110px; + display: block; +} + +.device-battery__circle-track, +.device-battery__circle-progress { + fill: none; +} + +.device-battery__circle-track { + stroke: $gray20; + stroke-linecap: round; +} + +.device-battery__circle-progress { + stroke-linecap: round; + transition: + stroke-dashoffset 0.35s ease, + stroke 0.25s ease; +} + .device-stats { display: flex; flex-direction: column; diff --git a/mdm-front/src/pages/DevicePage/DevicePage.tsx b/mdm-front/src/pages/DevicePage/DevicePage.tsx index 0635b29..afa29dd 100644 --- a/mdm-front/src/pages/DevicePage/DevicePage.tsx +++ b/mdm-front/src/pages/DevicePage/DevicePage.tsx @@ -1,14 +1,17 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { useQuery } from '@apollo/client/react' +import { DeviceHistoryModal } from './components/DeviceHistoryModal/DeviceHistoryModal' import type { Device as PageDevice } from './types' -import { GET_PHONE_QUERY } from '../../entities/device/api/device.graphql' +import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql' import type { - GetPhoneData, - GetPhoneVariables, Device as ApiDevice, + GetPhoneGpsTrackData, + GetTelemetryData, + GetDevicePageData, + GetDevicePageVariables, } from '../../entities/device/model/types' import { DeviceMainCard } from './components/DeviceMainCard/DeviceMainCard' @@ -19,7 +22,7 @@ import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards import './DevicePage.scss' -function formatLocationDate(timestamp?: number) { +function formatLocationDate(timestamp: number) { if (!timestamp) return 'Нет данных' return new Intl.DateTimeFormat('ru-RU', { @@ -28,104 +31,160 @@ function formatLocationDate(timestamp?: number) { year: 'numeric', hour: '2-digit', minute: '2-digit', - }).format(new Date(timestamp * 1000)) + }).format(new Date(timestamp)) } -function mapApiDeviceToPageDevice(device: ApiDevice): PageDevice { - const location = device.lastLocation +function formatTechValue(value?: number | null) { + return typeof value === 'number' ? String(value) : undefined +} + +function getLatestTelemetryItem(telemetry: GetTelemetryData['getTelemetry']) { + if (!telemetry.length) return null + + return [...telemetry].sort((a, b) => b.date - a.date)[0] +} + +function getSortedGpsTrack(track: GetPhoneGpsTrackData['getPhoneGpsTrack']) { + return [...track].sort((a, b) => a.date - b.date) +} + +function mapApiDeviceToPageDevice( + device: ApiDevice, + batteryLevel?: number, + gpsTrack: GetPhoneGpsTrackData['getPhoneGpsTrack'] = [], +): PageDevice { + const sortedGpsTrack = getSortedGpsTrack(gpsTrack) + const policy = device.policy + const techState = device.techState + const needMaintenance = techState?.needMaintenance ?? false + + const currentGpsPoint = + sortedGpsTrack.length > 0 + ? sortedGpsTrack[sortedGpsTrack.length - 1] + : null + + const routeGpsPoints = + sortedGpsTrack.length > 1 + ? sortedGpsTrack.slice(0, -1) + : [] + + const location = currentGpsPoint ? { - lat: device.lastLocation.lat, - lng: device.lastLocation.lng, + lat: currentGpsPoint.lat, + lng: currentGpsPoint.lng, } - : undefined - - return { - id: device.id, - - model: 'АРМАФОН S3.3+', - factoryNumber: device.serial || 'Заводской номер не указан', - imei: device.imei || 'IMEI не указан', - imei2: device.imei2 || 'IMEI 2 не указан', - serialNumber: device.serial || undefined, - - employee: null, - - condition: 'ok', - connection: device.lastLocation ? 'online' : 'offline', - connectionText: device.lastLocation ? 'В сети' : 'Нет геопозиции', - - workTime: null, - registeredAt: undefined, - - image: undefined, - - location, - lastLocationAt: device.lastLocation - ? formatLocationDate(device.lastLocation.date) - : 'Нет данных', - - route: device.lastLocation - ? [ - { + : device.lastLocation + ? { lat: device.lastLocation.lat, lng: device.lastLocation.lng, - time: formatLocationDate(device.lastLocation.date), - }, - ] - : [], + } + : undefined - permissions: { - wifi: true, - bluetooth: true, - gps: Boolean(device.lastLocation), - camera: true, - sim: true, - speaker: true, - }, + const lastLocationAt = currentGpsPoint + ? formatLocationDate(currentGpsPoint.date) + : device.lastLocation + ? formatLocationDate(device.lastLocation.date) + : 'Нет данных' - statusIcons: { - gps: Boolean(device.lastLocation), - wifi: true, - bluetooth: true, - lock: false, - camera: true, - sim: true, - sound: true, - kiosk: false, - }, + return { + id: device.id, - battery: undefined, - batteryMaxCapacity: undefined, - chargeCycles: undefined, - totalWorkTime: undefined, - mediumImpacts: undefined, -} + model: 'АРМАФОН S3.3+', + factoryNumber: device.serial || 'Заводской номер не указан', + imei: device.imei || 'IMEI не указан', + imei2: device.imei2 || 'IMEI 2 не указан', + serialNumber: device.serial || undefined, + + employee: device.org?.name ?? null, + organisation: device.org?.name, + + condition: needMaintenance ? 'inspection' : 'ok', + connection: location ? 'online' : 'offline', + connectionText: location ? 'В сети' : 'Не в сети', + + workTime: null, + registeredAt: device.registerDate + ? formatLocationDate(device.registerDate) + : undefined, + + image: undefined, + + location, + lastLocationAt, + lastLocationDate: device.lastLocation?.date, + + route: routeGpsPoints.map((point) => ({ + lat: point.lat, + lng: point.lng, + time: formatLocationDate(point.date), + })), + + permissions: { + wifi: false, + bluetooth: policy?.canUseBluetooth ?? false, + gps: policy?.canUseGPS ?? false, + camera: policy?.canUseCamera ?? false, + sim: policy?.canUseSim ?? false, + speaker: false, + }, + + statusIcons: { + gps: policy?.canUseGPS ?? false, + wifi: false, + bluetooth: policy?.canUseBluetooth ?? false, + lock: false, + camera: policy?.canUseCamera ?? false, + sim: policy?.canUseSim ?? false, + sound: false, + kiosk: false, + }, + + battery: techState?.batteryLevel ?? batteryLevel, + batteryMaxCapacity: techState?.batteryRemainingCapacity ?? undefined, + chargeCycles: formatTechValue(techState?.batteryCycles), + totalWorkTime: techState?.worktime ?? null, + mediumImpacts: formatTechValue(techState?.hits), + overheats: formatTechValue(techState?.overheats), + } } export function DevicePage() { const { deviceId } = useParams() const navigate = useNavigate() + const [isHistoryOpen, setIsHistoryOpen] = useState(false) - const phoneId = Number(deviceId) + const numericDeviceId = Number(deviceId) - const { data, loading, error } = useQuery( - GET_PHONE_QUERY, - { - variables: { - id: phoneId, - }, - skip: !phoneId, - fetchPolicy: 'cache-and-network', - }, +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, +}) + + const latestTelemetryItem = useMemo(() => { + return getLatestTelemetryItem(data?.getTelemetry ?? []) +}, [data]) + +const device = useMemo(() => { + if (!data?.getPhone) return null + + return mapApiDeviceToPageDevice( + data.getPhone, + latestTelemetryItem?.batteryLevel, + data.getPhoneGpsTrack ?? [], ) +}, [data, latestTelemetryItem]) - const device = useMemo(() => { - if (!data?.getPhone) return null - - return mapApiDeviceToPageDevice(data.getPhone) - }, [data]) - - if (!phoneId) { + if (!numericDeviceId) { return (
@@ -175,12 +234,20 @@ export function DevicePage() {
- + setIsHistoryOpen(true)} + />
+
) } \ 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 new file mode 100644 index 0000000..6fa683e --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/BatteryCircle/BatteryCircle.scss @@ -0,0 +1,42 @@ +.battery-circle { + position: relative; + width: 180px; + height: 180px; + display: flex; + align-items: center; + justify-content: center; +} + +.battery-circle__svg { + width: 100%; + height: 100%; +} + +.battery-circle__track, +.battery-circle__progress { + fill: none; +} + +.battery-circle__track { + stroke: #cfd5df; +} + +.battery-circle__progress { + stroke-linecap: round; + transition: + stroke-dashoffset 0.35s ease, + stroke 0.25s ease; +} + +.battery-circle__value { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + + font-size: 44px; + font-weight: 500; + line-height: 1; + color: #000; +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/BatteryCircle/BatteryCircle.tsx b/mdm-front/src/pages/DevicePage/components/BatteryCircle/BatteryCircle.tsx new file mode 100644 index 0000000..3efb5ec --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/BatteryCircle/BatteryCircle.tsx @@ -0,0 +1,57 @@ +type BatteryCircleProps = { + value?: number | null +} + +function getBatteryColor(value: number) { + if (value <= 20) return '#E53935' + if (value <= 50) return '#F59E0B' + return '#0B28B8' +} + +export function BatteryCircle({ value }: BatteryCircleProps) { + const battery = typeof value === 'number' + ? Math.max(0, Math.min(100, value)) + : 0 + + const size = 180 + const strokeWidth = 14 + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + + const dashOffset = circumference * (1 - battery / 100) + + return ( +
+ + + + + + + + {battery}% +
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/DeviceHistoryChart/DeviceHistoryChart.scss b/mdm-front/src/pages/DevicePage/components/DeviceHistoryChart/DeviceHistoryChart.scss new file mode 100644 index 0000000..df34a03 --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/DeviceHistoryChart/DeviceHistoryChart.scss @@ -0,0 +1,153 @@ +@use '../../../../shared/styles/variables' as *; + +.device-history-chart { + position: relative; + width: 100%; + min-height: 1000px; + margin-top: 40px; +} + +.device-history-chart .echarts-for-react { + width: 100%; + height: 1000px !important; +} + +.device-history-chart__events-title { + position: absolute; + left: 90px; + top: 60px; + z-index: 2; + pointer-events: none; + + h3 { + margin: 0; + color: #30394b; + font-size: 18px; + font-weight: 650; + } + + p { + margin: 0; + color: #738098; + font-size: 14px; + font-weight: 450; + line-height: 1.3; + } +} + +.device-history-events-legend { + position: absolute; + left: 90px; + right: 0; + top: 340px; + z-index: 3; + + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.device-history-events-legend__item { + border: none; + background: transparent; + padding: 0; + + display: inline-flex; + align-items: center; + gap: 8px; + + color: #30394b; + font-family: inherit; + font-size: 14px; + font-weight: 500; + + cursor: pointer; + opacity: 0.55; + transition: + opacity 0.2s ease, + transform 0.2s ease; + + &:hover { + opacity: 0.85; + } + + &.is-active { + opacity: 1; + } +} + +.device-history-events-legend__checkbox { + width: 16px; + height: 16px; + + border: 1.5px solid #c9d2e3; + border-radius: 5px; + + display: inline-flex; + align-items: center; + justify-content: center; + + transition: + background-color 0.2s ease, + border-color 0.2s ease; +} + +.device-history-events-legend__check { + width: 18px; + height: 18px; + + //border-radius: 2px; + //background: #ffffff; + color: white; +} + +.device-history-chart__battery-title, +.device-history-chart__temperature-title { + position: absolute; + left: 90px; + z-index: 2; + pointer-events: none; + + h3 { + margin: 0; + color: #30394b; + font-size: 18px; + font-weight: 650; + } + + p { + margin: 0; + color: #738098; + font-size: 14px; + font-weight: 450; + line-height: 1.2; + } +} + +.device-history-chart__battery-title { + top: 680px; +} + +.device-history-chart__temperature-title { + top: 400px; +} + +.device-history-chart__state { + position: absolute; + top: 52px; + right: 0; + z-index: 10; + + padding: 8px 12px; + border-radius: 999px; + background: #f1f4f8; + + color: #738098; + font-size: 13px; + font-weight: 600; +} + +.device-history-chart__state--error { + background: rgba(224, 0, 0, 0.08); + color: $red; +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/DeviceHistoryChart/DeviceHistoryChart.tsx b/mdm-front/src/pages/DevicePage/components/DeviceHistoryChart/DeviceHistoryChart.tsx new file mode 100644 index 0000000..fbf0bed --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/DeviceHistoryChart/DeviceHistoryChart.tsx @@ -0,0 +1,885 @@ +import { useMemo, useState } from 'react' +import ReactECharts from 'echarts-for-react' + +import type { DeviceTelemetryItem } from '../../../../entities/device/model/types' +import type { DeviceHistoryPeriodValue } from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl' + +import './DeviceHistoryChart.scss' + +type DeviceHistoryChartProps = { + period: DeviceHistoryPeriodValue | null + telemetry: DeviceTelemetryItem[] + isLoading?: boolean + isError?: boolean +} + +type EventCategoryKey = + | 'session' + | 'charging' + | 'screen' + | 'gps' + | 'network' + +type HistoryEvent = { + category: EventCategoryKey + name: string + start: number + end: number + color: string +} + +const FONT_FAMILY = 'Montserrat Variable, Montserrat, sans-serif' + +const EVENT_CATEGORIES: Array<{ + key: EventCategoryKey + label: string + color: string +}> = [ + { + key: 'session', + label: 'Сессия', + color: '#031d9a', + }, + { + key: 'charging', + label: 'Зарядка', + color: '#7a3fe8', + }, + { + key: 'screen', + label: 'Экран вкл', + color: '#2f80ed', + }, + { + key: 'gps', + label: 'GPS вкл', + color: '#18b89b', + }, + { + key: 'network', + label: 'Потеря сети', + color: '#ff8a34', + }, + ] + +function formatTooltipDate(value: number) { + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)) +} + +function getLineValueAtTime(data: number[][], hoverTime: number) { + if (!data.length) return null + + const firstPoint = data[0] + const lastPoint = data[data.length - 1] + + if (hoverTime <= firstPoint[0]) return firstPoint[1] + if (hoverTime >= lastPoint[0]) return lastPoint[1] + + for (let index = 0; index < data.length - 1; index += 1) { + const currentPoint = data[index] + const nextPoint = data[index + 1] + + const currentTime = currentPoint[0] + const currentValue = currentPoint[1] + const nextTime = nextPoint[0] + const nextValue = nextPoint[1] + + if (hoverTime >= currentTime && hoverTime <= nextTime) { + const progress = (hoverTime - currentTime) / (nextTime - currentTime) + const interpolatedValue = + currentValue + (nextValue - currentValue) * progress + + return Math.round(interpolatedValue) + } + } + + return null +} + +function formatTooltipRange(start: number, end: number) { + return `${formatTooltipDate(start)} — ${formatTooltipDate(end)}` +} + +function getChargeTypeLabel(chargeType: string) { + if (chargeType === 'USB') return 'USB-зарядка' + if (chargeType === 'Wireless') return 'Беспроводная зарядка' + if (chargeType === 'AC') return 'Зарядка от сети' + if (chargeType === 'Disconnected') return 'Не заряжается' + + return chargeType +} + +function sortTelemetry(telemetry: DeviceTelemetryItem[]) { + return [...telemetry].sort((a, b) => a.date - b.date) +} + +function createTelemetryLines(telemetry: DeviceTelemetryItem[]) { + const sortedTelemetry = sortTelemetry(telemetry) + + return { + battery: sortedTelemetry + .filter((item) => typeof item.batteryLevel === 'number') + .map((item) => [item.date, item.batteryLevel]), + + temperature: sortedTelemetry + .filter((item) => typeof item.temperature === 'number') + .map((item) => [item.date, item.temperature]), + } +} + +function createTelemetryEvents(telemetry: DeviceTelemetryItem[]): HistoryEvent[] { + const sortedTelemetry = sortTelemetry(telemetry) + const events: HistoryEvent[] = [] + + let screenStart: number | null = null + let chargingStart: number | null = null + let chargingType: string | null = null + + sortedTelemetry.forEach((item, index) => { + const nextItem = sortedTelemetry[index + 1] + const currentDate = item.date + const nextDate = nextItem?.date ?? item.date + + if (item.screenOn && screenStart === null) { + screenStart = currentDate + } + + if (!item.screenOn && screenStart !== null) { + events.push({ + category: 'screen', + name: 'Экран включен', + start: screenStart, + end: currentDate, + color: '#2f80ed', + }) + + screenStart = null + } + + const isCharging = item.chargeType && item.chargeType !== 'Disconnected' + + if (isCharging && chargingStart === null) { + chargingStart = currentDate + chargingType = item.chargeType + } + + if ( + isCharging && + chargingStart !== null && + chargingType !== null && + item.chargeType !== chargingType + ) { + events.push({ + category: 'charging', + name: getChargeTypeLabel(chargingType), + start: chargingStart, + end: currentDate, + color: '#7a3fe8', + }) + + chargingStart = currentDate + chargingType = item.chargeType + } + + if (!isCharging && chargingStart !== null && chargingType !== null) { + events.push({ + category: 'charging', + name: getChargeTypeLabel(chargingType), + start: chargingStart, + end: currentDate, + color: '#7a3fe8', + }) + + chargingStart = null + chargingType = null + } + + if (index === sortedTelemetry.length - 1) { + if (screenStart !== null) { + events.push({ + category: 'screen', + name: 'Экран включен', + start: screenStart, + end: nextDate, + color: '#2f80ed', + }) + } + + if (chargingStart !== null && chargingType !== null) { + events.push({ + category: 'charging', + name: getChargeTypeLabel(chargingType), + start: chargingStart, + end: nextDate, + color: '#7a3fe8', + }) + } + } + }) + + return events.filter((event) => event.end > event.start) +} + +function clipRectByRect(shape: any, rect: any) { + const x = Math.max(shape.x, rect.x) + const y = Math.max(shape.y, rect.y) + const x2 = Math.min(shape.x + shape.width, rect.x + rect.width) + const y2 = Math.min(shape.y + shape.height, rect.y + rect.height) + + if (x2 < x || y2 < y) return null + + return { + x, + y, + width: x2 - x, + height: y2 - y, + } +} + +function renderEventItem(params: any, api: any) { + const categoryIndex = api.value(0) + const start = api.coord([api.value(1), categoryIndex]) + const end = api.coord([api.value(2), categoryIndex]) + + const height = 10 + + const rectShape = clipRectByRect( + { + x: start[0], + y: start[1] - height / 2, + width: end[0] - start[0], + height, + }, + { + x: params.coordSys.x, + y: params.coordSys.y, + width: params.coordSys.width, + height: params.coordSys.height, + }, + ) + + if (!rectShape) return null + + return { + type: 'rect', + transition: ['shape', 'style'], + shape: { + ...rectShape, + r: 999, + }, + style: api.style(), + } +} + +export function DeviceHistoryChart({ + period, + telemetry, + isLoading = false, + isError = false, +}: DeviceHistoryChartProps) { + const [enabledCategories, setEnabledCategories] = useState( + EVENT_CATEGORIES.map((category) => category.key), + ) + + const periodStart = + period?.periodStart.getTime() ?? Date.now() - 24 * 60 * 60 * 1000 + + const periodEnd = period?.periodEnd.getTime() ?? Date.now() + + const allEvents = useMemo( + () => createTelemetryEvents(telemetry), + [telemetry], + ) + + const { battery, temperature } = useMemo( + () => createTelemetryLines(telemetry), + [telemetry], + ) + + const visibleCategories = EVENT_CATEGORIES.filter((category) => + enabledCategories.includes(category.key), + ) + + const visibleEvents = allEvents.filter((event) => + enabledCategories.includes(event.category), + ) + + const eventSeriesData = visibleEvents.map((event) => { + const categoryIndex = visibleCategories.findIndex( + (category) => category.key === event.category, + ) + + return { + name: event.name, + value: [categoryIndex, event.start, event.end], + itemStyle: { + color: event.color, + }, + meta: event, + } + }) + + function toggleCategory(categoryKey: EventCategoryKey) { + setEnabledCategories((prev) => { + if (prev.includes(categoryKey)) { + return prev.filter((item) => item !== categoryKey) + } + + return [...prev, categoryKey] + }) + } + + const option = { + animation: true, + animationDuration: 250, + animationDurationUpdate: 250, + animationEasingUpdate: 'cubicOut', + + textStyle: { + fontFamily: FONT_FAMILY, + fontWeight: 500, + }, + + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'line', + axis: 'x', + lineStyle: { + color: '#cfd7e6', + width: 1, + }, + label: { + show: true, + backgroundColor: '#20263a', + color: '#ffffff', + fontFamily: FONT_FAMILY, + formatter: (params: any) => { + return formatTooltipDate(Number(params.value)) + }, + }, + }, + borderWidth: 0, + backgroundColor: '#20263a', + padding: 14, + textStyle: { + color: '#ffffff', + fontSize: 13, + fontFamily: FONT_FAMILY, + }, + extraCssText: + 'border-radius: 14px; box-shadow: 0 12px 32px rgba(16, 24, 40, 0.22);', + formatter: (params: any[]) => { + if (!params?.length) return '' + + const hoverTime = Number(params[0].axisValue) + + if (!Number.isFinite(hoverTime)) return '' + + const eventsAtTime = visibleEvents.filter((event) => { + return event.start <= hoverTime && event.end >= hoverTime + }) + const batteryValue = getLineValueAtTime(battery, hoverTime) + const temperatureValue = getLineValueAtTime(temperature, hoverTime) + + if ( + !eventsAtTime.length && + temperatureValue === null && + batteryValue === null + ) { + return '' + } + + const rows = eventsAtTime + .map((event) => { + return ` +
+
+ + + ${event.name} + +
+ + + ${formatTooltipRange(event.start, event.end)} + +
+ ` + }) + .join('') + + const temperatureRow = + temperatureValue !== null + ? ` +
+
+ + + Температура + +
+ + + ${temperatureValue}°C + +
+ ` + : '' + + const batteryRow = + batteryValue !== null + ? ` +
+
+ + + Аккумулятор + +
+ + + ${batteryValue}% + +
+ ` + : '' + + return ` +
+
+ ${formatTooltipDate(hoverTime)} +
+ ${rows} + ${temperatureRow} + ${batteryRow} +
+ ` + }, + }, + + axisPointer: { + link: [ + { + xAxisIndex: [0, 1, 2], + }, + ], + }, + + dataZoom: [ + { + type: 'slider', + xAxisIndex: [0, 1, 2], + + top: 0, + height: 28, + start: 0, + end: 100, + + borderColor: 'transparent', + backgroundColor: '#eff2f7', + fillerColor: 'rgba(3, 28, 154, 0.15)', + + showDataShadow: false, + showDetail: true, + brushSelect: false, + + handleIcon: + 'path://M2,0 H4 Q6,0 6,2 V22 Q6,24 4,24 H2 Q0,24 0,22 V2 Q0,0 2,0 Z', + handleSize: 24, + handleStyle: { + color: '#031d9a', + borderColor: '#031d9a', + borderWidth: 0, + }, + + moveHandleSize: 0, + + textStyle: { + color: '#738098', + fontSize: 12, + fontFamily: FONT_FAMILY, + }, + + labelFormatter: (value: number) => { + return formatTooltipDate(value) + }, + }, + { + type: 'inside', + xAxisIndex: [0, 1, 2], + start: 0, + end: 100, + }, + ], + + grid: [ + { + id: 'events', + left: 90, + right: 90, + top: 140, + height: 170, + }, + { + id: 'temperature', + left: 90, + right: 90, + top: 470, + height: 150, + }, + { + id: 'battery', + left: 90, + right: 90, + top: 750, + height: 150, + }, + ], + + xAxis: [ + { + type: 'time', + gridIndex: 0, + min: periodStart, + max: periodEnd, + axisLabel: { + color: '#738098', + fontSize: 12, + fontFamily: FONT_FAMILY, + hideOverlap: true, + }, + axisLine: { + lineStyle: { + color: '#dbe2ee', + }, + }, + axisTick: { + show: false, + }, + splitLine: { + show: true, + lineStyle: { + color: '#edf0f5', + }, + }, + }, + { + type: 'time', + gridIndex: 1, + min: periodStart, + max: periodEnd, + axisLabel: { + color: '#738098', + fontSize: 12, + fontFamily: FONT_FAMILY, + hideOverlap: true, + }, + axisLine: { + lineStyle: { + color: '#dbe2ee', + }, + }, + axisTick: { + show: false, + }, + splitLine: { + show: true, + lineStyle: { + color: '#edf0f5', + }, + }, + }, + { + type: 'time', + gridIndex: 2, + min: periodStart, + max: periodEnd, + axisLabel: { + color: '#738098', + fontSize: 12, + fontFamily: FONT_FAMILY, + hideOverlap: true, + }, + axisLine: { + lineStyle: { + color: '#dbe2ee', + }, + }, + axisTick: { + show: false, + }, + splitLine: { + show: true, + lineStyle: { + color: '#edf0f5', + }, + }, + }, + ], + + yAxis: [ + { + type: 'category', + gridIndex: 0, + data: visibleCategories.map((category) => category.label), + inverse: true, + axisLabel: { + show: false, + }, + axisLine: { + show: false, + }, + axisTick: { + show: false, + }, + splitLine: { + show: true, + lineStyle: { + color: '#edf0f5', + }, + }, + }, + { + type: 'value', + gridIndex: 1, + min: 0, + max: 60, + interval: 15, + position: 'right', + axisLabel: { + formatter: '{value}°C', + color: '#738098', + fontSize: 12, + fontFamily: FONT_FAMILY, + }, + axisLine: { + show: false, + }, + axisTick: { + show: false, + }, + splitLine: { + show: true, + lineStyle: { + color: '#edf0f5', + }, + }, + }, + { + type: 'value', + gridIndex: 2, + min: 0, + max: 100, + interval: 25, + position: 'right', + axisLabel: { + formatter: '{value}%', + color: '#738098', + fontSize: 12, + fontFamily: FONT_FAMILY, + }, + axisLine: { + show: false, + }, + axisTick: { + show: false, + }, + splitLine: { + show: true, + lineStyle: { + color: '#edf0f5', + }, + }, + }, + ], + + series: [ + { + type: 'custom', + name: 'События', + xAxisIndex: 0, + yAxisIndex: 0, + renderItem: renderEventItem, + encode: { + x: [1, 2], + y: 0, + }, + data: eventSeriesData, + universalTransition: true, + }, + { + type: 'line', + name: 'Температура', + xAxisIndex: 1, + yAxisIndex: 1, + data: temperature, + symbol: 'circle', + symbolSize: 7, + smooth: true, + lineStyle: { + width: 2, + color: '#ff8a34', + }, + itemStyle: { + color: '#ff8a34', + }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { + offset: 0, + color: 'rgba(255, 138, 52, 0.24)', + }, + { + offset: 1, + color: 'rgba(255, 138, 52, 0.04)', + }, + ], + }, + }, + markArea: { + silent: true, + itemStyle: { + color: 'rgba(224, 0, 0, 0.045)', + }, + data: [[{ yAxis: 45 }, { yAxis: 60 }]], + }, + }, + { + type: 'line', + name: 'Аккумулятор', + xAxisIndex: 2, + yAxisIndex: 2, + data: battery, + symbol: 'circle', + symbolSize: 7, + smooth: true, + lineStyle: { + width: 2, + color: '#31b24a', + }, + itemStyle: { + color: '#31b24a', + }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { + offset: 0, + color: 'rgba(49, 178, 74, 0.28)', + }, + { + offset: 1, + color: 'rgba(49, 178, 74, 0.04)', + }, + ], + }, + }, + markArea: { + silent: true, + itemStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { + offset: 0, + color: 'rgba(224, 0, 0, 0.1)', + }, + { + offset: 1, + color: 'rgba(224, 0, 0, 0.1)', + }, + ], + }, + }, + data: [[{ yAxis: 0 }, { yAxis: 20 }]], + }, + }, + ], + } + + return ( +
+ {isLoading && ( +
+ Загрузка телеметрии... +
+ )} + + {isError && ( +
+ Не удалось загрузить телеметрию +
+ )} + + +
+

События

+

Хронология активности устройства за выбранный период

+
+ +
+ {EVENT_CATEGORIES.map((category) => { + const checked = enabledCategories.includes(category.key) + + return ( + + ) + })} +
+ +
+

Температура

+

Динамика температуры устройства за выбранный период

+
+ +
+

Аккумулятор

+

Динамика уровня заряда за выбранный период

+
+
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.scss b/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.scss new file mode 100644 index 0000000..c46fb18 --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.scss @@ -0,0 +1,532 @@ +@use '../../../../shared/styles/variables' as *; + +.device-history-modal__overlay { + position: fixed; + inset: 0; + z-index: 500; + background: rgba(15, 23, 42, 0.35); + animation: historyOverlayShow 0.2s ease; +} + +.device-history-modal { + position: fixed; + z-index: 600; + inset: 24px; + + border-radius: 24px; + background: #ffffff; + padding: 26px 28px; + + overflow: auto; + + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22); + animation: historyModalShow 0.2s ease; + + scrollbar-width: thin; + scrollbar-color: $gray50 transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: $gray50; + border-radius: 999px; + } +} + +.device-history-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 14px; +} + +.device-history-modal__title { + margin: 0; + + color: #111827; + font-size: 20px; + font-weight: 650; + text-transform: uppercase; + + span { + margin-left: 6px; + color: $gray50; + font-size: 14px; + font-weight: 500; + text-transform: none; + } +} + +.device-history-modal__meta { + margin-top: 4px; + + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px; + + color: $gray50; + font-size: 14px; + + .is-green { + position: relative; + padding-left: 12px; + color: #30394b; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + + width: 6px; + height: 6px; + border-radius: 50%; + + background: $green; + transform: translateY(-50%); + } + } +} + +.device-history-modal__close { + width: 36px; + height: 36px; + border: none; + border-radius: 12px; + background: transparent; + + display: inline-flex; + align-items: center; + justify-content: center; + + color: #111827; + cursor: pointer; + + &:hover { + background: $gray20; + color: $blue; + } +} + +.device-history-controls { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 18px; +} + +.device-history-tabs { + padding: 4px; + border-radius: 999px; + background: $color-bg; + + display: flex; + align-items: center; + gap: 2px; +} + +.device-history-tabs__item { + height: 34px; + padding: 0 16px; + + border: none; + border-radius: 999px; + background: transparent; + + color: $gray50; + font-size: 14px; + font-weight: 500; + cursor: pointer; + + &.is-active { + background: #ffffff; + color: $blue; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); + } +} + +.device-history-period { + height: 42px; + padding: 0 16px; + + border: none; + border-radius: 999px; + background: $color-bg; + + display: inline-flex; + align-items: center; + gap: 8px; + + color: #30394b; + font-size: 14px; + cursor: pointer; + + svg { + color: $blue; + } +} + +.device-history-range { + max-width: 1280px; + margin: 0 100px 22px; +} + +.device-history-range__labels { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + + color: $blue; + font-size: 14px; +} + +.device-history-range__track { + position: relative; + height: 22px; +} + +.device-history-range__active { + position: absolute; + left: 0; + top: 9px; + height: 4px; + border-radius: 999px; + background: $blue; +} + +.device-history-range__track input { + position: absolute; + inset: 0; + + width: 100%; + margin: 0; + + appearance: none; + background: transparent; + pointer-events: none; +} + +.device-history-range__track input::-webkit-slider-thumb { + appearance: none; + pointer-events: auto; + + width: 14px; + height: 14px; + border-radius: 50%; + border: none; + + background: $blue; + cursor: pointer; +} + +.device-history-modal__content { + display: flex; + flex-direction: column; + gap: 24px; +} + +.device-history-timeline { + max-width: 1280px; + margin: 0 auto; + width: 100%; +} + +.history-section { + margin-bottom: 28px; + + h3 { + margin: 0 0 8px 100px; + color: #30394b; + font-size: 16px; + font-weight: 600; + } +} + +.history-grid { + position: relative; +} + +.history-grid__axis { + margin-left: 100px; + height: 20px; + + display: grid; + grid-template-columns: repeat(5, 1fr); + + color: $gray50; + font-size: 13px; + + span { + transform: translateX(-50%); + + &:first-child { + transform: translateX(-25%); + } + + &:last-child { + text-align: right; + transform: translateX(0); + } + } +} + +.history-row { + display: grid; + grid-template-columns: 100px 1fr; + min-height: 28px; +} + +.history-row__label { + padding-right: 12px; + text-align: right; + + color: $gray50; + font-size: 14px; +} + +.history-row__line { + position: relative; + border-top: 1px solid #dbe2ee; + + &::before, + &::after { + content: ''; + position: absolute; + top: -1px; + bottom: 0; + width: 1px; + background: #dbe2ee; + } + + &::before { + left: 25%; + } + + &::after { + left: 75%; + } +} + +.history-segment { + position: absolute; + top: 9px; + height: 7px; + border-radius: 999px; +} + +.history-segment--blue { + background: $blue; +} + +.history-segment--purple { + background: #7a3fe8; +} + +.history-segment--green { + background: #18b89b; +} + +.history-segment--light-blue { + background: #2f80ed; +} + +.history-point { + position: absolute; + top: -5px; + + width: 8px; + height: 8px; + border-radius: 50%; + + transform: translateX(-50%); +} + +.history-point--red { + background: $red; +} + +.history-point--orange { + background: #ff7a1a; +} + +.battery-chart { + position: relative; + height: 110px; + margin-left: 100px; + border-bottom: 1px solid #dbe2ee; + + background: + linear-gradient(to bottom, transparent 19%, #dbe2ee 20%, transparent 21%), + linear-gradient(to bottom, transparent 39%, #dbe2ee 40%, transparent 41%), + linear-gradient(to bottom, transparent 59%, #dbe2ee 60%, transparent 61%), + linear-gradient(to bottom, transparent 79%, #dbe2ee 80%, transparent 81%); +} + +.battery-chart__area { + position: absolute; + inset: 0; + + background: linear-gradient( + 168deg, + rgba(49, 178, 74, 0.38) 0%, + rgba(49, 178, 74, 0.28) 45%, + rgba(49, 178, 74, 0.05) 46%, + rgba(49, 178, 74, 0.05) 100% + ); + + clip-path: polygon(0 10%, 100% 90%, 100% 100%, 0 100%); +} + +.battery-chart__lines { + position: absolute; + left: -55px; + top: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: space-between; + + color: $gray50; + font-size: 13px; +} + +.history-summary { + margin: 18px 0 0 100px; + + display: flex; + flex-wrap: wrap; + gap: 10px; + + span { + padding: 10px 16px; + border-radius: 999px; + background: $color-bg; + + color: $gray50; + font-size: 13px; + + b { + color: #111827; + font-weight: 600; + } + } +} + +.device-apps-card { + padding: 18px; + border-radius: 20px; + background: $color-bg; + + h3 { + margin: 0 0 10px; + padding-bottom: 10px; + border-bottom: 1px solid #dbe2ee; + + color: #30394b; + font-size: 16px; + font-weight: 600; + } +} + +.device-apps-list { + border-radius: 14px; + background: #ffffff; + overflow: hidden; +} + +.device-app-row { + width: 100%; + min-height: 48px; + padding: 0 16px; + + border: none; + border-bottom: 1px solid #e4e8f0; + background: #ffffff; + + display: grid; + grid-template-columns: 28px 220px 1fr auto 20px; + align-items: center; + gap: 12px; + + text-align: left; + cursor: pointer; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: #f8fafc; + } +} + +.device-app-row__icon { + width: 24px; + height: 24px; + border-radius: 7px; + background: $blue; +} + +.device-app-row__name { + color: #30394b; + font-size: 15px; + font-weight: 500; +} + +.device-app-row__time { + color: $gray50; + font-size: 13px; +} + +.device-app-row__percent { + color: $gray50; + font-size: 13px; +} + +.device-app-row__arrow { + color: $gray50; + font-size: 22px; +} + +.device-apps-card__more { + margin-top: 12px; + + border: none; + background: transparent; + + color: $blue; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +@keyframes historyOverlayShow { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes historyModalShow { + from { + opacity: 0; + transform: translateY(10px) scale(0.99); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.tsx b/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.tsx new file mode 100644 index 0000000..29698c9 --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { useQuery } from '@apollo/client/react' + +import type { Device } from '../../types' +import { + GET_TELEMETRY_QUERY, +} from '../../../../entities/device/api/device.graphql' +import type { + GetTelemetryData, + GetTelemetryVariables, +} from '../../../../entities/device/model/types' + +import { + DeviceHistoryPeriodControl, + type DeviceHistoryPeriodValue, +} from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl' +import { DeviceHistoryChart } from '../DeviceHistoryChart/DeviceHistoryChart' + +import './DeviceHistoryModal.scss' + +type DeviceHistoryModalProps = { + open: boolean + onOpenChange: (open: boolean) => void + device: Device +} + +export function DeviceHistoryModal({ + open, + onOpenChange, + device, +}: DeviceHistoryModalProps) { + const [selectedPeriod, setSelectedPeriod] = + useState(null) + + const { + data: telemetryData, + loading: telemetryLoading, + error: telemetryError, + } = useQuery(GET_TELEMETRY_QUERY, { + variables: { + phoneId: Number(device.id), + startDate: selectedPeriod?.periodStart.getTime() ?? 0, + endDate: selectedPeriod?.periodEnd.getTime(), + }, + skip: !open || !selectedPeriod, + fetchPolicy: 'network-only', + pollInterval: 15000, + }) + + return ( + + + + + +
+
+ + {device.factoryNumber} + ID: {device.id} + + +
+ {device.imei} + Версия приложения: 244 + Исправно + + {device.connectionText || 'В сети'} + +
+
+ + + + +
+ + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.scss b/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.scss new file mode 100644 index 0000000..faf0b13 --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.scss @@ -0,0 +1,343 @@ +@use '../../../../shared/styles/variables' as *; + +.history-period-control { + display: flex; + flex-direction: column; + gap: 18px; +} + +.history-period-control__top { + display: flex; + align-items: center; + gap: 10px; +} + +.history-period-tabs { + padding: 4px; + border-radius: 999px; + background: $color-bg; + + display: flex; + align-items: center; + gap: 2px; +} + +.history-period-tabs__item { + height: 34px; + padding: 0 16px; + + border: none; + border-radius: 999px; + background: transparent; + + color: $gray50; + font-size: 14px; + font-weight: 500; + cursor: pointer; + + transition: 0.2s ease; + + &:hover { + color: $blue; + } + + &.is-active { + background: #ffffff; + color: $blue; + } +} + +.history-date-picker { + position: relative; +} + +.history-date-picker__trigger { + height: 42px; + padding: 0 14px; + + border: none; + border-radius: 999px; + background: $color-bg; + + display: inline-flex; + align-items: center; + gap: 8px; + + color: #30394b; + font-size: 14px; + font-weight: 500; + + cursor: pointer; + transition: 0.2s ease; + + svg { + color: $gray50; + } + + &:hover, + &.is-active { + color: $blue; + box-shadow: inset 0 0 0 1px rgba(3, 29, 154, 0.16); + + svg { + color: $blue; + } + } +} + +.history-date-picker__dropdown { + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 120; + + border-radius: 18px; + background: #ffffff; + padding: 14px; + + border: 1.5px solid $gray20; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16); + + button{ + transition: .18s ease; + } + + .rdp-root { + --rdp-accent-color: $blue; + --rdp-accent-background-color: rgba(3, 29, 154, 0.1); + --rdp-day_button-border-radius: 10px; + --rdp-selected-border: 0; + + margin: 0; + } + + .rdp-months { + gap: 18px; + } + + .rdp-month_caption { + margin-bottom: 8px; + } + + .rdp-caption_label { + color: #30394b; + font-size: 15px; + font-weight: 600; + } + + .rdp-nav { + button { + width: 30px; + height: 30px; + border-radius: 10px; + + color: $gray50; + + &:hover { + background: $color-bg; + color: $blue; + } + } + } + + .rdp-weekday { + color: $gray50; + font-size: 12px; + font-weight: 500; + } + + .rdp-day { + width: 38px; + height: 38px; + padding: 0; + } + + .rdp-day_button { + width: 36px; + height: 36px; + + border: none; + border-radius: 10px; + + color: #30394b; + font-size: 14px; + font-weight: 500; + + &:hover { + background: $color-bg; + } + } + + .rdp-selected { + .rdp-day_button { + background: $blue; + color: #ffffff; + } + } + + .rdp-range_middle { + background-color: transparent; + .rdp-day_button { + background: #e8edff; + color: $blue; + border-radius: 0; + } + } + + .rdp-range_start { + .rdp-day_button { + border-radius: 10px; + } + } + + .rdp-range_end { + .rdp-day_button { + border-radius: 10px; + } + } + + .rdp-range_start.rdp-range_end { + .rdp-day_button { + border-radius: 10px; + } + + .rdp-today { + .rdp-day_button { + font-weight: 700; + } + } + } + + .rdp-outside { + opacity: 0.35; + } +} + +.history-range { + width: 100%; + padding: 0 60px; +} + +.history-range__labels { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + + color: $blue; + font-size: 14px; + font-weight: 500; +} + +.history-range__track { + position: relative; + height: 24px; +} + +.history-range__line { + position: absolute; + left: 0; + right: 0; + top: 10px; + + height: 4px; + border-radius: 999px; + background: rgba(3, 29, 154, 0.16); +} + +.history-range__active { + position: absolute; + top: 10px; + + height: 4px; + border-radius: 999px; + background: $blue; +} + +.history-range__track input { + position: absolute; + inset: 0; + + width: 100%; + height: 24px; + margin: 0; + + appearance: none; + background: transparent; + pointer-events: none; +} + +.history-range__track input::-webkit-slider-thumb { + appearance: none; + pointer-events: auto; + + width: 16px; + height: 16px; + + border: none; + border-radius: 50%; + background: $blue; + + cursor: pointer; +} + +.history-range__track input::-moz-range-thumb { + pointer-events: auto; + + width: 16px; + height: 16px; + + border: none; + border-radius: 50%; + background: $blue; + + cursor: pointer; +} + +/* react-day-picker */ + +.history-date-picker__dropdown .rdp-root { + --rdp-accent-color: $blue; + --rdp-accent-background-color: rgba(3, 29, 154, 0.1); + --rdp-day_button-border-radius: 10px; + --rdp-selected-border: 0; + + margin: 0; +} + +.history-date-picker__dropdown .rdp-months { + gap: 18px; +} + +.history-date-picker__dropdown .rdp-caption_label { + color: #30394b; + font-size: 15px; + font-weight: 600; +} + +.history-date-picker__dropdown .rdp-weekday { + color: $gray50; + font-size: 12px; + font-weight: 500; +} + +.history-date-picker__dropdown .rdp-day_button { + width: 36px; + height: 36px; + + color: #30394b; + font-size: 14px; +} + +.history-date-picker__dropdown .rdp-selected .rdp-day_button { + background: $blue; + color: #ffffff; +} + +.history-date-picker__dropdown .rdp-range_middle .rdp-day_button { + background: #e8edff; + color: $blue; +} + +.history-date-picker__dropdown .rdp-today .rdp-day_button { + font-weight: 700; +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.tsx b/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.tsx new file mode 100644 index 0000000..1e334f7 --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.tsx @@ -0,0 +1,210 @@ +import { useEffect, useMemo, useState } from 'react' +import { DayPicker } from 'react-day-picker' +import type { DateRange } from 'react-day-picker' +import { ru } from 'date-fns/locale/ru' +import { CalendarDays, ChevronDown } from 'lucide-react' + +import './DeviceHistoryPeriodControl.scss' + +type PeriodPreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom' + +export type DeviceHistoryPeriodValue = { + preset: PeriodPreset + periodStart: Date + periodEnd: Date +} + +type DeviceHistoryPeriodControlProps = { + onChange?: (value: DeviceHistoryPeriodValue) => void +} + +const presetTabs: Array<{ + value: PeriodPreset + label: string +}> = [ + { value: 'today', label: 'Сегодня' }, + { value: 'yesterday', label: 'Вчера' }, + { value: 'week', label: 'Неделя' }, + { value: 'month', label: 'Месяц' }, + ] + +function startOfDay(date: Date) { + const result = new Date(date) + result.setHours(0, 0, 0, 0) + return result +} + +function endOfDay(date: Date) { + const result = new Date(date) + result.setHours(23, 59, 59, 999) + return result +} + +function addDays(date: Date, days: number) { + const result = new Date(date) + result.setDate(result.getDate() + days) + return result +} + +function addMonths(date: Date, months: number) { + const result = new Date(date) + result.setMonth(result.getMonth() + months) + return result +} + +function getPresetRange(preset: PeriodPreset) { + const now = new Date() + + if (preset === 'today') { + return { + start: startOfDay(now), + end: now, + } + } + + if (preset === 'yesterday') { + const yesterday = addDays(now, -1) + + return { + start: startOfDay(yesterday), + end: endOfDay(yesterday), + } + } + + if (preset === 'week') { + return { + start: startOfDay(addDays(now, -6)), + end: now, + } + } + + if (preset === 'month') { + return { + start: startOfDay(addMonths(now, -1)), + end: now, + } + } + + return { + start: startOfDay(now), + end: now, + } +} + +function formatDate(date: Date) { + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(date) +} + +export function DeviceHistoryPeriodControl({ + onChange, +}: DeviceHistoryPeriodControlProps) { + const initialRange = useMemo(() => getPresetRange('today'), []) + + const [preset, setPreset] = useState('today') + const [isCalendarOpen, setIsCalendarOpen] = useState(false) + + const [periodStart, setPeriodStart] = useState(initialRange.start) + const [periodEnd, setPeriodEnd] = useState(initialRange.end) + + const [calendarRange, setCalendarRange] = useState({ + from: initialRange.start, + to: initialRange.end, + }) + + function applyPeriod(nextPreset: PeriodPreset, start: Date, end: Date) { + setPreset(nextPreset) + setPeriodStart(start) + setPeriodEnd(end) + + setCalendarRange({ + from: start, + to: end, + }) + } + + function handlePresetClick(nextPreset: PeriodPreset) { + const nextRange = getPresetRange(nextPreset) + applyPeriod(nextPreset, nextRange.start, nextRange.end) + } + + function handleCalendarSelect(nextRange: DateRange | undefined) { + setCalendarRange(nextRange) + + if (!nextRange?.from) return + + const from = startOfDay(nextRange.from) + const to = nextRange.to ? endOfDay(nextRange.to) : endOfDay(nextRange.from) + + applyPeriod('custom', from, to) + } + + useEffect(() => { + onChange?.({ + preset, + periodStart, + periodEnd, + }) + }, [preset, periodStart, periodEnd, onChange]) + + return ( +
+
+
+ {presetTabs.map((tab) => ( + + ))} +
+ +
+ + + {isCalendarOpen && ( +
+ +
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/DeviceMainCard/DeviceMainCard.tsx b/mdm-front/src/pages/DevicePage/components/DeviceMainCard/DeviceMainCard.tsx index d535722..219f337 100644 --- a/mdm-front/src/pages/DevicePage/components/DeviceMainCard/DeviceMainCard.tsx +++ b/mdm-front/src/pages/DevicePage/components/DeviceMainCard/DeviceMainCard.tsx @@ -1,12 +1,37 @@ +import { useState } from 'react' import { ShieldCheck, Signal, Smartphone, Trash2 } from 'lucide-react' import type { Device } from '../../types' import { conditionText, connectionText, getStatusClass } from '../../types' +import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog' type DeviceMainCardProps = { device: Device + onOpenHistory: () => void } -export function DeviceMainCard({ device }: DeviceMainCardProps) { +function getDeviceFullName(employee: Device) { + return [employee.serialNumber] + .filter(Boolean) + .join(' ') +} + + +export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps) { + + const [deletingDevice, setDeletingDevice] = useState(null) + + function handleConfirmDeleteDevice() { + if (!deletingDevice) return + + console.log('Удаление устройства пока без мутации', deletingDevice) + + setDeletingDevice(null) + } + + function handleDeleteDevice(device: Device) { + setDeletingDevice(device) + } + return (
@@ -14,7 +39,7 @@ export function DeviceMainCard({ device }: DeviceMainCardProps) { {device.image ? ( {device.model ) : ( - + )}
@@ -55,20 +80,36 @@ export function DeviceMainCard({ device }: DeviceMainCardProps) {
Зарегистрирован - {device.registeredAt ?? '10:00 20.04.2026'} + {device.registeredAt ?? 'Нет данных'}
- -
+ { + if (!open) { + setDeletingDevice(null) + } + }} + onConfirm={handleConfirmDeleteDevice} + /> ) } \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.scss b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.scss index 6224a48..669dade 100644 --- a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.scss +++ b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.scss @@ -6,7 +6,7 @@ flex: 1; position: relative; border-radius: 14px; - overflow: hidden; + overflow: visible; background: #eef1f6; } @@ -14,6 +14,7 @@ width: 100%; height: 100%; z-index: 1; + border-radius: 14px; } .device-map__coords { @@ -85,3 +86,28 @@ font-weight: 500; } } + +.device-map__period-control { + position: absolute; + top: 12px; + left: 12px; + z-index: 400; + border-radius: 24px; +} + +.device-map--modal { + padding: 0 !important; + box-shadow: none; + border-radius: 0; + background: transparent; + + .device-card__header { + display: none; + } + + .device-map__container { + height: 100%; + border-radius: 20px; + overflow: hidden; + } +} diff --git a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx index 8af2d6f..4afb4d1 100644 --- a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx +++ b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useQuery } from '@apollo/client/react' import { CircleMarker, MapContainer, @@ -10,13 +11,27 @@ import { } from 'react-leaflet' import L from 'leaflet' import { Map } from 'lucide-react' -import './DeviceMapCard.scss' import type { Device } from '../../types' +import { + GET_PHONE_GPS_TRACK_QUERY, +} from '../../../../entities/device/api/device.graphql' +import type { + DeviceGpsTrackPoint, + GetPhoneGpsTrackData, + GetPhoneGpsTrackVariables, +} from '../../../../entities/device/model/types' import { FullscreenControl } from '../../../../widgets/FullscreenControlLeaflet/FullscreenControl' +import { + MapTrackPeriodControl, + type MapTrackPeriodValue, +} from '../../../MapPage/components/MapTrackPeriodControl/MapTrackPeriodControl' + +import './DeviceMapCard.scss' type DeviceMapCardProps = { device: Device + variant?: 'card' | 'modal' } const defaultLocation = { @@ -36,6 +51,31 @@ const deviceMarkerIcon = L.divIcon({ 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)) +} + +function sortGpsTrack(track: DeviceGpsTrackPoint[]) { + return [...track].sort((a, b) => a.date - b.date) +} + +function isDateInPeriod(date: number | undefined, period: MapTrackPeriodValue | null) { + if (!date || !period) return false + + return ( + date >= period.periodStart.getTime() && + date <= period.periodEnd.getTime() + ) +} + function MapResizeWatcher() { const map = useMap() @@ -50,19 +90,113 @@ function MapResizeWatcher() { return null } -export function DeviceMapCard({ device }: DeviceMapCardProps) { - const location = device.location ?? defaultLocation +type MapFitBoundsProps = { + positions: [number, number][] +} + +function MapFitBounds({ positions }: MapFitBoundsProps) { + const map = useMap() + + useEffect(() => { + if (!positions.length) return + + if (positions.length === 1) { + map.setView(positions[0], 14) + return + } + + const bounds = L.latLngBounds(positions) + + map.fitBounds(bounds, { + padding: [40, 40], + maxZoom: 15, + animate: true, + }) + }, [map, positions]) + + return null +} + +export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps) { + const [selectedPeriod, setSelectedPeriod] = + useState(null) + + const { data: gpsTrackData } = useQuery< + GetPhoneGpsTrackData, + GetPhoneGpsTrackVariables + >(GET_PHONE_GPS_TRACK_QUERY, { + variables: { + phoneId: String(device.id), + startDate: selectedPeriod?.periodStart.getTime() ?? 0, + endDate: selectedPeriod?.periodEnd.getTime(), + }, + skip: !device.id || !selectedPeriod, + fetchPolicy: 'network-only', + pollInterval: selectedPeriod?.preset === 'today' ? 15000 : 0, + }) + + const sortedGpsTrack = useMemo(() => { + return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? []) + }, [gpsTrackData]) + + const shouldShowCurrentLocation = isDateInPeriod( + device.lastLocationDate, + selectedPeriod, + ) + + const selectedCurrentLocation = + shouldShowCurrentLocation && device.location + ? { + lat: device.location.lat, + lng: device.location.lng, + time: device.lastLocationAt, + } + : null + + const selectedRoutePoints = useMemo(() => { + if (!selectedCurrentLocation) { + return sortedGpsTrack + } + + return sortedGpsTrack.filter((point) => { + return ( + point.lat !== selectedCurrentLocation.lat || + point.lng !== selectedCurrentLocation.lng + ) + }) + }, [sortedGpsTrack, selectedCurrentLocation]) + + const routePositions = useMemo<[number, number][]>(() => { + const positions = selectedRoutePoints.map( + (point) => [point.lat, point.lng] as [number, number], + ) + + if (!selectedCurrentLocation) { + return positions + } + + return [ + ...positions, + [selectedCurrentLocation.lat, selectedCurrentLocation.lng], + ] + }, [selectedRoutePoints, selectedCurrentLocation]) + + const location = selectedCurrentLocation ?? device.location ?? defaultLocation const currentPosition = useMemo<[number, number]>(() => { return [location.lat, location.lng] }, [location.lat, location.lng]) - const routePositions = useMemo<[number, number][]>(() => { - return device.route?.map((point) => [point.lat, point.lng]) ?? [] - }, [device.route]) + const fitPositions = routePositions.length > 0 ? routePositions : [currentPosition] return ( -
+
@@ -71,22 +205,27 @@ export function DeviceMapCard({ device }: DeviceMapCardProps) {

Последнее местоположение:{' '} - {device.lastLocationAt ?? '12:56 23.04.2026'} + {device.lastLocationAt ?? 'Нет данных'}

+
+ +
+ + - + {routePositions.length > 1 && ( )} - {device.route?.map((point, index) => ( + {selectedRoutePoints.map((point, index) => (
Точка маршрута - {point.time && {point.time}} + {formatLocationDate(point.date)}
))} - - -
- {device.model ?? 'Устройство'} - {device.factoryNumber} - {device.imei} - {device.lastLocationAt ?? 'Время не указано'} -
-
-
- + {selectedCurrentLocation && ( + + +
+ {device.model ?? 'Устройство'} + {device.factoryNumber} + {device.imei} + {device.lastLocationAt ?? 'Время не указано'} +
+
+
+ )} + +
diff --git a/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.scss b/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.scss new file mode 100644 index 0000000..1066ad5 --- /dev/null +++ b/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.scss @@ -0,0 +1,105 @@ +@use '../../../../shared/styles/variables' as *; + +.device-permission__switch { + position: relative; + + width: 48px; + height: 24px; + flex: 0 0 48px; + padding: 0; + border: none; + outline: none; + + display: inline-flex; + align-items: center; + + border-radius: 999px; + background: $gray20; + box-shadow: inset 0 0 0 1px rgba($gray50, 0.08); + + cursor: pointer; + user-select: none; + + transition: + background-color 0.28s ease, + box-shadow 0.28s ease, + transform 0.18s ease; + + > span { + position: absolute; + top: 3px; + left: 3px; + + width: 26px; + height: 18px; + border-radius: 100px; + background: #ffffff; + + box-shadow: + 0 2px 5px rgba(15, 23, 42, 0.18), + 0 1px 1px rgba(15, 23, 42, 0.08); + + transition: + transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.28s ease, + width 0.18s ease; + } + + &.is-enabled { + background: $blue; + box-shadow: inset 0 0 0 1px rgba($blue, 0.12); + + > span { + transform: translateX(16px); + box-shadow: + 0 3px 7px rgba(3, 29, 154, 0.24), + 0 1px 1px rgba(15, 23, 42, 0.08); + } + } + + &:hover { + box-shadow: + inset 0 0 0 1px rgba($gray50, 0.12), + 0 4px 10px rgba(15, 23, 42, 0.06); + } + + &:active { + transform: scale(0.96); + + > span { + width: 23px; + } + } + + &.is-enabled:active > span { + transform: translateX(17px); + } + + &:focus-visible { + box-shadow: + 0 0 0 3px rgba($blue, 0.18), + inset 0 0 0 1px rgba($blue, 0.18); + } +} + +.device-permission { + transition: + opacity 0.2s ease, + filter 0.2s ease; + + &.is-disabled { + opacity: 0.45; + cursor: not-allowed; + + svg, + span, + button { + pointer-events: none; + } + + .device-permission__switch { + cursor: not-allowed; + box-shadow: inset 0 0 0 1px rgba($gray50, 0.08); + } + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.tsx b/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.tsx index 13c240b..f48dc15 100644 --- a/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.tsx +++ b/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.tsx @@ -1,5 +1,14 @@ -import type { ReactNode } from 'react' +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { useMutation } from '@apollo/client/react' + 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 { WifiIcon } from '../../../../assets/icons/Wifi' import { BluetoothIcon } from '../../../../assets/icons/Bluetooth' @@ -8,19 +17,150 @@ import { CameraIcon } from '../../../../assets/icons/Camera' import { SimIcon } from '../../../../assets/icons/Sim' import { VolumeIcon } from '../../../../assets/icons/Volume' +import './DevicePermissionsCard.scss' + type DevicePermissionsCardProps = { device: Device } +type DevicePolicyKey = 'wifi' | 'bluetooth' | 'gps' | 'camera' | 'sim' | 'speaker' + +type DevicePolicyState = Record + +type PermissionItemConfig = { + key: DevicePolicyKey + icon: ReactNode + label: string + disabled?: boolean +} + +const permissionItems: PermissionItemConfig[] = [ + { + key: 'wifi', + icon: , + label: 'Wi-Fi', + disabled: true, + }, + { + key: 'bluetooth', + icon: , + label: 'Bluetooth', + }, + { + key: 'gps', + icon: , + label: 'GPS', + }, + { + key: 'camera', + icon: , + label: 'Камера', + }, + { + key: 'sim', + icon: , + label: 'SIM-карта', + }, + { + key: 'speaker', + icon: , + label: 'Динамик', + disabled: true, + }, +] + +function mapDevicePermissionsToState(device: Device): DevicePolicyState { + return { + wifi: device.permissions?.wifi ?? false, + bluetooth: device.permissions?.bluetooth ?? false, + gps: device.permissions?.gps ?? false, + camera: device.permissions?.camera ?? false, + sim: device.permissions?.sim ?? false, + speaker: device.permissions?.speaker ?? false, + } +} + +function mapStateToMutationVariables(policy: DevicePolicyState) { + return { + canUseBluetooth: policy.bluetooth, + canUseCamera: policy.camera, + canUseGPS: policy.gps, + canUseSim: policy.sim, + } +} + export function DevicePermissionsCard({ device }: DevicePermissionsCardProps) { + const initialPolicy = useMemo(() => { + return mapDevicePermissionsToState(device) + }, [device]) + + const [policyState, setPolicyState] = useState(initialPolicy) + const [savingKey, setSavingKey] = useState(null) + const [saveError, setSaveError] = useState(false) + + const [changePhonePolicy] = useMutation< + ChangePhonePolicyData, + ChangePhonePolicyVariables + >(CHANGE_PHONE_POLICY_MUTATION) + + useEffect(() => { + setPolicyState(initialPolicy) + }, [initialPolicy]) + + async function handleToggle(key: DevicePolicyKey, disabled?: boolean) { + if (disabled || savingKey) return + + const previousPolicy = policyState + const nextPolicy = { + ...policyState, + [key]: !policyState[key], + } + + setPolicyState(nextPolicy) + setSavingKey(key) + setSaveError(false) + + try { + await changePhonePolicy({ + variables: { + id: String(device.id), + ...mapStateToMutationVariables(nextPolicy), + }, + }) + } catch { + setPolicyState(previousPolicy) + setSaveError(true) + } finally { + setSavingKey(null) + } + } + return (
- } label="Wi-Fi" enabled={device.permissions?.wifi ?? true} /> - } label="Bluetooth" enabled={device.permissions?.bluetooth ?? true} /> - } label="GPS" enabled={device.permissions?.gps ?? true} /> - } label="Камера" enabled={device.permissions?.camera ?? true} /> - } label="SIM-карта" enabled={device.permissions?.sim ?? true} /> - } label="Динамик" enabled={device.permissions?.speaker ?? true} /> + {permissionItems.map((item) => { + const enabled = policyState[item.key] + const isSaving = savingKey === item.key + const isDisabled = item.disabled || Boolean(savingKey) + + return ( + handleToggle(item.key, item.disabled)} + /> + ) + })} + + {saveError && ( +
+ Не удалось сохранить политику устройства +
+ )}
) } @@ -29,19 +169,43 @@ type PermissionItemProps = { icon: ReactNode label: string enabled: boolean + disabled?: boolean + isUnavailable?: boolean + isSaving?: boolean + onToggle: () => void } -function PermissionItem({ icon, label, enabled }: PermissionItemProps) { +function PermissionItem({ + icon, + label, + enabled, + disabled = false, + isUnavailable = false, + isSaving = false, + onToggle, +}: PermissionItemProps) { return ( -
+
{icon}
{label} - +
) diff --git a/mdm-front/src/pages/DevicePage/components/DeviceStatsCards/DeviceStatsCards.tsx b/mdm-front/src/pages/DevicePage/components/DeviceStatsCards/DeviceStatsCards.tsx index 924b58a..b0b9e1c 100644 --- a/mdm-front/src/pages/DevicePage/components/DeviceStatsCards/DeviceStatsCards.tsx +++ b/mdm-front/src/pages/DevicePage/components/DeviceStatsCards/DeviceStatsCards.tsx @@ -1,11 +1,50 @@ -import { Battery, RotateCcw, ShieldCheck, Smartphone } from 'lucide-react' +import { Battery, RotateCcw, ShieldCheck, Smartphone, ThermometerSun } from 'lucide-react' import type { Device } from '../../types' type DeviceStatsCardsProps = { device: Device } +function formatWorkTimeHours(worktime?: number | null) { + if (typeof worktime !== 'number') return '-' + + const hours = worktime / 1000 / 60 / 60 + + if (hours < 1) { + const minutes = Math.round(worktime / 1000 / 60) + + return `${minutes} мин` + } + + if (hours < 10) { + return `${hours.toFixed(1)} ч` + } + + return `${Math.round(hours)} ч` +} + export function DeviceStatsCards({ device }: DeviceStatsCardsProps) { + + const batteryValue = + typeof device.battery === 'number' + ? Math.max(0, Math.min(100, device.battery)) + : null + + const batterySize = 110 + const batteryStroke = 10 + const batteryRadius = (batterySize - batteryStroke) / 2 + const batteryLength = 2 * Math.PI * batteryRadius + const batteryProgress = batteryValue !== null ? batteryValue / 100 : 0 + const batteryDashOffset = batteryLength * (1 - batteryProgress) + + function getBatteryColor(value: number | null) { + if (value === null) return '#cfd5df' + if (value <= 20) return '#e00000' + if (value <= 50) return '#ff8a34' + + return '#031d9a' + } + return (
@@ -13,7 +52,36 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
- {device.battery ?? '-'}% + + + {batteryValue !== null ? `${batteryValue}%` : '-'}
@@ -21,7 +89,7 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
- Максимальная емкость + Остаточная емкость {device.batteryMaxCapacity ?? '-'}%
@@ -40,7 +108,7 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
Общее время работы - {device.totalWorkTime ?? '-'} + {formatWorkTimeHours(device.totalWorkTime)}
@@ -50,8 +118,8 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
- Средних ударов - {device.mediumImpacts ?? '-'} + Средних ударов + {device.mediumImpacts ?? '-'}
diff --git a/mdm-front/src/pages/DevicePage/types.ts b/mdm-front/src/pages/DevicePage/types.ts index 0e0c30b..9354aa8 100644 --- a/mdm-front/src/pages/DevicePage/types.ts +++ b/mdm-front/src/pages/DevicePage/types.ts @@ -8,6 +8,7 @@ export type Device = { imei: string imei2: string serialNumber?: string + organisation?: string workTime: string | null employee: string | null condition: DeviceCondition @@ -15,11 +16,13 @@ export type Device = { connectionText: string registeredAt?: string lastLocationAt?: string + lastLocationDate?: number battery?: number batteryMaxCapacity?: number chargeCycles?: string - totalWorkTime?: string + totalWorkTime?: number | null mediumImpacts?: string + overheats?: string image?: string location?: { lat: number @@ -52,7 +55,7 @@ export type Device = { export const conditionText: Record = { ok: 'Исправно', - inspection: 'Требует осмотра', + inspection: 'Требует ТО', } export const connectionText: Record = { diff --git a/mdm-front/src/pages/DevicesPage/DevicesPage.scss b/mdm-front/src/pages/DevicesPage/DevicesPage.scss index c9e5371..cdd5f84 100644 --- a/mdm-front/src/pages/DevicesPage/DevicesPage.scss +++ b/mdm-front/src/pages/DevicesPage/DevicesPage.scss @@ -33,23 +33,23 @@ scrollbar-width: thin; scrollbar-color: $gray50 transparent; - &::-webkit-scrollbar { - width: 6px; - height: 6px; - } + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } - &::-webkit-scrollbar-track { - background: transparent; - } + &::-webkit-scrollbar-track { + background: transparent; + } - &::-webkit-scrollbar-thumb { - background: $gray30; - border-radius: 999px; - } + &::-webkit-scrollbar-thumb { + background: $gray30; + border-radius: 999px; + } - &::-webkit-scrollbar-thumb:hover { - background: $gray50; - } + &::-webkit-scrollbar-thumb:hover { + background: $gray50; + } } .devices-table { @@ -62,10 +62,11 @@ cursor: pointer; &:hover { - background-color: $gray20; + background-color: rgba($blue, 0.025); + box-shadow: inset 3px 0 0 rgba($blue, 0.8); - .devices-map-btn { - background-color: white; + .device-info__number { + color: $blue; } } } @@ -140,6 +141,7 @@ color: #111827; font-size: 18px; font-weight: 600; + transition: .2s ease; } .device-info__imei, @@ -169,15 +171,18 @@ padding: 0px 8px; border-radius: 12px; font-weight: 550; - &--green{ + + &--green { background-color: hsla(128, 56%, 45%, 0.15); color: $green; } - &--gray{ + + &--gray { background-color: $color-bg; color: $gray50; } - &--red{ + + &--red { background-color: hsla(0, 100%, 43%, 0.15); color: $red; } @@ -230,7 +235,7 @@ padding: 12px; border: none; border-radius: 12px; - background: $color-bg; + background: $gray20; display: inline-flex; align-items: center; @@ -243,7 +248,7 @@ transition: .2s ease; &:hover { - background-color: $gray20; + background-color: $blue20; color: $blue; } @@ -261,7 +266,7 @@ justify-content: space-between; color: #738098; - font-size: 13px; + font-size: 16px; } .devices-pagination__controls { @@ -279,7 +284,7 @@ background: #e9edf5; color: #738098; - font-size: 14px; + font-size: 17px; cursor: pointer; &.is-active { diff --git a/mdm-front/src/pages/DevicesPage/DevicesPage.tsx b/mdm-front/src/pages/DevicesPage/DevicesPage.tsx index b2a111d..69fa2e5 100644 --- a/mdm-front/src/pages/DevicesPage/DevicesPage.tsx +++ b/mdm-front/src/pages/DevicesPage/DevicesPage.tsx @@ -2,28 +2,32 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { useApolloClient, useQuery } from '@apollo/client/react' import { - Bluetooth, - Camera, Map, - MapPin, - SlidersHorizontal, - Volume2, - Wifi, Lock, Store, } from 'lucide-react' +import { WifiIcon } from '../../assets/icons/Wifi' +import { BluetoothIcon } from '../../assets/icons/Bluetooth' +import { GpsIcon } from '../../assets/icons/Gps' +import { CameraIcon } from '../../assets/icons/Camera' +import { SimIcon } from '../../assets/icons/Sim' +import { VolumeIcon } from '../../assets/icons/Volume' + import './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 { DeviceMapModal } from './components/DeviceMapModal/DeviceMapModal' import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql' import type { Device, GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types' +import type { Device as PageDevice } from '../DevicePage/types' +import type { Device as ApiDevice } from '../../entities/device/model/types' -function formatLocationDate(timestamp: number) { +function formatDateTime(timestamp: number) { if (!timestamp) return 'Нет данных' return new Intl.DateTimeFormat('ru-RU', { @@ -32,7 +36,19 @@ function formatLocationDate(timestamp: number) { year: 'numeric', hour: '2-digit', minute: '2-digit', - }).format(new Date(timestamp * 1000)) + }).format(new Date(timestamp)) +} + +function getDeviceConditionLabel(needMaintenance?: boolean) { + return needMaintenance ? 'Требует ТО' : 'Исправно' +} + +function getDeviceConditionClass(needMaintenance?: boolean) { + return needMaintenance ? 'devices-status--red' : 'devices-status--green' +} + +function getDeviceConditionDotClass(needMaintenance?: boolean) { + return needMaintenance ? 'devices-dot--red' : 'devices-dot--green' } export function DevicesPage() { @@ -49,6 +65,8 @@ export function DevicesPage() { const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false) + const [mapDevice, setMapDevice] = useState(null) + const { data: firstPageData, loading: isFirstPageLoading, @@ -122,6 +140,71 @@ export function DevicesPage() { setCurrentPage((prev) => Math.max(1, prev - 1)) } + function mapTableDeviceToPageDevice(device: ApiDevice): PageDevice { + const policy = device.policy + const needMaintenance = device.techState?.needMaintenance ?? false + return { + id: device.id, + model: 'АРМАФОН S3.3+', + factoryNumber: device.serial || 'Заводской номер не указан', + imei: device.imei || 'IMEI не указан', + imei2: device.imei2 || 'IMEI 2 не указан', + serialNumber: device.serial || undefined, + + workTime: null, + employee: device.org?.name ?? null, + organisation: device.org?.name, + + condition: needMaintenance ? 'inspection' : 'ok', + connection: device.lastLocation ? 'online' : 'offline', + connectionText: device.lastLocation ? 'Есть геопозиция' : 'Нет данных', + + registeredAt: device.registerDate + ? formatDateTime(device.registerDate) + : undefined, + + lastLocationDate: device.lastLocation?.date, + lastLocationAt: device.lastLocation?.date + ? formatDateTime(device.lastLocation.date) + : undefined, + + location: device.lastLocation + ? { + lat: device.lastLocation.lat, + lng: device.lastLocation.lng, + } + : undefined, + + route: [], + + permissions: { + wifi: false, + bluetooth: policy?.canUseBluetooth ?? false, + gps: policy?.canUseGPS ?? false, + camera: policy?.canUseCamera ?? false, + sim: policy?.canUseSim ?? false, + speaker: false, + }, + + statusIcons: { + gps: policy?.canUseGPS ?? false, + wifi: false, + bluetooth: policy?.canUseBluetooth ?? false, + lock: false, + camera: policy?.canUseCamera ?? false, + sim: policy?.canUseSim ?? false, + sound: false, + kiosk: false, + }, + + battery: undefined, + batteryMaxCapacity: undefined, + chargeCycles: undefined, + totalWorkTime: undefined, + mediumImpacts: undefined, + } + } + return (
@@ -176,20 +259,25 @@ export function DevicesPage() { IMEI 2: {device.imei2 || '—'}
- {device.lastLocation && ( -
- Последняя геопозиция:{' '} - {formatLocationDate(device.lastLocation.date)} -
- )} +
+ {device.org?.name || 'Не указана'} +
-
- +
+ - {device.lastLocation ? 'Исправно' : 'Требует ТО'} + {getDeviceConditionLabel(device.techState?.needMaintenance)}
@@ -208,19 +296,28 @@ export function DevicesPage() {
- + + + + - - - - - - + + + + + +
@@ -229,8 +326,10 @@ export function DevicesPage() { className="devices-map-btn" type="button" onClick={(event) => { + event.preventDefault() event.stopPropagation() - navigate(`/devices/${device.id}`) + + setMapDevice(mapTableDeviceToPageDevice(device)) }} > @@ -277,6 +376,15 @@ export function DevicesPage() { open={isAddDeviceOpen} onOpenChange={setIsAddDeviceOpen} /> + { + if (!open) { + setMapDevice(null) + } + }} + /> ) } \ 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 index c936bbc..94e1ee6 100644 --- a/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.scss +++ b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.scss @@ -102,7 +102,7 @@ align-items: center; justify-content: center; gap: 14px; - + padding: 20px; color: $blue; span { @@ -110,6 +110,12 @@ font-size: 18px; font-weight: 600; } + .add-device-qr__state, .add-device-qr__content{ + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + } } .add-device-modal__footer { diff --git a/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx index 752a130..7ee4490 100644 --- a/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx +++ b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx @@ -1,5 +1,14 @@ +import { useEffect, useMemo } from 'react' import * as Dialog from '@radix-ui/react-dialog' -import { QrCode, X } from 'lucide-react' +import { QRCodeSVG } from 'qrcode.react' +import { QrCode, RefreshCw, X } from 'lucide-react' +import { useMutation } from '@apollo/client/react' + +import { CREATE_PHONE_REGISTRATION_TOKEN_MUTATION } from '../../../../entities/device/api/device.graphql' +import type { + CreatePhoneRegistrationTokenData, + CreatePhoneRegistrationTokenVariables, +} from '../../../../entities/device/model/types' import './AddDeviceModal.scss' @@ -8,7 +17,53 @@ type AddDeviceModalProps = { onOpenChange: (open: boolean) => void } +function formatExpiresIn(expiresIn: number) { + if (!expiresIn) return '—' + + const now = Date.now() + const expiresAt = expiresIn > 10_000_000_000 + ? expiresIn + : now + expiresIn * 1000 + + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(expiresAt)) +} + export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) { + + const [createToken, { data, loading, error }] = useMutation< + CreatePhoneRegistrationTokenData, + CreatePhoneRegistrationTokenVariables + >(CREATE_PHONE_REGISTRATION_TOKEN_MUTATION, { + fetchPolicy: 'network-only', + }) + + const registrationData = data?.createPhoneRegistrationToken + + const qrValue = useMemo(() => { + if (!registrationData) return '' + + return JSON.stringify({ + address: registrationData.address, + token: registrationData.token, + }) + }, [registrationData]) + + useEffect(() => { + if (!open) return + + createToken() + }, [open, createToken]) + + function handleRefreshToken() { + createToken() + } + return ( @@ -33,8 +88,32 @@ export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
- - Здесь будет QR-код + {loading && ( +
+ + Генерация QR-кода... +
+ )} + + {error && !loading && ( +
+ + Не удалось получить QR-код +
+ )} + + {!loading && !error && qrValue && ( +
+ +
+ )}

diff --git a/mdm-front/src/pages/DevicesPage/components/DeviceMapModal/DeviceMapModal.scss b/mdm-front/src/pages/DevicesPage/components/DeviceMapModal/DeviceMapModal.scss new file mode 100644 index 0000000..622dd79 --- /dev/null +++ b/mdm-front/src/pages/DevicesPage/components/DeviceMapModal/DeviceMapModal.scss @@ -0,0 +1,118 @@ +@use '../../../../shared/styles/variables' as *; + +.device-map-modal__overlay { + position: fixed; + inset: 0; + z-index: 90; + + background: rgba(15, 23, 42, 0.42); + backdrop-filter: blur(2px); + + animation: deviceMapModalOverlayShow 0.18s ease; +} + +.device-map-modal { + position: fixed; + z-index: 100; + inset: 24px; + + padding: 24px; + border-radius: 24px; + background: #ffffff; + + display: flex; + flex-direction: column; + gap: 18px; + + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.24); + animation: deviceMapModalShow 0.2s ease; +} + +.device-map-modal__header { + flex: 0 0 auto; + + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.device-map-modal__title { + margin: 0; + + color: $color-text; + font-size: 24px; + font-weight: 650; +} + +.device-map-modal__description { + margin: 4px 0 0; + + color: $gray50; + font-size: 15px; + font-weight: 500; +} + +.device-map-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; + } +} + +.device-map-modal__body { + flex: 1; + min-height: 0; + + .device-map { + height: 100%; + min-height: 0; + } + + .device-map__container { + height: 100%; + min-height: 0; + } + + .device-map__leaflet { + min-height: 0; + height: 100%; + } +} + +@keyframes deviceMapModalOverlayShow { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes deviceMapModalShow { + from { + opacity: 0; + transform: translateY(10px) scale(0.99); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicesPage/components/DeviceMapModal/DeviceMapModal.tsx b/mdm-front/src/pages/DevicesPage/components/DeviceMapModal/DeviceMapModal.tsx new file mode 100644 index 0000000..8a6963d --- /dev/null +++ b/mdm-front/src/pages/DevicesPage/components/DeviceMapModal/DeviceMapModal.tsx @@ -0,0 +1,51 @@ +import * as Dialog from '@radix-ui/react-dialog' +import { X } from 'lucide-react' + +import type { Device } from '../../../DevicePage/types' +import { DeviceMapCard } from '../../../DevicePage/components/DeviceMapCard/DeviceMapCard' + +import './DeviceMapModal.scss' + +type DeviceMapModalProps = { + open: boolean + device: Device | null + onOpenChange: (open: boolean) => void +} + +export function DeviceMapModal({ + open, + device, + onOpenChange, +}: DeviceMapModalProps) { + return ( + + + + + +

+
+ + Устройство на карте + + + + {device?.factoryNumber || 'Выбранное устройство'} + +
+ + + + +
+ + {device && ( +
+ +
+ )} + + + + ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesFiltersPanel/DevicesFiltersPanel.scss b/mdm-front/src/pages/DevicesPage/components/DevicesFiltersPanel/DevicesFiltersPanel.scss index 4cdbce2..8ef50b3 100644 --- a/mdm-front/src/pages/DevicesPage/components/DevicesFiltersPanel/DevicesFiltersPanel.scss +++ b/mdm-front/src/pages/DevicesPage/components/DevicesFiltersPanel/DevicesFiltersPanel.scss @@ -35,7 +35,10 @@ .devices-filter-item { border-radius: 14px; background: #ffffff; - overflow: visible; + overflow: hidden; + &:nth-child(1)[data-state=open]{ + overflow: visible; + } } .devices-filter-item__header { diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesFiltersPanel/DevicesFiltersPanel.tsx b/mdm-front/src/pages/DevicesPage/components/DevicesFiltersPanel/DevicesFiltersPanel.tsx index ce76032..17add99 100644 --- a/mdm-front/src/pages/DevicesPage/components/DevicesFiltersPanel/DevicesFiltersPanel.tsx +++ b/mdm-front/src/pages/DevicesPage/components/DevicesFiltersPanel/DevicesFiltersPanel.tsx @@ -45,7 +45,7 @@ export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) { - + {/* Статусы @@ -108,7 +108,7 @@ export function DevicesFiltersPanel({ isOpen }: DevicesFiltersPanelProps) {
- + */} diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss index ee00a4d..7ecfc9b 100644 --- a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss +++ b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss @@ -12,14 +12,17 @@ .devices-search { width: 250px; + height: calc(100% - 20px); padding: 10px 12px; - border-radius: 20px; + border-radius: 24px; background: $color-bg; display: flex; align-items: center; gap: 8px; color: $gray50; + transition: .2s ease; + svg { height: 16px; width: auto; @@ -37,6 +40,10 @@ color: $gray50; } } + + &:focus-within{ + box-shadow: inset 0 0 0 1.5px rgba($blue, .15); + } } .devices-toolbar__right { @@ -47,7 +54,7 @@ button { padding: 12px 20px; font-size: 16px; - font-weight: 450; + font-weight: 500; background-color: $color-bg; border-radius: 20px; border: none; @@ -63,7 +70,7 @@ .devices-sort { //height: 32px; - padding: 12px 14px 12px 20px !important; + padding: 11px 14px 11px 20px !important; border: none; border-radius: 20px; @@ -80,20 +87,17 @@ cursor: pointer; transition: 0.2s ease; - &:hover { - color: $blue; + &:hover{ + background-color: $gray20; } -} -.devices-sort__chevron { - transition: transform 0.2s ease; -} - -.devices-sort[data-state='open'] { - color: $blue; - - .devices-sort__chevron { - transform: rotate(180deg); + &[data-state='open'] { + color: $blue; + background-color: $blue20; + } + svg{ + height: 22px; + width: auto; } } @@ -168,8 +172,13 @@ cursor: pointer; + &:hover{ + background-color: $gray20; + } + &--active{ color: $blue; + background-color: $blue20 !important; } svg { diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx index 6ab2f59..c283112 100644 --- a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx +++ b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx @@ -56,7 +56,9 @@ export function DevicesToolbar({ diff --git a/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss b/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss index afd300f..9b02c3d 100644 --- a/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss +++ b/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss @@ -11,6 +11,40 @@ overflow: hidden; } +.employees-sections { + width: fit-content; + display: flex; + align-items: center; +} + +.employees-sections__item { + padding: 10px 18px; + + border: none; + border-radius: 12px; + background: transparent; + + color: $gray50; + font-size: 18px; + font-weight: 500; + + cursor: pointer; + transition: 0.2s ease; + + &:hover { + background-color: $gray20; + } + + &.is-active { + background: #ffffff; + color: $blue; + } +} + +.employees-toolbar--organisations { + justify-content: flex-start; +} + .employees-table-container { display: flex; flex: 1; @@ -21,6 +55,7 @@ } .employees-table-card { + position: relative; flex: 1; min-height: 0; overflow: auto; @@ -52,7 +87,7 @@ .employees-table { width: 100%; - min-width: 700px; + min-width: 1180px; border-collapse: collapse; table-layout: fixed; @@ -66,7 +101,7 @@ th { padding: 14px 20px; line-height: 1; - border-bottom: 1px solid #e3e8f0; + border-bottom: 1px solid $gray20; color: $gray50; font-size: 16px; @@ -82,27 +117,163 @@ vertical-align: middle; text-align: left; - color: #151a24; - font-size: 18px; + color: $color-text; + font-size: 17px; font-weight: 400; } th:nth-child(1), td:nth-child(1) { - width: 140px; + width: 90px; + } + + th:nth-child(2), + td:nth-child(2) { + width: 30%; + } + + th:nth-child(3), + td:nth-child(3) { + width: 26%; + } + + th:nth-child(4), + td:nth-child(4) { + width: 180px; + } + + th:nth-child(5), + td:nth-child(5) { + width: 330px; + } +} + +.organisations-table { + min-width: 900px; + + th:nth-child(1), + td:nth-child(1) { + width: 120px; } th:nth-child(2), td:nth-child(2) { width: auto; } + + th:nth-child(3), + td:nth-child(3) { + width: 330px; + } +} + +.employee-person { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + + span { + color: $color-text; + font-size: 17px; + font-weight: 500; + line-height: 1.2; + } +} + +.employee-organisation, +.employee-organisation-name { + color: $gray50; + font-size: 16px; + font-weight: 400; +} + +.employee-organisation-name { + color: $color-text; + font-weight: 500; +} + +.employee-role { + display: inline-flex; + align-items: center; + + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + + background: $blue20; + color: $blue; + + font-size: 15px; + font-weight: 600; +} + +.employee-role--admin { + background: $orange; + color: #ffffff; +} + +.employees-table-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.employees-action-btn { + height: 34px; + padding: 0 12px; + + border: none; + border-radius: 12px; + + display: inline-flex; + align-items: center; + gap: 6px; + + font-size: 14px; + font-weight: 600; + cursor: pointer; + + opacity: 0.88; + + transition: + background-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease, + box-shadow 0.2s ease; + + &:hover { + opacity: 1; + } +} + +.employees-action-btn--edit { + background: $blue20; + color: $blue; + + &:hover { + box-shadow: inset 0 0 0 1px $blue; + } +} + +.employees-action-btn--delete { + background: rgba(224, 0, 0, 0.12); + color: $red; + + &:hover { + box-shadow: inset 0 0 0 1px $red; + } } .employees-table__row { + position: relative; transition: 0.2s ease; + box-shadow: inset 0px 0 0 transparent; + cursor: pointer; &:hover { - background-color: $gray20; + background-color: rgba($blue, 0.025); + box-shadow: inset 3px 0 0 rgba($blue, 0.8); } } @@ -121,16 +292,70 @@ font-weight: 500; } +.employees-table-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.employees-action-btn { + height: 34px; + padding: 0 12px; + + border: none; + border-radius: 12px; + + display: inline-flex; + align-items: center; + gap: 6px; + + font-size: 14px; + font-weight: 600; + cursor: pointer; + + opacity: 0.88; + + box-shadow: inset 0px 0 0 transparent; + + transition: + background-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease, + box-shadow 0.2s ease; + + &:hover { + opacity: 1; + } +} + +.employees-action-btn--edit { + background: $blue20; + color: $blue; + + &:hover { + box-shadow: inset 0 0 0 1px $blue; + } +} + +.employees-action-btn--delete { + background: rgba(224, 0, 0, 0.12); + color: $red; + + &:hover { + box-shadow: inset 0 0 0 1px $red; + } +} + .employees-pagination { flex: 0 0 auto; padding: 12px 14px 0; display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-end; color: #738098; - font-size: 13px; + font-size: 16px; } .employees-pagination__controls { @@ -139,21 +364,35 @@ gap: 6px; button { - min-width: 30px; - height: 30px; - padding: 0 12px; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + min-width: 38px; + height: 38px; + //padding: 0 12px; + font-weight: 500; border: none; border-radius: 10px; - background: #e9edf5; + background: $gray20; - color: #738098; - font-size: 14px; + color: $gray50; + font-size: 16px; cursor: pointer; + transition: .2s ease; + &:hover{ + background-color: rgba($blue, 0.1); + } &.is-active { - background: #031d9a; + background: $blue; color: #ffffff; + + &:disabled { + opacity: 1; + cursor: default; + } } &:disabled { @@ -163,6 +402,35 @@ } } +.employees-pagination__ellipsis-button { + position: relative; +} + +.employees-pagination__ellipsis-dots, +.employees-pagination__ellipsis-arrow { + transition: + opacity 0.18s ease, + transform 0.18s ease; +} + +.employees-pagination__ellipsis-arrow { + position: absolute; + opacity: 0; + transform: translateY(2px); +} + +.employees-pagination__ellipsis-button:hover { + .employees-pagination__ellipsis-dots { + opacity: 0; + transform: translateY(-2px); + } + + .employees-pagination__ellipsis-arrow { + opacity: 1; + transform: translateY(0); + } +} + .employees-state { min-height: 220px; height: 100%; diff --git a/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx b/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx index 9f0531f..c6a5bf2 100644 --- a/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx +++ b/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx @@ -1,162 +1,722 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' import { useApolloClient, useQuery } from '@apollo/client/react' +import { Pencil, Trash2 } from 'lucide-react' -import { GET_USERS_PAGE_QUERY } from '../../entities/employee/api/employee.graphql' +import { + GET_ORGANISATIONS_QUERY, + GET_USERS_PAGE_QUERY, +} from '../../entities/employee/api/employee.graphql' import type { Employee, + GetOrganisationsData, + GetOrganisationsVariables, GetUsersPageData, GetUsersPageVariables, + Organisation, } from '../../entities/employee/model/types' -import { EmployeesToolbar } from './components/EmployeesToolbar/EmployeesToolbar' +import { + EmployeesToolbar, + employeesSortOptions, + type EmployeesSortOption, +} from './components/EmployeesToolbar/EmployeesToolbar' import { AddEmployeeModal } from './components/AddEmployeeModal/AddEmployeeModal' +import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog' +import { AddOrganisationModal } from './components/AddOrganisationModal/AddOrganisationModal' import './EmployeesPage.scss' +type EmployeesSection = 'users' | 'organisations' + +function getEmployeeFullName(employee: Employee) { + return [employee.lastName, employee.firstName, employee.middleName] + .filter(Boolean) + .join(' ') +} + +function getEmployeeRoleLabel(role: string) { + if (role === 'Admin') return 'Администратор' + if (role === 'User') return 'Пользователь' + + return role +} + +type PaginationItem = number | 'prev-ellipsis' | 'next-ellipsis' + +function getPaginationItems(currentPage: number, totalPages: number): PaginationItem[] { + if (totalPages <= 0) return [] + + const current = currentPage + 1 + + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, index) => index + 1) + } + + const pages = new Set() + + pages.add(1) + pages.add(totalPages) + pages.add(current) + pages.add(current - 1) + pages.add(current + 1) + + if (current <= 4) { + pages.add(2) + pages.add(3) + pages.add(4) + pages.add(5) + } + + if (current >= totalPages - 3) { + pages.add(totalPages - 1) + pages.add(totalPages - 2) + pages.add(totalPages - 3) + pages.add(totalPages - 4) + } + + const sortedPages = Array.from(pages) + .filter((page) => page >= 1 && page <= totalPages) + .sort((a, b) => a - b) + + const result: PaginationItem[] = [] + + sortedPages.forEach((page, index) => { + const previousPage = sortedPages[index - 1] + + if (previousPage && page - previousPage > 1) { + if (page - previousPage === 2) { + result.push(previousPage + 1) + } else { + result.push(page < current ? 'prev-ellipsis' : 'next-ellipsis') + } + } + + result.push(page) + }) + + return result +} + export function EmployeesPage() { + const navigate = useNavigate() + const client = useApolloClient() - const [currentPage, setCurrentPage] = useState(1) - const [loadedPages, setLoadedPages] = useState([]) - const [loadedNextKeys, setLoadedNextKeys] = useState>([]) - const [isPageLoading, setIsPageLoading] = useState(false) + const [activeSection, setActiveSection] = useState('users') + + const [usersCurrentPage, setUsersCurrentPage] = useState(1) + const [usersLoadedPages, setUsersLoadedPages] = useState([]) + const [usersLoadedNextKeys, setUsersLoadedNextKeys] = useState>([]) + const [isUsersPageLoading, setIsUsersPageLoading] = useState(false) + + const [organisationsCurrentPage, setOrganisationsCurrentPage] = useState(0) + const [organisationSearch, setOrganisationSearch] = useState('') + const [debouncedOrganisationSearch, setDebouncedOrganisationSearch] = + useState('') + const [organisationSort, setOrganisationSort] = + useState(employeesSortOptions[0]) + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedOrganisationSearch(organisationSearch.trim()) + }, 350) + + return () => { + window.clearTimeout(timeoutId) + } + }, [organisationSearch]) + + useEffect(() => { + setOrganisationsCurrentPage(0) + }, [debouncedOrganisationSearch, organisationSort]) const [isAddEmployeeOpen, setIsAddEmployeeOpen] = useState(false) + const [isAddOrganisationOpen, setIsAddOrganisationOpen] = useState(false) + const [editingEmployee, setEditingEmployee] = useState(null) + const [deletingEmployee, setDeletingEmployee] = useState(null) + + const [editingOrganisation, setEditingOrganisation] = + useState(null) + const [deletingOrganisation, setDeletingOrganisation] = + useState(null) const { - data: firstPageData, - loading: isFirstPageLoading, - error, + data: firstUsersPageData, + loading: isFirstUsersPageLoading, + error: usersError, } = useQuery(GET_USERS_PAGE_QUERY, { variables: {}, fetchPolicy: 'network-only', }) - const firstPageEmployees = firstPageData?.getUsersPage.page ?? [] - const firstPageNextKey = firstPageData?.getUsersPage.nextKey ?? null + const { + data: organisationsData, + previousData: previousOrganisationsData, + loading: organisationsLoading, + error: organisationsError, + refetch: refetchOrganisations, + } = useQuery( + GET_ORGANISATIONS_QUERY, + { + variables: { + page: organisationsCurrentPage, + query: debouncedOrganisationSearch || undefined, + sortDirection: organisationSort.sortDirection, + sortField: organisationSort.sortField, + }, + fetchPolicy: 'network-only', + notifyOnNetworkStatusChange: true, + }, + ) - const pages = [firstPageEmployees, ...loadedPages] - const employees = pages[currentPage - 1] ?? [] + const firstPageEmployees = firstUsersPageData?.getUsersPage.page ?? [] + const firstUsersPageNextKey = firstUsersPageData?.getUsersPage.nextKey ?? null - const nextKey = - currentPage === 1 - ? firstPageNextKey - : loadedNextKeys[currentPage - 2] ?? null + const usersPages = [firstPageEmployees, ...usersLoadedPages] + const employees = usersPages[usersCurrentPage - 1] ?? [] - const loading = isFirstPageLoading || isPageLoading + const usersNextKey = + usersCurrentPage === 1 + ? firstUsersPageNextKey + : usersLoadedNextKeys[usersCurrentPage - 2] ?? null - async function handleNextPage() { - const alreadyLoadedNextPage = pages[currentPage] + const usersLoading = isFirstUsersPageLoading || isUsersPageLoading + + const organisationsResponse = + organisationsData?.getOrganisations ?? + previousOrganisationsData?.getOrganisations + + const organisations = organisationsResponse?.page ?? [] + const organisationsTotalPages = organisationsResponse?.totalPages ?? 0 + + const isOrganisationsInitialLoading = + organisationsLoading && !organisationsResponse + + const isOrganisationsUpdating = + organisationsLoading && Boolean(organisationsResponse) + //const organisationsTotalElements = organisationsData?.getOrganisations.totalElements ?? 0 + + const hasPrevOrganisationsPage = organisationsCurrentPage > 0 + const hasNextOrganisationsPage = + organisationsTotalPages > 0 && + organisationsCurrentPage < organisationsTotalPages - 1 + + const organisationsPaginationItems = getPaginationItems( + organisationsCurrentPage, + organisationsTotalPages, + ) + + async function handleNextUsersPage() { + const alreadyLoadedNextPage = usersPages[usersCurrentPage] if (alreadyLoadedNextPage) { - setCurrentPage((prev) => prev + 1) + setUsersCurrentPage((prev) => prev + 1) return } - if (!nextKey) return + if (!usersNextKey) return - setIsPageLoading(true) + setIsUsersPageLoading(true) try { const result = await client.query({ query: GET_USERS_PAGE_QUERY, variables: { - key: nextKey, + key: usersNextKey, }, fetchPolicy: 'network-only', }) const nextPageData = result.data?.getUsersPage - if (!nextPageData) { - return - } + if (!nextPageData) return - setLoadedPages((prev) => [...prev, nextPageData.page]) - setLoadedNextKeys((prev) => [...prev, nextPageData.nextKey]) - setCurrentPage((prev) => prev + 1) + setUsersLoadedPages((prev) => [...prev, nextPageData.page]) + setUsersLoadedNextKeys((prev) => [...prev, nextPageData.nextKey]) + setUsersCurrentPage((prev) => prev + 1) } finally { - setIsPageLoading(false) + setIsUsersPageLoading(false) } } - function handlePrevPage() { - setCurrentPage((prev) => Math.max(1, prev - 1)) + function handlePrevUsersPage() { + setUsersCurrentPage((prev) => Math.max(1, prev - 1)) + } + + function handleNextOrganisationsPage() { + if (!hasNextOrganisationsPage || organisationsLoading) return + + setOrganisationsCurrentPage((prev) => prev + 1) + } + + function handlePrevOrganisationsPage() { + if (!hasPrevOrganisationsPage || organisationsLoading) return + + setOrganisationsCurrentPage((prev) => Math.max(0, prev - 1)) + } + + function handleJumpPrevOrganisationsPage() { + if (organisationsLoading) return + + setOrganisationsCurrentPage((prev) => Math.max(0, prev - 5)) + } + + function handleJumpNextOrganisationsPage() { + if (organisationsLoading) return + + setOrganisationsCurrentPage((prev) => + Math.min(organisationsTotalPages - 1, prev + 5), + ) + } + + function handleEditEmployee(employee: Employee) { + setEditingEmployee(employee) + } + + function handleDeleteEmployee(employee: Employee) { + setDeletingEmployee(employee) + } + + function handleConfirmDeleteEmployee() { + if (!deletingEmployee) return + + console.log('Удаление сотрудника пока без мутации', deletingEmployee) + + setDeletingEmployee(null) + } + + function handleEditOrganisation(organisation: Organisation) { + setEditingOrganisation(organisation) + } + + function handleDeleteOrganisation(organisation: Organisation) { + setDeletingOrganisation(organisation) + } + + function handleConfirmDeleteOrganisation() { + if (!deletingOrganisation) return + + console.log('Удаление организации пока без мутации', deletingOrganisation) + + setDeletingOrganisation(null) } return (
- setIsAddEmployeeOpen(true)} /> +
+ + + +
+ + { + if (activeSection === 'organisations') { + setOrganisationSearch(value) + } + }} + selectedSort={organisationSort} + onSortChange={setOrganisationSort} + showSort={activeSection === 'organisations'} + onAdd={() => { + if (activeSection === 'users') { + setIsAddEmployeeOpen(true) + return + } + + setIsAddOrganisationOpen(true) + }} + />
-
- {loading && employees.length === 0 && ( -
Загрузка сотрудников...
+
+ {activeSection === 'users' && ( + <> + {usersLoading && employees.length === 0 && ( +
Загрузка сотрудников...
+ )} + + {usersError && ( +
+ Не удалось загрузить сотрудников +
+ )} + + {!usersLoading && !usersError && employees.length === 0 && ( +
Сотрудники не найдены
+ )} + + {!usersError && employees.length > 0 && ( + + + + + + + + + + + + + {employees.map((employee) => ( + + + + + + + + + + + + ))} + +
IDФИООрганизацияРольУправление
{employee.id} +
+ + {getEmployeeFullName(employee) || 'ФИО не указано'} + +
+
+ + {employee.org?.name || 'Организация не указана'} + + + + {getEmployeeRoleLabel(employee.role)} + + +
+ + + +
+
+ )} + )} - {error && ( -
- Не удалось загрузить сотрудников + {activeSection === 'organisations' && ( + <> + {isOrganisationsInitialLoading && ( +
Загрузка организаций...
+ )} + + {organisationsError && ( +
+ Не удалось загрузить организации +
+ )} + + {!isOrganisationsInitialLoading && + !organisationsError && + organisations.length === 0 && ( +
Организации не найдены
+ )} + + {!organisationsError && organisations.length > 0 && ( + + + + + + + + + + + {organisations.map((organisation) => ( + navigate(`/employees/organisations/${organisation.id}`)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + navigate(`/employees/organisations/${organisation.id}`) + } + }} + > + + + + + + + ))} + +
IDНазвание организацииУправление
{organisation.id} + + {organisation.name} + + +
+ + + +
+
+ )} + + )} +
+ + {activeSection === 'users' && ( +
+ Страница {usersCurrentPage} + +
+ + + + +
- )} - - {!loading && !error && employees.length === 0 && ( -
Сотрудники не найдены
- )} - - {!error && employees.length > 0 && ( - - - - - - - - - - {employees.map((employee) => ( - - - - - ))} - -
IDРоль
{employee.id} - {employee.role} -
- )} -
- -
- Страница {currentPage} - -
- - - - -
-
+ )} + + {activeSection === 'organisations' && ( +
+ +
+ + + {organisationsPaginationItems.map((item, index) => { + if (item === 'prev-ellipsis' || item === 'next-ellipsis') { + const isPrev = item === 'prev-ellipsis' + + return ( + + ) + } + + const pageIndex = item - 1 + const isActive = pageIndex === organisationsCurrentPage + + return ( + + ) + })} + + +
+
+ )}
+ + + { + if (!open) { + setEditingEmployee(null) + } + }} + /> + + { + setOrganisationsCurrentPage(0) + + await refetchOrganisations({ + page: 0, + query: debouncedOrganisationSearch || undefined, + sortDirection: organisationSort.sortDirection, + sortField: organisationSort.sortField, + }) + }} + /> + + { + if (!open) { + setEditingOrganisation(null) + } + }} + /> + + { + if (!open) { + setDeletingOrganisation(null) + } + }} + onConfirm={handleConfirmDeleteOrganisation} + /> + + { + if (!open) { + setDeletingEmployee(null) + } + }} + onConfirm={handleConfirmDeleteEmployee} + />
) } \ 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 index 8ba19ad..b6ebd98 100644 --- a/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.scss +++ b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.scss @@ -82,6 +82,11 @@ display: flex; flex-direction: column; gap: 14px; + &__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} } .add-employee-field { @@ -95,7 +100,7 @@ font-weight: 500; } - input { + input, select { height: 44px; border: 1px solid #dfe5ef; border-radius: 14px; diff --git a/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx index 233a1e6..f905126 100644 --- a/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx +++ b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx @@ -1,13 +1,18 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import * as Dialog from '@radix-ui/react-dialog' import { X } from 'lucide-react' -import { useMutation } from '@apollo/client/react' +import { useMutation, useQuery } from '@apollo/client/react' import { + GET_ORGANISATIONS_QUERY, GET_USERS_PAGE_QUERY, SIGN_UP_MUTATION, } from '../../../../entities/employee/api/employee.graphql' import type { + Employee, + EmployeeRole, + GetOrganisationsData, + GetOrganisationsVariables, SignUpData, SignUpVariables, } from '../../../../entities/employee/model/types' @@ -16,14 +21,73 @@ import './AddEmployeeModal.scss' type AddEmployeeModalProps = { open: boolean + mode?: 'create' | 'edit' + employee?: Employee | null onOpenChange: (open: boolean) => void } -export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps) { - const [organisationId, setOrganisationId] = useState('') +export function AddEmployeeModal({ + open, + mode = 'create', + employee = null, + onOpenChange, +}: AddEmployeeModalProps) { + const isEditMode = mode === 'edit' + const [orgId, setOrgId] = useState('') + const [lastName, setLastName] = useState('') + const [firstName, setFirstName] = useState('') + const [middleName, setMiddleName] = useState('') const [username, setUsername] = useState('') const [password, setPassword] = useState('') - const [groupId, setGroupId] = useState('') + const [role, setRole] = useState('User') + + useEffect(() => { + if (!open) return + + if (isEditMode && employee) { + setOrgId(String(employee.org?.id ?? employee.orgId ?? '')) + setLastName(employee.lastName ?? '') + setFirstName(employee.firstName ?? '') + setMiddleName(employee.middleName ?? '') + setUsername('') + setPassword('') + setRole(employee.role ?? 'User') + return + } + + if (!isEditMode) { + setLastName('') + setFirstName('') + setMiddleName('') + setUsername('') + setPassword('') + setRole('User') + setOrgId('') + } + }, [open, isEditMode, employee]) + + const { + data: organisationsData, + loading: organisationsLoading, + error: organisationsError, + } = useQuery( + GET_ORGANISATIONS_QUERY, + { + variables: {}, + skip: !open, + fetchPolicy: 'network-only', + }, + ) + + const organisations = organisationsData?.getOrganisations.page ?? [] + + useEffect(() => { + if (!open) return + if (orgId) return + if (!organisations.length) return + + setOrgId(String(organisations[0].id)) + }, [open, organisations, orgId]) const [signUp, { loading, error }] = useMutation( SIGN_UP_MUTATION, @@ -35,23 +99,49 @@ export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps) }, ], onCompleted: () => { + setLastName('') + setFirstName('') + setMiddleName('') setUsername('') setPassword('') - setGroupId('') + setRole('User') + setOrgId('') + onOpenChange(false) }, }, ) - function handleSubmit(event: React.SubmitEvent) { + function handleSubmit(event: React.FormEvent) { event.preventDefault() + if (!orgId) return + + if (isEditMode) { + console.log('Редактирование сотрудника пока без мутации', { + id: employee?.id, + orgId, + lastName, + firstName, + middleName, + username, + password, + role, + }) + + onOpenChange(false) + return + } + signUp({ variables: { - organisationId, + orgId, + lastName, + firstName, + middleName, username, password, - groupId: groupId.trim() ? groupId : null, + role, }, }) } @@ -65,11 +155,13 @@ export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps)
- Добавить сотрудника + {isEditMode ? 'Редактировать' : 'Добавить сотрудника'} - Создание пользователя для доступа к смартфонам + {isEditMode + ? 'Изменение данных пользователя системы MDM' + : 'Создание пользователя для доступа к системе MDM'}
@@ -80,44 +172,103 @@ export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps)
+ +
+ + + +
+ + - +
+ + + +
- + {organisationsError && ( +
+ Не удалось загрузить список организаций +
+ )} {error && (
@@ -133,9 +284,9 @@ export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps)
diff --git a/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.scss b/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.scss new file mode 100644 index 0000000..ec67dd2 --- /dev/null +++ b/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.scss @@ -0,0 +1,366 @@ +@use '../../../../shared/styles/variables' as *; + +.add-organisation-modal__overlay { + position: fixed; + inset: 0; + z-index: 80; + + background: rgba(15, 23, 42, 0.42); + backdrop-filter: blur(2px); + + animation: addOrganisationOverlayShow 0.18s ease; +} + +.add-organisation-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: addOrganisationModalShow 0.2s ease; +} + +.add-organisation-modal__header { + margin-bottom: 22px; + + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.add-organisation-modal__title { + margin: 0; + + color: $color-text; + font-size: 24px; + font-weight: 650; +} + +.add-organisation-modal__description { + margin: 6px 0 0; + + color: $gray50; + font-size: 15px; + font-weight: 400; + line-height: 1.4; +} + +.add-organisation-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-organisation-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.add-organisation-field { + display: flex; + flex-direction: column; + gap: 8px; + + span { + color: $color-text; + font-size: 14px; + font-weight: 500; + } + + input { + height: 44px; + border: 1px solid #dfe5ef; + border-radius: 14px; + padding: 0 14px; + + background: #f8fafc; + outline: none; + + color: $color-text; + font-size: 15px; + font-weight: 400; + + transition: 0.2s ease; + + &:focus { + border-color: $blue; + background: #ffffff; + } + + &::placeholder { + color: $gray50; + } + } +} + +.add-organisation-error { + border-radius: 14px; + background: rgba(224, 0, 0, 0.08); + padding: 12px 14px; + + color: $red; + font-size: 14px; + font-weight: 500; +} + +.add-organisation-modal__footer { + margin-top: 8px; + + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.add-organisation-cancel, +.add-organisation-submit { + height: 42px; + padding: 0 18px; + + border: none; + border-radius: 14px; + + font-size: 15px; + font-weight: 600; + cursor: pointer; + + transition: 0.2s ease; + + &:disabled { + opacity: 0.6; + cursor: default; + } +} + +.add-organisation-cancel { + background: $color-bg; + color: $gray50; + + &:hover { + background: $gray20; + color: $color-text; + } +} + +.add-organisation-submit { + background: $blue; + color: #ffffff; + + &:hover { + opacity: 0.9; + } +} + +.add-organisation-policy { + margin-top: 2px; + padding: 14px; + border: 1px solid #dfe5ef; + border-radius: 18px; + background: #f8fafc; +} + +.add-organisation-policy__header { + margin-bottom: 8px; + + h3 { + margin: 0; + color: $color-text; + font-size: 16px; + font-weight: 650; + } + + p { + margin: 4px 0 0; + color: $gray50; + font-size: 13px; + font-weight: 500; + line-height: 1.35; + } +} + +.add-organisation-policy__list { + display: flex; + flex-direction: column; +} + +.add-organisation-policy-item { + display: flex; + align-items: center; + gap: 10px; + + svg { + width: 22px; + height: 22px; + padding: 4px; + border-radius: 8px; + color: $blue; + background: #eef1f6; + } + + &:first-child { + .add-organisation-policy-item__content { + border-top: none; + } + } + + &.is-disabled { + opacity: 0.45; + cursor: not-allowed; + + svg, + span, + button { + pointer-events: none; + } + } +} + +.add-organisation-policy-item__content { + min-height: 44px; + flex: 1; + border-top: 1px solid $gray20; + + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + > span { + color: $color-text; + font-size: 15px; + font-weight: 500; + } +} + +.add-organisation-policy-switch { + position: relative; + + width: 48px; + height: 24px; + flex: 0 0 48px; + padding: 0; + border: none; + outline: none; + + display: inline-flex; + align-items: center; + + border-radius: 999px; + background: $gray20; + box-shadow: inset 0 0 0 1px rgba($gray50, 0.08); + + cursor: pointer; + user-select: none; + + transition: + background-color 0.28s ease, + box-shadow 0.28s ease, + transform 0.18s ease; + + > span { + position: absolute; + top: 3px; + left: 3px; + + width: 24px; + height: 18px; + border-radius: 100px; + background: #ffffff; + + box-shadow: + 0 2px 5px rgba(15, 23, 42, 0.18), + 0 1px 1px rgba(15, 23, 42, 0.08); + + transition: + transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.28s ease, + width 0.18s ease; + } + + &.is-enabled { + background: $blue; + box-shadow: inset 0 0 0 1px rgba($blue, 0.12); + + > span { + transform: translateX(18px); + box-shadow: + 0 3px 7px rgba(3, 29, 154, 0.24), + 0 1px 1px rgba(15, 23, 42, 0.08); + } + } + + &:hover { + box-shadow: + inset 0 0 0 1px rgba($gray50, 0.12), + 0 4px 10px rgba(15, 23, 42, 0.06); + } + + &:active { + transform: scale(0.96); + + > span { + width: 23px; + } + } + + &.is-enabled:active > span { + transform: translateX(17px); + } + + &:focus-visible { + box-shadow: + 0 0 0 3px rgba($blue, 0.18), + inset 0 0 0 1px rgba($blue, 0.18); + } + + &:disabled { + cursor: not-allowed; + } +} + +@keyframes addOrganisationOverlayShow { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes addOrganisationModalShow { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.98); + } + + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.tsx b/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.tsx new file mode 100644 index 0000000..cf7f902 --- /dev/null +++ b/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.tsx @@ -0,0 +1,369 @@ +import { useEffect, useState, type FormEvent, type ReactNode } from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { useApolloClient, useMutation } from '@apollo/client/react' + +import { WifiIcon } from '../../../../assets/icons/Wifi' +import { BluetoothIcon } from '../../../../assets/icons/Bluetooth' +import { GpsIcon } from '../../../../assets/icons/Gps' +import { CameraIcon } from '../../../../assets/icons/Camera' +import { SimIcon } from '../../../../assets/icons/Sim' +import { VolumeIcon } from '../../../../assets/icons/Volume' +import { + CHANGE_ORGANISATION_MUTATION, + CREATE_ORGANISATION_MUTATION, + GET_ORGANISATION_QUERY, + GET_ORGANISATIONS_QUERY, +} from '../../../../entities/employee/api/employee.graphql' +import type { + ChangeOrganisationData, + ChangeOrganisationVariables, + CreateOrganisationData, + CreateOrganisationVariables, + GetOrganisationData, + GetOrganisationVariables, + Organisation, + OrganisationPolicy, +} from '../../../../entities/employee/model/types' + +import './AddOrganisationModal.scss' + +type AddOrganisationModalProps = { + open: boolean + mode?: 'create' | 'edit' + organisation?: Organisation | null + onOpenChange: (open: boolean) => void + onSuccess?: () => void | Promise +} + +type ModalPolicyKey = + | keyof OrganisationPolicy + | 'wifi' + | 'speaker' + +type ModalPolicyItem = { + key: ModalPolicyKey + label: string + icon: ReactNode + disabled?: boolean +} + +const defaultOrganisationPolicy: OrganisationPolicy = { + canUseBluetooth: true, + canUseCamera: true, + canUseGPS: true, + canUseSim: true, +} + +const policyItems: ModalPolicyItem[] = [ + { + key: 'wifi', + label: 'Wi-Fi', + icon: , + disabled: true, + }, + { + key: 'canUseBluetooth', + label: 'Bluetooth', + icon: , + }, + { + key: 'canUseGPS', + label: 'GPS', + icon: , + }, + { + key: 'canUseCamera', + label: 'Камера', + icon: , + }, + { + key: 'canUseSim', + label: 'SIM-карта', + icon: , + }, + { + key: 'speaker', + label: 'Динамик', + icon: , + disabled: true, + }, +] + +const defaultOrganisationsQueryVariables = { + page: 0, + query: undefined, + sortDirection: 'ASC' as const, + sortField: 'ID' as const, +} + +function getPolicyVariables(policy?: OrganisationPolicy | null): OrganisationPolicy { + return { + canUseBluetooth: policy?.canUseBluetooth ?? true, + canUseCamera: policy?.canUseCamera ?? true, + canUseGPS: policy?.canUseGPS ?? true, + canUseSim: policy?.canUseSim ?? true, + } +} + +function isBackendPolicyKey(key: ModalPolicyKey): key is keyof OrganisationPolicy { + return ( + key === 'canUseBluetooth' || + key === 'canUseCamera' || + key === 'canUseGPS' || + key === 'canUseSim' + ) +} + +export function AddOrganisationModal({ + open, + mode = 'create', + organisation = null, + onOpenChange, + onSuccess, +}: AddOrganisationModalProps) { + const client = useApolloClient() + + const [name, setName] = useState('') + const [policy, setPolicy] = useState( + defaultOrganisationPolicy, + ) + + const isEditMode = mode === 'edit' + + const [createOrganisation, { loading: isCreating, error: createError }] = + useMutation( + CREATE_ORGANISATION_MUTATION, + ) + + const [changeOrganisation, { loading: isChanging, error: changeError }] = + useMutation( + CHANGE_ORGANISATION_MUTATION, + ) + + const isLoading = isCreating || isChanging + const mutationError = createError || changeError + + useEffect(() => { + if (!open) return + + if (isEditMode && organisation) { + setName(organisation.name ?? '') + return + } + + setName('') + setPolicy(defaultOrganisationPolicy) + }, [open, isEditMode, organisation]) + + function handlePolicyToggle(key: ModalPolicyKey, disabled?: boolean) { + if (disabled || !isBackendPolicyKey(key)) return + + setPolicy((prev) => ({ + ...prev, + [key]: !prev[key], + })) + } + + function getPolicyValue(key: ModalPolicyKey) { + if (!isBackendPolicyKey(key)) return true + + return policy[key] + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + + const trimmedName = name.trim() + + if (!trimmedName) return + + if (isEditMode) { + if (!organisation) return + + let currentPolicy = organisation.policy + + if (!currentPolicy) { + const result = await client.query< + GetOrganisationData, + GetOrganisationVariables + >({ + query: GET_ORGANISATION_QUERY, + variables: { + id: String(organisation.id), + }, + fetchPolicy: 'network-only', + }) + + currentPolicy = result.data?.getOrganisation?.policy ?? null + } + + const policyVariables = getPolicyVariables(currentPolicy) + + await changeOrganisation({ + variables: { + id: String(organisation.id), + name: trimmedName, + ...policyVariables, + }, + refetchQueries: [ + { + query: GET_ORGANISATION_QUERY, + variables: { + id: String(organisation.id), + }, + }, + { + query: GET_ORGANISATIONS_QUERY, + variables: defaultOrganisationsQueryVariables, + }, + ], + }) + + await onSuccess?.() + + onOpenChange(false) + return + } + + console.log('createOrganisation variables', { + name: trimmedName, + ...policy, + }) + + await createOrganisation({ + variables: { + name: trimmedName, + ...policy, + }, + }) + + await onSuccess?.() + + setName('') + setPolicy(defaultOrganisationPolicy) + onOpenChange(false) + } + + return ( + + + + + +
+
+ + {isEditMode ? 'Редактировать организацию' : 'Добавить организацию'} + + + + {isEditMode + ? 'Изменение данных организации' + : 'Создание организации для привязки пользователей и устройств'} + +
+ + + + +
+ +
+ + + {!isEditMode && ( +
+
+

Начальная политика

+

Будет применяться к сотрудникам этой организации

+
+ +
+ {policyItems.map((item) => { + const enabled = getPolicyValue(item.key) + + return ( +
+ {item.icon} + +
+ {item.label} + + +
+
+ ) + })} +
+
+ )} + + {mutationError && ( +
+ {isEditMode + ? 'Не удалось сохранить организацию. Проверьте данные.' + : 'Не удалось добавить организацию. Проверьте данные.'} +
+ )} + +
+ + Отмена + + + +
+
+
+
+
+ ) +} \ 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 index 80fdda8..ed7bf82 100644 --- a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss +++ b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss @@ -4,15 +4,19 @@ display: flex; align-items: center; gap: 8px; + transition: .2s ease; svg{ height: 18px; width: auto; } + &:hover{ + opacity: .85; + } } .employees-sort { //height: 32px; - padding: 12px 14px 12px 20px !important; + padding: 11px 14px 11px 20px !important; border: none; border-radius: 20px; background: #ffffff; @@ -23,7 +27,7 @@ color: $gray50; font-size: 14px; - font-weight: 500; + font-weight: 550 !important; cursor: pointer; transition: 0.2s ease; @@ -32,21 +36,24 @@ outline: none; } - &:hover { + &:hover{ + background-color: $gray20; + } + + &[data-state='open'] { color: $blue; + background-color: $blue20; + } + svg{ + height: 22px; + width: auto; } } -.employees-sort__chevron { - transition: transform 0.2s ease; -} .employees-sort[data-state='open'] { color: $blue; - .employees-sort__chevron { - transform: rotate(180deg); - } } .employees-sort-menu { diff --git a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx index 158c34f..41e4190 100644 --- a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx +++ b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx @@ -1,85 +1,181 @@ -import { useState } from 'react' -import { ChevronDown, Search } from 'lucide-react' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { Search } from 'lucide-react' + +import type { + OrganisationSortDirection, + OrganisationSortField, +} from '../../../../entities/employee/model/types' -import '../../../DevicesPage/components/DevicesToolbar/DevicesToolbar.scss' import './EmployeesToolbar.scss' -type EmployeesToolbarProps = { - onAddEmployee: () => void +export type EmployeesSortOption = { + value: string + label: string + sortField: OrganisationSortField + sortDirection: OrganisationSortDirection } -const sortOptions = [ - { - value: 'default', - label: 'По умолчанию', - }, - { - value: 'id-asc', - label: 'ID по возрастанию', - }, - { - value: 'id-desc', - label: 'ID по убыванию', - }, - { - value: 'role-asc', - label: 'Роль А–Я', - }, - { - value: 'role-desc', - label: 'Роль Я–А', - }, +export const employeesSortOptions: EmployeesSortOption[] = [ + { + value: 'id-asc', + label: 'ID по возрастанию', + sortField: 'ID', + sortDirection: 'ASC', + }, + { + value: 'id-desc', + label: 'ID по убыванию', + sortField: 'ID', + sortDirection: 'DESC', + }, + { + value: 'name-asc', + label: 'Название А–Я', + sortField: 'Name', + sortDirection: 'ASC', + }, + { + value: 'name-desc', + label: 'Название Я–А', + sortField: 'Name', + sortDirection: 'DESC', + }, + { + value: 'date-desc', + label: 'Сначала новые', + sortField: 'Date', + sortDirection: 'DESC', + }, + { + value: 'date-asc', + label: 'Сначала старые', + sortField: 'Date', + sortDirection: 'ASC', + }, ] -export function EmployeesToolbar({ onAddEmployee }: EmployeesToolbarProps) { - const [selectedSort, setSelectedSort] = useState(sortOptions[0]) +type EmployeesToolbarProps = { + addButtonText: string + onAdd: () => void - return ( -
- + searchValue?: string + onSearchChange?: (value: string) => void -
- - - - - + selectedSort?: EmployeesSortOption + onSortChange?: (option: EmployeesSortOption) => void - - + + +
+
-
- ) + {addButtonText} + + + {showSort && ( + + + + + + + + {employeesSortOptions.map((option) => ( + onSortChange?.(option)} + > + {option.label} + + {selectedSort.value === option.value && ( + + )} + + ))} + + + + )} +
+
+ ) } \ No newline at end of file diff --git a/mdm-front/src/pages/LoginPage/LoginPage.tsx b/mdm-front/src/pages/LoginPage/LoginPage.tsx index e56d33b..e2a39b6 100644 --- a/mdm-front/src/pages/LoginPage/LoginPage.tsx +++ b/mdm-front/src/pages/LoginPage/LoginPage.tsx @@ -10,7 +10,7 @@ type LoginPageProps = { } export function LoginPage({ onSuccess }: LoginPageProps) { - const [username, setUsername] = useState('User1') + const [username, setUsername] = useState('Admin1') const [password, setPassword] = useState('123456') const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, { diff --git a/mdm-front/src/pages/MapPage/MapPage.scss b/mdm-front/src/pages/MapPage/MapPage.scss index c9569a9..e56a0e4 100644 --- a/mdm-front/src/pages/MapPage/MapPage.scss +++ b/mdm-front/src/pages/MapPage/MapPage.scss @@ -2,6 +2,8 @@ .map-page { display: flex; + position: relative; + z-index: 99; flex: 1; min-height: 0; height: 100%; @@ -163,4 +165,63 @@ margin-top: 8px; color: #738098; font-size: 12px; +} + +.leaflet-touch .leaflet-bar { + border: none !important; + background-clip: padding-box; + display: flex; + flex-direction: column; + gap: 4px; +} + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + --sans: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + ; + font: 28px/100% var(--sans) !important; + width: 36px !important; + height: 36px !important; + display: flex !important; + align-items: center; + justify-content: center; + font-weight: 500 !important; + text-indent: 0px !important; + border-radius: 8px !important; + border: none !important; + transition: .2s ease; + + &:hover { + background-color: white !important; + color: $blue !important; + } +} + +.leaflet-top.leaflet-left { + top: 50%; + left: auto; + right: 12px; + transform: translateY(-50%); +} + +.leaflet-left .leaflet-control { + margin-left: 0px !important; +} + +.leaflet-top .leaflet-control { + margin-top: 0px !important; +} + +.map-track-filter { + position: absolute; + top: 12px; + left: 12px; + z-index: 600; + border-radius: 24px; + + display: flex; + flex-direction: column; + gap: 12px; + + box-shadow: 0 14px 40px rgba(15, 23, 42, 0.16); } \ 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 ee182da..649a0e8 100644 --- a/mdm-front/src/pages/MapPage/MapPage.tsx +++ b/mdm-front/src/pages/MapPage/MapPage.tsx @@ -1,13 +1,38 @@ +import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { useQuery } from '@apollo/client/react' -import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet' +import { + CircleMarker, + MapContainer, + Marker, + Polyline, + Popup, + TileLayer, + useMap, + useMapEvents, +} 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 { + GET_PHONE_GPS_TRACK_QUERY, + GET_PHONES_PAGE_QUERY, +} from '../../entities/device/api/device.graphql' +import type { + Device, + DeviceGpsTrackPoint, + GetPhoneGpsTrackData, + GetPhoneGpsTrackVariables, + GetPhonesPageData, + GetPhonesPageVariables, +} from '../../entities/device/model/types' + +import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl' +import { + MapTrackPeriodControl, + type MapTrackPeriodValue, +} from './components/MapTrackPeriodControl/MapTrackPeriodControl' import './MapPage.scss' -import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl' const markerIcon = L.divIcon({ className: 'device-map-marker', @@ -30,20 +55,153 @@ function formatLocationDate(timestamp: number) { year: 'numeric', hour: '2-digit', minute: '2-digit', - }).format(new Date(timestamp * 1000)) + }).format(new Date(timestamp)) +} + +function sortGpsTrack(track: DeviceGpsTrackPoint[]) { + return [...track].sort((a, b) => a.date - b.date) +} + +function isDateInPeriod(date: number, period: MapTrackPeriodValue | null) { + if (!period) return false + + return ( + date >= period.periodStart.getTime() && + date <= period.periodEnd.getTime() + ) +} + +type MapResetHandlerProps = { + onReset: () => void +} + +function MapResetHandler({ onReset }: MapResetHandlerProps) { + useMapEvents({ + click: () => { + onReset() + }, + }) + + return null +} + +type MapFitBoundsProps = { + positions: [number, number][] +} + +function MapFitBounds({ positions }: MapFitBoundsProps) { + const map = useMap() + + useEffect(() => { + if (!positions.length) return + + if (positions.length === 1) { + map.setView(positions[0], 14) + return + } + + const bounds = L.latLngBounds(positions) + + map.fitBounds(bounds, { + padding: [60, 60], + maxZoom: 15, + animate: true, + }) + }, [map, positions]) + + return null } export function MapPage() { - const { data, loading, error } = useQuery( - GET_PHONES_PAGE_QUERY, + const [selectedDevice, setSelectedDevice] = useState(null) + const [selectedPeriod, setSelectedPeriod] = + useState(null) + + const { data, loading, error } = useQuery< + GetPhonesPageData, + GetPhonesPageVariables + >(GET_PHONES_PAGE_QUERY, { + variables: {}, + fetchPolicy: 'network-only', + }) + + const { + data: gpsTrackData, + loading: isGpsTrackLoading, + error: gpsTrackError, + } = useQuery( + GET_PHONE_GPS_TRACK_QUERY, { - variables: {}, + variables: { + phoneId: String(selectedDevice?.id), + startDate: selectedPeriod?.periodStart.getTime() ?? 0, + endDate: selectedPeriod?.periodEnd.getTime(), + }, + skip: !selectedDevice || !selectedPeriod, fetchPolicy: 'network-only', }, ) const devices = data?.getPhonesPage.page ?? [] - const devicesWithLocation = devices.filter((device) => device.lastLocation) + + const devicesWithLocation = devices.filter((device) => + Boolean(device.lastLocation), + ) + + const sortedGpsTrack = useMemo(() => { + return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? []) + }, [gpsTrackData]) + + const shouldShowCurrentLocation = Boolean( + selectedDevice?.lastLocation && + isDateInPeriod(selectedDevice.lastLocation.date, selectedPeriod), + ) + + const selectedCurrentLocation = + shouldShowCurrentLocation && selectedDevice?.lastLocation + ? selectedDevice.lastLocation + : null + + const selectedRoutePoints = useMemo(() => { + if (!selectedCurrentLocation) { + return sortedGpsTrack + } + + return sortedGpsTrack.filter((point) => { + return point.date !== selectedCurrentLocation.date + }) + }, [sortedGpsTrack, selectedCurrentLocation]) + + const selectedRoutePositions = useMemo<[number, number][]>(() => { + const routePositions = selectedRoutePoints.map( + (point) => [point.lat, point.lng] as [number, number], + ) + + if (!selectedCurrentLocation) { + return routePositions + } + + return [ + ...routePositions, + [selectedCurrentLocation.lat, selectedCurrentLocation.lng], + ] + }, [selectedRoutePoints, selectedCurrentLocation]) + + const isSelectedMode = Boolean(selectedDevice) + + const visibleMapPositions = useMemo<[number, number][]>(() => { + if (isSelectedMode && selectedRoutePositions.length > 0) { + return selectedRoutePositions + } + + return devicesWithLocation.map( + (device) => + [ + device.lastLocation!.lat, + device.lastLocation!.lng, + ] as [number, number], + ) + }, [isSelectedMode, selectedRoutePositions, devicesWithLocation]) const firstLocation = devicesWithLocation[0]?.lastLocation @@ -51,44 +209,26 @@ export function MapPage() { ? [firstLocation.lat, firstLocation.lng] : [55.397243, 86.117034] + function handleSelectDevice(device: Device) { + setSelectedDevice(device) + setSelectedPeriod(null) + } + + function handleResetSelection() { + setSelectedDevice(null) + setSelectedPeriod(null) + } + return (
- {/*
-
- - {loading &&

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

} - - {error && ( -

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

- )} - - {!loading && !error && ( - <> - -
- {devicesWithLocation.map((device) => ( - - {device.serial || `Устройство #${device.id}`} - - {device.lastLocation - ? formatLocationDate(device.lastLocation.date) - : 'Нет данных'} - - - ))} -
- - )} -
-
*/} -
+ {selectedDevice && ( +
+ + +
+ )} + - + + - {devicesWithLocation.map((device) => { - if (!device.lastLocation) return null + - return ( - - -
- - {device.serial || `Устройство #${device.id}`} - + {!isSelectedMode && + devicesWithLocation.map((device) => { + if (!device.lastLocation) return null -
- {formatLocationDate(device.lastLocation.date)} + return ( + { + event.originalEvent.stopPropagation() + handleSelectDevice(device) + }, + }} + > + +
+ + {device.serial || `Устройство #${device.id}`} + + +
+ {formatLocationDate(device.lastLocation.date)} +
+ +
+ {device.lastLocation.lat}, {device.lastLocation.lng} +
+
+
+ ) + })} -
- {device.lastLocation.lat}, {device.lastLocation.lng} + {isSelectedMode && ( + <> + {selectedRoutePositions.length > 1 && ( + + )} + + {selectedRoutePoints.map((point, index) => ( + + +
+
+ {formatLocationDate(point.date)} +
+ +
+ {point.lat}, {point.lng} +
-
- - - ) - })} + + + ))} + + {selectedCurrentLocation && ( + { + event.originalEvent.stopPropagation() + }, + }} + > + +
+ + {selectedDevice?.serial || + `Устройство #${selectedDevice?.id}`} + + +
+ + Текущее местоположение:{' '} + {formatLocationDate(selectedCurrentLocation.date)} + +
+ +
+ {selectedCurrentLocation.lat},{' '} + {selectedCurrentLocation.lng} +
+
+
+
+ )} + + )} +
diff --git a/mdm-front/src/pages/MapPage/components/MapTrackPeriodControl/MapTrackPeriodControl.scss b/mdm-front/src/pages/MapPage/components/MapTrackPeriodControl/MapTrackPeriodControl.scss new file mode 100644 index 0000000..ed293b9 --- /dev/null +++ b/mdm-front/src/pages/MapPage/components/MapTrackPeriodControl/MapTrackPeriodControl.scss @@ -0,0 +1,346 @@ +@use '../../../../shared/styles/variables' as *; + +.history-period-control { + display: flex; + flex-direction: column; + gap: 18px; +} + +.history-period-control__top { + display: flex; + align-items: center; + gap: 10px; +} + +.history-period-tabs { + padding: 2px; + border-radius: 999px; + background: rgba($color-bg, .8); + backdrop-filter: blur(10px); + display: flex; + align-items: center; + gap: 2px; +} + +.history-period-tabs__item { + height: 32px; + padding: 0 16px; + + border: none; + border-radius: 999px; + background: transparent; + + color: $gray50; + font-size: 14px; + font-weight: 550; + cursor: pointer; + + transition: 0.2s ease; + + &:hover { + color: $blue; + } + + &.is-active { + background: #ffffff; + color: $blue; + } +} + +.history-date-picker { + position: relative; +} + +.history-date-picker__trigger { + height: 36px; + padding: 0 14px; + + border: none; + border-radius: 999px; + background: rgba($color-bg, .8); + backdrop-filter: blur(10px); + + display: inline-flex; + align-items: center; + gap: 8px; + + color: #30394b; + font-size: 14px; + font-weight: 500; + + cursor: pointer; + transition: 0.2s ease; + + svg { + color: $gray50; + transition: .2s ease; + } + + &:hover, + &.is-active { + background: rgba($color-bg, 1); + color: $blue; + box-shadow: inset 0 0 0 1px rgba(3, 29, 154, 0.16); + + svg { + color: $blue; + } + } +} + +.history-date-picker__dropdown { + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 120; + + border-radius: 18px; + background: #ffffff; + padding: 14px; + + border: 1.5px solid $gray20; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16); + + button{ + transition: .18s ease; + } + + .rdp-root { + --rdp-accent-color: $blue; + --rdp-accent-background-color: rgba(3, 29, 154, 0.1); + --rdp-day_button-border-radius: 10px; + --rdp-selected-border: 0; + + margin: 0; + } + + .rdp-months { + gap: 18px; + } + + .rdp-month_caption { + margin-bottom: 8px; + } + + .rdp-caption_label { + color: #30394b; + font-size: 15px; + font-weight: 600; + } + + .rdp-nav { + button { + width: 30px; + height: 30px; + border-radius: 10px; + + color: $gray50; + + &:hover { + background: $color-bg; + color: $blue; + } + } + } + + .rdp-weekday { + color: $gray50; + font-size: 12px; + font-weight: 500; + } + + .rdp-day { + width: 38px; + height: 38px; + padding: 0; + } + + .rdp-day_button { + width: 36px; + height: 36px; + + border: none; + border-radius: 10px; + + color: #30394b; + font-size: 14px; + font-weight: 500; + + &:hover { + background: $color-bg; + } + } + + .rdp-selected { + .rdp-day_button { + background: $blue; + color: #ffffff; + } + } + + .rdp-range_middle { + background-color: transparent; + .rdp-day_button { + background: #e8edff; + color: $blue; + border-radius: 0; + } + } + + .rdp-range_start { + .rdp-day_button { + border-radius: 10px; + } + } + + .rdp-range_end { + .rdp-day_button { + border-radius: 10px; + } + } + + .rdp-range_start.rdp-range_end { + .rdp-day_button { + border-radius: 10px; + } + + .rdp-today { + .rdp-day_button { + font-weight: 700; + } + } + } + + .rdp-outside { + opacity: 0.35; + } +} + +.history-range { + width: 100%; + padding: 0 60px; +} + +.history-range__labels { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + + color: $blue; + font-size: 14px; + font-weight: 500; +} + +.history-range__track { + position: relative; + height: 24px; +} + +.history-range__line { + position: absolute; + left: 0; + right: 0; + top: 10px; + + height: 4px; + border-radius: 999px; + background: rgba(3, 29, 154, 0.16); +} + +.history-range__active { + position: absolute; + top: 10px; + + height: 4px; + border-radius: 999px; + background: $blue; +} + +.history-range__track input { + position: absolute; + inset: 0; + + width: 100%; + height: 24px; + margin: 0; + + appearance: none; + background: transparent; + pointer-events: none; +} + +.history-range__track input::-webkit-slider-thumb { + appearance: none; + pointer-events: auto; + + width: 16px; + height: 16px; + + border: none; + border-radius: 50%; + background: $blue; + + cursor: pointer; +} + +.history-range__track input::-moz-range-thumb { + pointer-events: auto; + + width: 16px; + height: 16px; + + border: none; + border-radius: 50%; + background: $blue; + + cursor: pointer; +} + +/* react-day-picker */ + +.history-date-picker__dropdown .rdp-root { + --rdp-accent-color: $blue; + --rdp-accent-background-color: rgba(3, 29, 154, 0.1); + --rdp-day_button-border-radius: 10px; + --rdp-selected-border: 0; + + margin: 0; +} + +.history-date-picker__dropdown .rdp-months { + gap: 18px; +} + +.history-date-picker__dropdown .rdp-caption_label { + color: #30394b; + font-size: 15px; + font-weight: 600; +} + +.history-date-picker__dropdown .rdp-weekday { + color: $gray50; + font-size: 12px; + font-weight: 500; +} + +.history-date-picker__dropdown .rdp-day_button { + width: 36px; + height: 36px; + + color: #30394b; + font-size: 14px; +} + +.history-date-picker__dropdown .rdp-selected .rdp-day_button { + background: $blue; + color: #ffffff; +} + +.history-date-picker__dropdown .rdp-range_middle .rdp-day_button { + background: #e8edff; + color: $blue; +} + +.history-date-picker__dropdown .rdp-today .rdp-day_button { + font-weight: 700; +} \ No newline at end of file diff --git a/mdm-front/src/pages/MapPage/components/MapTrackPeriodControl/MapTrackPeriodControl.tsx b/mdm-front/src/pages/MapPage/components/MapTrackPeriodControl/MapTrackPeriodControl.tsx new file mode 100644 index 0000000..e21ca57 --- /dev/null +++ b/mdm-front/src/pages/MapPage/components/MapTrackPeriodControl/MapTrackPeriodControl.tsx @@ -0,0 +1,208 @@ +import { useEffect, useMemo, useState } from 'react' +import { DayPicker } from 'react-day-picker' +import type { DateRange } from 'react-day-picker' +import { ru } from 'date-fns/locale/ru' +import { CalendarDays, ChevronDown } from 'lucide-react' + +import './MapTrackPeriodControl.scss' + +type PeriodPreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom' + +export type MapTrackPeriodValue = { + preset: PeriodPreset + periodStart: Date + periodEnd: Date +} + +type MapTrackPeriodControlProps = { + onChange?: (value: MapTrackPeriodValue) => void +} + +const presetTabs: Array<{ + value: PeriodPreset + label: string +}> = [ + { value: 'today', label: 'Сегодня' }, + { value: 'yesterday', label: 'Вчера' }, + { value: 'week', label: 'Неделя' }, + { value: 'month', label: 'Месяц' }, +] + +function startOfDay(date: Date) { + const result = new Date(date) + result.setHours(0, 0, 0, 0) + return result +} + +function endOfDay(date: Date) { + const result = new Date(date) + result.setHours(23, 59, 59, 999) + return result +} + +function addDays(date: Date, days: number) { + const result = new Date(date) + result.setDate(result.getDate() + days) + return result +} + +function addMonths(date: Date, months: number) { + const result = new Date(date) + result.setMonth(result.getMonth() + months) + return result +} + +function getPresetRange(preset: PeriodPreset) { + const now = new Date() + + if (preset === 'today') { + return { + start: startOfDay(now), + end: now, + } + } + + if (preset === 'yesterday') { + const yesterday = addDays(now, -1) + + return { + start: startOfDay(yesterday), + end: endOfDay(yesterday), + } + } + + if (preset === 'week') { + return { + start: startOfDay(addDays(now, -6)), + end: now, + } + } + + if (preset === 'month') { + return { + start: startOfDay(addMonths(now, -1)), + end: now, + } + } + + return { + start: startOfDay(now), + end: now, + } +} + +function formatDate(date: Date) { + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(date) +} + +export function MapTrackPeriodControl({ onChange }: MapTrackPeriodControlProps) { + const initialRange = useMemo(() => getPresetRange('today'), []) + + const [preset, setPreset] = useState('today') + const [isCalendarOpen, setIsCalendarOpen] = useState(false) + + const [periodStart, setPeriodStart] = useState(initialRange.start) + const [periodEnd, setPeriodEnd] = useState(initialRange.end) + + const [calendarRange, setCalendarRange] = useState({ + from: initialRange.start, + to: initialRange.end, + }) + + function applyPeriod(nextPreset: PeriodPreset, start: Date, end: Date) { + setPreset(nextPreset) + setPeriodStart(start) + setPeriodEnd(end) + + setCalendarRange({ + from: start, + to: end, + }) + } + + function handlePresetClick(nextPreset: PeriodPreset) { + const nextRange = getPresetRange(nextPreset) + applyPeriod(nextPreset, nextRange.start, nextRange.end) + } + + function handleCalendarSelect(nextRange: DateRange | undefined) { + setCalendarRange(nextRange) + + if (!nextRange?.from) return + + const from = startOfDay(nextRange.from) + const to = nextRange.to ? endOfDay(nextRange.to) : endOfDay(nextRange.from) + + applyPeriod('custom', from, to) + } + + useEffect(() => { + onChange?.({ + preset, + periodStart, + periodEnd, + }) + }, [preset, periodStart, periodEnd, onChange]) + + return ( +
+
+
+ {presetTabs.map((tab) => ( + + ))} +
+ +
+ + + {isCalendarOpen && ( +
+ +
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/OrganisationPage/OrganisationPage.scss b/mdm-front/src/pages/OrganisationPage/OrganisationPage.scss new file mode 100644 index 0000000..eeecb42 --- /dev/null +++ b/mdm-front/src/pages/OrganisationPage/OrganisationPage.scss @@ -0,0 +1,390 @@ +@use '../../shared/styles/variables' as *; + +.organisation-page { + display: flex; + flex-direction: column; + gap: 20px; + min-height: 0; +} + +.organisation-breadcrumbs { + display: flex; + align-items: center; + gap: 6px; + + color: $gray50; + font-size: 16px; + font-weight: 500; + padding: 11px 0; + + a { + color: $gray50; + text-decoration: none; + transition: 0.2s ease; + + &:hover { + color: $blue; + } + } + + span:last-child { + color: $color-text; + } +} + +.organisation-page__top { + display: grid; + grid-template-columns: minmax(515px, 550px) minmax(420px, 1fr); + gap: 20px; + align-items: stretch; +} + +.organisation-card { + padding: 20px; + border-radius: 20px; + background: #ffffff; + + display: flex; + flex-direction: column; + gap: 20px; + height: fit-content; +} + +.organisation-card__main { + display: flex; + align-items: center; + gap: 16px; +} + +.organisation-card__avatar { + width: 96px; + height: 96px; + flex: 0 0 96px; + border-radius: 28px; + + display: flex; + align-items: center; + justify-content: center; + + background: + radial-gradient(circle at 25% 20%, rgba(255, 255, 255, 0.1), transparent 32%), + linear-gradient(135deg, $blue, #425de8); + color: #ffffff; + font-size: 28px; + font-weight: 750; + letter-spacing: 0.04em; +} + +.organisation-card__info { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; + gap: 0px; + + h2 { + margin: 0px 0 4px; + color: $color-text; + font-size: 30px; + font-weight: 650; + line-height: 1.1; + + span { + margin: 0 0 0 8px; + color: $gray50; + font-size: 16px; + font-weight: 500; + } + } + + p { + margin: 0px 0 0; + color: $gray50; + font-size: 15px; + font-weight: 500; + } +} + +.organisation-card__actions { + margin-top: auto; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.organisation-action-btn { + height: 38px; + padding: 0 12px; + + border: none; + border-radius: 12px; + + display: inline-flex; + align-items: center; + gap: 6px; + + font-size: 14px; + font-weight: 600; + cursor: pointer; + opacity: 0.88; + + box-shadow: inset 0 0 0 transparent; + transition: + background-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease, + box-shadow 0.2s ease; + + &:hover { + opacity: 1; + } +} + +.organisation-action-btn--edit { + background: $blue20; + color: $blue; + + &:hover { + box-shadow: inset 0 0 0 1px $blue; + } +} + +.organisation-action-btn--delete { + background: rgba($red, 0.12); + color: $red; + + &:hover { + box-shadow: inset 0 0 0 1px $red; + } +} + +.organisation-employees-card { + min-height: 0; + border-radius: 20px; + background: #ffffff; + overflow: hidden; + + display: flex; + flex-direction: column; +} + +.organisation-employees-card__header { + padding: 18px 20px; + border-bottom: 1px solid $gray20; + + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + + h3 { + margin: 0; + color: $color-text; + font-size: 22px; + font-weight: 650; + } + + p { + margin: 6px 0 0; + color: $gray50; + font-size: 15px; + font-weight: 500; + } + + >span { + min-width: 38px; + height: 32px; + padding: 0 12px; + border-radius: 999px; + background: $blue20; + + display: inline-flex; + align-items: center; + justify-content: center; + + color: $blue; + font-size: 16px; + font-weight: 650; + } +} + +.organisation-employees-table-card { + min-height: 260px; + overflow: auto; + + 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; + } +} + +.organisation-employees-table { + width: 100%; + min-width: 900px; + 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 $gray20; + + 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: $color-text; + font-size: 17px; + font-weight: 400; + } + + th:nth-child(1), + td:nth-child(1) { + width: 90px; + } + + th:nth-child(2), + td:nth-child(2) { + width: 36%; + } + + th:nth-child(3), + td:nth-child(3) { + width: 34%; + } + + th:nth-child(4), + td:nth-child(4) { + width: 180px; + } + + tbody tr { + transition: 0.2s ease; + box-shadow: inset 0 0 0 transparent; + + &:hover { + background-color: rgba($blue, 0.025); + box-shadow: inset 3px 0 0 rgba($blue, 0.8); + } + + &:last-child { + td { + border-bottom: none; + } + } + } +} + +.organisation-employee-person { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + + span { + color: $color-text; + font-size: 17px; + font-weight: 500; + line-height: 1.2; + } +} + +.organisation-employee-org { + color: $gray50; + font-size: 16px; + font-weight: 400; +} + +.organisation-employee-role { + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + + display: inline-flex; + align-items: center; + + background: rgba($blue, 0.08); + color: $blue; + + font-size: 16px; + font-weight: 500; +} + +.organisation-employee-role--admin { + background: rgba($orange, .2); + color: $orange; +} + +.organisation-page__state, +.organisation-page__empty { + min-height: 220px; + border-radius: 20px; + background: #ffffff; + + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + + color: $gray50; + font-size: 16px; + font-weight: 500; + + h2 { + margin: 0; + color: $color-text; + font-size: 24px; + } + + a { + color: $blue; + text-decoration: none; + font-weight: 600; + } +} + +.organisation-page__state { + border-radius: 0; +} + +.organisation-page__state--error, +.organisation-page__empty--error { + color: $red; +} + +@media (max-width: 1180px) { + .organisation-page__top { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/OrganisationPage/OrganisationPage.tsx b/mdm-front/src/pages/OrganisationPage/OrganisationPage.tsx new file mode 100644 index 0000000..d41fcfe --- /dev/null +++ b/mdm-front/src/pages/OrganisationPage/OrganisationPage.tsx @@ -0,0 +1,337 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { useApolloClient, useQuery } from '@apollo/client/react' +import { Building2, Pencil, Trash2 } from 'lucide-react' + +import { + GET_ORGANISATION_QUERY, + GET_USERS_PAGE_QUERY, +} from '../../entities/employee/api/employee.graphql' +import type { + Employee, + GetOrganisationData, + GetOrganisationVariables, + GetUsersPageData, + GetUsersPageVariables, + Organisation, +} from '../../entities/employee/model/types' +import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog' +import { AddOrganisationModal } from '../EmployeesPage/components/AddOrganisationModal/AddOrganisationModal' +import { OrganisationPolicyCard } from './components/OrganisationPolicyCard/OrganisationPolicyCard' + +import './OrganisationPage.scss' + +type LoadStatus = 'idle' | 'loading' | 'success' | 'error' + +function getEmployeeFullName(employee: Employee) { + return [employee.lastName, employee.firstName, employee.middleName] + .filter(Boolean) + .join(' ') +} + +function getEmployeeRoleLabel(role: string) { + if (role === 'Admin') return 'Администратор' + if (role === 'User') return 'Пользователь' + + return role +} + +function getOrganisationInitials(name: string) { + const words = name.trim().split(/\s+/).filter(Boolean) + + if (words.length === 0) return 'ОР' + + return words + .slice(0, 2) + .map((word) => word[0]) + .join('') + .toUpperCase() +} + +function formatCreationDate(timestamp: number) { + if (!timestamp) return 'Нет данных' + + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(timestamp)) +} + +export function OrganisationPage() { + const client = useApolloClient() + const navigate = useNavigate() + const { organisationId } = useParams() + + const [employees, setEmployees] = useState([]) + const [employeesStatus, setEmployeesStatus] = useState('idle') + const [editingOrganisation, setEditingOrganisation] = + useState(null) + const [deletingOrganisation, setDeletingOrganisation] = + useState(null) + + const { + data: organisationData, + loading: organisationLoading, + error: organisationError, + } = useQuery( + GET_ORGANISATION_QUERY, + { + variables: { + id: organisationId ?? '', + }, + skip: !organisationId, + fetchPolicy: 'network-only', + }, + ) + + const organisation = organisationData?.getOrganisation ?? null + + const organisationInitials = useMemo(() => { + if (!organisation) return 'ОР' + + return getOrganisationInitials(organisation.name) + }, [organisation]) + + const loadEmployees = useCallback(async () => { + if (!organisationId) { + setEmployeesStatus('error') + return + } + + setEmployeesStatus('loading') + + try { + let nextKey: string | null = null + let pageCounter = 0 + const allEmployees: Employee[] = [] + + do { + const result = await client.query< + GetUsersPageData, + GetUsersPageVariables + >({ + query: GET_USERS_PAGE_QUERY, + variables: nextKey ? { key: nextKey } : {}, + fetchPolicy: 'network-only', + }) + + const pageData = result.data?.getUsersPage + + if (!pageData) break + + allEmployees.push( + ...pageData.page.filter( + (employee) => String(employee.orgId) === organisationId, + ), + ) + + nextKey = pageData.nextKey + pageCounter += 1 + } while (nextKey && pageCounter < 50) + + setEmployees(allEmployees) + setEmployeesStatus('success') + } catch { + setEmployeesStatus('error') + } + }, [client, organisationId]) + + useEffect(() => { + void loadEmployees() + }, [loadEmployees]) + + function handleConfirmDeleteOrganisation() { + if (!deletingOrganisation) return + + console.log('Удаление организации пока без мутации', deletingOrganisation) + + setDeletingOrganisation(null) + navigate('/employees') + } + + if (!organisationId) { + return ( +
+
+

Некорректный ID организации

+ Вернуться к списку +
+
+ ) + } + + return ( +
+
+ Сотрудники + / + {organisation?.name ?? 'Организация'} +
+ + {organisationLoading && !organisation && ( +
Загрузка организации...
+ )} + + {organisationError && ( +
+ Не удалось загрузить организацию +
+ )} + + {!organisationLoading && !organisationError && !organisation && ( +
+

Организация не найдена

+ Вернуться к списку +
+ )} + + {organisation && ( + <> +
+
+
+
+ + +
+

+ {organisation.name} + ID: {organisation.id} +

+

Создана {formatCreationDate(organisation.creationDate)}

+
+ + + +
+
+
+
+ + +
+
+ +
+ {employeesStatus === 'loading' && employees.length === 0 && ( +
+ Загрузка сотрудников... +
+ )} + + {employeesStatus === 'error' && ( +
+ Не удалось загрузить сотрудников +
+ )} + + {employeesStatus === 'success' && employees.length === 0 && ( +
+ В этой организации пока нет сотрудников +
+ )} + + {employees.length > 0 && ( + + + + + + + + + + + + {employees.map((employee) => ( + + + + + + + + + + ))} + +
IDФИООрганизацияРоль
{employee.id} +
+ + {getEmployeeFullName(employee) || 'ФИО не указано'} + +
+
+ + {employee.org?.name || organisation.name} + + + + {getEmployeeRoleLabel(employee.role)} + +
+ )} +
+
+ +
+ + + + { + if (!open) { + setEditingOrganisation(null) + } + }} + /> + + { + if (!open) { + setDeletingOrganisation(null) + } + }} + onConfirm={handleConfirmDeleteOrganisation} + /> + + )} +
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/pages/OrganisationPage/components/OrganisationPolicyCard/OrganisationPolicyCard.scss b/mdm-front/src/pages/OrganisationPage/components/OrganisationPolicyCard/OrganisationPolicyCard.scss new file mode 100644 index 0000000..d824901 --- /dev/null +++ b/mdm-front/src/pages/OrganisationPage/components/OrganisationPolicyCard/OrganisationPolicyCard.scss @@ -0,0 +1,181 @@ +@use '../../../../shared/styles/variables' as *; + +.organisation-policy-card { + //height: 100%; + padding: 20px; + border-radius: 20px; + background: #ffffff; + display: flex; + flex-direction: column; + flex: 1; + margin-top: 20px; +} + +.organisation-policy-card__header { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + margin-bottom: 8px; + padding-bottom: 14px; + border-bottom: 1px solid $gray20; + + h3 { + margin: 0; + color: $color-text; + font-size: 22px; + font-weight: 650; + line-height: 1.15; + } + + p { + margin: 6px 0 0; + color: $gray50; + font-size: 15px; + font-weight: 500; + line-height: 1.35; + } +} + +.organisation-policy-card__list { + display: flex; + flex-direction: column; +} + +.organisation-policy-item { + display: flex; + align-items: center; + gap: 10px; + + color: $color-text; + font-size: 18px; + font-weight: 500; + + svg { + width: 24px; + height: 24px; + padding: 4px; + border-radius: 8px; + color: $blue; + background: $color-bg; + } + + &:first-child { + .organisation-policy-item__content { + border-top: none; + } + } + + &.is-disabled { + opacity: 0.45; + cursor: not-allowed; + + svg, + span, + button { + pointer-events: none; + } + + .organisation-policy-switch { + cursor: not-allowed; + box-shadow: inset 0 0 0 1px rgba($gray50, 0.08); + + &:disabled { + cursor: not-allowed; + } + } + } +} + +.organisation-policy-item__content { + min-height: 52px; + flex: 1; + border-top: 1px solid $gray20; + + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.organisation-policy-switch { + position: relative; + + width: 48px; + height: 24px; + flex: 0 0 48px; + padding: 0; + border: none; + outline: none; + + display: inline-flex; + align-items: center; + + border-radius: 999px; + background: $gray20; + box-shadow: inset 0 0 0 1px rgba($gray50, 0.08); + + cursor: pointer; + user-select: none; + + transition: + background-color 0.28s ease, + box-shadow 0.28s ease, + transform 0.18s ease; + + >span { + position: absolute; + top: 3px; + left: 3px; + + width: 26px; + height: 18px; + border-radius: 100px; + background: #ffffff; + + box-shadow: + 0 2px 5px rgba(15, 23, 42, 0.18), + 0 1px 1px rgba(15, 23, 42, 0.08); + + transition: + transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.28s ease, + width 0.18s ease; + } + + &.is-enabled { + background: $blue; + box-shadow: inset 0 0 0 1px rgba($blue, 0.12); + + >span { + transform: translateX(16px); + box-shadow: + 0 3px 7px rgba(3, 29, 154, 0.24), + 0 1px 1px rgba(15, 23, 42, 0.08); + } + } + + &:hover { + box-shadow: + inset 0 0 0 1px rgba($gray50, 0.12), + 0 4px 10px rgba(15, 23, 42, 0.06); + } + + &:active { + transform: scale(0.96); + + >span { + width: 23px; + } + } + + &.is-enabled:active>span { + transform: translateX(17px); + } + + &:focus-visible { + box-shadow: + 0 0 0 3px rgba($blue, 0.18), + inset 0 0 0 1px rgba($blue, 0.18); + } +} \ No newline at end of file diff --git a/mdm-front/src/pages/OrganisationPage/components/OrganisationPolicyCard/OrganisationPolicyCard.tsx b/mdm-front/src/pages/OrganisationPage/components/OrganisationPolicyCard/OrganisationPolicyCard.tsx new file mode 100644 index 0000000..872495b --- /dev/null +++ b/mdm-front/src/pages/OrganisationPage/components/OrganisationPolicyCard/OrganisationPolicyCard.tsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { useMutation } from '@apollo/client/react' + +import { WifiIcon } from '../../../../assets/icons/Wifi' +import { BluetoothIcon } from '../../../../assets/icons/Bluetooth' +import { GpsIcon } from '../../../../assets/icons/Gps' +import { CameraIcon } from '../../../../assets/icons/Camera' +import { SimIcon } from '../../../../assets/icons/Sim' +import { VolumeIcon } from '../../../../assets/icons/Volume' +import { + CHANGE_ORGANISATION_MUTATION, + GET_ORGANISATION_QUERY, + GET_ORGANISATIONS_QUERY, +} from '../../../../entities/employee/api/employee.graphql' +import type { + ChangeOrganisationData, + ChangeOrganisationVariables, + Organisation, + OrganisationPolicy, +} from '../../../../entities/employee/model/types' + +import './OrganisationPolicyCard.scss' + +type PolicyKey = 'wifi' | 'bluetooth' | 'gps' | 'camera' | 'sim' | 'speaker' + +type PolicyItem = { + key: PolicyKey + label: string + icon: ReactNode + disabled?: boolean +} + +type OrganisationPolicyCardProps = { + organisation: Organisation +} + +const policyItems: PolicyItem[] = [ + { + key: 'wifi', + label: 'Wi-Fi', + icon: , + disabled: true, + }, + { + key: 'bluetooth', + label: 'Bluetooth', + icon: , + }, + { + key: 'gps', + label: 'GPS', + icon: , + }, + { + key: 'camera', + label: 'Камера', + icon: , + }, + { + key: 'sim', + label: 'SIM-карта', + icon: , + }, + { + key: 'speaker', + label: 'Динамик', + icon: , + disabled: true, + }, +] + +function mapApiPolicyToState( + policy?: OrganisationPolicy | null, +): Record { + return { + wifi: true, + bluetooth: policy?.canUseBluetooth ?? true, + gps: policy?.canUseGPS ?? true, + camera: policy?.canUseCamera ?? true, + sim: policy?.canUseSim ?? true, + speaker: true, + } +} + +function mapStateToMutationVariables(policy: Record) { + return { + canUseBluetooth: policy.bluetooth, + canUseCamera: policy.camera, + canUseGPS: policy.gps, + canUseSim: policy.sim, + } +} + +export function OrganisationPolicyCard({ + organisation, +}: OrganisationPolicyCardProps) { + const initialPolicy = useMemo( + () => mapApiPolicyToState(organisation.policy), + [organisation.policy], + ) + + const [policyState, setPolicyState] = useState(initialPolicy) + const [savingKey, setSavingKey] = useState(null) + const [saveError, setSaveError] = useState(false) + + const [changeOrganisation] = useMutation< + ChangeOrganisationData, + ChangeOrganisationVariables + >(CHANGE_ORGANISATION_MUTATION) + + useEffect(() => { + setPolicyState(initialPolicy) + }, [initialPolicy]) + + async function handleToggle(key: PolicyKey, disabled?: boolean) { + if (disabled || savingKey) return + + const previousPolicy = policyState + const nextPolicy = { + ...policyState, + [key]: !policyState[key], + } + + setPolicyState(nextPolicy) + setSavingKey(key) + setSaveError(false) + + try { + await changeOrganisation({ + variables: { + id: String(organisation.id), + name: organisation.name, + ...mapStateToMutationVariables(nextPolicy), + }, + refetchQueries: [ + { + query: GET_ORGANISATION_QUERY, + variables: { + id: String(organisation.id), + }, + }, + { + query: GET_ORGANISATIONS_QUERY, + variables: { + page: 0, + query: undefined, + sortDirection: 'ID', + sortField: 'ASC', + }, + }, + ], + }) + } catch { + setPolicyState(previousPolicy) + setSaveError(true) + } finally { + setSavingKey(null) + } + } + + return ( +
+
+
+

Групповая политика

+

Применяется при авторизации сотрудника на устройстве

+
+
+ +
+ {policyItems.map((item) => { + const enabled = policyState[item.key] + const isSaving = savingKey === item.key + const isDisabled = item.disabled || Boolean(savingKey) + + return ( +
+ {item.icon} + +
+ {item.label} + + +
+
+ ) + })} +
+ + {saveError && ( +
+ Не удалось сохранить групповую политику +
+ )} +
+ ) +} \ No newline at end of file diff --git a/mdm-front/src/shared/api/apolloClient.ts b/mdm-front/src/shared/api/apolloClient.ts index b11f0ea..ff3177f 100644 --- a/mdm-front/src/shared/api/apolloClient.ts +++ b/mdm-front/src/shared/api/apolloClient.ts @@ -27,7 +27,23 @@ function isUnauthorizedError(error: unknown) { } return error.errors.some((graphQLError) => { - return graphQLError.extensions?.code === 'Unauthorized' + const code = String(graphQLError.extensions?.code ?? '').toLowerCase() + const classification = String( + graphQLError.extensions?.classification ?? '', + ).toLowerCase() + const message = String(graphQLError.message ?? '').toLowerCase() + + return ( + code === 'unauthorized' || + code === 'unauthenticated' || + classification === 'unauthorized' || + message.includes('unauthorized') || + message.includes('unauthenticated') || + message.includes('required any role') || + message.includes('required role') || + message.includes('access denied') || + message.includes('forbidden') + ) }) } @@ -66,15 +82,23 @@ const errorLink = new ErrorLink(({ error, operation, forward }) => { return } - /** - * Важно: если Unauthorized прилетел уже на refreshSession, - * повторять refresh нельзя, иначе можно уйти в цикл. - */ if (operation.operationName === 'RefreshSession') { window.dispatchEvent(new Event('auth:logout')) return } + const alreadyRetried = operation.getContext().alreadyRetriedAfterRefresh + + if (alreadyRetried) { + window.dispatchEvent(new Event('auth:logout')) + return + } + + operation.setContext({ + ...operation.getContext(), + alreadyRetriedAfterRefresh: true, + }) + return new Observable((observer) => { const retryRequest = () => { forward(operation).subscribe({ diff --git a/mdm-front/src/shared/styles/_variables.scss b/mdm-front/src/shared/styles/_variables.scss index e09b235..3befdc2 100644 --- a/mdm-front/src/shared/styles/_variables.scss +++ b/mdm-front/src/shared/styles/_variables.scss @@ -1,12 +1,13 @@ // colors light $color-bg: #EFF2F7; $gray50: #6A768E; -$gray30: hsla(219, 32%, 76%, 0.2); +$gray30: #aebcd533; $gray20: #E2E7F0; $color-text: #2F3747; $red: #DD0000; $red20: hsla(0, 100%, 43%, 0.2); $green: #33B343; $blue: #031D9A; -$blue20: hsla(230, 96%, 31%, 0.2); +//$blue20: hsla(230, 96%, 31%, 0.2); +$blue20: #d4deff; $orange: #FF6B16; diff --git a/mdm-front/src/widgets/ConfirmDangerDialog/ConfirmDangerDialog.scss b/mdm-front/src/widgets/ConfirmDangerDialog/ConfirmDangerDialog.scss new file mode 100644 index 0000000..2099bd3 --- /dev/null +++ b/mdm-front/src/widgets/ConfirmDangerDialog/ConfirmDangerDialog.scss @@ -0,0 +1,159 @@ +@use '../../shared/styles/variables' as *; + +.confirm-danger-dialog__overlay { + position: fixed; + inset: 0; + z-index: 500; + + background: rgba(15, 23, 42, 0.42); + backdrop-filter: blur(2px); + + animation: confirmDangerOverlayShow 0.18s ease; +} + +.confirm-danger-dialog { + position: fixed; + z-index: 1000; + top: 50%; + left: 50%; + + width: min(440px, calc(100vw - 32px)); + padding: 22px; + + border-radius: 22px; + background: #ffffff; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22); + + transform: translate(-50%, -50%); + animation: confirmDangerDialogShow 0.2s ease; +} + +.confirm-danger-dialog__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + + margin-bottom: 14px; +} + +.confirm-danger-dialog__icon { + width: 44px; + height: 44px; + + border-radius: 14px; + background: rgba(224, 0, 0, 0.08); + + display: inline-flex; + align-items: center; + justify-content: center; + + color: $red; +} + +.confirm-danger-dialog__close { + width: 38px; + height: 38px; + + 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: $red; + background: rgba(224, 0, 0, 0.08); + } +} + +.confirm-danger-dialog__title { + margin: 0; + + color: $color-text; + font-size: 22px; + font-weight: 650; +} + +.confirm-danger-dialog__description { + margin: 8px 0 0; + + color: $gray50; + font-size: 15px; + line-height: 1.45; +} + +.confirm-danger-dialog__footer { + margin-top: 24px; + + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.confirm-danger-dialog__cancel, +.confirm-danger-dialog__confirm { + height: 42px; + padding: 0 18px; + + border: none; + border-radius: 14px; + + font-size: 15px; + font-weight: 600; + cursor: pointer; + + transition: 0.2s ease; + + &:disabled { + opacity: 0.6; + cursor: default; + } +} + +.confirm-danger-dialog__cancel { + background: $color-bg; + color: $gray50; + + &:hover { + background: $gray20; + color: $color-text; + } +} + +.confirm-danger-dialog__confirm { + background: $red; + color: #ffffff; + + &:hover { + opacity: 0.9; + } +} + +@keyframes confirmDangerOverlayShow { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes confirmDangerDialogShow { + 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/widgets/ConfirmDangerDialog/ConfirmDangerDialog.tsx b/mdm-front/src/widgets/ConfirmDangerDialog/ConfirmDangerDialog.tsx new file mode 100644 index 0000000..416255c --- /dev/null +++ b/mdm-front/src/widgets/ConfirmDangerDialog/ConfirmDangerDialog.tsx @@ -0,0 +1,76 @@ +import * as Dialog from '@radix-ui/react-dialog' +import { AlertTriangle, X } from 'lucide-react' + +import './ConfirmDangerDialog.scss' + +type ConfirmDangerDialogProps = { + open: boolean + title: string + description: string + confirmText?: string + cancelText?: string + isLoading?: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void +} + +export function ConfirmDangerDialog({ + open, + title, + description, + confirmText = 'Удалить', + cancelText = 'Отмена', + isLoading = false, + onOpenChange, + onConfirm, +}: ConfirmDangerDialogProps) { + return ( + + + + + +
+
+ +
+ + + + +
+ + + {title} + + + + {description} + + +
+ + {cancelText} + + + +
+
+
+
+ ) +} \ 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 66671da..35957c5 100644 --- a/mdm-front/src/widgets/Navbar/Navbar.tsx +++ b/mdm-front/src/widgets/Navbar/Navbar.tsx @@ -29,6 +29,8 @@ function getPageTitle(pathname: string) { if (pathname.startsWith('/devices/')) return 'Устройства' if (pathname === '/employees') return 'Сотрудники' + if (pathname.startsWith('/employees/organisations/')) return 'Организация' + if (pathname === '/map') return 'Карта' return 'Обзор'