Добавлен интерфейс технического обслуживания устройств

This commit is contained in:
neizbejnoezlo 2026-08-19 16:40:24 +07:00
parent ee339bf301
commit 4cf0b0d542
55 changed files with 7657 additions and 1688 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# Separate repository
/Arma_mdm/

10
mdm-front/.gitignore vendored
View File

@ -12,6 +12,16 @@ dist
dist-ssr
*.local
# Local environment
.env
.env.*
!.env.example
# Local working files
.artifact-third-sheet-work/
/*.xlsx
/public/photo_2025-03-29_20-05-51.jpg
# Editor directories and files
.vscode/*
!.vscode/extensions.json

24
mdm-front/codegen.ts Normal file
View File

@ -0,0 +1,24 @@
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: '../Arma_mdm/src/main/resources/graphql/**/*.graphql',
documents: ['src/**/*.graphql'],
generates: {
'src/shared/api/generated/': {
preset: 'client',
config: {
enumsAsTypes: true,
scalars: {
ID: {
input: 'string',
output: 'number',
},
},
useTypeImports: true,
},
},
},
ignoreNoDocuments: true,
}
export default config

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,10 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 192.168.1.181",
"dev": "vite --host 192.168.1.91",
"build": "tsc -b && vite build",
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
"lint": "eslint .",
"preview": "vite preview"
},
@ -41,6 +43,8 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@graphql-codegen/cli": "^7.2.0",
"@graphql-codegen/client-preset": "^6.1.3",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",

View File

@ -0,0 +1,471 @@
query GetPhones(
$page: Int!
$query: String
$locked: Boolean
$needMaintenance: Boolean
$networkStatus: [PhoneNetworkStatus!]
$technicalStatus: [PhoneTechnicalStatus!]
$orgs: [ID!]
$sortDirection: SortDirection!
$sortField: PhoneSortField!
) {
getPhones(
page: $page
query: $query
locked: $locked
needMaintenance: $needMaintenance
networkStatus: $networkStatus
technicalStatus: $technicalStatus
orgs: $orgs
sortDirection: $sortDirection
sortField: $sortField
) {
page {
id
imei
imei2
serial
networkStatus
lastLocation {
alt
date
lat
lng
}
org {
id
logoUrl
name
policy {
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
orgId
policy {
bluetooth
camera
GPS
sim
locked
}
techState {
batteryLevel
malfunctions
malfunctionComment
needMaintenance
status
temperature
}
}
totalElements
totalPages
}
}
query GetPhonesTabsStats($query: String, $orgs: [ID!]) {
totalPhones: getPhones(
page: 0
query: $query
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
onlinePhones: getPhones(
page: 0
query: $query
networkStatus: [Online]
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
lockedPhones: getPhones(
page: 0
query: $query
locked: true
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
lostPhones: getPhones(
page: 0
query: $query
networkStatus: [Lost]
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
maintenancePhones: getPhones(
page: 0
query: $query
technicalStatus: [NeedsMaintenance]
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
servicePhones: getPhones(
page: 0
query: $query
technicalStatus: [InService]
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
}
query GetPhone($id: Int!) {
getPhone(id: $id) {
id
imei
imei2
networkStatus
lastLocation {
alt
date
lat
lng
}
org {
creationDate
id
name
policy {
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
orgId
policy {
bluetooth
camera
GPS
sim
locked
}
registerDate
serial
techState {
batteryCycles
batteryLevel
batteryRemainingCapacity
hits
malfunctions
malfunctionComment
needMaintenance
overheats
status
temperature
worktime
}
}
}
query GetPhoneGpsTrack($phoneId: ID!, $startDate: Float!, $endDate: Float) {
getPhoneGpsTrack(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
alt
date
lat
lng
}
}
query GetPhonePackages($phoneId: ID!) {
getPhonePackages(phoneId: $phoneId) {
category
iconUrl
installDate
name
packageName
version
versionName
}
}
query GetTelemetry(
$phoneId: Int!
$packagesPhoneId: ID!
$startDate: Float!
$endDate: Float
) {
getTelemetry(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
batteryCapacity
batteryCycles
batteryLevel
date
temperature
}
getPhoneStateEvents(phoneId: $packagesPhoneId, startDate: $startDate, endDate: $endDate) {
date
phoneId
type
}
getPhonePackagesUseEvents(
phoneId: $packagesPhoneId
startDate: $startDate
endDate: $endDate
) {
events {
type
packageName
date
}
phonePackage {
category
iconUrl
installDate
name
packageName
version
versionName
}
}
}
query GetDevicePage(
$id: Int!
$phoneId: ID!
$telemetryStartDate: Float!
$telemetryEndDate: Float
$gpsStartDate: Float!
$gpsEndDate: Float
) {
getPhone(id: $id) {
id
imei
imei2
networkStatus
lastLocation {
alt
date
lat
lng
}
org {
creationDate
id
name
policy {
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
orgId
policy {
bluetooth
camera
GPS
sim
locked
}
registerDate
serial
techState {
batteryCycles
batteryLevel
batteryRemainingCapacity
hits
malfunctions
malfunctionComment
needMaintenance
overheats
status
temperature
worktime
}
}
getTelemetry(
phoneId: $id
startDate: $telemetryStartDate
endDate: $telemetryEndDate
) {
batteryCapacity
batteryCycles
batteryLevel
date
temperature
}
getPhoneGpsTrack(
phoneId: $phoneId
startDate: $gpsStartDate
endDate: $gpsEndDate
) {
alt
date
lat
lng
}
}
mutation CreatePhoneRegistrationToken {
createPhoneRegistrationToken {
address
expiresIn
token
}
}
mutation ChangePhonePolicy(
$id: ID!
$bluetooth: ServiceUseOption!
$camera: ComponentUseOption!
$GPS: ServiceUseOption!
$sim: ServiceUseOption!
$locked: Boolean!
) {
changePhonePolicy(
id: $id
policy: {
bluetooth: $bluetooth
camera: $camera
GPS: $GPS
sim: $sim
locked: $locked
}
) {
bluetooth
camera
GPS
sim
locked
}
}
query GetActivePhoneMaintenanceCase($phoneId: ID!) {
getActivePhoneMaintenanceCase(phoneId: $phoneId) {
id
phoneId
status
malfunctions
comment
detectedAt
serviceStartedAt
closedAt
metrics {
hits
overheats
batteryCycles
worktime
batteryCapacity
}
}
}
query GetPhoneMaintenanceHistory($phoneId: ID!) {
getPhoneMaintenanceHistory(phoneId: $phoneId) {
id
phoneId
status
malfunctions
comment
detectedAt
serviceStartedAt
closedAt
metrics {
hits
overheats
batteryCycles
worktime
batteryCapacity
}
}
}
mutation SetPhoneMaintenanceRequired(
$id: ID!
$malfunctions: [PhoneMalfunction!]!
$comment: String
) {
setPhoneMaintenanceRequired(
id: $id
form: {
malfunctions: $malfunctions
comment: $comment
}
)
}
mutation MarkPhoneHealthy($id: ID!) {
markPhoneHealthy(id: $id)
}
mutation SendPhoneToMaintenance(
$id: ID!
$malfunctions: [PhoneMalfunction!]!
$comment: String
) {
sendPhoneToMaintenance(
id: $id
form: {
malfunctions: $malfunctions
comment: $comment
}
)
}
mutation ReturnPhoneFromMaintenance($id: ID!) {
returnPhoneFromMaintenance(id: $id)
}

View File

@ -1,390 +1,17 @@
import { gql } from '@apollo/client'
export const GET_PHONES_QUERY = gql`
query GetPhones(
$page: Int!
$query: String
$locked: Boolean
$needMaintenance: Boolean
$networkStatus: [PhoneNetworkStatus!]
$orgs: [ID!]
$sortDirection: SortDirection!
$sortField: PhoneSortField!
) {
getPhones(
page: $page
query: $query
locked: $locked
needMaintenance: $needMaintenance
networkStatus: $networkStatus
orgs: $orgs
sortDirection: $sortDirection
sortField: $sortField
) {
page {
id
imei
imei2
serial
networkStatus
lastLocation {
alt
date
lat
lng
}
org {
id
logoUrl
name
policy {
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
orgId
policy {
bluetooth
camera
GPS
sim
locked
}
techState {
batteryLevel
needMaintenance
temperature
}
}
totalElements
totalPages
}
}
`
export const GET_PHONES_TABS_STATS_QUERY = gql`
query GetPhonesTabsStats($query: String, $orgs: [ID!]) {
totalPhones: getPhones(
page: 0
query: $query
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
onlinePhones: getPhones(
page: 0
query: $query
networkStatus: [Online]
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
lockedPhones: getPhones(
page: 0
query: $query
locked: true
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
lostPhones: getPhones(
page: 0
query: $query
networkStatus: [Lost]
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
maintenancePhones: getPhones(
page: 0
query: $query
needMaintenance: true
orgs: $orgs
sortDirection: DESC
sortField: ID
) {
totalElements
}
}
`
export const GET_PHONE_QUERY = gql`
query GetPhone($id: Int!) {
getPhone(id: $id) {
id
imei
imei2
networkStatus
lastLocation {
alt
date
lat
lng
}
org {
creationDate
id
name
policy {
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
orgId
policy {
bluetooth
camera
GPS
sim
locked
}
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_PHONE_PACKAGES_QUERY = gql`
query GetPhonePackages($phoneId: ID!) {
getPhonePackages(phoneId: $phoneId) {
category
iconUrl
installDate
name
packageName
version
versionName
}
}
`
export const GET_TELEMETRY_QUERY = gql`
query GetTelemetry(
$phoneId: Int!
$packagesPhoneId: ID!
$startDate: Float!
$endDate: Float
) {
getTelemetry(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
batteryCapacity
batteryCycles
batteryLevel
date
temperature
}
getPhoneStateEvents(phoneId: $packagesPhoneId, startDate: $startDate, endDate: $endDate) {
date
phoneId
type
}
getPhonePackagesUseEvents(
phoneId: $packagesPhoneId
startDate: $startDate
endDate: $endDate
) {
events {
type
packageName
date
}
phonePackage {
category
iconUrl
installDate
name
packageName
version
versionName
}
}
}
`
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
networkStatus
lastLocation {
alt
date
lat
lng
}
org {
creationDate
id
name
policy {
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
orgId
policy {
bluetooth
camera
GPS
sim
locked
}
registerDate
serial
techState {
batteryCycles
batteryLevel
batteryRemainingCapacity
hits
needMaintenance
overheats
temperature
worktime
}
}
getTelemetry(
phoneId: $id
startDate: $telemetryStartDate
endDate: $telemetryEndDate
) {
batteryCapacity
batteryCycles
batteryLevel
date
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!
$bluetooth: ServiceUseOption!
$camera: ComponentUseOption!
$GPS: ServiceUseOption!
$sim: ServiceUseOption!
$locked: Boolean!
) {
changePhonePolicy(
id: $id
policy: {
bluetooth: $bluetooth
camera: $camera
GPS: $GPS
sim: $sim
locked: $locked
}
) {
bluetooth
camera
GPS
sim
locked
}
}
`
export {
GetPhonesDocument as GET_PHONES_QUERY,
GetPhonesTabsStatsDocument as GET_PHONES_TABS_STATS_QUERY,
GetPhoneDocument as GET_PHONE_QUERY,
GetPhoneGpsTrackDocument as GET_PHONE_GPS_TRACK_QUERY,
GetPhonePackagesDocument as GET_PHONE_PACKAGES_QUERY,
GetTelemetryDocument as GET_TELEMETRY_QUERY,
GetDevicePageDocument as GET_DEVICE_PAGE_QUERY,
CreatePhoneRegistrationTokenDocument as CREATE_PHONE_REGISTRATION_TOKEN_MUTATION,
ChangePhonePolicyDocument as CHANGE_PHONE_POLICY_MUTATION,
GetActivePhoneMaintenanceCaseDocument as GET_ACTIVE_PHONE_MAINTENANCE_CASE_QUERY,
GetPhoneMaintenanceHistoryDocument as GET_PHONE_MAINTENANCE_HISTORY_QUERY,
SetPhoneMaintenanceRequiredDocument as SET_PHONE_MAINTENANCE_REQUIRED_MUTATION,
MarkPhoneHealthyDocument as MARK_PHONE_HEALTHY_MUTATION,
SendPhoneToMaintenanceDocument as SEND_PHONE_TO_MAINTENANCE_MUTATION,
ReturnPhoneFromMaintenanceDocument as RETURN_PHONE_FROM_MAINTENANCE_MUTATION,
} from '../../../shared/api/generated/graphql'

View File

@ -0,0 +1,54 @@
import type {
DeviceMalfunction,
DeviceTechnicalStatus,
} from '../model/types'
export const deviceMalfunctionLabels: Record<DeviceMalfunction, string> = {
LowBatteryCapacity: 'Низкая ёмкость АКБ',
CriticalHits: 'Критические удары',
CriticalChargeCycles: 'Критические циклы зарядки',
DoesNotTurnOn: 'Не включается',
Other: 'Другое',
}
export const deviceTechnicalStatusLabels: Record<
DeviceTechnicalStatus,
string
> = {
Healthy: 'Исправно',
NeedsMaintenance: 'Требует ТО',
InService: 'На обслуживании',
}
export function getDeviceMalfunctionSummary(
malfunctions: DeviceMalfunction[] = [],
comment?: string | null,
) {
const details = malfunctions.map((item) => deviceMalfunctionLabels[item])
const normalizedDetails = new Set(
details.map((item) => item.trim().toLocaleLowerCase('ru-RU')),
)
if (
comment?.trim() &&
!normalizedDetails.has(comment.trim().toLocaleLowerCase('ru-RU'))
) {
details.push(comment.trim())
}
return details.join('. ')
}
export function isRedundantMalfunctionComment(
malfunctions: DeviceMalfunction[] = [],
comment?: string | null,
) {
if (!comment?.trim()) return true
const normalizedComment = comment.trim().toLocaleLowerCase('ru-RU')
return malfunctions.some(
(item) =>
deviceMalfunctionLabels[item].trim().toLocaleLowerCase('ru-RU') ===
normalizedComment,
)
}

View File

@ -1,3 +1,42 @@
import type {
ChangePhonePolicyMutation,
ChangePhonePolicyMutationVariables,
ComponentUseOption as GeneratedComponentUseOption,
CreatePhoneRegistrationTokenMutation,
CreatePhoneRegistrationTokenMutationVariables,
GetDevicePageQuery,
GetDevicePageQueryVariables,
GetActivePhoneMaintenanceCaseQuery,
GetActivePhoneMaintenanceCaseQueryVariables,
GetPhoneGpsTrackQuery,
GetPhoneGpsTrackQueryVariables,
GetPhoneMaintenanceHistoryQuery,
GetPhoneMaintenanceHistoryQueryVariables,
GetPhonePackagesQuery,
GetPhonePackagesQueryVariables,
GetPhoneQuery,
GetPhoneQueryVariables,
GetPhonesQuery,
GetPhonesQueryVariables,
GetPhonesTabsStatsQuery,
GetPhonesTabsStatsQueryVariables,
GetTelemetryQuery,
GetTelemetryQueryVariables,
MarkPhoneHealthyMutation,
MarkPhoneHealthyMutationVariables,
PhoneMaintenanceCaseStatus as GeneratedPhoneMaintenanceCaseStatus,
PhoneMalfunction as GeneratedPhoneMalfunction,
PhoneNetworkStatus,
PhoneTechnicalStatus as GeneratedPhoneTechnicalStatus,
ReturnPhoneFromMaintenanceMutation,
ReturnPhoneFromMaintenanceMutationVariables,
SendPhoneToMaintenanceMutation,
SendPhoneToMaintenanceMutationVariables,
ServiceUseOption as GeneratedServiceUseOption,
SetPhoneMaintenanceRequiredMutation,
SetPhoneMaintenanceRequiredMutationVariables,
} from '../../../shared/api/generated/graphql'
export type DeviceLocation = {
alt: number
date: number
@ -5,14 +44,9 @@ export type DeviceLocation = {
lng: number
}
export type ServiceUseOption =
| 'Disabled'
| 'Enabled'
| 'Unspecified'
export type ServiceUseOption = GeneratedServiceUseOption
export type ComponentUseOption =
| 'Allowed'
| 'Disallowed'
export type ComponentUseOption = GeneratedComponentUseOption
export type PhoneUsePolicyOption = ServiceUseOption | ComponentUseOption
@ -47,17 +81,23 @@ export type DevicePolicy = {
}
export type DeviceTechState = {
batteryCycles: number | null
batteryLevel: number | null
batteryRemainingCapacity: number | null
hits: number | null
needMaintenance: boolean
overheats: number | null
batteryCycles?: number | null
batteryLevel?: number | null
batteryRemainingCapacity?: number | null
hits?: number | null
malfunctions?: DeviceMalfunction[]
malfunctionComment?: string | null
needMaintenance?: boolean
overheats?: number | null
status?: DeviceTechnicalStatus
temperature?: number | null
worktime: number | null
worktime?: number | null
}
export type DeviceNetworkStatus = 'Online' | 'Offline' | 'Lost'
export type DeviceNetworkStatus = PhoneNetworkStatus
export type DeviceTechnicalStatus = GeneratedPhoneTechnicalStatus
export type DeviceMalfunction = GeneratedPhoneMalfunction
export type DeviceMaintenanceCaseStatus = GeneratedPhoneMaintenanceCaseStatus
export type DeviceOrganisation = {
creationDate?: number
@ -70,60 +110,23 @@ export type DeviceOrganisation = {
export type Device = {
id: number
imei: string
imei2: string
imei2: string | null
serial: string
networkStatus?: DeviceNetworkStatus
networkStatus: DeviceNetworkStatus
orgId: number
registerDate: number
registerDate?: number
org: DeviceOrganisation | null
policy: DevicePolicy | null
techState: DeviceTechState | null
lastLocation: DeviceLocation | null
}
export type GetPhonesData = {
getPhones: {
page: Device[]
totalElements: number
totalPages: number
}
}
export type GetPhonesVariables = {
page: number
query?: string
locked?: boolean
needMaintenance?: boolean
networkStatus?: DeviceNetworkStatus[]
orgs?: string[]
sortDirection: 'ASC' | 'DESC'
sortField: 'ID' | 'Serial' | 'Date'
}
type GetPhonesStatsPage = {
totalElements: number
}
export type GetPhonesTabsStatsData = {
totalPhones: GetPhonesStatsPage
onlinePhones: GetPhonesStatsPage
lockedPhones: GetPhonesStatsPage
lostPhones: GetPhonesStatsPage
maintenancePhones: GetPhonesStatsPage
}
export type GetPhonesTabsStatsVariables = {
query?: string
orgs?: string[]
}
export type GetPhoneData = {
getPhone: Device | null
}
export type GetPhoneVariables = {
id: number
}
export type GetPhonesData = GetPhonesQuery
export type GetPhonesVariables = GetPhonesQueryVariables
export type GetPhonesTabsStatsData = GetPhonesTabsStatsQuery
export type GetPhonesTabsStatsVariables = GetPhonesTabsStatsQueryVariables
export type GetPhoneData = GetPhoneQuery
export type GetPhoneVariables = GetPhoneQueryVariables
export type DeviceTelemetryItem = {
batteryCapacity: number | null
@ -174,13 +177,8 @@ export type DevicePackage = {
versionName: string | null
}
export type GetPhonePackagesData = {
getPhonePackages: DevicePackage[]
}
export type GetPhonePackagesVariables = {
phoneId: string
}
export type GetPhonePackagesData = GetPhonePackagesQuery
export type GetPhonePackagesVariables = GetPhonePackagesQueryVariables
export type DevicePackageUseEventType =
| 'ResumeActivity'
@ -199,18 +197,8 @@ export type DevicePackageUseEventsGroup = {
phonePackage: DevicePackage | null
}
export type GetTelemetryData = {
getTelemetry: DeviceTelemetryItem[]
getPhoneStateEvents: DevicePhoneStateEvent[]
getPhonePackagesUseEvents: DevicePackageUseEventsGroup[]
}
export type GetTelemetryVariables = {
phoneId: number
packagesPhoneId: string
startDate: number
endDate?: number
}
export type GetTelemetryData = GetTelemetryQuery
export type GetTelemetryVariables = GetTelemetryQueryVariables
export type DeviceGpsTrackPoint = {
alt: number
@ -219,50 +207,33 @@ export type DeviceGpsTrackPoint = {
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: DevicePolicy
}
export type ChangePhonePolicyVariables = {
id: string
bluetooth: ServiceUseOption
camera: ComponentUseOption
GPS: ServiceUseOption
sim: ServiceUseOption
locked: boolean
}
export type CreatePhoneRegistrationTokenData = {
createPhoneRegistrationToken: {
address: string
expiresIn: number
token: string
}
}
export type CreatePhoneRegistrationTokenVariables = Record<string, never>
export type GetPhoneGpsTrackData = GetPhoneGpsTrackQuery
export type GetPhoneGpsTrackVariables = GetPhoneGpsTrackQueryVariables
export type GetDevicePageData = GetDevicePageQuery
export type GetDevicePageVariables = GetDevicePageQueryVariables
export type ChangePhonePolicyData = ChangePhonePolicyMutation
export type ChangePhonePolicyVariables = ChangePhonePolicyMutationVariables
export type CreatePhoneRegistrationTokenData =
CreatePhoneRegistrationTokenMutation
export type CreatePhoneRegistrationTokenVariables =
CreatePhoneRegistrationTokenMutationVariables
export type GetPhoneMaintenanceHistoryData = GetPhoneMaintenanceHistoryQuery
export type GetPhoneMaintenanceHistoryVariables =
GetPhoneMaintenanceHistoryQueryVariables
export type GetActivePhoneMaintenanceCaseData =
GetActivePhoneMaintenanceCaseQuery
export type GetActivePhoneMaintenanceCaseVariables =
GetActivePhoneMaintenanceCaseQueryVariables
export type SetPhoneMaintenanceRequiredData =
SetPhoneMaintenanceRequiredMutation
export type SetPhoneMaintenanceRequiredVariables =
SetPhoneMaintenanceRequiredMutationVariables
export type MarkPhoneHealthyData = MarkPhoneHealthyMutation
export type MarkPhoneHealthyVariables = MarkPhoneHealthyMutationVariables
export type SendPhoneToMaintenanceData = SendPhoneToMaintenanceMutation
export type SendPhoneToMaintenanceVariables =
SendPhoneToMaintenanceMutationVariables
export type ReturnPhoneFromMaintenanceData =
ReturnPhoneFromMaintenanceMutation
export type ReturnPhoneFromMaintenanceVariables =
ReturnPhoneFromMaintenanceMutationVariables

View File

@ -0,0 +1,299 @@
query GetUsers(
$page: Int!
$query: String
$orgs: [ID!]
$roles: [Role!]
$sortDirection: SortDirection!
$sortField: UserSortField!
) {
getUsers(
page: $page
query: $query
orgs: $orgs
roles: $roles
sortDirection: $sortDirection
sortField: $sortField
) {
totalPages
totalElements
page {
avatarUrl
firstName
id
lastName
middleName
org {
creationDate
id
logoUrl
name
}
orgId
role
username
}
}
}
query GetOrganisation($id: ID!) {
getOrganisation(id: $id) {
creationDate
id
name
logoUrl
policy {
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
}
mutation DeleteOrganisation($id: ID!) {
deleteOrganisation(id: $id)
}
query GetOrganisations(
$page: Int!
$query: String
$sortDirection: SortDirection!
$sortField: OrganisationSortField!
) {
getOrganisations(
page: $page
query: $query
sortDirection: $sortDirection
sortField: $sortField
) {
totalPages
totalElements
page {
creationDate
id
name
logoUrl
}
}
}
mutation CreateOrganisation(
$name: String!
$bluetooth: ServiceUseOption!
$bluetoothEditable: Boolean!
$camera: ComponentUseOption!
$cameraEditable: Boolean!
$GPS: ServiceUseOption!
$gpsEditable: Boolean!
$sim: ServiceUseOption!
$simEditable: Boolean!
$offlineTimeThreshold: Float!
) {
createOrganisation(
name: $name
policy: {
bluetooth: $bluetooth
bluetoothEditable: $bluetoothEditable
camera: $camera
cameraEditable: $cameraEditable
GPS: $GPS
gpsEditable: $gpsEditable
sim: $sim
simEditable: $simEditable
offlineTimeThreshold: $offlineTimeThreshold
}
) {
id
name
}
}
mutation ChangeOrganisation(
$id: ID!
$name: String!
$bluetooth: ServiceUseOption!
$bluetoothEditable: Boolean!
$camera: ComponentUseOption!
$cameraEditable: Boolean!
$GPS: ServiceUseOption!
$gpsEditable: Boolean!
$sim: ServiceUseOption!
$simEditable: Boolean!
$offlineTimeThreshold: Float!
) {
changeOrganisation(
id: $id
name: $name
policy: {
bluetooth: $bluetooth
bluetoothEditable: $bluetoothEditable
camera: $camera
cameraEditable: $cameraEditable
GPS: $GPS
gpsEditable: $gpsEditable
sim: $sim
simEditable: $simEditable
offlineTimeThreshold: $offlineTimeThreshold
}
) {
creationDate
id
name
}
}
mutation ChangeOrganisationPolicy(
$id: ID!
$name: String!
$bluetooth: ServiceUseOption!
$bluetoothEditable: Boolean!
$camera: ComponentUseOption!
$cameraEditable: Boolean!
$GPS: ServiceUseOption!
$gpsEditable: Boolean!
$sim: ServiceUseOption!
$simEditable: Boolean!
$offlineTimeThreshold: Float!
) {
changeOrganisation(
id: $id
name: $name
policy: {
bluetooth: $bluetooth
bluetoothEditable: $bluetoothEditable
camera: $camera
cameraEditable: $cameraEditable
GPS: $GPS
gpsEditable: $gpsEditable
sim: $sim
simEditable: $simEditable
offlineTimeThreshold: $offlineTimeThreshold
}
) {
creationDate
id
name
}
}
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
}
}
}
mutation ChangeUser(
$userId: ID!
$orgId: ID!
$firstName: String!
$lastName: String!
$middleName: String!
$username: String!
$role: Role!
) {
changeUser(
userId: $userId
form: {
orgId: $orgId
firstName: $firstName
lastName: $lastName
middleName: $middleName
username: $username
role: $role
}
) {
avatarUrl
firstName
id
lastName
middleName
org {
creationDate
id
logoUrl
name
}
orgId
role
username
}
}
mutation ChangeUserWithPassword(
$userId: ID!
$orgId: ID!
$firstName: String!
$lastName: String!
$middleName: String!
$username: String!
$password: String!
$role: Role!
) {
changeUser(
userId: $userId
form: {
orgId: $orgId
firstName: $firstName
lastName: $lastName
middleName: $middleName
username: $username
password: $password
role: $role
}
) {
avatarUrl
firstName
id
lastName
middleName
org {
creationDate
id
logoUrl
name
}
orgId
role
username
}
}
mutation CreateUploadUserAvatarUrl($userId: ID!) {
createUploadUserAvatarUrl(userId: $userId)
}
mutation CreateUploadOrganisationLogoUrl($orgId: ID!) {
createUploadOrganisationLogoUrl(orgId: $orgId)
}

View File

@ -1,325 +1,14 @@
import { gql } from '@apollo/client'
export const GET_USERS_QUERY = gql`
query GetUsers(
$page: Int!
$query: String
$orgs: [ID!]
$roles: [Role!]
$sortDirection: SortDirection!
$sortField: UserSortField!
) {
getUsers(
page: $page
query: $query
orgs: $orgs
roles: $roles
sortDirection: $sortDirection
sortField: $sortField
) {
totalPages
totalElements
page {
avatarUrl
firstName
id
lastName
middleName
org {
creationDate
id
logoUrl
name
}
orgId
role
username
}
}
}
`
export const GET_ORGANISATION_QUERY = gql`
query GetOrganisation($id: ID!) {
getOrganisation(id: $id) {
creationDate
id
name
logoUrl
policy {
bluetooth
bluetoothEditable
camera
cameraEditable
GPS
gpsEditable
sim
simEditable
offlineTimeThreshold
}
}
}
`
export const DELETE_ORGANISATION_MUTATION = gql`
mutation DeleteOrganisation($id: ID!) {
deleteOrganisation(id: $id)
}
`
export const GET_ORGANISATIONS_QUERY = gql`
query GetOrganisations(
$page: Int!
$query: String
$sortDirection: SortDirection!
$sortField: OrganisationSortField!
) {
getOrganisations(
page: $page
query: $query
sortDirection: $sortDirection
sortField: $sortField
) {
totalPages
totalElements
page {
creationDate
id
name
logoUrl
}
}
}
`
export const CREATE_ORGANISATION_MUTATION = gql`
mutation CreateOrganisation(
$name: String!
$bluetooth: ServiceUseOption!
$bluetoothEditable: Boolean!
$camera: ComponentUseOption!
$cameraEditable: Boolean!
$GPS: ServiceUseOption!
$gpsEditable: Boolean!
$sim: ServiceUseOption!
$simEditable: Boolean!
$offlineTimeThreshold: Float!
) {
createOrganisation(
name: $name
policy: {
bluetooth: $bluetooth
bluetoothEditable: $bluetoothEditable
camera: $camera
cameraEditable: $cameraEditable
GPS: $GPS
gpsEditable: $gpsEditable
sim: $sim
simEditable: $simEditable
offlineTimeThreshold: $offlineTimeThreshold
}
) {
id
name
}
}
`
export const CHANGE_ORGANISATION_MUTATION = gql`
mutation ChangeOrganisation(
$id: ID!
$name: String!
$bluetooth: ServiceUseOption!
$bluetoothEditable: Boolean!
$camera: ComponentUseOption!
$cameraEditable: Boolean!
$GPS: ServiceUseOption!
$gpsEditable: Boolean!
$sim: ServiceUseOption!
$simEditable: Boolean!
$offlineTimeThreshold: Float!
) {
changeOrganisation(
id: $id
name: $name
policy: {
bluetooth: $bluetooth
bluetoothEditable: $bluetoothEditable
camera: $camera
cameraEditable: $cameraEditable
GPS: $GPS
gpsEditable: $gpsEditable
sim: $sim
simEditable: $simEditable
offlineTimeThreshold: $offlineTimeThreshold
}
) {
creationDate
id
name
}
}
`
export const CHANGE_ORGANISATION_POLICY_MUTATION = gql`
mutation ChangeOrganisationPolicy(
$id: ID!
$name: String!
$bluetooth: ServiceUseOption!
$bluetoothEditable: Boolean!
$camera: ComponentUseOption!
$cameraEditable: Boolean!
$GPS: ServiceUseOption!
$gpsEditable: Boolean!
$sim: ServiceUseOption!
$simEditable: Boolean!
$offlineTimeThreshold: Float!
) {
changeOrganisation(
id: $id
name: $name
policy: {
bluetooth: $bluetooth
bluetoothEditable: $bluetoothEditable
camera: $camera
cameraEditable: $cameraEditable
GPS: $GPS
gpsEditable: $gpsEditable
sim: $sim
simEditable: $simEditable
offlineTimeThreshold: $offlineTimeThreshold
}
) {
creationDate
id
name
}
}
`
export const CREATE_USER_MUTATION = gql`
mutation createUser(
$orgId: ID!
$firstName: String!
$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
}
}
}
`
export const CHANGE_USER_MUTATION = gql`
mutation ChangeUser(
$userId: ID!
$orgId: ID!
$firstName: String!
$lastName: String!
$middleName: String!
$username: String!
$role: Role!
) {
changeUser(
userId: $userId
form: {
orgId: $orgId
firstName: $firstName
lastName: $lastName
middleName: $middleName
username: $username
role: $role
}
) {
avatarUrl
firstName
id
lastName
middleName
org {
creationDate
id
logoUrl
name
}
orgId
role
username
}
}
`
export const CHANGE_USER_WITH_PASSWORD_MUTATION = gql`
mutation ChangeUserWithPassword(
$userId: ID!
$orgId: ID!
$firstName: String!
$lastName: String!
$middleName: String!
$username: String!
$password: String!
$role: Role!
) {
changeUser(
userId: $userId
form: {
orgId: $orgId
firstName: $firstName
lastName: $lastName
middleName: $middleName
username: $username
password: $password
role: $role
}
) {
avatarUrl
firstName
id
lastName
middleName
org {
creationDate
id
logoUrl
name
}
orgId
role
username
}
}
`
export const CREATE_UPLOAD_USER_AVATAR_URL_MUTATION = gql`
mutation CreateUploadUserAvatarUrl($userId: ID!) {
createUploadUserAvatarUrl(userId: $userId)
}
`
export const CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION = gql`
mutation CreateUploadOrganisationLogoUrl($orgId: ID!) {
createUploadOrganisationLogoUrl(orgId: $orgId)
}
`
export {
GetUsersDocument as GET_USERS_QUERY,
GetOrganisationDocument as GET_ORGANISATION_QUERY,
DeleteOrganisationDocument as DELETE_ORGANISATION_MUTATION,
GetOrganisationsDocument as GET_ORGANISATIONS_QUERY,
CreateOrganisationDocument as CREATE_ORGANISATION_MUTATION,
ChangeOrganisationDocument as CHANGE_ORGANISATION_MUTATION,
ChangeOrganisationPolicyDocument as CHANGE_ORGANISATION_POLICY_MUTATION,
CreateUserDocument as CREATE_USER_MUTATION,
ChangeUserDocument as CHANGE_USER_MUTATION,
ChangeUserWithPasswordDocument as CHANGE_USER_WITH_PASSWORD_MUTATION,
CreateUploadUserAvatarUrlDocument as CREATE_UPLOAD_USER_AVATAR_URL_MUTATION,
CreateUploadOrganisationLogoUrlDocument as CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION,
} from '../../../shared/api/generated/graphql'

View File

@ -1,11 +1,42 @@
export type EmployeeRole = 'User' | 'Admin' | string
import type {
ChangeOrganisationMutation,
ChangeOrganisationMutationVariables,
ChangeOrganisationPolicyMutationVariables,
ChangeUserMutation,
ChangeUserMutationVariables,
ChangeUserWithPasswordMutationVariables,
ComponentUseOption as GeneratedComponentUseOption,
CreateOrganisationMutation,
CreateOrganisationMutationVariables,
CreateUploadOrganisationLogoUrlMutation,
CreateUploadOrganisationLogoUrlMutationVariables,
CreateUploadUserAvatarUrlMutation,
CreateUploadUserAvatarUrlMutationVariables,
CreateUserMutation,
CreateUserMutationVariables,
DeleteOrganisationMutation,
DeleteOrganisationMutationVariables,
GetOrganisationQuery,
GetOrganisationQueryVariables,
GetOrganisationsQuery,
GetOrganisationsQueryVariables,
GetUsersQuery,
GetUsersQueryVariables,
OrganisationSortField as GeneratedOrganisationSortField,
Role,
ServiceUseOption as GeneratedServiceUseOption,
SortDirection,
UserSortField as GeneratedUserSortField,
} from '../../../shared/api/generated/graphql'
export type EmployeeRole = Role
export type Employee = {
avatarUrl?: string | null
id: number
firstName: string
lastName: string
middleName: string
middleName: string | null
username?: string
orgId: number
role: EmployeeRole
@ -17,41 +48,19 @@ export type Employee = {
} | null
}
export type UserSortDirection = 'ASC' | 'DESC'
export type UserSortDirection = SortDirection
export type UserSortField = 'ID' | 'Name' | 'Date'
export type UserSortField = GeneratedUserSortField
export type ServiceUseOption =
| 'Disabled'
| 'Enabled'
| 'Unspecified'
export type ServiceUseOption = GeneratedServiceUseOption
export type ComponentUseOption =
| 'Allowed'
| 'Disallowed'
export type ComponentUseOption = GeneratedComponentUseOption
export type GroupUsePolicyOption =
| 'Allowed'
| 'Disallowed'
| 'Changeable'
export type GetUsersData = {
getUsers: {
totalPages: number
totalElements: number
page: Employee[]
}
}
export type GetUsersVariables = {
page: number
query?: string
orgs?: string[]
roles?: EmployeeRole[]
sortDirection: UserSortDirection
sortField: UserSortField
}
export type OrganisationPolicy = {
bluetooth?: ServiceUseOption | GroupUsePolicyOption
bluetoothEditable?: boolean
@ -77,161 +86,33 @@ export type Organisation = {
logoUrl?: string | 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
bluetooth: ServiceUseOption
bluetoothEditable: boolean
camera: ComponentUseOption
cameraEditable: boolean
GPS: ServiceUseOption
gpsEditable: boolean
sim: ServiceUseOption
simEditable: boolean
offlineTimeThreshold: number
}
export type ChangeOrganisationPolicyVariables = {
id: string
name: string
bluetooth: ServiceUseOption
bluetoothEditable: boolean
camera: ComponentUseOption
cameraEditable: boolean
GPS: ServiceUseOption
gpsEditable: boolean
sim: ServiceUseOption
simEditable: boolean
offlineTimeThreshold: number
}
export type GetUsersPageData = {
getUsersPage: {
page: Employee[]
nextKey: string | null
}
}
export type GetUsersPageVariables = {
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 DeleteOrganisationData = {
deleteOrganisation: boolean
}
export type DeleteOrganisationVariables = {
id: string
}
export type CreateUserData = {
createUser: Employee
}
export type CreateUserVariables = {
orgId: string
firstName: string
lastName: string
middleName: string
username: string
password: string
role: EmployeeRole
}
export type ChangeUserData = {
changeUser: Employee
}
export type ChangeUserVariables = {
userId: string
orgId: string
firstName: string
lastName: string
middleName: string
username: string
role: EmployeeRole
}
export type ChangeUserWithPasswordVariables = ChangeUserVariables & {
password: string
}
export type SignUpVariables = {
orgId: string
firstName: string
lastName: string
middleName: string
username: string
password: string
role: EmployeeRole
}
export type CreateOrganisationData = {
createOrganisation: {
id: number
name: string
}
}
export type CreateOrganisationVariables = {
name: string
bluetooth: ServiceUseOption
bluetoothEditable: boolean
camera: ComponentUseOption
cameraEditable: boolean
GPS: ServiceUseOption
gpsEditable: boolean
sim: ServiceUseOption
simEditable: boolean
offlineTimeThreshold: number
}
export type OrganisationSortDirection = 'ASC' | 'DESC'
export type OrganisationSortField = 'ID' | 'Name' | 'Date'
export type CreateUploadUserAvatarUrlData = {
createUploadUserAvatarUrl: string
}
export type CreateUploadUserAvatarUrlVariables = {
userId: string
}
export type CreateUploadOrganisationLogoUrlData = {
createUploadOrganisationLogoUrl: string
}
export type CreateUploadOrganisationLogoUrlVariables = {
orgId: string
}
export type GetUsersData = GetUsersQuery
export type GetUsersVariables = GetUsersQueryVariables
export type GetOrganisationData = GetOrganisationQuery
export type GetOrganisationVariables = GetOrganisationQueryVariables
export type ChangeOrganisationData = ChangeOrganisationMutation
export type ChangeOrganisationVariables = ChangeOrganisationMutationVariables
export type ChangeOrganisationPolicyVariables =
ChangeOrganisationPolicyMutationVariables
export type GetOrganisationsData = GetOrganisationsQuery
export type GetOrganisationsVariables = GetOrganisationsQueryVariables
export type DeleteOrganisationData = DeleteOrganisationMutation
export type DeleteOrganisationVariables = DeleteOrganisationMutationVariables
export type CreateUserData = CreateUserMutation
export type CreateUserVariables = CreateUserMutationVariables
export type ChangeUserData = ChangeUserMutation
export type ChangeUserVariables = ChangeUserMutationVariables
export type ChangeUserWithPasswordVariables =
ChangeUserWithPasswordMutationVariables
export type CreateOrganisationData = CreateOrganisationMutation
export type CreateOrganisationVariables = CreateOrganisationMutationVariables
export type OrganisationSortDirection = SortDirection
export type OrganisationSortField = GeneratedOrganisationSortField
export type CreateUploadUserAvatarUrlData =
CreateUploadUserAvatarUrlMutation
export type CreateUploadUserAvatarUrlVariables =
CreateUploadUserAvatarUrlMutationVariables
export type CreateUploadOrganisationLogoUrlData =
CreateUploadOrganisationLogoUrlMutation
export type CreateUploadOrganisationLogoUrlVariables =
CreateUploadOrganisationLogoUrlMutationVariables

View File

@ -0,0 +1,37 @@
mutation SignIn($username: String!, $password: String!) {
signIn(username: $username, password: $password) {
id
role
}
}
mutation RefreshSession {
refreshSession {
id
firstName
lastName
middleName
orgId
role
org {
id
name
}
}
}
query CurrentUser {
currentUser {
avatarUrl
id
role
firstName
middleName
lastName
username
org {
id
name
}
}
}

View File

@ -1,45 +1,5 @@
import { gql } from '@apollo/client'
export const SIGN_IN_MUTATION = gql`
mutation SignIn($username: String!, $password: String!) {
signIn(username: $username, password: $password) {
id
role
}
}
`
export const REFRESH_SESSION_MUTATION = gql`
mutation RefreshSession {
refreshSession {
id
firstName
lastName
middleName
orgId
role
org {
id
name
}
}
}
`
export const CURRENT_USER_QUERY = gql`
query CurrentUser {
currentUser {
avatarUrl
id
role
firstName
middleName
lastName
username
org {
id
name
}
}
}
`
export {
SignInDocument as SIGN_IN_MUTATION,
RefreshSessionDocument as REFRESH_SESSION_MUTATION,
CurrentUserDocument as CURRENT_USER_QUERY,
} from '../../../shared/api/generated/graphql'

View File

@ -8,28 +8,6 @@ import {
} from '../api/auth.graphql'
import { LoginPage } from '../../../pages/LoginPage/LoginPage'
type CurrentUser = {
id: string
role: string
avatarUrl: string
firstName: string
middleName: string
lastName: string
username: string
org: {
id: number
name: string
} | null
}
type CurrentUserQueryData = {
currentUser: CurrentUser | null
}
type RefreshSessionData = {
refreshSession: CurrentUser | null
}
type AuthGateProps = {
children: ReactNode
}
@ -38,7 +16,7 @@ export function AuthGate({ children }: AuthGateProps) {
const [isForcedLogout, setIsForcedLogout] = useState(false)
const [isRefreshFailed, setIsRefreshFailed] = useState(false)
const { data, loading, error, refetch } = useQuery<CurrentUserQueryData>(
const { data, loading, error, refetch } = useQuery(
CURRENT_USER_QUERY,
{
fetchPolicy: 'network-only',
@ -47,7 +25,7 @@ export function AuthGate({ children }: AuthGateProps) {
)
const [refreshSession, { loading: isRefreshing }] =
useMutation<RefreshSessionData>(REFRESH_SESSION_MUTATION, {
useMutation(REFRESH_SESSION_MUTATION, {
onCompleted: async (result) => {
if (!result.refreshSession) {
setIsRefreshFailed(true)

View File

@ -49,6 +49,18 @@ html{
background: $color-bg;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
html.theme-transition-no-css-transitions *,
html.theme-transition-no-css-transitions *::before,
html.theme-transition-no-css-transitions *::after {
transition: none !important;
}
button{
font-family: inherit;
}

View File

@ -12,6 +12,35 @@
padding: 20px 20px 36px 20px;
}
.device-page--apps {
flex: 1 1 0;
box-sizing: border-box;
min-width: 0;
min-height: 0;
height: 100%;
max-height: 100%;
overflow: visible;
.device-page__motion-view {
overflow: visible;
}
.device-page__apps-motion {
display: flex;
flex: 1 1 0;
min-width: 0;
min-height: 0;
height: 100%;
max-height: 100%;
flex-direction: column;
}
.device-apps-view {
min-height: 0;
height: 100%;
}
}
.device-page__grid {
display: grid;
grid-template-columns: 340px 340px minmax(360px, 1fr);
@ -30,7 +59,7 @@
.device-card {
border-radius: 20px;
background: rgba($color-surface-rgb, .5);
background: $color-surface;
backdrop-filter: blur(22px);
padding: 20px;
box-shadow: $shadow-card;
@ -116,35 +145,153 @@
gap: 4px;
}
.device-status-row {
display: flex;
align-items: center;
gap: 4px;
}
.device-status {
display: inline-flex;
align-items: center;
gap: 9px;
gap: 2px;
color: $color-text-strong;
font-size: $font-size-18;
font-weight: 500;
font-size: $font-size-14;
font-weight: 550;
text-transform: uppercase;
padding-right: 12px;
border-radius: 12px;
line-height: 1;
border: none;
svg {
padding: 4px;
width: 24px;
height: 24px;
border-radius: 8px;
padding: 6px;
width: 20px;
height: 20px;
border-radius: 12px;
background: $color-bg;
}
&.is-success svg {
&.is-success {
color: $green;
background: $color-success-bg;
background-color: var(--color-success-bg);
svg {
color: $green;
background: $color-success-bg;
}
}
&.is-danger svg {
color: $red;
&.is-danger {
background: $color-danger-bg;
color: $red;
svg {
color: $red;
background: $color-danger-bg;
}
}
&.is-muted svg {
&.is-muted {
background: var(--color-bg);
color: $gray50;
svg {
color: $gray50;
}
}
&.is-service {
background: $color-primary-muted;
color: $blue;
svg {
color: $blue;
background: $color-primary-muted;
}
}
}
.device-status--interactive {
border: 0;
font-family: inherit;
cursor: pointer;
transition:
filter 0.18s ease,
box-shadow 0.18s ease;
}
.device-status-menu__trigger {
width: auto;
aspect-ratio: 1/1;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
color: $gray50;
cursor: pointer;
transition: 0.18s ease;
&:hover,
&[data-state='open'] {
background: $color-bg;
}
&:focus-visible {
outline: none;
outline-offset: 1px;
}
}
.device-status-menu {
z-index: 600;
min-width: 228px;
border: 1px solid $color-border-muted;
border-radius: 12px;
background: $color-surface;
padding: 6px;
box-shadow: $shadow-popover;
animation: device-status-menu-in 0.16s ease;
}
.device-status-menu__item {
min-height: 38px;
display: flex;
align-items: center;
gap: 9px;
border-radius: 8px;
padding: 0 10px;
outline: none;
color: $color-text-strong;
font-size: $font-size-13;
font-weight: 550;
cursor: pointer;
svg {
flex: 0 0 auto;
color: $gray50;
}
&[data-highlighted] {
background: $color-surface-hover;
}
}
@keyframes device-status-menu-in {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@ -744,7 +891,8 @@
}
.device-page__grid {
grid-template-columns: 540px minmax(360px, 1fr);;
grid-template-columns: 540px minmax(360px, 1fr);
;
}
.device-card--main {
@ -761,19 +909,23 @@
height: calc(100% - 36px);
border-radius: 16px;
}
.device-card-stats{
.device-card-stats {
height: calc(100%);
}
.device-impacts{
.device-impacts {
flex: 0;
}
.device-battery{
.device-battery {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
}
.device-permissions{
.device-permissions {
height: calc(100% - 12px);
padding: 8px 8px 4px 18px;
}
@ -789,22 +941,27 @@
.device-map {
min-height: 252px;
}
.history-period-tabs{
.history-period-tabs {
display: none;
}
.device-card__title{
.device-card__title {
gap: 8px;
svg{
svg {
width: 22px;
height: 22px;
}
h3{
h3 {
font-size: var(--font-size-16);
}
}
.device-card__header{
p{
font-size: var(--font-size-14);
.device-card__header {
p {
font-size: var(--font-size-14);
}
}
}

View File

@ -4,6 +4,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
@ -21,6 +22,7 @@ import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql'
import type {
Device as ApiDevice,
DeviceNetworkStatus,
DeviceTechnicalStatus,
GetPhoneGpsTrackData,
GetTelemetryData,
GetDevicePageData,
@ -42,8 +44,10 @@ import { DevicePageSkeleton } from './components/DevicePageSkeleton/DevicePageSk
import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout'
import {
preloadOnIdle,
useDeferredModalPayload,
useLazyMount,
} from '../../shared/lib/lazyMount'
import { isPresent } from '../../shared/lib/isPresent'
const loadDeviceHistoryModal = () =>
import('./components/DeviceHistoryModal/DeviceHistoryModal').then(
@ -54,6 +58,26 @@ const loadDeviceHistoryModal = () =>
const DeviceHistoryModal = lazy(loadDeviceHistoryModal)
const loadDeviceMaintenanceModal = () =>
import('./components/DeviceMaintenanceModal/DeviceMaintenanceModal').then(
(module) => ({
default: module.DeviceMaintenanceModal,
}),
)
const DeviceMaintenanceModal = lazy(loadDeviceMaintenanceModal)
const loadDeviceMaintenanceHistoryModal = () =>
import(
'./components/DeviceMaintenanceHistoryModal/DeviceMaintenanceHistoryModal'
).then((module) => ({
default: module.DeviceMaintenanceHistoryModal,
}))
const DeviceMaintenanceHistoryModal = lazy(
loadDeviceMaintenanceHistoryModal,
)
function formatLocationDate(timestamp: number) {
if (!timestamp) return 'Нет данных'
@ -77,7 +101,7 @@ function getLatestTelemetryItem(telemetry: GetTelemetryData['getTelemetry']) {
}
function getSortedGpsTrack(track: GetPhoneGpsTrackData['getPhoneGpsTrack']) {
return [...track].sort((a, b) => a.date - b.date)
return track.filter(isPresent).sort((a, b) => a.date - b.date)
}
function getDeviceNetworkConnection(status?: DeviceNetworkStatus) {
@ -94,6 +118,13 @@ function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
return 'Не в сети'
}
function getDeviceCondition(status?: DeviceTechnicalStatus) {
if (status === 'InService') return 'service'
if (status === 'NeedsMaintenance') return 'inspection'
return 'ok'
}
function mapApiDeviceToPageDevice(
device: ApiDevice,
batteryLevel?: number,
@ -103,7 +134,6 @@ function mapApiDeviceToPageDevice(
const sortedGpsTrack = getSortedGpsTrack(gpsTrack)
const policy = device.policy
const techState = device.techState
const needMaintenance = techState?.needMaintenance ?? false
const bluetoothEnabled = isPhonePolicyOptionEnabled(
policy?.bluetooth ?? policy?.canUseBluetooth,
)
@ -157,7 +187,10 @@ function mapApiDeviceToPageDevice(
policy,
organisationPolicy: device.org?.policy ?? null,
condition: needMaintenance ? 'inspection' : 'ok',
condition: getDeviceCondition(techState?.status),
technicalStatus: techState?.status ?? 'Healthy',
malfunctions: techState?.malfunctions ?? [],
malfunctionComment: techState?.malfunctionComment,
connection: getDeviceNetworkConnection(device.networkStatus),
connectionText: getDeviceNetworkLabel(device.networkStatus),
@ -217,12 +250,35 @@ export function DevicePage() {
const [searchParams, setSearchParams] = useSearchParams()
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const shouldMountHistoryModal = useLazyMount(isHistoryOpen)
const [maintenanceMode, setMaintenanceMode] = useState<
'view' | 'manage' | null
>(null)
const renderMaintenanceMode = useDeferredModalPayload(maintenanceMode)
const shouldMountMaintenanceModal = useLazyMount(maintenanceMode !== null)
const [isMaintenanceHistoryOpen, setIsMaintenanceHistoryOpen] =
useState(false)
const shouldMountMaintenanceHistoryModal = useLazyMount(
isMaintenanceHistoryOpen,
)
const maintenanceHistoryTimerRef = useRef<number | null>(null)
const numericDeviceId = Number(deviceId)
const isAppsView = searchParams.get('view') === 'apps'
useEffect(() => {
return preloadOnIdle([loadDeviceHistoryModal])
return preloadOnIdle([
loadDeviceHistoryModal,
loadDeviceMaintenanceModal,
loadDeviceMaintenanceHistoryModal,
])
}, [])
useEffect(() => {
return () => {
if (maintenanceHistoryTimerRef.current !== null) {
window.clearTimeout(maintenanceHistoryTimerRef.current)
}
}
}, [])
const {
@ -315,6 +371,35 @@ export function DevicePage() {
setIsHistoryOpen(true)
}, [])
const handleOpenMaintenanceDetails = useCallback(() => {
setMaintenanceMode('view')
}, [])
const handleManageMaintenance = useCallback(() => {
setMaintenanceMode('manage')
}, [])
const handleOpenMaintenanceHistory = useCallback(() => {
setMaintenanceMode(null)
if (maintenanceHistoryTimerRef.current !== null) {
window.clearTimeout(maintenanceHistoryTimerRef.current)
}
maintenanceHistoryTimerRef.current = window.setTimeout(() => {
setIsMaintenanceHistoryOpen(true)
maintenanceHistoryTimerRef.current = null
}, 220)
}, [])
const handleOpenMaintenanceHistoryFromCard = useCallback(() => {
setIsMaintenanceHistoryOpen(true)
}, [])
const handleMaintenanceUpdated = useCallback(async () => {
await refetch()
}, [refetch])
const handleRefetchPolicy = useCallback(async () => {
const result = await refetch()
@ -372,12 +457,12 @@ export function DevicePage() {
}
return (
<section className="device-page">
<section className={`device-page ${isAppsView ? 'device-page--apps' : ''}`}>
<AnimatePresence mode="wait" initial={false}>
{isAppsView ? (
<m.div
key="apps"
className="device-page__motion-view"
className="device-page__motion-view device-page__apps-motion"
variants={pageSwapVariants}
initial="initial"
animate="animate"
@ -404,6 +489,9 @@ export function DevicePage() {
<DeviceMainCard
device={device}
onOpenHistory={handleOpenHistory}
onOpenMaintenanceDetails={handleOpenMaintenanceDetails}
onManageMaintenance={handleManageMaintenance}
onOpenMaintenanceHistory={handleOpenMaintenanceHistoryFromCard}
/>
<DeviceMapCard device={device} />
@ -433,6 +521,29 @@ export function DevicePage() {
/>
</Suspense>
)}
{shouldMountMaintenanceModal && renderMaintenanceMode && (
<Suspense fallback={null}>
<DeviceMaintenanceModal
open={maintenanceMode !== null}
mode={renderMaintenanceMode}
device={device}
onOpenHistory={handleOpenMaintenanceHistory}
onOpenChange={(nextOpen) => {
if (!nextOpen) setMaintenanceMode(null)
}}
onUpdated={handleMaintenanceUpdated}
/>
</Suspense>
)}
{shouldMountMaintenanceHistoryModal && (
<Suspense fallback={null}>
<DeviceMaintenanceHistoryModal
open={isMaintenanceHistoryOpen}
device={device}
onOpenChange={setIsMaintenanceHistoryOpen}
/>
</Suspense>
)}
</section>
)
}

View File

@ -1,10 +1,13 @@
@use '../../../../shared/styles/variables' as *;
.device-apps-view {
display: flex;
flex: 1;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
flex: 1 1 0;
min-width: 0;
min-height: 0;
flex-direction: column;
height: 100%;
max-height: 100%;
gap: 14px;
}
@ -237,16 +240,43 @@
}
.device-apps-view__panel {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
flex: 1;
height: 100%;
max-height: 100%;
flex: 1 1 0;
border-radius: 20px;
background: $color-surface;
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
overflow: hidden;
}
.device-apps-view__scroll {
position: relative;
flex: 1 1 0;
width: 100%;
min-height: 0;
height: 100%;
max-height: 100%;
border-radius: 20px;
background: $color-surface;
.simplebar-wrapper,
.simplebar-mask,
.simplebar-offset,
.simplebar-content-wrapper {
min-width: 0;
min-height: 0;
max-width: 100%;
height: 100%;
max-height: 100%;
}
.simplebar-content-wrapper {
border-radius: 20px;
}
.simplebar-content {
min-height: 100%;
@ -254,18 +284,29 @@
.simplebar-track.simplebar-vertical {
top: 14px;
right: 6px;
right: -7px;
bottom: 14px;
width: 8px;
width: 14px;
}
.simplebar-scrollbar::before {
background: $gray50;
border-radius: 4px;
background: color-mix(in srgb, var(--gray50) 85%, transparent);
border-radius: 6px;
opacity: 1;
}
}
.device-apps-view__reveal {
display: block;
min-height: 100%;
.reveal-content__fallback,
.reveal-content__content {
display: block;
min-height: 100%;
}
}
.device-apps-view__state {
min-height: 220px;
padding: 24px;

View File

@ -483,7 +483,11 @@ export function DeviceAppsView({
<div className="device-apps-view__panel">
<SimpleBar className="device-apps-view__scroll">
<RevealContent loading={loading} fallback={<ListSkeleton />}>
<RevealContent
className="device-apps-view__reveal"
loading={loading}
fallback={<ListSkeleton />}
>
{error && (
<div className="device-apps-view__state is-error">
Не удалось загрузить список приложений

View File

@ -25,6 +25,7 @@ import type {
DevicePackageUseEventsGroup,
DevicePhoneStateEventType
} from '../../../../entities/device/model/types'
import { isPresent } from '../../../../shared/lib/isPresent'
import {
DeviceHistoryPeriodControl,
@ -194,7 +195,7 @@ export function DeviceHistoryModal({
return [
...telemetryData.getTelemetry,
...telemetryData.getPhoneStateEvents.map(
...telemetryData.getPhoneStateEvents.filter(isPresent).map(
(event): DeviceHistoryChartItem => ({
date: event.date,
type: 'event',

View File

@ -1,23 +1,35 @@
import { memo, useState } from 'react'
import { AnimatePresence, m, useReducedMotion } from 'framer-motion'
import { Link } from 'react-router-dom'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {
Building2,
CircleAlert,
Eye,
History,
Lock,
Menu,
Pencil,
ShieldCheck,
Signal,
Smartphone,
Trash2,
Wrench,
} from 'lucide-react'
import type { Device } from '../../types'
import { conditionText, connectionText, getStatusClass } from '../../types'
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
import { useDeferredModalPayload } from '../../../../shared/lib/lazyMount'
import { getDeviceMalfunctionSummary } from '../../../../entities/device/lib/maintenance'
import { Tooltip } from '../../../../shared/ui/Tooltip/Tooltip'
type DeviceMainCardProps = {
device: Device
onOpenHistory: () => void
onOpenMaintenanceDetails: () => void
onManageMaintenance: () => void
onOpenMaintenanceHistory: () => void
}
function getDeviceFullName(device: Device) {
@ -30,6 +42,9 @@ function areDeviceMainCardPropsEqual(
) {
return (
prev.onOpenHistory === next.onOpenHistory &&
prev.onOpenMaintenanceDetails === next.onOpenMaintenanceDetails &&
prev.onManageMaintenance === next.onManageMaintenance &&
prev.onOpenMaintenanceHistory === next.onOpenMaintenanceHistory &&
prev.device.id === next.device.id &&
prev.device.image === next.device.image &&
prev.device.model === next.device.model &&
@ -38,6 +53,9 @@ function areDeviceMainCardPropsEqual(
prev.device.imei === next.device.imei &&
prev.device.imei2 === next.device.imei2 &&
prev.device.condition === next.device.condition &&
prev.device.technicalStatus === next.device.technicalStatus &&
prev.device.malfunctionComment === next.device.malfunctionComment &&
prev.device.malfunctions.join(',') === next.device.malfunctions.join(',') &&
prev.device.connection === next.device.connection &&
prev.device.connectionText === next.device.connectionText &&
prev.device.permissions?.locked === next.device.permissions?.locked &&
@ -51,6 +69,9 @@ function areDeviceMainCardPropsEqual(
export const DeviceMainCard = memo(function DeviceMainCard({
device,
onOpenHistory,
onOpenMaintenanceDetails,
onManageMaintenance,
onOpenMaintenanceHistory,
}: DeviceMainCardProps) {
const [deletingDevice, setDeletingDevice] = useState<Device | null>(null)
const renderDeletingDevice = useDeferredModalPayload(deletingDevice)
@ -61,12 +82,32 @@ export const DeviceMainCard = memo(function DeviceMainCard({
duration: 0.24,
ease: [0.34, 1.42, 0.64, 1] as [number, number, number, number],
}
const statusItems = [
const statusItems: Array<{
key: string
className: string
icon: React.ReactNode
content: string
title?: string
isTechnical?: boolean
}> = [
{
key: `condition-${device.condition}`,
className: getStatusClass(device.condition),
icon: <ShieldCheck size={17} />,
icon:
device.condition === 'ok' ? (
<ShieldCheck size={17} />
) : device.condition === 'service' ? (
<Wrench size={17} />
) : (
<CircleAlert size={17} />
),
content: conditionText[device.condition],
title:
getDeviceMalfunctionSummary(
device.malfunctions,
device.malfunctionComment,
) || undefined,
isTechnical: true,
},
{
key: `connection-${device.connection}-${device.connectionText ?? ''}`,
@ -133,7 +174,7 @@ export const DeviceMainCard = memo(function DeviceMainCard({
<m.div
key={status.key}
layout={!shouldReduceMotion}
className={`device-status ${status.className}`}
className="device-status-row"
initial={{ opacity: 0, x: shouldReduceMotion ? 0 : -8 }}
animate={{
opacity: 1,
@ -149,8 +190,67 @@ export const DeviceMainCard = memo(function DeviceMainCard({
transition: statusTransition,
}}
>
{status.icon}
{status.content}
<Tooltip content={status.title}>
{status.isTechnical ? (
<button
className={`device-status device-status--interactive ${status.className}`}
type="button"
onClick={onOpenMaintenanceDetails}
>
{status.icon}
{status.content}
</button>
) : (
<span className={`device-status ${status.className}`}>
{status.icon}
{status.content}
</span>
)}
</Tooltip>
{status.isTechnical && (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className="device-status-menu__trigger"
type="button"
aria-label="Действия с техническим состоянием"
>
<Menu size={16} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="device-status-menu"
align="start"
sideOffset={7}
>
<DropdownMenu.Item
className="device-status-menu__item"
onSelect={onOpenMaintenanceDetails}
>
<Eye size={16} />
Просмотреть состояние
</DropdownMenu.Item>
<DropdownMenu.Item
className="device-status-menu__item"
onSelect={onManageMaintenance}
>
<Pencil size={16} />
Изменить состояние
</DropdownMenu.Item>
<DropdownMenu.Item
className="device-status-menu__item"
onSelect={onOpenMaintenanceHistory}
>
<History size={16} />
Журнал обслуживания
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</m.div>
))}
</AnimatePresence>

View File

@ -0,0 +1,235 @@
@use '../../../../shared/styles/variables' as *;
.device-maintenance-history-modal__overlay {
position: fixed;
inset: 0;
z-index: 500;
background: $color-backdrop;
backdrop-filter: blur(3px);
}
.device-maintenance-history-modal {
position: fixed;
z-index: 510;
top: 50%;
left: 50%;
width: min(860px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 24px;
background: $color-surface;
box-shadow: $shadow-modal;
}
.device-maintenance-history-modal__header {
flex: 0 0 auto;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 22px 24px 18px;
border-bottom: 1px solid $color-border-muted;
h2 {
margin: 0;
color: $color-text-strong;
font-size: $font-size-24;
font-weight: 650;
}
p {
margin: 5px 0 0;
color: $color-text-muted;
font-size: $font-size-14;
}
}
.device-maintenance-history-modal__close {
flex: 0 0 auto;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 11px;
background: $color-bg;
color: $color-text-muted;
cursor: pointer;
transition: 0.2s ease;
&:hover {
background: $color-surface-hover;
color: $color-primary;
}
}
.device-maintenance-history-modal__scroll {
flex: 1 1 auto;
min-height: 0;
}
.device-maintenance-history-modal__body {
padding: 20px 24px 24px;
}
.device-maintenance-history {
display: flex;
flex-direction: column;
gap: 9px;
}
.device-maintenance-history-case {
overflow: hidden;
border: 1px solid $color-border-muted;
border-radius: 15px;
background: $color-surface-muted;
h3 {
margin: 0;
}
[data-radix-collection-item] {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border: 0;
background: transparent;
padding: 13px 14px;
color: $color-text-strong;
text-align: left;
cursor: pointer;
> span {
display: flex;
flex-direction: column;
gap: 3px;
}
b {
font-size: $font-size-14;
font-weight: 600;
}
small {
color: $color-text-muted;
font-size: $font-size-12;
}
svg {
flex: 0 0 auto;
color: $color-text-muted;
transition: transform 0.2s ease;
}
&[data-state='open'] svg {
transform: rotate(180deg);
}
}
}
.device-maintenance-history-case__details {
padding: 0 14px 14px;
}
.device-maintenance-history-case__malfunctions {
display: flex;
flex-wrap: wrap;
gap: 6px;
span {
border-radius: 12px;
background: $color-danger-bg;
padding: 4px 10px;
color: $color-danger;
font-size: $font-size-12;
font-weight: 600;
}
}
.device-maintenance-history-case__comment {
margin-top: 11px;
padding: 10px 12px;
border-radius: 11px;
background: $color-surface;
span {
color: $color-text-muted;
font-size: $font-size-11;
font-weight: 500;
}
p {
margin: 3px 0 0;
color: $color-text;
font-size: $font-size-13;
line-height: 1.45;
}
}
.device-maintenance-history-case__dates {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
margin-top: 12px;
color: $color-text-muted;
font-size: $font-size-12;
b {
color: $color-text;
font-weight: 600;
}
}
.device-maintenance-history-case__metrics {
margin-top: 12px;
}
.device-maintenance-history-modal__message {
padding: 28px;
border-radius: 15px;
background: $color-surface-muted;
color: $color-text-muted;
font-size: $font-size-14;
text-align: center;
&.is-error {
background: $color-danger-bg;
color: $color-danger;
}
}
.device-maintenance-history-modal__footer {
position: relative;
z-index: 2;
flex: 0 0 auto;
min-height: 72px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 14px 24px;
border-top: 1px solid $color-border-muted;
background: $color-surface;
button {
min-height: 42px;
border: 0;
border-radius: 12px;
background: $color-bg;
padding: 0 18px;
color: $color-text-strong;
font-size: $font-size-14;
font-weight: 600;
cursor: pointer;
transition: 0.18s ease;
&:hover {
background: $color-surface-hover;
color: $color-primary;
}
}
}

View File

@ -0,0 +1,193 @@
import { useMemo } from 'react'
import * as Accordion from '@radix-ui/react-accordion'
import * as Dialog from '@radix-ui/react-dialog'
import { useQuery } from '@apollo/client/react'
import SimpleBar from 'simplebar-react'
import {
ChevronDown,
X,
} from 'lucide-react'
import { GET_PHONE_MAINTENANCE_HISTORY_QUERY } from '../../../../entities/device/api/device.graphql'
import {
deviceMalfunctionLabels,
isRedundantMalfunctionComment,
} from '../../../../entities/device/lib/maintenance'
import type {
GetPhoneMaintenanceHistoryData,
GetPhoneMaintenanceHistoryVariables,
} from '../../../../entities/device/model/types'
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import type { Device } from '../../types'
import { DeviceMaintenanceMetrics } from '../DeviceMaintenanceMetrics/DeviceMaintenanceMetrics'
import './DeviceMaintenanceHistoryModal.scss'
type DeviceMaintenanceHistoryModalProps = {
open: boolean
device: Device
onOpenChange: (open: boolean) => void
}
type MaintenanceCase =
GetPhoneMaintenanceHistoryData['getPhoneMaintenanceHistory'][number]
const maintenanceStatusLabels = {
Open: 'Ожидает обслуживания',
InService: 'На обслуживании',
Closed: 'Обслуживание завершено',
} as const
function formatDate(value?: number | null) {
if (!value) return 'Не указано'
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
function CaseDetails({ item }: { item: MaintenanceCase }) {
const showComment = !isRedundantMalfunctionComment(
item.malfunctions,
item.comment,
)
return (
<div className="device-maintenance-history-case__details">
<div className="device-maintenance-history-case__malfunctions">
{item.malfunctions.map((malfunction) => (
<span key={malfunction}>{deviceMalfunctionLabels[malfunction]}</span>
))}
</div>
{showComment && (
<div className="device-maintenance-history-case__comment">
<span>Комментарий</span>
<p>{item.comment}</p>
</div>
)}
<div className="device-maintenance-history-case__dates">
<span>Обнаружено: <b>{formatDate(item.detectedAt)}</b></span>
{item.serviceStartedAt && (
<span>Передано в сервис: <b>{formatDate(item.serviceStartedAt)}</b></span>
)}
{item.closedAt && (
<span>Возвращено: <b>{formatDate(item.closedAt)}</b></span>
)}
</div>
<div className="device-maintenance-history-case__metrics">
<DeviceMaintenanceMetrics metrics={item.metrics} />
</div>
</div>
)
}
export function DeviceMaintenanceHistoryModal({
open,
device,
onOpenChange,
}: DeviceMaintenanceHistoryModalProps) {
const { data, loading, error } = useQuery<
GetPhoneMaintenanceHistoryData,
GetPhoneMaintenanceHistoryVariables
>(GET_PHONE_MAINTENANCE_HISTORY_QUERY, {
variables: { phoneId: String(device.id) },
skip: !open,
fetchPolicy: 'network-only',
})
const history = useMemo(
() => [...(data?.getPhoneMaintenanceHistory ?? [])]
.sort((a, b) => b.detectedAt - a.detectedAt),
[data],
)
const activeCase = history.find((item) => item.status !== 'Closed') ?? null
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<MotionDialogContent
open={open}
overlayClassName="device-maintenance-history-modal__overlay"
contentClassName="device-maintenance-history-modal"
motionPreset="fade"
>
<header className="device-maintenance-history-modal__header">
<div>
<Dialog.Title>Журнал обслуживания</Dialog.Title>
<Dialog.Description>
{device.factoryNumber} · ID {device.id}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
className="device-maintenance-history-modal__close"
type="button"
aria-label="Закрыть"
>
<X size={20} />
</button>
</Dialog.Close>
</header>
<SimpleBar className="device-maintenance-history-modal__scroll">
<div className="device-maintenance-history-modal__body">
{loading && history.length === 0 ? (
<div className="device-maintenance-history-modal__message">
Загружаем журнал...
</div>
) : error ? (
<div className="device-maintenance-history-modal__message is-error">
Не удалось загрузить журнал обслуживания.
</div>
) : history.length > 0 ? (
<Accordion.Root
className="device-maintenance-history"
type="single"
defaultValue={activeCase ? String(activeCase.id) : undefined}
collapsible
>
{history.map((item) => (
<Accordion.Item
className="device-maintenance-history-case"
value={String(item.id)}
key={item.id}
>
<Accordion.Header>
<Accordion.Trigger>
<span>
<b>{maintenanceStatusLabels[item.status]}</b>
<small>{formatDate(item.detectedAt)}</small>
</span>
<ChevronDown size={18} />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<CaseDetails item={item} />
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
) : (
<div className="device-maintenance-history-modal__message">
История обслуживания пока пуста.
</div>
)}
</div>
</SimpleBar>
<footer className="device-maintenance-history-modal__footer">
<Dialog.Close asChild>
<button type="button">Закрыть</button>
</Dialog.Close>
</footer>
</MotionDialogContent>
</Dialog.Root>
)
}

View File

@ -0,0 +1,42 @@
@use '../../../../shared/styles/variables' as *;
.device-maintenance-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.device-maintenance-metric {
min-width: 0;
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
padding: 9px;
border-radius: 10px;
background: $color-surface;
color: $color-text-muted;
font-size: $font-size-12;
svg {
color: $color-primary;
}
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
b {
color: $color-text-strong;
font-weight: 650;
}
}
@media (max-width: 760px) {
.device-maintenance-metrics {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,82 @@
import {
BatteryMedium,
Clock3,
Hammer,
RotateCcw,
Thermometer,
} from 'lucide-react'
import './DeviceMaintenanceMetrics.scss'
export type DeviceMaintenanceMetricsValue = {
hits: number
overheats: number
batteryCycles: number
worktime: number
batteryCapacity?: number | null
}
type DeviceMaintenanceMetricsProps = {
metrics: DeviceMaintenanceMetricsValue
}
function formatWorktime(value: number) {
if (!Number.isFinite(value)) return '-'
const hours = value / 1000 / 60 / 60
if (hours < 1) {
return `${Math.round(value / 1000 / 60).toLocaleString('ru-RU')} мин`
}
return `${(Math.round(hours * 10) / 10).toLocaleString('ru-RU')} ч`
}
function formatMetric(value?: number | null, suffix = '') {
if (value === null || value === undefined) return '-'
return `${value.toLocaleString('ru-RU')}${suffix}`
}
export function DeviceMaintenanceMetrics({
metrics,
}: DeviceMaintenanceMetricsProps) {
const items = [
{
label: 'Удары',
value: formatMetric(metrics.hits),
icon: <Hammer size={17} />,
},
{
label: 'Перегревы',
value: formatMetric(metrics.overheats),
icon: <Thermometer size={17} />,
},
{
label: 'Циклы зарядки',
value: formatMetric(metrics.batteryCycles),
icon: <RotateCcw size={17} />,
},
{
label: 'Время работы',
value: formatWorktime(metrics.worktime),
icon: <Clock3 size={17} />,
},
{
label: 'Ёмкость АКБ',
value: formatMetric(metrics.batteryCapacity, '%'),
icon: <BatteryMedium size={17} />,
},
]
return (
<div className="device-maintenance-metrics">
{items.map((item) => (
<div className="device-maintenance-metric" key={item.label}>
{item.icon}
<span>{item.label}</span>
<b>{item.value}</b>
</div>
))}
</div>
)
}

View File

@ -0,0 +1,421 @@
@use '../../../../shared/styles/variables' as *;
.device-maintenance-modal__overlay {
position: fixed;
inset: 0;
z-index: 500;
background: $color-backdrop;
backdrop-filter: blur(3px);
}
.device-maintenance-modal {
position: fixed;
z-index: 510;
top: 50%;
left: 50%;
width: min(860px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 24px;
background: $color-surface;
box-shadow: $shadow-modal;
}
.device-maintenance-modal__header {
flex: 0 0 auto;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 22px 24px 18px;
border-bottom: 1px solid $color-border-muted;
h2 {
margin: 0;
color: $color-text-strong;
font-size: $font-size-24;
font-weight: 650;
}
p {
margin: 5px 0 0;
color: $color-text-muted;
font-size: $font-size-14;
}
}
.device-maintenance-modal__close {
flex: 0 0 auto;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 11px;
background: $color-bg;
color: $color-text-muted;
cursor: pointer;
transition: 0.2s ease;
&:hover {
background: $color-surface-hover;
color: $color-primary;
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.device-maintenance-modal__scroll {
flex: 1 1 auto;
min-height: 0;
max-height: calc(100vh - 190px);
}
.device-maintenance-modal__body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 24px 24px;
}
.device-maintenance-summary {
display: grid;
grid-template-columns: 44px minmax(170px, auto) minmax(0, 1fr);
align-items: center;
gap: 14px;
padding: 15px;
border-radius: 16px;
background: $color-surface-muted;
}
.device-maintenance-summary__icon {
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 13px;
background: $color-bg;
color: $color-text-muted;
&.is-success {
background: $color-success-bg;
color: $color-success;
}
&.is-danger {
background: $color-danger-bg;
color: $color-danger;
}
&.is-service {
background: $color-primary-muted;
color: $color-primary;
}
}
.device-maintenance-summary__field {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
line-height: 1;
> span {
color: $color-text-muted;
font-size: $font-size-12;
font-weight: 500;
}
strong {
color: $color-text-strong;
font-size: $font-size-17;
}
p {
margin: 0;
overflow-wrap: anywhere;
color: $color-text;
font-size: $font-size-13;
line-height: 1.4;
}
}
.device-maintenance-summary__comment {
align-self: stretch;
justify-content: center;
padding-left: 16px;
border-left: 1px solid $color-border-muted;
}
.device-maintenance-section {
padding: 16px;
border: 1px solid $color-border-muted;
border-radius: 18px;
background: $color-surface;
}
.device-maintenance-section__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
h3 {
margin: 0;
color: $color-text-strong;
font-size: $font-size-16;
font-weight: 650;
}
p {
margin: 0;
color: $color-text-muted;
font-size: $font-size-13;
}
> svg {
color: $color-text-muted;
}
}
.device-maintenance-current-malfunctions {
display: flex;
flex-wrap: wrap;
gap: 6px;
span {
border-radius: 12px;
background: $color-danger-bg;
padding: 4px 10px;
color: $color-danger;
font-size: $font-size-12;
font-weight: 600;
}
}
.device-maintenance-metrics-message {
margin: 0;
color: $color-text-muted;
font-size: $font-size-13;
&.is-error {
color: $color-danger;
}
}
.device-maintenance-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.device-maintenance-option {
min-width: 0;
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
align-items: flex-start;
gap: 10px;
padding: 11px;
border: 1px solid $color-border-muted;
border-radius: 13px;
background: $color-surface-muted;
color: $color-text;
text-align: left;
cursor: pointer;
transition: 0.18s ease;
&:hover {
border-color: $color-border-accent;
background: $color-surface-hover;
}
&.is-selected {
border-color: $color-primary;
background: $color-primary-tint-soft;
}
&:disabled {
opacity: 0.65;
cursor: default;
}
> span:last-child {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
b {
color: $color-text-strong;
font-size: $font-size-14;
font-weight: 600;
}
small {
color: $color-text-muted;
font-size: $font-size-12;
line-height: 1.35;
}
}
.device-maintenance-option__check {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid $color-border-strong;
border-radius: 7px;
background: $color-surface;
color: $color-text-inverse;
.is-selected & {
border-color: $color-primary;
background: $color-primary;
}
}
.device-maintenance-comment {
display: flex;
flex-direction: column;
gap: 7px;
margin-top: 12px;
> span {
color: $color-text-strong;
font-size: $font-size-13;
font-weight: 600;
b {
color: $color-danger;
font-weight: 600;
}
}
textarea {
min-height: 78px;
resize: vertical;
border: 1px solid $color-border-strong;
border-radius: 13px;
background: $color-surface-muted;
padding: 11px 13px;
outline: none;
color: $color-text-strong;
font: inherit;
font-size: $font-size-14;
transition: 0.18s ease;
&:focus {
border-color: $color-primary;
box-shadow: 0 0 0 3px $color-primary-focus;
}
&::placeholder {
color: $color-text-soft;
}
}
}
.device-maintenance-modal__footer {
position: relative;
z-index: 2;
flex: 0 0 auto;
min-height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 24px;
border-top: 1px solid $color-border-muted;
background: $color-surface;
}
.device-maintenance-modal__actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 9px;
}
.device-maintenance-action {
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 12px;
background: $color-bg;
padding: 0 16px;
color: $color-text-strong;
font-size: $font-size-14;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: 0.18s ease;
&:hover:not(:disabled) {
background: $color-surface-hover;
//color: $color-primary;
}
&.is-primary {
background: $color-primary;
color: $color-text-inverse;
&:hover:not(:disabled) {
background: $color-primary-hover;
color: $color-text-inverse;
}
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.device-maintenance-action--history {
flex: 0 0 auto;
}
@media (max-width: 760px) {
.device-maintenance-summary {
grid-template-columns: 44px minmax(0, 1fr);
}
.device-maintenance-summary__comment {
grid-column: 1 / -1;
padding: 12px 0 0;
border-top: 1px solid $color-border-muted;
border-left: 0;
}
.device-maintenance-options {
grid-template-columns: 1fr;
}
.device-maintenance-modal__footer {
align-items: stretch;
flex-direction: column;
}
.device-maintenance-modal__actions,
.device-maintenance-action--history {
width: 100%;
}
.device-maintenance-action {
flex: 1;
}
}

View File

@ -0,0 +1,525 @@
import {
useEffect,
useRef,
useState,
type ReactNode,
} from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { useMutation, useQuery } from '@apollo/client/react'
import SimpleBar from 'simplebar-react'
import { Check, CircleAlert, History, Wrench, X } from 'lucide-react'
import {
GET_ACTIVE_PHONE_MAINTENANCE_CASE_QUERY,
MARK_PHONE_HEALTHY_MUTATION,
RETURN_PHONE_FROM_MAINTENANCE_MUTATION,
SEND_PHONE_TO_MAINTENANCE_MUTATION,
SET_PHONE_MAINTENANCE_REQUIRED_MUTATION,
} from '../../../../entities/device/api/device.graphql'
import {
deviceMalfunctionLabels,
isRedundantMalfunctionComment,
} from '../../../../entities/device/lib/maintenance'
import type {
DeviceMalfunction,
GetActivePhoneMaintenanceCaseData,
GetActivePhoneMaintenanceCaseVariables,
MarkPhoneHealthyData,
MarkPhoneHealthyVariables,
ReturnPhoneFromMaintenanceData,
ReturnPhoneFromMaintenanceVariables,
SendPhoneToMaintenanceData,
SendPhoneToMaintenanceVariables,
SetPhoneMaintenanceRequiredData,
SetPhoneMaintenanceRequiredVariables,
} from '../../../../entities/device/model/types'
import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import {
Notification,
type NotificationVariant,
} from '../../../../widgets/Notification/Notification'
import type { Device } from '../../types'
import { conditionText, getStatusClass } from '../../types'
import { DeviceMaintenanceMetrics } from '../DeviceMaintenanceMetrics/DeviceMaintenanceMetrics'
import './DeviceMaintenanceModal.scss'
export type DeviceMaintenanceMode = 'view' | 'manage'
type DeviceMaintenanceModalProps = {
open: boolean
mode: DeviceMaintenanceMode
device: Device
onOpenChange: (open: boolean) => void
onOpenHistory: () => void
onUpdated: () => Promise<unknown> | unknown
}
type NotificationState = {
variant: NotificationVariant
title: string
description?: string
}
const malfunctionOptions: Array<{
value: DeviceMalfunction
description: string
}> = [
{
value: 'LowBatteryCapacity',
description: 'Остаточная ёмкость аккумулятора ниже допустимой.',
},
{
value: 'CriticalHits',
description: 'Количество зафиксированных ударов достигло лимита.',
},
{
value: 'CriticalChargeCycles',
description: 'Количество циклов зарядки достигло лимита.',
},
{
value: 'DoesNotTurnOn',
description: 'Устройство не загружается или не реагирует на включение.',
},
{
value: 'Other',
description: 'Неисправность не относится к перечисленным категориям.',
},
]
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : 'Не удалось выполнить действие.'
}
function ActionButton({
children,
className = '',
disabled,
onClick,
}: {
children: ReactNode
className?: string
disabled?: boolean
onClick: () => void
}) {
return (
<button
className={`device-maintenance-action ${className}`}
type="button"
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
export function DeviceMaintenanceModal({
open,
mode,
device,
onOpenChange,
onOpenHistory,
onUpdated,
}: DeviceMaintenanceModalProps) {
const [selectedMalfunctions, setSelectedMalfunctions] = useState<DeviceMalfunction[]>([])
const [comment, setComment] = useState('')
const [notification, setNotification] = useState<NotificationState | null>(null)
const [activeAction, setActiveAction] = useState<string | null>(null)
const initializedDraftKey = useRef('')
const {
data: activeCaseData,
loading: isActiveCaseLoading,
error: activeCaseError,
refetch: refetchActiveCase,
} = useQuery<
GetActivePhoneMaintenanceCaseData,
GetActivePhoneMaintenanceCaseVariables
>(GET_ACTIVE_PHONE_MAINTENANCE_CASE_QUERY, {
variables: { phoneId: String(device.id) },
skip: !open,
fetchPolicy: 'cache-and-network',
})
const [setMaintenanceRequired] = useMutation<
SetPhoneMaintenanceRequiredData,
SetPhoneMaintenanceRequiredVariables
>(SET_PHONE_MAINTENANCE_REQUIRED_MUTATION)
const [markPhoneHealthy] = useMutation<
MarkPhoneHealthyData,
MarkPhoneHealthyVariables
>(MARK_PHONE_HEALTHY_MUTATION)
const [sendPhoneToMaintenance] = useMutation<
SendPhoneToMaintenanceData,
SendPhoneToMaintenanceVariables
>(SEND_PHONE_TO_MAINTENANCE_MUTATION)
const [returnPhoneFromMaintenance] = useMutation<
ReturnPhoneFromMaintenanceData,
ReturnPhoneFromMaintenanceVariables
>(RETURN_PHONE_FROM_MAINTENANCE_MUTATION)
const isManageMode = mode === 'manage'
const isBusy = activeAction !== null
const isOtherSelected = selectedMalfunctions.includes('Other')
const isDraftValid =
selectedMalfunctions.length > 0 && (!isOtherSelected || comment.trim().length > 0)
const visibleComment = isRedundantMalfunctionComment(
device.malfunctions,
device.malfunctionComment,
)
? ''
: device.malfunctionComment?.trim() ?? ''
const activeMaintenanceCase =
activeCaseData?.getActivePhoneMaintenanceCase ?? null
useEffect(() => {
if (!open) {
initializedDraftKey.current = ''
return
}
const draftKey = `${device.id}:${device.technicalStatus}`
if (initializedDraftKey.current === draftKey) return
initializedDraftKey.current = draftKey
setSelectedMalfunctions(device.malfunctions)
setComment(device.malfunctionComment ?? '')
}, [
device.id,
device.malfunctionComment,
device.malfunctions,
device.technicalStatus,
open,
])
function toggleMalfunction(value: DeviceMalfunction) {
setSelectedMalfunctions((current) =>
current.includes(value)
? current.filter((item) => item !== value)
: [...current, value],
)
}
async function runAction(
action: string,
title: string,
callback: () => Promise<unknown>,
) {
setActiveAction(action)
try {
await callback()
await Promise.all([
Promise.resolve(onUpdated()),
refetchActiveCase(),
])
setNotification({ variant: 'success', title })
} catch (actionError) {
setNotification({
variant: 'error',
title: 'Не удалось изменить состояние устройства',
description: getErrorMessage(actionError),
})
} finally {
setActiveAction(null)
}
}
function saveMaintenanceRequired() {
if (!isDraftValid) return
void runAction('save', 'Неисправность сохранена', () =>
setMaintenanceRequired({
variables: {
id: String(device.id),
malfunctions: selectedMalfunctions,
comment: comment.trim() || null,
},
}),
)
}
function setHealthy() {
void runAction('healthy', 'Устройство признано исправным', () =>
markPhoneHealthy({ variables: { id: String(device.id) } }),
)
}
function sendToMaintenance() {
if (!isDraftValid) return
void runAction('service', 'Устройство отправлено на обслуживание', () =>
sendPhoneToMaintenance({
variables: {
id: String(device.id),
malfunctions: selectedMalfunctions,
comment: comment.trim() || null,
},
}),
)
}
function returnToService() {
void runAction('return', 'Устройство возвращено в эксплуатацию', () =>
returnPhoneFromMaintenance({ variables: { id: String(device.id) } }),
)
}
return (
<>
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<MotionDialogContent
open={open}
overlayClassName="device-maintenance-modal__overlay"
contentClassName="device-maintenance-modal"
motionPreset="fade"
>
<header className="device-maintenance-modal__header">
<div>
<Dialog.Title>Техническое состояние</Dialog.Title>
<Dialog.Description>
{device.factoryNumber} · ID {device.id}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
className="device-maintenance-modal__close"
type="button"
aria-label="Закрыть"
disabled={isBusy}
>
<X size={20} />
</button>
</Dialog.Close>
</header>
<SimpleBar className="device-maintenance-modal__scroll">
<div className="device-maintenance-modal__body">
<section className="device-maintenance-summary">
<div className={`device-maintenance-summary__icon ${getStatusClass(device.condition)}`}>
{device.condition === 'ok' ? <Check size={22} /> : <Wrench size={22} />}
</div>
<div className="device-maintenance-summary__field">
<span>Текущее состояние</span>
<strong>{conditionText[device.condition]}</strong>
</div>
{visibleComment && (
<div className="device-maintenance-summary__field device-maintenance-summary__comment">
<span>Комментарий</span>
<p>{visibleComment}</p>
</div>
)}
</section>
{device.technicalStatus !== 'Healthy' && (
<section className="device-maintenance-section">
<div className="device-maintenance-section__heading">
<div>
<h3>
{device.technicalStatus === 'InService'
? 'Показатели при отправке'
: 'Показатели устройства'}
</h3>
<p>
{device.technicalStatus === 'InService'
? 'Снимок устройства при передаче на обслуживание.'
: 'Текущие показатели для открытого случая.'}
</p>
</div>
</div>
{activeMaintenanceCase ? (
<DeviceMaintenanceMetrics
metrics={activeMaintenanceCase.metrics}
/>
) : (
<p className={`device-maintenance-metrics-message ${activeCaseError ? 'is-error' : ''}`}>
{isActiveCaseLoading
? 'Загружаем показатели...'
: activeCaseError
? 'Не удалось загрузить показатели.'
: 'Показатели не зафиксированы.'}
</p>
)}
</section>
)}
{!isManageMode && device.malfunctions.length > 0 && (
<section className="device-maintenance-section">
<div className="device-maintenance-section__heading">
<div>
<h3>Неисправности</h3>
<p>Неисправности, закреплённые за текущим состоянием.</p>
</div>
</div>
<div className="device-maintenance-current-malfunctions">
{device.malfunctions.map((malfunction) => (
<span key={malfunction}>
{deviceMalfunctionLabels[malfunction]}
</span>
))}
</div>
</section>
)}
{isManageMode && device.technicalStatus !== 'InService' && (
<section className="device-maintenance-section">
<div className="device-maintenance-section__heading">
<div>
<h3>Неисправности</h3>
<p>Укажите одну или несколько обнаруженных неисправностей.</p>
</div>
<CircleAlert size={19} />
</div>
<div className="device-maintenance-options">
{malfunctionOptions.map((option) => {
const isSelected = selectedMalfunctions.includes(option.value)
return (
<button
className={`device-maintenance-option ${isSelected ? 'is-selected' : ''}`}
type="button"
key={option.value}
aria-pressed={isSelected}
disabled={isBusy}
onClick={() => toggleMalfunction(option.value)}
>
<span className="device-maintenance-option__check">
{isSelected && <Check size={15} />}
</span>
<span>
<b>{deviceMalfunctionLabels[option.value]}</b>
<small>{option.description}</small>
</span>
</button>
)
})}
</div>
<label className="device-maintenance-comment">
<span>Комментарий {isOtherSelected && <b>обязателен</b>}</span>
<textarea
value={comment}
disabled={isBusy}
placeholder="Опишите неисправность или добавьте уточнение"
onChange={(event) => setComment(event.target.value)}
/>
</label>
</section>
)}
{isManageMode &&
device.technicalStatus === 'InService' &&
device.malfunctions.length > 0 && (
<section className="device-maintenance-section">
<div className="device-maintenance-section__heading">
<div>
<h3>Неисправности</h3>
<p>Зафиксированы при отправке устройства на обслуживание.</p>
</div>
</div>
<div className="device-maintenance-current-malfunctions">
{device.malfunctions.map((malfunction) => (
<span key={malfunction}>
{deviceMalfunctionLabels[malfunction]}
</span>
))}
</div>
</section>
)}
</div>
</SimpleBar>
<footer className="device-maintenance-modal__footer">
<button
className="device-maintenance-action device-maintenance-action--history"
type="button"
disabled={isBusy}
onClick={onOpenHistory}
>
<History size={17} />
Журнал обслуживания
</button>
<div className="device-maintenance-modal__actions">
{isManageMode ? (
<>
{device.technicalStatus === 'Healthy' && (
<>
<ActionButton
disabled={!isDraftValid || isBusy}
onClick={saveMaintenanceRequired}
>
{activeAction === 'save' ? 'Сохраняем...' : 'Зафиксировать неисправность'}
</ActionButton>
<ActionButton
className="is-primary"
disabled={!isDraftValid || isBusy}
onClick={sendToMaintenance}
>
{activeAction === 'service' ? 'Отправляем...' : 'Отправить на обслуживание'}
</ActionButton>
</>
)}
{device.technicalStatus === 'NeedsMaintenance' && (
<>
<ActionButton disabled={isBusy} onClick={setHealthy}>
{activeAction === 'healthy' ? 'Сохраняем...' : 'Признать исправным'}
</ActionButton>
<ActionButton
disabled={!isDraftValid || isBusy}
onClick={saveMaintenanceRequired}
>
{activeAction === 'save' ? 'Сохраняем...' : 'Сохранить'}
</ActionButton>
<ActionButton
className="is-primary"
disabled={!isDraftValid || isBusy}
onClick={sendToMaintenance}
>
{activeAction === 'service' ? 'Отправляем...' : 'Отправить на обслуживание'}
</ActionButton>
</>
)}
{device.technicalStatus === 'InService' && (
<ActionButton
className="is-primary"
disabled={isBusy}
onClick={returnToService}
>
{activeAction === 'return' ? 'Возвращаем...' : 'Вернуть в эксплуатацию'}
</ActionButton>
)}
</>
) : (
<Dialog.Close asChild>
<button className="device-maintenance-action" type="button">
Закрыть
</button>
</Dialog.Close>
)}
</div>
</footer>
</MotionDialogContent>
</Dialog.Root>
{notification && (
<Notification
variant={notification.variant}
title={notification.title}
description={notification.description}
onClose={() => setNotification(null)}
/>
)}
</>
)
}

View File

@ -19,6 +19,7 @@ import type {
GetPhoneGpsTrackData,
GetPhoneGpsTrackVariables,
} from '../../../../entities/device/model/types'
import { isPresent } from '../../../../shared/lib/isPresent'
import { FullscreenControl } from '../../../../widgets/FullscreenControlLeaflet/FullscreenControl'
import {
MapTrackPeriodControl,
@ -249,7 +250,9 @@ export function DeviceMapCard({
})
const sortedGpsTrack = useMemo(() => {
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
return sortGpsTrack(
(gpsTrackData?.getPhoneGpsTrack ?? []).filter(isPresent),
)
}, [gpsTrackData])
const telemetry = useMemo(() => {

View File

@ -1,10 +1,12 @@
import type {
DeviceMalfunction,
DevicePolicy,
DeviceTelemetryItem,
DeviceTechnicalStatus,
GroupUsePolicy,
} from '../../entities/device/model/types'
export type DeviceCondition = 'ok' | 'inspection'
export type DeviceCondition = 'ok' | 'inspection' | 'service'
export type DeviceConnection = 'online' | 'offline' | 'offlineDanger'
export type Device = {
@ -21,6 +23,9 @@ export type Device = {
workTime: string | null
employee: string | null
condition: DeviceCondition
technicalStatus: DeviceTechnicalStatus
malfunctions: DeviceMalfunction[]
malfunctionComment?: string | null
connection: DeviceConnection
connectionText: string
registeredAt?: string
@ -67,6 +72,7 @@ export type Device = {
export const conditionText: Record<DeviceCondition, string> = {
ok: 'Исправно',
inspection: 'Требует ТО',
service: 'На обслуживании',
}
export const connectionText: Record<DeviceConnection, string> = {
@ -78,6 +84,7 @@ export const connectionText: Record<DeviceConnection, string> = {
export function getStatusClass(status: DeviceCondition | DeviceConnection) {
if (status === 'ok' || status === 'online') return 'is-success'
if (status === 'inspection' || status === 'offlineDanger') return 'is-danger'
if (status === 'service') return 'is-service'
return 'is-muted'
}

View File

@ -387,6 +387,11 @@
background-color: $color-danger-status-bg;
color: $red;
}
&--blue {
background-color: $color-primary-muted;
color: $blue;
}
}
.devices-dot {
@ -408,6 +413,10 @@
background: $gray50;
}
.devices-dot--blue {
background: $blue;
}
.device-icons {
display: grid;
grid-template-columns: repeat(4, 20px);
@ -514,7 +523,7 @@
}
.device-icons svg:not(.is-active):not(.is-danger) {
background-color: $color-surface-active;
background-color: rgba($gray50-rgb, .15);
}
.devices-map-btn:hover {

View File

@ -42,9 +42,14 @@ import { GET_PHONES_QUERY } from '../../entities/device/api/device.graphql'
import type {
DeviceNetworkStatus,
DeviceTechnicalStatus,
GetPhonesData,
GetPhonesVariables,
} from '../../entities/device/model/types'
import {
getDeviceMalfunctionSummary,
deviceTechnicalStatusLabels,
} from '../../entities/device/lib/maintenance'
import type { Device as PageDevice } from '../DevicePage/types'
import type { Device as ApiDevice } from '../../entities/device/model/types'
@ -58,6 +63,7 @@ import {
import { RevealContent } from '../../shared/ui/RevealContent/RevealContent'
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
import { Tooltip } from '../../shared/ui/Tooltip/Tooltip'
import {
preloadOnIdle,
useLazyMount,
@ -95,6 +101,12 @@ const DEVICE_NETWORK_STATUSES: DeviceNetworkStatus[] = [
'Lost',
]
const DEVICE_TECHNICAL_STATUSES: DeviceTechnicalStatus[] = [
'Healthy',
'NeedsMaintenance',
'InService',
]
const FILTERS_DRAWER_MEDIA_QUERY = '(max-width: 1600px)'
function getIsFiltersDrawerViewport() {
@ -109,6 +121,12 @@ function isDeviceNetworkStatus(
return DEVICE_NETWORK_STATUSES.includes(value as DeviceNetworkStatus)
}
function isDeviceTechnicalStatus(
value: string,
): value is DeviceTechnicalStatus {
return DEVICE_TECHNICAL_STATUSES.includes(value as DeviceTechnicalStatus)
}
function formatDateTime(timestamp: number) {
if (!timestamp) return 'Нет данных'
@ -121,16 +139,22 @@ function formatDateTime(timestamp: number) {
}).format(new Date(timestamp))
}
function getDeviceConditionLabel(needMaintenance?: boolean) {
return needMaintenance ? 'Требует ТО' : 'Исправно'
function getDeviceConditionLabel(status?: DeviceTechnicalStatus) {
return deviceTechnicalStatusLabels[status ?? 'Healthy']
}
function getDeviceConditionClass(needMaintenance?: boolean) {
return needMaintenance ? 'devices-status--red' : 'devices-status--green'
function getDeviceConditionClass(status?: DeviceTechnicalStatus) {
if (status === 'NeedsMaintenance') return 'devices-status--red'
if (status === 'InService') return 'devices-status--blue'
return 'devices-status--green'
}
function getDeviceConditionDotClass(needMaintenance?: boolean) {
return needMaintenance ? 'devices-dot--red' : 'devices-dot--green'
function getDeviceConditionDotClass(status?: DeviceTechnicalStatus) {
if (status === 'NeedsMaintenance') return 'devices-dot--red'
if (status === 'InService') return 'devices-dot--blue'
return 'devices-dot--green'
}
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
@ -215,19 +239,26 @@ const DevicesTableRow = memo(function DevicesTableRow({
</td>
<td>
<div
className={`devices-status ${getDeviceConditionClass(
device.techState?.needMaintenance,
)}`}
<Tooltip
content={getDeviceMalfunctionSummary(
device.techState?.malfunctions,
device.techState?.malfunctionComment,
)}
>
<span
className={`devices-dot ${getDeviceConditionDotClass(
device.techState?.needMaintenance,
className={`devices-status ${getDeviceConditionClass(
device.techState?.status,
)}`}
/>
>
<span
className={`devices-dot ${getDeviceConditionDotClass(
device.techState?.status,
)}`}
/>
{getDeviceConditionLabel(device.techState?.needMaintenance)}
</div>
{getDeviceConditionLabel(device.techState?.status)}
</span>
</Tooltip>
</td>
<td>
@ -396,11 +427,9 @@ export function DevicesPage() {
const selectedNetworkStatuses = useMemo(
() =>
networkStatusParam === 'none'
? []
: networkStatusParam
?.split(',')
.filter(isDeviceNetworkStatus) ?? [],
networkStatusParam
?.split(',')
.filter(isDeviceNetworkStatus) ?? [],
[networkStatusParam],
)
@ -412,6 +441,15 @@ export function DevicesPage() {
? false
: undefined
const technicalStatusParam = searchParams.get('dTechnicalStatus')
const selectedTechnicalStatuses = useMemo(
() =>
technicalStatusParam
?.split(',')
.filter(isDeviceTechnicalStatus) ?? [],
[technicalStatusParam],
)
const lockedParam = searchParams.get('dLocked')
const locked =
lockedParam === 'true'
@ -488,21 +526,26 @@ export function DevicesPage() {
: undefined,
locked,
networkStatus:
networkStatusParam
selectedNetworkStatuses.length > 0
? selectedNetworkStatuses
: undefined,
needMaintenance,
technicalStatus:
selectedTechnicalStatuses.length > 0
? selectedTechnicalStatuses
: undefined,
}),
[
currentPage,
debouncedDeviceSearch,
locked,
needMaintenance,
networkStatusParam,
selectedTechnicalStatuses,
selectedNetworkStatuses,
selectedOrganisationIds,
sortDirection,
sortField,
technicalStatusParam,
],
)
@ -534,8 +577,9 @@ export function DevicesPage() {
const hasAppliedFilters = Boolean(
debouncedDeviceSearch ||
selectedOrganisationIds.length > 0 ||
networkStatusParam ||
selectedNetworkStatuses.length > 0 ||
needMaintenance !== undefined ||
selectedTechnicalStatuses.length > 0 ||
locked !== undefined,
)
const shouldShowEmptyState = !loading && !error && !hasDevices
@ -628,8 +672,7 @@ export function DevicesPage() {
)
const isLocked = policy?.locked ?? false
const needMaintenance =
device.techState?.needMaintenance ?? false
const technicalStatus = device.techState?.status ?? 'Healthy'
return {
id: device.id,
@ -647,9 +690,15 @@ export function DevicesPage() {
policy,
organisationPolicy: device.org?.policy ?? null,
condition: needMaintenance
? 'inspection'
: 'ok',
condition:
technicalStatus === 'InService'
? 'service'
: technicalStatus === 'NeedsMaintenance'
? 'inspection'
: 'ok',
technicalStatus,
malfunctions: device.techState?.malfunctions ?? [],
malfunctionComment: device.techState?.malfunctionComment,
connection: getDeviceNetworkConnection(device.networkStatus),

View File

@ -3,7 +3,10 @@ import * as Accordion from '@radix-ui/react-accordion'
import { useSearchParams } from 'react-router-dom'
import { ChevronDown } from 'lucide-react'
import type { DeviceNetworkStatus } from '../../../../entities/device/model/types'
import type {
DeviceNetworkStatus,
DeviceTechnicalStatus,
} from '../../../../entities/device/model/types'
import {
DevicesDateRangePicker,
type DevicesDateRangePickerValue,
@ -31,21 +34,32 @@ const networkStatusOptions: {
},
]
const maintenanceOptions = [
const technicalStatusOptions: Array<{
value: DeviceTechnicalStatus
label: string
}> = [
{
value: false,
value: 'Healthy',
label: 'Исправно',
},
{
value: true,
value: 'NeedsMaintenance',
label: 'Требует ТО',
},
{
value: 'InService',
label: 'На обслуживании',
},
]
function isDeviceNetworkStatus(value: string): value is DeviceNetworkStatus {
return networkStatusOptions.some((option) => option.value === value)
}
function isDeviceTechnicalStatus(value: string): value is DeviceTechnicalStatus {
return technicalStatusOptions.some((option) => option.value === value)
}
type DevicesFiltersPanelProps = {
isOpen: boolean
}
@ -63,24 +77,31 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
const networkStatusParam = searchParams.get('dNetwork')
const selectedNetworkStatuses = useMemo<DeviceNetworkStatus[]>(
() =>
networkStatusParam === 'none'
? []
: networkStatusParam
?.split(',')
.filter(isDeviceNetworkStatus) ??
networkStatusOptions.map((option) => option.value),
networkStatusParam
?.split(',')
.filter(isDeviceNetworkStatus) ?? [],
[networkStatusParam],
)
const technicalStatusParam = searchParams.get('dTechnicalStatus')
const needMaintenanceParam = searchParams.get('dNeedMaintenance')
const selectedMaintenanceStates = useMemo(
() =>
needMaintenanceParam === 'true'
? [true]
: needMaintenanceParam === 'false'
? [false]
: maintenanceOptions.map((option) => option.value),
[needMaintenanceParam],
const selectedTechnicalStatuses = useMemo<DeviceTechnicalStatus[]>(
() => {
if (technicalStatusParam) {
return technicalStatusParam
.split(',')
.filter(isDeviceTechnicalStatus)
}
if (needMaintenanceParam === 'true') {
return ['NeedsMaintenance', 'InService']
}
if (needMaintenanceParam === 'false') return ['Healthy']
return []
},
[needMaintenanceParam, technicalStatusParam],
)
const [workPeriod, setWorkPeriod] = useState<DevicesDateRangePickerValue>({
@ -123,17 +144,18 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
updateSearchParams({
dNetwork:
nextStatuses.length === networkStatusOptions.length
? null
: nextStatuses.length === 0
? 'none'
: nextStatuses.join(','),
nextStatuses.length > 0
? nextStatuses.join(',')
: null,
dPage: '0',
})
}, [selectedNetworkStatuses, updateSearchParams])
const handleMaintenanceChange = useCallback((value: boolean, checked: boolean) => {
const currentStates = new Set(selectedMaintenanceStates)
const handleTechnicalStatusChange = useCallback((
value: DeviceTechnicalStatus,
checked: boolean,
) => {
const currentStates = new Set(selectedTechnicalStatuses)
if (checked) {
currentStates.add(value)
@ -141,20 +163,19 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
currentStates.delete(value)
}
if (currentStates.size === 0) return
const nextStates = maintenanceOptions
const nextStates = technicalStatusOptions
.map((option) => option.value)
.filter((optionValue) => currentStates.has(optionValue))
updateSearchParams({
dNeedMaintenance:
nextStates.length === maintenanceOptions.length
? null
: String(nextStates[0]),
dTechnicalStatus:
nextStates.length > 0
? nextStates.join(',')
: null,
dNeedMaintenance: null,
dPage: '0',
})
}, [selectedMaintenanceStates, updateSearchParams])
}, [selectedTechnicalStatuses, updateSearchParams])
const handleOrganisationsChange = useCallback((ids: string[]) => {
updateSearchParams({
@ -173,7 +194,7 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
type="multiple"
defaultValue={['work-period']}
>
<Accordion.Item className="devices-filter-item" value="work-period">
{/* <Accordion.Item className="devices-filter-item" value="work-period">
<Accordion.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger">
<span>Период работы</span>
@ -194,7 +215,7 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
</div>
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Item> */}
<Accordion.Item
className="devices-filter-item"
@ -269,13 +290,13 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
<Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner">
<div className="devices-checkbox-list">
{maintenanceOptions.map((option) => (
<label className="devices-checkbox" key={String(option.value)}>
{technicalStatusOptions.map((option) => (
<label className="devices-checkbox" key={option.value}>
<input
type="checkbox"
checked={selectedMaintenanceStates.includes(option.value)}
checked={selectedTechnicalStatuses.includes(option.value)}
onChange={(event) => {
handleMaintenanceChange(
handleTechnicalStatusChange(
option.value,
event.target.checked,
)

View File

@ -1,5 +1,5 @@
import { useQuery } from '@apollo/client/react'
import { memo, useCallback, useMemo } from 'react'
import { memo, useCallback, useEffect, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
@ -14,15 +14,18 @@ import {
type PageTabItem,
} from '../../../../shared/ui/PageTabs/PageTabs'
type DevicesTab = 'working' | 'lost' | 'locked' | 'maintenance'
type DevicesTab = 'working' | 'lost' | 'locked' | 'maintenance' | 'service'
type DevicesTabsValue = DevicesTab | 'none'
function getActiveTab(searchParams: URLSearchParams): DevicesTabsValue {
const locked = searchParams.get('dLocked')
const needMaintenance = searchParams.get('dNeedMaintenance')
const networkStatus = searchParams.get('dNetwork')
const technicalStatus = searchParams.get('dTechnicalStatus')
if (locked === 'true') return 'locked'
if (technicalStatus === 'NeedsMaintenance') return 'maintenance'
if (technicalStatus === 'InService') return 'service'
if (needMaintenance === 'true') return 'maintenance'
if (networkStatus === 'Lost') return 'lost'
if (networkStatus === 'Online') return 'working'
@ -40,6 +43,27 @@ export const DevicesTabs = memo(function DevicesTabs() {
const [searchParams, setSearchParams] = useSearchParams()
const activeTab = useMemo(() => getActiveTab(searchParams), [searchParams])
useEffect(() => {
const legacyNeedMaintenance = searchParams.get('dNeedMaintenance')
if (
searchParams.has('dTechnicalStatus') ||
(legacyNeedMaintenance !== 'true' && legacyNeedMaintenance !== 'false')
) {
return
}
const nextParams = new URLSearchParams(searchParams)
nextParams.delete('dNeedMaintenance')
nextParams.set(
'dTechnicalStatus',
legacyNeedMaintenance === 'true' ? 'NeedsMaintenance' : 'Healthy',
)
setSearchParams(nextParams, { replace: true })
}, [searchParams, setSearchParams])
const query = searchParams.get('dQuery')?.trim() || undefined
const selectedOrganisationIds = useMemo(() => {
return searchParams.get('dOrgs')?.split(',').filter(Boolean) ?? []
@ -70,6 +94,7 @@ export const DevicesTabs = memo(function DevicesTabs() {
const lockedCount = stats?.lockedPhones.totalElements
const lostCount = stats?.lostPhones.totalElements
const maintenanceCount = stats?.maintenancePhones.totalElements
const serviceCount = stats?.servicePhones.totalElements
const tabs = useMemo<Array<PageTabItem<DevicesTabsValue>>>(() => {
const nextTabs: Array<PageTabItem<DevicesTabsValue>> = [
@ -119,8 +144,19 @@ export const DevicesTabs = memo(function DevicesTabs() {
})
}
if ((serviceCount ?? 0) > 0) {
nextTabs.push({
value: 'service',
label: (
<>
На обслуживании: <b>{formatCount(serviceCount)}</b>
</>
),
})
}
return nextTabs
}, [lockedCount, lostCount, maintenanceCount, onlineCount, totalCount])
}, [lockedCount, lostCount, maintenanceCount, onlineCount, serviceCount, totalCount])
const applyQuickFilter = useCallback((tab: DevicesTabsValue) => {
if (tab === 'none') return
@ -133,6 +169,7 @@ export const DevicesTabs = memo(function DevicesTabs() {
nextParams.delete('dLocked')
nextParams.delete('dNetwork')
nextParams.delete('dNeedMaintenance')
nextParams.delete('dTechnicalStatus')
setSearchParams(nextParams, { replace: true })
return
}
@ -141,24 +178,35 @@ export const DevicesTabs = memo(function DevicesTabs() {
nextParams.set('dNetwork', 'Online')
nextParams.delete('dLocked')
nextParams.delete('dNeedMaintenance')
nextParams.delete('dTechnicalStatus')
}
if (tab === 'locked') {
nextParams.set('dLocked', 'true')
nextParams.delete('dNetwork')
nextParams.delete('dNeedMaintenance')
nextParams.delete('dTechnicalStatus')
}
if (tab === 'lost') {
nextParams.set('dNetwork', 'Lost')
nextParams.delete('dLocked')
nextParams.delete('dNeedMaintenance')
nextParams.delete('dTechnicalStatus')
}
if (tab === 'maintenance') {
nextParams.delete('dLocked')
nextParams.delete('dNetwork')
nextParams.set('dNeedMaintenance', 'true')
nextParams.delete('dNeedMaintenance')
nextParams.set('dTechnicalStatus', 'NeedsMaintenance')
}
if (tab === 'service') {
nextParams.delete('dLocked')
nextParams.delete('dNetwork')
nextParams.delete('dNeedMaintenance')
nextParams.set('dTechnicalStatus', 'InService')
}
setSearchParams(nextParams, { replace: true })

View File

@ -29,6 +29,12 @@
.add-organisation-modal--with-policy {
width: min(980px, calc(100vw - 32px));
}
.add-organisation-modal--without-policy{
width: min(460px, calc(100vw - 32px));
.avatar-upload{
width: inherit;
}
}
.add-organisation-modal__header {
margin-bottom: 22px;

View File

@ -104,7 +104,7 @@ export function AddOrganisationModal({
const modalClassName = useMemo(
() =>
`add-organisation-modal ${
!isEditMode ? 'add-organisation-modal--with-policy' : ''
!isEditMode ? 'add-organisation-modal--with-policy' : 'add-organisation-modal--without-policy'
}`,
[isEditMode],
)

View File

@ -57,154 +57,60 @@
}
}
.login-theme-switch {
.login-theme-button {
position: absolute;
top: 24px;
right: 24px;
z-index: 2;
width: 72px;
height: 32px;
width: 42px;
height: 42px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
border-radius: 14px;
background: $color-surface-glass;
backdrop-filter: blur(12px);
box-shadow:
inset 0 0 1px 1px rgba($gray50-rgb, 0.18),
0 10px 28px rgba($color-shadow-rgb, 0.12);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.login-theme-switch__track {
position: relative;
width: 62px;
height: 26px;
padding: 4px;
border-radius: 999px;
background: $color-surface-glass;
box-shadow:
inset 0 0 1px 1px rgba($gray50-rgb, 0.24),
0 10px 28px rgba($color-shadow-rgb, 0.12);
overflow: hidden;
transition:
background 0.25s ease,
box-shadow 0.25s ease;
}
.login-theme-switch__track::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background:
radial-gradient(circle at 25% 35%, rgba($orange, 0.22), transparent 34%),
radial-gradient(circle at 78% 65%, rgba($blue, 0.2), transparent 38%);
opacity: 0.92;
transition: opacity 0.25s ease;
}
.login-theme-switch__thumb {
position: relative;
z-index: 2;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: $white;
color: $orange;
box-shadow: 0 1px 8px -2px rgba($gray50-rgb, 0.8);
transform: translateX(0);
cursor: pointer;
transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
background 0.25s ease,
color 0.25s ease,
box-shadow 0.25s ease;
}
transform 0.18s ease,
background 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
.login-theme-switch__icon {
position: absolute;
z-index: 1;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
color: $color-text-muted;
opacity: 0.55;
transform: translateY(-50%);
transition:
opacity 0.25s ease,
color 0.25s ease;
}
.login-theme-switch__icon--sun {
left: 10px;
}
.login-theme-switch__icon--moon {
right: 10px;
}
.login-theme-switch.is-light {
.login-theme-switch__icon--sun {
opacity: 0;
svg {
flex: 0 0 auto;
transition: transform 0.2s ease;
}
.login-theme-switch__icon--moon {
opacity: 0.45;
}
}
&:hover {
background: $color-surface;
color: $blue;
transform: translateY(-1px);
.login-theme-switch.is-dark {
.login-theme-switch__track {
background: $color-surface-glass;
svg {
transform: rotate(-8deg) scale(1.05);
}
}
&:active {
transform: scale(0.96);
}
&.is-dark {
color: $blue;
box-shadow:
inset 0 0 0 1px rgba($white, 0.08),
0 12px 30px rgba($black, 0.24);
}
.login-theme-switch__track::before {
opacity: 0.65;
}
.login-theme-switch__thumb {
transform: translateX(36px);
background: $color-surface;
color: $blue;
box-shadow: 0 1px 6px -2px $gray50;
}
.login-theme-switch__icon--sun {
opacity: 0.38;
}
.login-theme-switch__icon--moon {
opacity: 0;
}
}
.login-theme-switch:active {
.login-theme-switch__thumb {
transform: scale(0.94);
}
&.is-dark .login-theme-switch__thumb {
transform: translateX(36px) scale(0.94);
}
}
.login-card {
@ -410,7 +316,7 @@
padding: 16px;
}
.login-theme-switch {
.login-theme-button {
top: 16px;
right: 16px;
}

View File

@ -54,25 +54,13 @@ export function LoginPage({ onSuccess }: LoginPageProps) {
return (
<main className="login-page">
<button
className={`login-theme-switch ${isDark ? 'is-dark' : 'is-light'}`}
className={`login-theme-button ${isDark ? 'is-dark' : 'is-light'}`}
type="button"
aria-label={isDark ? 'Включить светлую тему' : 'Включить тёмную тему'}
aria-pressed={isDark}
onClick={toggleTheme}
onClick={(event) => toggleTheme(event.currentTarget)}
>
<span className="login-theme-switch__track">
<span className="login-theme-switch__icon login-theme-switch__icon--sun">
<Sun size={14} />
</span>
<span className="login-theme-switch__icon login-theme-switch__icon--moon">
<Moon size={14} />
</span>
<span className="login-theme-switch__thumb">
{isDark ? <Moon size={15} /> : <Sun size={15} />}
</span>
</span>
{isDark ? <Moon size={18} /> : <Sun size={18} />}
</button>
<form className="login-card" onSubmit={handleSubmit}>

View File

@ -34,6 +34,7 @@ import {
type MapTrackPeriodValue,
} from './components/MapTrackPeriodControl/MapTrackPeriodControl'
import { popoverVariants, uiFadeTransition } from '../../shared/lib/motion'
import { isPresent } from '../../shared/lib/isPresent'
import 'leaflet/dist/leaflet.css'
import './MapPage.scss'
@ -51,6 +52,12 @@ type MapInfoPoint = {
lng: number
}
type MapDevice = GetPhonesData['getPhones']['page'][number]
type MapDeviceWithLocation = MapDevice & {
lastLocation: NonNullable<MapDevice['lastLocation']>
}
function getNetworkStatusClass(status?: DeviceNetworkStatus) {
if (status === 'Online') return 'is-online'
if (status === 'Lost') return 'is-lost'
@ -348,9 +355,8 @@ export function MapPage() {
const devicesWithLocation = useMemo(() => {
return devices.filter(
(device): device is Device & {
lastLocation: NonNullable<Device['lastLocation']>
} => Boolean(device.lastLocation),
(device): device is MapDeviceWithLocation =>
device.lastLocation !== null,
)
}, [devices])
const shouldShowPhonesLoadState =
@ -359,7 +365,9 @@ export function MapPage() {
(isPhonesLoading && devicesWithLocation.length === 0))
const sortedGpsTrack = useMemo(() => {
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
return sortGpsTrack(
(gpsTrackData?.getPhoneGpsTrack ?? []).filter(isPresent),
)
}, [gpsTrackData])
const selectedLastLocation = selectedDevice?.lastLocation ?? null

View File

@ -604,6 +604,11 @@
background-color: $color-danger-status-bg;
color: $red;
}
&.devices-status--blue {
background-color: $color-primary-muted;
color: $blue;
}
}
.organisation-device-dot {
@ -623,6 +628,10 @@
&.devices-dot--gray {
background: $gray50;
}
&.devices-dot--blue {
background: $blue;
}
}
.organisation-device-icons {

View File

@ -39,9 +39,14 @@ import type {
import type {
Device as OrganisationDevice,
DeviceNetworkStatus,
DeviceTechnicalStatus,
GetPhonesData,
GetPhonesVariables,
} from '../../entities/device/model/types'
import {
getDeviceMalfunctionSummary,
deviceTechnicalStatusLabels,
} from '../../entities/device/lib/maintenance'
import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
import { EmployeesPagination } from '../EmployeesPage/components/EmployeesPagination/EmployeesPagination'
import {
@ -67,6 +72,7 @@ import {
import { RevealContent } from '../../shared/ui/RevealContent/RevealContent'
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
import { Tooltip } from '../../shared/ui/Tooltip/Tooltip'
import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout'
import {
useDeferredModalPayload,
@ -108,16 +114,22 @@ function getEmployeeRoleLabel(role: string) {
return role
}
function getDeviceConditionLabel(needMaintenance?: boolean) {
return needMaintenance ? 'Требует ТО' : 'Исправно'
function getDeviceConditionLabel(status?: DeviceTechnicalStatus) {
return deviceTechnicalStatusLabels[status ?? 'Healthy']
}
function getDeviceConditionClass(needMaintenance?: boolean) {
return needMaintenance ? 'devices-status--red' : 'devices-status--green'
function getDeviceConditionClass(status?: DeviceTechnicalStatus) {
if (status === 'NeedsMaintenance') return 'devices-status--red'
if (status === 'InService') return 'devices-status--blue'
return 'devices-status--green'
}
function getDeviceConditionDotClass(needMaintenance?: boolean) {
return needMaintenance ? 'devices-dot--red' : 'devices-dot--green'
function getDeviceConditionDotClass(status?: DeviceTechnicalStatus) {
if (status === 'NeedsMaintenance') return 'devices-dot--red'
if (status === 'InService') return 'devices-dot--blue'
return 'devices-dot--green'
}
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
@ -941,21 +953,28 @@ export function OrganisationPage() {
</td>
<td>
<div
className={`organisation-device-status ${getDeviceConditionClass(
device.techState?.needMaintenance,
)}`}
<Tooltip
content={getDeviceMalfunctionSummary(
device.techState?.malfunctions,
device.techState?.malfunctionComment,
)}
>
<span
className={`organisation-device-dot ${getDeviceConditionDotClass(
device.techState?.needMaintenance,
className={`organisation-device-status ${getDeviceConditionClass(
device.techState?.status,
)}`}
/>
>
<span
className={`organisation-device-dot ${getDeviceConditionDotClass(
device.techState?.status,
)}`}
/>
{getDeviceConditionLabel(
device.techState?.needMaintenance,
)}
</div>
{getDeviceConditionLabel(
device.techState?.status,
)}
</span>
</Tooltip>
</td>
<td>

View File

@ -0,0 +1,87 @@
/* eslint-disable */
import type { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
import type { FragmentDefinitionNode } from 'graphql';
import type { Incremental } from './graphql';
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<
infer TType,
any
>
? [TType] extends [{ ' $fragmentName'?: infer TKey }]
? TKey extends string
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
: never
: never
: never;
// return non-nullable if `fragmentType` is non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>
): TType;
// return nullable if `fragmentType` is undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined
): TType | undefined;
// return nullable if `fragmentType` is nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null
): TType | null;
// return nullable if `fragmentType` is nullable or undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | undefined
): TType | null | undefined;
// return array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>>
): Array<TType>;
// return array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): Array<TType> | null | undefined;
// return readonly array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
): ReadonlyArray<TType>;
// return readonly array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): ReadonlyArray<TType> | null | undefined;
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | Array<FragmentType<DocumentTypeDecoration<TType, any>>> | ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined {
return fragmentType as any;
}
export function makeFragmentData<
F extends DocumentTypeDecoration<any, any>,
FT extends ResultOf<F>
>(data: FT, _fragment: F): FragmentType<F> {
return data as FragmentType<F>;
}
export function isFragmentReady<TQuery, TFrag>(
queryNode: DocumentTypeDecoration<TQuery, any>,
fragmentNode: TypedDocumentNode<TFrag>,
data: FragmentType<TypedDocumentNode<Incremental<TFrag>, any>> | null | undefined
): data is FragmentType<typeof fragmentNode> {
const deferredFields = (queryNode as { __meta__?: { deferredFields: Record<string, (keyof TFrag)[]> } }).__meta__
?.deferredFields;
if (!deferredFields) return true;
const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined;
const fragName = fragDef?.name?.value;
const fields = (fragName && deferredFields[fragName]) || [];
return fields.length > 0 && fields.every(field => data && field in data);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
export * from "./fragment-masking";
export * from "./gql";

View File

@ -0,0 +1,5 @@
export function isPresent<T>(
value: T | null | undefined,
): value is T {
return value !== null && value !== undefined
}

View File

@ -0,0 +1,112 @@
import type { AppTheme } from './theme'
type ViewTransition = {
finished: Promise<void>
ready: Promise<void>
skipTransition: () => void
}
type ViewTransitionDocument = Document & {
startViewTransition?: (callback: () => void) => ViewTransition
}
type AnimationOptionsWithPseudoElement = KeyframeAnimationOptions & {
pseudoElement?: string
}
type ThemeTransitionOptions = {
source?: Element | null
theme: AppTheme
updateTheme: (theme: AppTheme) => void
}
const THEME_TRANSITION_CLASS = 'theme-transition-no-css-transitions'
const THEME_TRANSITION_DURATION = 620
const THEME_TRANSITION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'
let activeTransition: ViewTransition | null = null
function shouldReduceMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function getTransitionGeometry(source?: Element | null) {
const rect = source?.getBoundingClientRect()
const x = rect ? rect.left + rect.width / 2 : window.innerWidth - 48
const y = rect ? rect.top + rect.height / 2 : 48
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
)
return {
endRadius,
x,
y,
}
}
export function updateThemeWithTransition({
source,
theme,
updateTheme,
}: ThemeTransitionOptions) {
if (typeof document === 'undefined' || typeof window === 'undefined') {
updateTheme(theme)
return
}
const transitionDocument = document as ViewTransitionDocument
if (!transitionDocument.startViewTransition || shouldReduceMotion()) {
updateTheme(theme)
return
}
activeTransition?.skipTransition()
const root = document.documentElement
const { endRadius, x, y } = getTransitionGeometry(source)
root.classList.add(THEME_TRANSITION_CLASS)
const transition = transitionDocument.startViewTransition(() => {
updateTheme(theme)
})
activeTransition = transition
transition.ready
.then(() => {
root.classList.remove(THEME_TRANSITION_CLASS)
root.animate(
[
{
clipPath: `circle(0px at ${x}px ${y}px)`,
},
{
clipPath: `circle(${endRadius}px at ${x}px ${y}px)`,
},
],
{
duration: THEME_TRANSITION_DURATION,
easing: THEME_TRANSITION_EASING,
pseudoElement: '::view-transition-new(root)',
} as AnimationOptionsWithPseudoElement,
)
})
.catch(() => {
root.classList.remove(THEME_TRANSITION_CLASS)
})
transition.finished
.catch(() => undefined)
.finally(() => {
root.classList.remove(THEME_TRANSITION_CLASS)
if (activeTransition === transition) {
activeTransition = null
}
})
}

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { flushSync } from 'react-dom'
import type { AppTheme } from './theme'
import {
applyTheme,
@ -6,6 +7,7 @@ import {
getThemeStorageKey,
saveTheme,
} from './theme'
import { updateThemeWithTransition } from './themeTransition'
export function useTheme() {
const [theme, setTheme] = useState<AppTheme>(() => getPreferredTheme())
@ -30,12 +32,22 @@ export function useTheme() {
}
}, [])
function toggleTheme() {
const nextTheme = theme === 'dark' ? 'light' : 'dark'
function commitTheme(nextTheme: AppTheme) {
applyTheme(nextTheme)
saveTheme(nextTheme)
setTheme(nextTheme)
flushSync(() => {
setTheme(nextTheme)
})
}
function toggleTheme(source?: Element | null) {
const nextTheme = theme === 'dark' ? 'light' : 'dark'
updateThemeWithTransition({
source,
theme: nextTheme,
updateTheme: commitTheme,
})
}
return {

View File

@ -0,0 +1,54 @@
@use '../../styles/variables' as *;
.ui-tooltip {
position: relative;
display: inline-flex;
min-width: 0;
outline: none;
}
.ui-tooltip__content {
position: fixed;
z-index: 1000;
width: max-content;
max-width: min(320px, calc(100vw - 32px));
padding: 8px 10px;
border: 1px solid $color-border-muted;
border-radius: 10px;
background: $color-surface;
color: $color-text-strong;
box-shadow: $shadow-popover;
font-size: $font-size-12;
font-weight: 500;
line-height: 1.4;
text-align: left;
white-space: normal;
pointer-events: none;
transform: translate(-50%, -100%);
animation: ui-tooltip-in 0.18s ease both;
&::after {
content: '';
position: absolute;
top: calc(100% - 1px);
left: 50%;
width: 8px;
height: 8px;
border-right: 1px solid $color-border-muted;
border-bottom: 1px solid $color-border-muted;
background: $color-surface;
transform: translate(-50%, -4px) rotate(45deg);
}
}
@keyframes ui-tooltip-in {
from {
opacity: 0;
transform: translate(-50%, calc(-100% + 4px));
}
to {
opacity: 1;
transform: translate(-50%, -100%);
}
}

View File

@ -0,0 +1,84 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import './Tooltip.scss'
type TooltipProps = {
children: ReactNode
content?: ReactNode
className?: string
}
export function Tooltip({ children, content, className = '' }: TooltipProps) {
const triggerRef = useRef<HTMLSpanElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState({ left: 0, top: 0 })
const tooltipId = useId()
const updatePosition = useCallback(() => {
const trigger = triggerRef.current
if (!trigger) return
const rect = trigger.getBoundingClientRect()
setPosition({
left: rect.left + rect.width / 2,
top: rect.top - 9,
})
}, [])
const openTooltip = useCallback(() => {
updatePosition()
setIsOpen(true)
}, [updatePosition])
useEffect(() => {
if (!isOpen) return
const handleViewportChange = () => updatePosition()
window.addEventListener('resize', handleViewportChange)
window.addEventListener('scroll', handleViewportChange, true)
return () => {
window.removeEventListener('resize', handleViewportChange)
window.removeEventListener('scroll', handleViewportChange, true)
}
}, [isOpen, updatePosition])
if (!content) return <>{children}</>
return (
<>
<span
ref={triggerRef}
className={`ui-tooltip ${className}`}
tabIndex={0}
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openTooltip}
onMouseLeave={() => setIsOpen(false)}
onFocus={openTooltip}
onBlur={() => setIsOpen(false)}
>
{children}
</span>
{isOpen && typeof document !== 'undefined' && createPortal(
<span
id={tooltipId}
className="ui-tooltip__content"
role="tooltip"
style={position}
>
{content}
</span>,
document.body,
)}
</>
)
}

View File

@ -169,166 +169,37 @@
}
}
.navbar__theme-btn {
color: $color-primary;
&[aria-pressed='true'] {
background: $color-surface;
box-shadow: $shadow-control;
}
}
.navbar__theme-switch {
width: 72px;
.navbar__theme-button {
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 999px;
border-radius: 12px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: $gray50;
cursor: pointer;
}
transition: 0.2s ease;
.navbar__theme-switch-track {
position: relative;
width: 62px;
height: 26px;
padding: 4px;
border-radius: 999px;
background: $color-surface;
box-shadow:
inset 0 0 1px 1px rgba(var(--gray50-rgb), 0.3),
0 6px 20px -6px rgba(var(--gray50-rgb), 0.3);
overflow: hidden;
transition:
background 0.25s ease,
box-shadow 0.25s ease;
}
.navbar__theme-switch-track::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background:
radial-gradient(circle at 25% 35%, rgba($orange, 0.2), transparent 32%),
radial-gradient(circle at 78% 65%, rgba($blue, 0.18), transparent 36%);
opacity: 0.9;
transition: opacity 0.25s ease;
}
.navbar__theme-switch-thumb {
position: relative;
z-index: 2;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
color: $orange;
box-shadow: 0px 1px 8px -2px rgba(var(--gray50-rgb), 0.8);
transform: translateX(0);
transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
background 0.25s ease,
color 0.25s ease,
box-shadow 0.25s ease;
}
.navbar__theme-switch-icon {
position: absolute;
z-index: 1;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
color: $color-text-muted;
opacity: 0.55;
transform: translateY(-50%);
transition:
opacity 0.25s ease,
color 0.25s ease;
}
.navbar__theme-switch-icon--sun {
left: 10px;
}
.navbar__theme-switch-icon--moon {
right: 10px;
}
.navbar__theme-switch.is-light {
.navbar__theme-switch-icon--sun {
opacity: 0;
svg {
flex: 0 0 auto;
transition: transform 0.2s ease;
}
.navbar__theme-switch-icon--moon {
opacity: 0.45;
}
}
.navbar__theme-switch.is-dark {
.navbar__theme-switch-track {
background: $color-surface;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08), 0 8px 22px rgba(0, 0, 0, 0.22);
&:hover {
background: $gray30;
}
.navbar__theme-switch-track::before {
opacity: 0.65;
&:active {
transform: scale(0.96);
}
.navbar__theme-switch-thumb {
transform: translateX(36px);
background: $color-surface;
color: $blue;
box-shadow: 0px 1px 6px -2px $gray50;
}
.navbar__theme-switch-icon--sun {
opacity: 0.38;
}
.navbar__theme-switch-icon--moon {
opacity: 0;
}
}
.navbar__theme-switch:hover {
.navbar__theme-switch-track {
//box-shadow: inset 0 0 0 1px rgba($blue, 0.35), 0 8px 20px rgba($blue, 0.12);
}
}
.navbar__theme-switch:active {
.navbar__theme-switch-thumb {
transform: scale(0.94);
}
&.is-dark .navbar__theme-switch-thumb {
transform: translateX(24px) scale(0.94);
&.is-dark:hover {
background: var(--color-surface-hover);
}
}

View File

@ -12,6 +12,7 @@ import {
useDeferredModalPayload,
} from '../../shared/lib/lazyMount'
import { AddEmployeeModal } from '../../pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal'
import type { CurrentUserQuery } from '../../shared/api/generated/graphql'
function clearAuthCookies() {
document.cookie = 'Access-token=; Max-Age=0; path=/'
@ -24,22 +25,6 @@ async function handleLogout() {
window.dispatchEvent(new Event('auth:logout'))
}
type CurrentUserData = {
currentUser: {
id: string
role: string
avatarUrl: string
firstName: string
middleName: string
lastName: string
username: string
org: {
id: number
name: string
} | null
} | null
}
function getPageTitle(location: ReturnType<typeof useLocation>) {
const { pathname, search } = location
const searchParams = new URLSearchParams(search)
@ -76,10 +61,10 @@ function getPageTitleLink(location: ReturnType<typeof useLocation>) {
}
function mapCurrentUserToEmployee(
user: NonNullable<CurrentUserData['currentUser']>,
user: CurrentUserQuery['currentUser'],
): Employee {
return {
id: Number(user.id),
id: user.id,
avatarUrl: user.avatarUrl,
firstName: user.firstName,
lastName: user.lastName,
@ -109,7 +94,7 @@ export function Navbar({
}: NavbarProps) {
const { isDark, toggleTheme } = useTheme()
const { data, refetch } = useQuery<CurrentUserData>(CURRENT_USER_QUERY, {
const { data, refetch } = useQuery(CURRENT_USER_QUERY, {
fetchPolicy: 'cache-first',
})
@ -180,25 +165,13 @@ export function Navbar({
</button> */}
<button
className={`navbar__theme-switch ${isDark ? 'is-dark' : 'is-light'}`}
className={`navbar__theme-button ${isDark ? 'is-dark' : 'is-light'}`}
type="button"
aria-label={isDark ? 'Включить светлую тему' : 'Включить тёмную тему'}
aria-pressed={isDark}
onClick={toggleTheme}
onClick={(event) => toggleTheme(event.currentTarget)}
>
<span className="navbar__theme-switch-track">
<span className="navbar__theme-switch-icon navbar__theme-switch-icon--sun">
<Sun size={14} />
</span>
<span className="navbar__theme-switch-icon navbar__theme-switch-icon--moon">
<Moon size={14} />
</span>
<span className="navbar__theme-switch-thumb">
{isDark ? <Moon size={15} /> : <Sun size={15} />}
</span>
</span>
{isDark ? <Moon size={17} /> : <Sun size={17} />}
</button>
<DropdownMenu.Root>

View File

@ -2,9 +2,9 @@
.notification {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 300;
right: max(24px, env(safe-area-inset-right));
bottom: max(24px, env(safe-area-inset-bottom));
z-index: 1200;
width: min(520px, calc(100vw - 32px));
//min-height: 76px;
@ -21,7 +21,7 @@
opacity: 0;
transform: translateY(14px) scale(0.98);
pointer-events: none;
pointer-events: auto;
transition:
opacity 0.22s ease,
transform 0.22s ease;

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { CheckCircle2, X, XCircle } from 'lucide-react'
import { m } from 'framer-motion'
@ -49,7 +50,9 @@ export function Notification({
const Icon = variant === 'success' ? CheckCircle2 : XCircle
return (
if (typeof document === 'undefined') return null
return createPortal(
<m.div
className={`notification notification--${variant}`}
role="status"
@ -92,6 +95,7 @@ export function Notification({
>
<X size={20} />
</button>
</m.div>
</m.div>,
document.body,
)
}

View File

@ -9,20 +9,20 @@ export default defineConfig({
host: true,
proxy: {
'/graphql': {
target: 'http://192.168.1.179:8080',
target: 'http://192.168.1.91:8080',
changeOrigin: true,
secure: false,
},
'/user': {
target: 'http://192.168.1.179:8080',
target: 'http://192.168.1.91:8080',
changeOrigin: true,
},
'/org': {
target: 'http://192.168.1.179:8080',
target: 'http://192.168.1.91:8080',
changeOrigin: true,
},
'/phone': {
target: 'http://192.168.1.179:8080',
target: 'http://192.168.1.91:8080',
changeOrigin: true,
},
},