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

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 dist-ssr
*.local *.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 # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.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", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 192.168.1.181", "dev": "vite --host 192.168.1.91",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
@ -41,6 +43,8 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@graphql-codegen/cli": "^7.2.0",
"@graphql-codegen/client-preset": "^6.1.3",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@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 {
GetPhonesDocument as GET_PHONES_QUERY,
export const GET_PHONES_QUERY = gql` GetPhonesTabsStatsDocument as GET_PHONES_TABS_STATS_QUERY,
query GetPhones( GetPhoneDocument as GET_PHONE_QUERY,
$page: Int! GetPhoneGpsTrackDocument as GET_PHONE_GPS_TRACK_QUERY,
$query: String GetPhonePackagesDocument as GET_PHONE_PACKAGES_QUERY,
$locked: Boolean GetTelemetryDocument as GET_TELEMETRY_QUERY,
$needMaintenance: Boolean GetDevicePageDocument as GET_DEVICE_PAGE_QUERY,
$networkStatus: [PhoneNetworkStatus!] CreatePhoneRegistrationTokenDocument as CREATE_PHONE_REGISTRATION_TOKEN_MUTATION,
$orgs: [ID!] ChangePhonePolicyDocument as CHANGE_PHONE_POLICY_MUTATION,
$sortDirection: SortDirection! GetActivePhoneMaintenanceCaseDocument as GET_ACTIVE_PHONE_MAINTENANCE_CASE_QUERY,
$sortField: PhoneSortField! GetPhoneMaintenanceHistoryDocument as GET_PHONE_MAINTENANCE_HISTORY_QUERY,
) { SetPhoneMaintenanceRequiredDocument as SET_PHONE_MAINTENANCE_REQUIRED_MUTATION,
getPhones( MarkPhoneHealthyDocument as MARK_PHONE_HEALTHY_MUTATION,
page: $page SendPhoneToMaintenanceDocument as SEND_PHONE_TO_MAINTENANCE_MUTATION,
query: $query ReturnPhoneFromMaintenanceDocument as RETURN_PHONE_FROM_MAINTENANCE_MUTATION,
locked: $locked } from '../../../shared/api/generated/graphql'
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
}
}
`

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 = { export type DeviceLocation = {
alt: number alt: number
date: number date: number
@ -5,14 +44,9 @@ export type DeviceLocation = {
lng: number lng: number
} }
export type ServiceUseOption = export type ServiceUseOption = GeneratedServiceUseOption
| 'Disabled'
| 'Enabled'
| 'Unspecified'
export type ComponentUseOption = export type ComponentUseOption = GeneratedComponentUseOption
| 'Allowed'
| 'Disallowed'
export type PhoneUsePolicyOption = ServiceUseOption | ComponentUseOption export type PhoneUsePolicyOption = ServiceUseOption | ComponentUseOption
@ -47,17 +81,23 @@ export type DevicePolicy = {
} }
export type DeviceTechState = { export type DeviceTechState = {
batteryCycles: number | null batteryCycles?: number | null
batteryLevel: number | null batteryLevel?: number | null
batteryRemainingCapacity: number | null batteryRemainingCapacity?: number | null
hits: number | null hits?: number | null
needMaintenance: boolean malfunctions?: DeviceMalfunction[]
overheats: number | null malfunctionComment?: string | null
needMaintenance?: boolean
overheats?: number | null
status?: DeviceTechnicalStatus
temperature?: number | null 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 = { export type DeviceOrganisation = {
creationDate?: number creationDate?: number
@ -70,60 +110,23 @@ export type DeviceOrganisation = {
export type Device = { export type Device = {
id: number id: number
imei: string imei: string
imei2: string imei2: string | null
serial: string serial: string
networkStatus?: DeviceNetworkStatus networkStatus: DeviceNetworkStatus
orgId: number orgId: number
registerDate: number registerDate?: number
org: DeviceOrganisation | null org: DeviceOrganisation | null
policy: DevicePolicy | null policy: DevicePolicy | null
techState: DeviceTechState | null techState: DeviceTechState | null
lastLocation: DeviceLocation | null lastLocation: DeviceLocation | null
} }
export type GetPhonesData = { export type GetPhonesData = GetPhonesQuery
getPhones: { export type GetPhonesVariables = GetPhonesQueryVariables
page: Device[] export type GetPhonesTabsStatsData = GetPhonesTabsStatsQuery
totalElements: number export type GetPhonesTabsStatsVariables = GetPhonesTabsStatsQueryVariables
totalPages: number export type GetPhoneData = GetPhoneQuery
} export type GetPhoneVariables = GetPhoneQueryVariables
}
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 DeviceTelemetryItem = { export type DeviceTelemetryItem = {
batteryCapacity: number | null batteryCapacity: number | null
@ -174,13 +177,8 @@ export type DevicePackage = {
versionName: string | null versionName: string | null
} }
export type GetPhonePackagesData = { export type GetPhonePackagesData = GetPhonePackagesQuery
getPhonePackages: DevicePackage[] export type GetPhonePackagesVariables = GetPhonePackagesQueryVariables
}
export type GetPhonePackagesVariables = {
phoneId: string
}
export type DevicePackageUseEventType = export type DevicePackageUseEventType =
| 'ResumeActivity' | 'ResumeActivity'
@ -199,18 +197,8 @@ export type DevicePackageUseEventsGroup = {
phonePackage: DevicePackage | null phonePackage: DevicePackage | null
} }
export type GetTelemetryData = { export type GetTelemetryData = GetTelemetryQuery
getTelemetry: DeviceTelemetryItem[] export type GetTelemetryVariables = GetTelemetryQueryVariables
getPhoneStateEvents: DevicePhoneStateEvent[]
getPhonePackagesUseEvents: DevicePackageUseEventsGroup[]
}
export type GetTelemetryVariables = {
phoneId: number
packagesPhoneId: string
startDate: number
endDate?: number
}
export type DeviceGpsTrackPoint = { export type DeviceGpsTrackPoint = {
alt: number alt: number
@ -219,50 +207,33 @@ export type DeviceGpsTrackPoint = {
lng: number lng: number
} }
export type GetPhoneGpsTrackData = { export type GetPhoneGpsTrackData = GetPhoneGpsTrackQuery
getPhoneGpsTrack: DeviceGpsTrackPoint[] export type GetPhoneGpsTrackVariables = GetPhoneGpsTrackQueryVariables
} export type GetDevicePageData = GetDevicePageQuery
export type GetDevicePageVariables = GetDevicePageQueryVariables
export type GetPhoneGpsTrackVariables = { export type ChangePhonePolicyData = ChangePhonePolicyMutation
phoneId: string export type ChangePhonePolicyVariables = ChangePhonePolicyMutationVariables
startDate: number export type CreatePhoneRegistrationTokenData =
endDate?: number CreatePhoneRegistrationTokenMutation
} export type CreatePhoneRegistrationTokenVariables =
CreatePhoneRegistrationTokenMutationVariables
export type GetDevicePageData = { export type GetPhoneMaintenanceHistoryData = GetPhoneMaintenanceHistoryQuery
getPhone: Device | null export type GetPhoneMaintenanceHistoryVariables =
getTelemetry: DeviceTelemetryItem[] GetPhoneMaintenanceHistoryQueryVariables
getPhoneGpsTrack: DeviceGpsTrackPoint[] export type GetActivePhoneMaintenanceCaseData =
} GetActivePhoneMaintenanceCaseQuery
export type GetActivePhoneMaintenanceCaseVariables =
export type GetDevicePageVariables = { GetActivePhoneMaintenanceCaseQueryVariables
id: number export type SetPhoneMaintenanceRequiredData =
phoneId: string SetPhoneMaintenanceRequiredMutation
telemetryStartDate: number export type SetPhoneMaintenanceRequiredVariables =
telemetryEndDate?: number SetPhoneMaintenanceRequiredMutationVariables
gpsStartDate: number export type MarkPhoneHealthyData = MarkPhoneHealthyMutation
gpsEndDate?: number export type MarkPhoneHealthyVariables = MarkPhoneHealthyMutationVariables
} export type SendPhoneToMaintenanceData = SendPhoneToMaintenanceMutation
export type SendPhoneToMaintenanceVariables =
export type ChangePhonePolicyData = { SendPhoneToMaintenanceMutationVariables
changePhonePolicy: DevicePolicy export type ReturnPhoneFromMaintenanceData =
} ReturnPhoneFromMaintenanceMutation
export type ReturnPhoneFromMaintenanceVariables =
export type ChangePhonePolicyVariables = { ReturnPhoneFromMaintenanceMutationVariables
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>

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 {
GetUsersDocument as GET_USERS_QUERY,
export const GET_USERS_QUERY = gql` GetOrganisationDocument as GET_ORGANISATION_QUERY,
query GetUsers( DeleteOrganisationDocument as DELETE_ORGANISATION_MUTATION,
$page: Int! GetOrganisationsDocument as GET_ORGANISATIONS_QUERY,
$query: String CreateOrganisationDocument as CREATE_ORGANISATION_MUTATION,
$orgs: [ID!] ChangeOrganisationDocument as CHANGE_ORGANISATION_MUTATION,
$roles: [Role!] ChangeOrganisationPolicyDocument as CHANGE_ORGANISATION_POLICY_MUTATION,
$sortDirection: SortDirection! CreateUserDocument as CREATE_USER_MUTATION,
$sortField: UserSortField! ChangeUserDocument as CHANGE_USER_MUTATION,
) { ChangeUserWithPasswordDocument as CHANGE_USER_WITH_PASSWORD_MUTATION,
getUsers( CreateUploadUserAvatarUrlDocument as CREATE_UPLOAD_USER_AVATAR_URL_MUTATION,
page: $page CreateUploadOrganisationLogoUrlDocument as CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION,
query: $query } from '../../../shared/api/generated/graphql'
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)
}
`

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 = { export type Employee = {
avatarUrl?: string | null avatarUrl?: string | null
id: number id: number
firstName: string firstName: string
lastName: string lastName: string
middleName: string middleName: string | null
username?: string username?: string
orgId: number orgId: number
role: EmployeeRole role: EmployeeRole
@ -17,41 +48,19 @@ export type Employee = {
} | null } | null
} }
export type UserSortDirection = 'ASC' | 'DESC' export type UserSortDirection = SortDirection
export type UserSortField = 'ID' | 'Name' | 'Date' export type UserSortField = GeneratedUserSortField
export type ServiceUseOption = export type ServiceUseOption = GeneratedServiceUseOption
| 'Disabled'
| 'Enabled'
| 'Unspecified'
export type ComponentUseOption = export type ComponentUseOption = GeneratedComponentUseOption
| 'Allowed'
| 'Disallowed'
export type GroupUsePolicyOption = export type GroupUsePolicyOption =
| 'Allowed' | 'Allowed'
| 'Disallowed' | 'Disallowed'
| 'Changeable' | '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 = { export type OrganisationPolicy = {
bluetooth?: ServiceUseOption | GroupUsePolicyOption bluetooth?: ServiceUseOption | GroupUsePolicyOption
bluetoothEditable?: boolean bluetoothEditable?: boolean
@ -77,161 +86,33 @@ export type Organisation = {
logoUrl?: string | null logoUrl?: string | null
} }
export type GetOrganisationData = { export type GetUsersData = GetUsersQuery
getOrganisation: Organisation | null export type GetUsersVariables = GetUsersQueryVariables
} export type GetOrganisationData = GetOrganisationQuery
export type GetOrganisationVariables = GetOrganisationQueryVariables
export type GetOrganisationVariables = { export type ChangeOrganisationData = ChangeOrganisationMutation
id: string export type ChangeOrganisationVariables = ChangeOrganisationMutationVariables
} export type ChangeOrganisationPolicyVariables =
ChangeOrganisationPolicyMutationVariables
export type ChangeOrganisationData = { export type GetOrganisationsData = GetOrganisationsQuery
changeOrganisation: { export type GetOrganisationsVariables = GetOrganisationsQueryVariables
id: number export type DeleteOrganisationData = DeleteOrganisationMutation
name: string export type DeleteOrganisationVariables = DeleteOrganisationMutationVariables
} export type CreateUserData = CreateUserMutation
} export type CreateUserVariables = CreateUserMutationVariables
export type ChangeUserData = ChangeUserMutation
export type ChangeOrganisationVariables = { export type ChangeUserVariables = ChangeUserMutationVariables
id: string export type ChangeUserWithPasswordVariables =
name: string ChangeUserWithPasswordMutationVariables
bluetooth: ServiceUseOption export type CreateOrganisationData = CreateOrganisationMutation
bluetoothEditable: boolean export type CreateOrganisationVariables = CreateOrganisationMutationVariables
camera: ComponentUseOption export type OrganisationSortDirection = SortDirection
cameraEditable: boolean export type OrganisationSortField = GeneratedOrganisationSortField
GPS: ServiceUseOption export type CreateUploadUserAvatarUrlData =
gpsEditable: boolean CreateUploadUserAvatarUrlMutation
sim: ServiceUseOption export type CreateUploadUserAvatarUrlVariables =
simEditable: boolean CreateUploadUserAvatarUrlMutationVariables
offlineTimeThreshold: number export type CreateUploadOrganisationLogoUrlData =
} CreateUploadOrganisationLogoUrlMutation
export type CreateUploadOrganisationLogoUrlVariables =
export type ChangeOrganisationPolicyVariables = { CreateUploadOrganisationLogoUrlMutationVariables
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
}

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 {
SignInDocument as SIGN_IN_MUTATION,
export const SIGN_IN_MUTATION = gql` RefreshSessionDocument as REFRESH_SESSION_MUTATION,
mutation SignIn($username: String!, $password: String!) { CurrentUserDocument as CURRENT_USER_QUERY,
signIn(username: $username, password: $password) { } from '../../../shared/api/generated/graphql'
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
}
}
}
`

View File

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

View File

@ -49,6 +49,18 @@ html{
background: $color-bg; 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{ button{
font-family: inherit; font-family: inherit;
} }

View File

@ -12,6 +12,35 @@
padding: 20px 20px 36px 20px; 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 { .device-page__grid {
display: grid; display: grid;
grid-template-columns: 340px 340px minmax(360px, 1fr); grid-template-columns: 340px 340px minmax(360px, 1fr);
@ -30,7 +59,7 @@
.device-card { .device-card {
border-radius: 20px; border-radius: 20px;
background: rgba($color-surface-rgb, .5); background: $color-surface;
backdrop-filter: blur(22px); backdrop-filter: blur(22px);
padding: 20px; padding: 20px;
box-shadow: $shadow-card; box-shadow: $shadow-card;
@ -116,35 +145,153 @@
gap: 4px; gap: 4px;
} }
.device-status-row {
display: flex;
align-items: center;
gap: 4px;
}
.device-status { .device-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 9px; gap: 2px;
color: $color-text-strong; color: $color-text-strong;
font-size: $font-size-18; font-size: $font-size-14;
font-weight: 500; font-weight: 550;
text-transform: uppercase;
padding-right: 12px;
border-radius: 12px;
line-height: 1;
border: none;
svg { svg {
padding: 4px; padding: 6px;
width: 24px; width: 20px;
height: 24px; height: 20px;
border-radius: 8px; border-radius: 12px;
background: $color-bg; background: $color-bg;
} }
&.is-success svg { &.is-success {
color: $green;
background-color: var(--color-success-bg);
svg {
color: $green; color: $green;
background: $color-success-bg; background: $color-success-bg;
} }
}
&.is-danger svg { &.is-danger {
background: $color-danger-bg;
color: $red;
svg {
color: $red; color: $red;
background: $color-danger-bg; background: $color-danger-bg;
} }
}
&.is-muted svg { &.is-muted {
background: var(--color-bg);
color: $gray50; 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 { .device-page__grid {
grid-template-columns: 540px minmax(360px, 1fr);; grid-template-columns: 540px minmax(360px, 1fr);
;
} }
.device-card--main { .device-card--main {
@ -761,18 +909,22 @@
height: calc(100% - 36px); height: calc(100% - 36px);
border-radius: 16px; border-radius: 16px;
} }
.device-card-stats { .device-card-stats {
height: calc(100%); height: calc(100%);
} }
.device-impacts { .device-impacts {
flex: 0; flex: 0;
} }
.device-battery { .device-battery {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
flex: 1; flex: 1;
} }
.device-permissions { .device-permissions {
height: calc(100% - 12px); height: calc(100% - 12px);
padding: 8px 8px 4px 18px; padding: 8px 8px 4px 18px;
@ -789,19 +941,24 @@
.device-map { .device-map {
min-height: 252px; min-height: 252px;
} }
.history-period-tabs { .history-period-tabs {
display: none; display: none;
} }
.device-card__title { .device-card__title {
gap: 8px; gap: 8px;
svg { svg {
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
h3 { h3 {
font-size: var(--font-size-16); font-size: var(--font-size-16);
} }
} }
.device-card__header { .device-card__header {
p { p {
font-size: var(--font-size-14); font-size: var(--font-size-14);

View File

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

View File

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

View File

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

View File

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

View File

@ -1,23 +1,35 @@
import { memo, useState } from 'react' import { memo, useState } from 'react'
import { AnimatePresence, m, useReducedMotion } from 'framer-motion' import { AnimatePresence, m, useReducedMotion } from 'framer-motion'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { import {
Building2, Building2,
CircleAlert,
Eye,
History,
Lock, Lock,
Menu,
Pencil,
ShieldCheck, ShieldCheck,
Signal, Signal,
Smartphone, Smartphone,
Trash2, Trash2,
Wrench,
} from 'lucide-react' } from 'lucide-react'
import type { Device } from '../../types' import type { Device } from '../../types'
import { conditionText, connectionText, getStatusClass } from '../../types' import { conditionText, connectionText, getStatusClass } from '../../types'
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog' import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
import { useDeferredModalPayload } from '../../../../shared/lib/lazyMount' import { useDeferredModalPayload } from '../../../../shared/lib/lazyMount'
import { getDeviceMalfunctionSummary } from '../../../../entities/device/lib/maintenance'
import { Tooltip } from '../../../../shared/ui/Tooltip/Tooltip'
type DeviceMainCardProps = { type DeviceMainCardProps = {
device: Device device: Device
onOpenHistory: () => void onOpenHistory: () => void
onOpenMaintenanceDetails: () => void
onManageMaintenance: () => void
onOpenMaintenanceHistory: () => void
} }
function getDeviceFullName(device: Device) { function getDeviceFullName(device: Device) {
@ -30,6 +42,9 @@ function areDeviceMainCardPropsEqual(
) { ) {
return ( return (
prev.onOpenHistory === next.onOpenHistory && prev.onOpenHistory === next.onOpenHistory &&
prev.onOpenMaintenanceDetails === next.onOpenMaintenanceDetails &&
prev.onManageMaintenance === next.onManageMaintenance &&
prev.onOpenMaintenanceHistory === next.onOpenMaintenanceHistory &&
prev.device.id === next.device.id && prev.device.id === next.device.id &&
prev.device.image === next.device.image && prev.device.image === next.device.image &&
prev.device.model === next.device.model && prev.device.model === next.device.model &&
@ -38,6 +53,9 @@ function areDeviceMainCardPropsEqual(
prev.device.imei === next.device.imei && prev.device.imei === next.device.imei &&
prev.device.imei2 === next.device.imei2 && prev.device.imei2 === next.device.imei2 &&
prev.device.condition === next.device.condition && 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.connection === next.device.connection &&
prev.device.connectionText === next.device.connectionText && prev.device.connectionText === next.device.connectionText &&
prev.device.permissions?.locked === next.device.permissions?.locked && prev.device.permissions?.locked === next.device.permissions?.locked &&
@ -51,6 +69,9 @@ function areDeviceMainCardPropsEqual(
export const DeviceMainCard = memo(function DeviceMainCard({ export const DeviceMainCard = memo(function DeviceMainCard({
device, device,
onOpenHistory, onOpenHistory,
onOpenMaintenanceDetails,
onManageMaintenance,
onOpenMaintenanceHistory,
}: DeviceMainCardProps) { }: DeviceMainCardProps) {
const [deletingDevice, setDeletingDevice] = useState<Device | null>(null) const [deletingDevice, setDeletingDevice] = useState<Device | null>(null)
const renderDeletingDevice = useDeferredModalPayload(deletingDevice) const renderDeletingDevice = useDeferredModalPayload(deletingDevice)
@ -61,12 +82,32 @@ export const DeviceMainCard = memo(function DeviceMainCard({
duration: 0.24, duration: 0.24,
ease: [0.34, 1.42, 0.64, 1] as [number, number, number, number], 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}`, key: `condition-${device.condition}`,
className: getStatusClass(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], content: conditionText[device.condition],
title:
getDeviceMalfunctionSummary(
device.malfunctions,
device.malfunctionComment,
) || undefined,
isTechnical: true,
}, },
{ {
key: `connection-${device.connection}-${device.connectionText ?? ''}`, key: `connection-${device.connection}-${device.connectionText ?? ''}`,
@ -133,7 +174,7 @@ export const DeviceMainCard = memo(function DeviceMainCard({
<m.div <m.div
key={status.key} key={status.key}
layout={!shouldReduceMotion} layout={!shouldReduceMotion}
className={`device-status ${status.className}`} className="device-status-row"
initial={{ opacity: 0, x: shouldReduceMotion ? 0 : -8 }} initial={{ opacity: 0, x: shouldReduceMotion ? 0 : -8 }}
animate={{ animate={{
opacity: 1, opacity: 1,
@ -148,9 +189,68 @@ export const DeviceMainCard = memo(function DeviceMainCard({
x: shouldReduceMotion ? 0 : -8, x: shouldReduceMotion ? 0 : -8,
transition: statusTransition, transition: statusTransition,
}} }}
>
<Tooltip content={status.title}>
{status.isTechnical ? (
<button
className={`device-status device-status--interactive ${status.className}`}
type="button"
onClick={onOpenMaintenanceDetails}
> >
{status.icon} {status.icon}
{status.content} {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> </m.div>
))} ))}
</AnimatePresence> </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, GetPhoneGpsTrackData,
GetPhoneGpsTrackVariables, GetPhoneGpsTrackVariables,
} from '../../../../entities/device/model/types' } from '../../../../entities/device/model/types'
import { isPresent } from '../../../../shared/lib/isPresent'
import { FullscreenControl } from '../../../../widgets/FullscreenControlLeaflet/FullscreenControl' import { FullscreenControl } from '../../../../widgets/FullscreenControlLeaflet/FullscreenControl'
import { import {
MapTrackPeriodControl, MapTrackPeriodControl,
@ -249,7 +250,9 @@ export function DeviceMapCard({
}) })
const sortedGpsTrack = useMemo(() => { const sortedGpsTrack = useMemo(() => {
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? []) return sortGpsTrack(
(gpsTrackData?.getPhoneGpsTrack ?? []).filter(isPresent),
)
}, [gpsTrackData]) }, [gpsTrackData])
const telemetry = useMemo(() => { const telemetry = useMemo(() => {

View File

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

View File

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

View File

@ -42,9 +42,14 @@ import { GET_PHONES_QUERY } from '../../entities/device/api/device.graphql'
import type { import type {
DeviceNetworkStatus, DeviceNetworkStatus,
DeviceTechnicalStatus,
GetPhonesData, GetPhonesData,
GetPhonesVariables, GetPhonesVariables,
} from '../../entities/device/model/types' } 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 PageDevice } from '../DevicePage/types'
import type { Device as ApiDevice } from '../../entities/device/model/types' import type { Device as ApiDevice } from '../../entities/device/model/types'
@ -58,6 +63,7 @@ import {
import { RevealContent } from '../../shared/ui/RevealContent/RevealContent' import { RevealContent } from '../../shared/ui/RevealContent/RevealContent'
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy' import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState' import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
import { Tooltip } from '../../shared/ui/Tooltip/Tooltip'
import { import {
preloadOnIdle, preloadOnIdle,
useLazyMount, useLazyMount,
@ -95,6 +101,12 @@ const DEVICE_NETWORK_STATUSES: DeviceNetworkStatus[] = [
'Lost', 'Lost',
] ]
const DEVICE_TECHNICAL_STATUSES: DeviceTechnicalStatus[] = [
'Healthy',
'NeedsMaintenance',
'InService',
]
const FILTERS_DRAWER_MEDIA_QUERY = '(max-width: 1600px)' const FILTERS_DRAWER_MEDIA_QUERY = '(max-width: 1600px)'
function getIsFiltersDrawerViewport() { function getIsFiltersDrawerViewport() {
@ -109,6 +121,12 @@ function isDeviceNetworkStatus(
return DEVICE_NETWORK_STATUSES.includes(value as DeviceNetworkStatus) 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) { function formatDateTime(timestamp: number) {
if (!timestamp) return 'Нет данных' if (!timestamp) return 'Нет данных'
@ -121,16 +139,22 @@ function formatDateTime(timestamp: number) {
}).format(new Date(timestamp)) }).format(new Date(timestamp))
} }
function getDeviceConditionLabel(needMaintenance?: boolean) { function getDeviceConditionLabel(status?: DeviceTechnicalStatus) {
return needMaintenance ? 'Требует ТО' : 'Исправно' return deviceTechnicalStatusLabels[status ?? 'Healthy']
} }
function getDeviceConditionClass(needMaintenance?: boolean) { function getDeviceConditionClass(status?: DeviceTechnicalStatus) {
return needMaintenance ? 'devices-status--red' : 'devices-status--green' if (status === 'NeedsMaintenance') return 'devices-status--red'
if (status === 'InService') return 'devices-status--blue'
return 'devices-status--green'
} }
function getDeviceConditionDotClass(needMaintenance?: boolean) { function getDeviceConditionDotClass(status?: DeviceTechnicalStatus) {
return needMaintenance ? 'devices-dot--red' : 'devices-dot--green' if (status === 'NeedsMaintenance') return 'devices-dot--red'
if (status === 'InService') return 'devices-dot--blue'
return 'devices-dot--green'
} }
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) { function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
@ -215,19 +239,26 @@ const DevicesTableRow = memo(function DevicesTableRow({
</td> </td>
<td> <td>
<div <Tooltip
content={getDeviceMalfunctionSummary(
device.techState?.malfunctions,
device.techState?.malfunctionComment,
)}
>
<span
className={`devices-status ${getDeviceConditionClass( className={`devices-status ${getDeviceConditionClass(
device.techState?.needMaintenance, device.techState?.status,
)}`} )}`}
> >
<span <span
className={`devices-dot ${getDeviceConditionDotClass( className={`devices-dot ${getDeviceConditionDotClass(
device.techState?.needMaintenance, device.techState?.status,
)}`} )}`}
/> />
{getDeviceConditionLabel(device.techState?.needMaintenance)} {getDeviceConditionLabel(device.techState?.status)}
</div> </span>
</Tooltip>
</td> </td>
<td> <td>
@ -396,9 +427,7 @@ export function DevicesPage() {
const selectedNetworkStatuses = useMemo( const selectedNetworkStatuses = useMemo(
() => () =>
networkStatusParam === 'none' networkStatusParam
? []
: networkStatusParam
?.split(',') ?.split(',')
.filter(isDeviceNetworkStatus) ?? [], .filter(isDeviceNetworkStatus) ?? [],
[networkStatusParam], [networkStatusParam],
@ -412,6 +441,15 @@ export function DevicesPage() {
? false ? false
: undefined : undefined
const technicalStatusParam = searchParams.get('dTechnicalStatus')
const selectedTechnicalStatuses = useMemo(
() =>
technicalStatusParam
?.split(',')
.filter(isDeviceTechnicalStatus) ?? [],
[technicalStatusParam],
)
const lockedParam = searchParams.get('dLocked') const lockedParam = searchParams.get('dLocked')
const locked = const locked =
lockedParam === 'true' lockedParam === 'true'
@ -488,21 +526,26 @@ export function DevicesPage() {
: undefined, : undefined,
locked, locked,
networkStatus: networkStatus:
networkStatusParam selectedNetworkStatuses.length > 0
? selectedNetworkStatuses ? selectedNetworkStatuses
: undefined, : undefined,
needMaintenance, needMaintenance,
technicalStatus:
selectedTechnicalStatuses.length > 0
? selectedTechnicalStatuses
: undefined,
}), }),
[ [
currentPage, currentPage,
debouncedDeviceSearch, debouncedDeviceSearch,
locked, locked,
needMaintenance, needMaintenance,
networkStatusParam, selectedTechnicalStatuses,
selectedNetworkStatuses, selectedNetworkStatuses,
selectedOrganisationIds, selectedOrganisationIds,
sortDirection, sortDirection,
sortField, sortField,
technicalStatusParam,
], ],
) )
@ -534,8 +577,9 @@ export function DevicesPage() {
const hasAppliedFilters = Boolean( const hasAppliedFilters = Boolean(
debouncedDeviceSearch || debouncedDeviceSearch ||
selectedOrganisationIds.length > 0 || selectedOrganisationIds.length > 0 ||
networkStatusParam || selectedNetworkStatuses.length > 0 ||
needMaintenance !== undefined || needMaintenance !== undefined ||
selectedTechnicalStatuses.length > 0 ||
locked !== undefined, locked !== undefined,
) )
const shouldShowEmptyState = !loading && !error && !hasDevices const shouldShowEmptyState = !loading && !error && !hasDevices
@ -628,8 +672,7 @@ export function DevicesPage() {
) )
const isLocked = policy?.locked ?? false const isLocked = policy?.locked ?? false
const needMaintenance = const technicalStatus = device.techState?.status ?? 'Healthy'
device.techState?.needMaintenance ?? false
return { return {
id: device.id, id: device.id,
@ -647,9 +690,15 @@ export function DevicesPage() {
policy, policy,
organisationPolicy: device.org?.policy ?? null, organisationPolicy: device.org?.policy ?? null,
condition: needMaintenance condition:
technicalStatus === 'InService'
? 'service'
: technicalStatus === 'NeedsMaintenance'
? 'inspection' ? 'inspection'
: 'ok', : 'ok',
technicalStatus,
malfunctions: device.techState?.malfunctions ?? [],
malfunctionComment: device.techState?.malfunctionComment,
connection: getDeviceNetworkConnection(device.networkStatus), 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 { useSearchParams } from 'react-router-dom'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import type { DeviceNetworkStatus } from '../../../../entities/device/model/types' import type {
DeviceNetworkStatus,
DeviceTechnicalStatus,
} from '../../../../entities/device/model/types'
import { import {
DevicesDateRangePicker, DevicesDateRangePicker,
type DevicesDateRangePickerValue, type DevicesDateRangePickerValue,
@ -31,21 +34,32 @@ const networkStatusOptions: {
}, },
] ]
const maintenanceOptions = [ const technicalStatusOptions: Array<{
value: DeviceTechnicalStatus
label: string
}> = [
{ {
value: false, value: 'Healthy',
label: 'Исправно', label: 'Исправно',
}, },
{ {
value: true, value: 'NeedsMaintenance',
label: 'Требует ТО', label: 'Требует ТО',
}, },
{
value: 'InService',
label: 'На обслуживании',
},
] ]
function isDeviceNetworkStatus(value: string): value is DeviceNetworkStatus { function isDeviceNetworkStatus(value: string): value is DeviceNetworkStatus {
return networkStatusOptions.some((option) => option.value === value) return networkStatusOptions.some((option) => option.value === value)
} }
function isDeviceTechnicalStatus(value: string): value is DeviceTechnicalStatus {
return technicalStatusOptions.some((option) => option.value === value)
}
type DevicesFiltersPanelProps = { type DevicesFiltersPanelProps = {
isOpen: boolean isOpen: boolean
} }
@ -63,24 +77,31 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
const networkStatusParam = searchParams.get('dNetwork') const networkStatusParam = searchParams.get('dNetwork')
const selectedNetworkStatuses = useMemo<DeviceNetworkStatus[]>( const selectedNetworkStatuses = useMemo<DeviceNetworkStatus[]>(
() => () =>
networkStatusParam === 'none' networkStatusParam
? []
: networkStatusParam
?.split(',') ?.split(',')
.filter(isDeviceNetworkStatus) ?? .filter(isDeviceNetworkStatus) ?? [],
networkStatusOptions.map((option) => option.value),
[networkStatusParam], [networkStatusParam],
) )
const technicalStatusParam = searchParams.get('dTechnicalStatus')
const needMaintenanceParam = searchParams.get('dNeedMaintenance') const needMaintenanceParam = searchParams.get('dNeedMaintenance')
const selectedMaintenanceStates = useMemo( const selectedTechnicalStatuses = useMemo<DeviceTechnicalStatus[]>(
() => () => {
needMaintenanceParam === 'true' if (technicalStatusParam) {
? [true] return technicalStatusParam
: needMaintenanceParam === 'false' .split(',')
? [false] .filter(isDeviceTechnicalStatus)
: maintenanceOptions.map((option) => option.value), }
[needMaintenanceParam],
if (needMaintenanceParam === 'true') {
return ['NeedsMaintenance', 'InService']
}
if (needMaintenanceParam === 'false') return ['Healthy']
return []
},
[needMaintenanceParam, technicalStatusParam],
) )
const [workPeriod, setWorkPeriod] = useState<DevicesDateRangePickerValue>({ const [workPeriod, setWorkPeriod] = useState<DevicesDateRangePickerValue>({
@ -123,17 +144,18 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
updateSearchParams({ updateSearchParams({
dNetwork: dNetwork:
nextStatuses.length === networkStatusOptions.length nextStatuses.length > 0
? null ? nextStatuses.join(',')
: nextStatuses.length === 0 : null,
? 'none'
: nextStatuses.join(','),
dPage: '0', dPage: '0',
}) })
}, [selectedNetworkStatuses, updateSearchParams]) }, [selectedNetworkStatuses, updateSearchParams])
const handleMaintenanceChange = useCallback((value: boolean, checked: boolean) => { const handleTechnicalStatusChange = useCallback((
const currentStates = new Set(selectedMaintenanceStates) value: DeviceTechnicalStatus,
checked: boolean,
) => {
const currentStates = new Set(selectedTechnicalStatuses)
if (checked) { if (checked) {
currentStates.add(value) currentStates.add(value)
@ -141,20 +163,19 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
currentStates.delete(value) currentStates.delete(value)
} }
if (currentStates.size === 0) return const nextStates = technicalStatusOptions
const nextStates = maintenanceOptions
.map((option) => option.value) .map((option) => option.value)
.filter((optionValue) => currentStates.has(optionValue)) .filter((optionValue) => currentStates.has(optionValue))
updateSearchParams({ updateSearchParams({
dNeedMaintenance: dTechnicalStatus:
nextStates.length === maintenanceOptions.length nextStates.length > 0
? null ? nextStates.join(',')
: String(nextStates[0]), : null,
dNeedMaintenance: null,
dPage: '0', dPage: '0',
}) })
}, [selectedMaintenanceStates, updateSearchParams]) }, [selectedTechnicalStatuses, updateSearchParams])
const handleOrganisationsChange = useCallback((ids: string[]) => { const handleOrganisationsChange = useCallback((ids: string[]) => {
updateSearchParams({ updateSearchParams({
@ -173,7 +194,7 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
type="multiple" type="multiple"
defaultValue={['work-period']} 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.Header className="devices-filter-item__header">
<Accordion.Trigger className="devices-filter-item__trigger"> <Accordion.Trigger className="devices-filter-item__trigger">
<span>Период работы</span> <span>Период работы</span>
@ -194,7 +215,7 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
</div> </div>
</div> </div>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item> */}
<Accordion.Item <Accordion.Item
className="devices-filter-item" className="devices-filter-item"
@ -269,13 +290,13 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
<Accordion.Content className="devices-filter-item__content"> <Accordion.Content className="devices-filter-item__content">
<div className="devices-filter-item__inner"> <div className="devices-filter-item__inner">
<div className="devices-checkbox-list"> <div className="devices-checkbox-list">
{maintenanceOptions.map((option) => ( {technicalStatusOptions.map((option) => (
<label className="devices-checkbox" key={String(option.value)}> <label className="devices-checkbox" key={option.value}>
<input <input
type="checkbox" type="checkbox"
checked={selectedMaintenanceStates.includes(option.value)} checked={selectedTechnicalStatuses.includes(option.value)}
onChange={(event) => { onChange={(event) => {
handleMaintenanceChange( handleTechnicalStatusChange(
option.value, option.value,
event.target.checked, event.target.checked,
) )

View File

@ -1,5 +1,5 @@
import { useQuery } from '@apollo/client/react' 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 { useSearchParams } from 'react-router-dom'
import { import {
@ -14,15 +14,18 @@ import {
type PageTabItem, type PageTabItem,
} from '../../../../shared/ui/PageTabs/PageTabs' } from '../../../../shared/ui/PageTabs/PageTabs'
type DevicesTab = 'working' | 'lost' | 'locked' | 'maintenance' type DevicesTab = 'working' | 'lost' | 'locked' | 'maintenance' | 'service'
type DevicesTabsValue = DevicesTab | 'none' type DevicesTabsValue = DevicesTab | 'none'
function getActiveTab(searchParams: URLSearchParams): DevicesTabsValue { function getActiveTab(searchParams: URLSearchParams): DevicesTabsValue {
const locked = searchParams.get('dLocked') const locked = searchParams.get('dLocked')
const needMaintenance = searchParams.get('dNeedMaintenance') const needMaintenance = searchParams.get('dNeedMaintenance')
const networkStatus = searchParams.get('dNetwork') const networkStatus = searchParams.get('dNetwork')
const technicalStatus = searchParams.get('dTechnicalStatus')
if (locked === 'true') return 'locked' if (locked === 'true') return 'locked'
if (technicalStatus === 'NeedsMaintenance') return 'maintenance'
if (technicalStatus === 'InService') return 'service'
if (needMaintenance === 'true') return 'maintenance' if (needMaintenance === 'true') return 'maintenance'
if (networkStatus === 'Lost') return 'lost' if (networkStatus === 'Lost') return 'lost'
if (networkStatus === 'Online') return 'working' if (networkStatus === 'Online') return 'working'
@ -40,6 +43,27 @@ export const DevicesTabs = memo(function DevicesTabs() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const activeTab = useMemo(() => getActiveTab(searchParams), [searchParams]) 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 query = searchParams.get('dQuery')?.trim() || undefined
const selectedOrganisationIds = useMemo(() => { const selectedOrganisationIds = useMemo(() => {
return searchParams.get('dOrgs')?.split(',').filter(Boolean) ?? [] return searchParams.get('dOrgs')?.split(',').filter(Boolean) ?? []
@ -70,6 +94,7 @@ export const DevicesTabs = memo(function DevicesTabs() {
const lockedCount = stats?.lockedPhones.totalElements const lockedCount = stats?.lockedPhones.totalElements
const lostCount = stats?.lostPhones.totalElements const lostCount = stats?.lostPhones.totalElements
const maintenanceCount = stats?.maintenancePhones.totalElements const maintenanceCount = stats?.maintenancePhones.totalElements
const serviceCount = stats?.servicePhones.totalElements
const tabs = useMemo<Array<PageTabItem<DevicesTabsValue>>>(() => { const tabs = useMemo<Array<PageTabItem<DevicesTabsValue>>>(() => {
const nextTabs: 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 return nextTabs
}, [lockedCount, lostCount, maintenanceCount, onlineCount, totalCount]) }, [lockedCount, lostCount, maintenanceCount, onlineCount, serviceCount, totalCount])
const applyQuickFilter = useCallback((tab: DevicesTabsValue) => { const applyQuickFilter = useCallback((tab: DevicesTabsValue) => {
if (tab === 'none') return if (tab === 'none') return
@ -133,6 +169,7 @@ export const DevicesTabs = memo(function DevicesTabs() {
nextParams.delete('dLocked') nextParams.delete('dLocked')
nextParams.delete('dNetwork') nextParams.delete('dNetwork')
nextParams.delete('dNeedMaintenance') nextParams.delete('dNeedMaintenance')
nextParams.delete('dTechnicalStatus')
setSearchParams(nextParams, { replace: true }) setSearchParams(nextParams, { replace: true })
return return
} }
@ -141,24 +178,35 @@ export const DevicesTabs = memo(function DevicesTabs() {
nextParams.set('dNetwork', 'Online') nextParams.set('dNetwork', 'Online')
nextParams.delete('dLocked') nextParams.delete('dLocked')
nextParams.delete('dNeedMaintenance') nextParams.delete('dNeedMaintenance')
nextParams.delete('dTechnicalStatus')
} }
if (tab === 'locked') { if (tab === 'locked') {
nextParams.set('dLocked', 'true') nextParams.set('dLocked', 'true')
nextParams.delete('dNetwork') nextParams.delete('dNetwork')
nextParams.delete('dNeedMaintenance') nextParams.delete('dNeedMaintenance')
nextParams.delete('dTechnicalStatus')
} }
if (tab === 'lost') { if (tab === 'lost') {
nextParams.set('dNetwork', 'Lost') nextParams.set('dNetwork', 'Lost')
nextParams.delete('dLocked') nextParams.delete('dLocked')
nextParams.delete('dNeedMaintenance') nextParams.delete('dNeedMaintenance')
nextParams.delete('dTechnicalStatus')
} }
if (tab === 'maintenance') { if (tab === 'maintenance') {
nextParams.delete('dLocked') nextParams.delete('dLocked')
nextParams.delete('dNetwork') 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 }) setSearchParams(nextParams, { replace: true })

View File

@ -29,6 +29,12 @@
.add-organisation-modal--with-policy { .add-organisation-modal--with-policy {
width: min(980px, calc(100vw - 32px)); 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 { .add-organisation-modal__header {
margin-bottom: 22px; margin-bottom: 22px;

View File

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

View File

@ -57,154 +57,60 @@
} }
} }
.login-theme-switch { .login-theme-button {
position: absolute; position: absolute;
top: 24px; top: 24px;
right: 24px; right: 24px;
z-index: 2; z-index: 2;
width: 72px; width: 42px;
height: 32px; height: 42px;
padding: 0; padding: 0;
border: none; border: none;
border-radius: 999px; border-radius: 14px;
background: transparent; 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; display: inline-flex;
align-items: center; align-items: center;
justify-content: 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; color: $orange;
box-shadow: 0 1px 8px -2px rgba($gray50-rgb, 0.8); cursor: pointer;
transform: translateX(0);
transition: transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.18s ease,
background 0.25s ease, background 0.18s ease,
color 0.25s ease, color 0.18s ease,
box-shadow 0.25s ease; box-shadow 0.18s ease;
svg {
flex: 0 0 auto;
transition: transform 0.2s ease;
} }
.login-theme-switch__icon { &:hover {
position: absolute; background: $color-surface;
z-index: 1; color: $blue;
top: 50%; transform: translateY(-1px);
display: flex; svg {
align-items: center; transform: rotate(-8deg) scale(1.05);
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;
}
.login-theme-switch__icon--moon {
opacity: 0.45;
} }
} }
.login-theme-switch.is-dark { &:active {
.login-theme-switch__track { transform: scale(0.96);
background: $color-surface-glass; }
&.is-dark {
color: $blue;
box-shadow: box-shadow:
inset 0 0 0 1px rgba($white, 0.08), inset 0 0 0 1px rgba($white, 0.08),
0 12px 30px rgba($black, 0.24); 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 { .login-card {
@ -410,7 +316,7 @@
padding: 16px; padding: 16px;
} }
.login-theme-switch { .login-theme-button {
top: 16px; top: 16px;
right: 16px; right: 16px;
} }

View File

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

View File

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

View File

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

View File

@ -39,9 +39,14 @@ import type {
import type { import type {
Device as OrganisationDevice, Device as OrganisationDevice,
DeviceNetworkStatus, DeviceNetworkStatus,
DeviceTechnicalStatus,
GetPhonesData, GetPhonesData,
GetPhonesVariables, GetPhonesVariables,
} from '../../entities/device/model/types' } from '../../entities/device/model/types'
import {
getDeviceMalfunctionSummary,
deviceTechnicalStatusLabels,
} from '../../entities/device/lib/maintenance'
import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog' import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
import { EmployeesPagination } from '../EmployeesPage/components/EmployeesPagination/EmployeesPagination' import { EmployeesPagination } from '../EmployeesPage/components/EmployeesPagination/EmployeesPagination'
import { import {
@ -67,6 +72,7 @@ import {
import { RevealContent } from '../../shared/ui/RevealContent/RevealContent' import { RevealContent } from '../../shared/ui/RevealContent/RevealContent'
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy' import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState' import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
import { Tooltip } from '../../shared/ui/Tooltip/Tooltip'
import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout' import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout'
import { import {
useDeferredModalPayload, useDeferredModalPayload,
@ -108,16 +114,22 @@ function getEmployeeRoleLabel(role: string) {
return role return role
} }
function getDeviceConditionLabel(needMaintenance?: boolean) { function getDeviceConditionLabel(status?: DeviceTechnicalStatus) {
return needMaintenance ? 'Требует ТО' : 'Исправно' return deviceTechnicalStatusLabels[status ?? 'Healthy']
} }
function getDeviceConditionClass(needMaintenance?: boolean) { function getDeviceConditionClass(status?: DeviceTechnicalStatus) {
return needMaintenance ? 'devices-status--red' : 'devices-status--green' if (status === 'NeedsMaintenance') return 'devices-status--red'
if (status === 'InService') return 'devices-status--blue'
return 'devices-status--green'
} }
function getDeviceConditionDotClass(needMaintenance?: boolean) { function getDeviceConditionDotClass(status?: DeviceTechnicalStatus) {
return needMaintenance ? 'devices-dot--red' : 'devices-dot--green' if (status === 'NeedsMaintenance') return 'devices-dot--red'
if (status === 'InService') return 'devices-dot--blue'
return 'devices-dot--green'
} }
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) { function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
@ -941,21 +953,28 @@ export function OrganisationPage() {
</td> </td>
<td> <td>
<div <Tooltip
content={getDeviceMalfunctionSummary(
device.techState?.malfunctions,
device.techState?.malfunctionComment,
)}
>
<span
className={`organisation-device-status ${getDeviceConditionClass( className={`organisation-device-status ${getDeviceConditionClass(
device.techState?.needMaintenance, device.techState?.status,
)}`} )}`}
> >
<span <span
className={`organisation-device-dot ${getDeviceConditionDotClass( className={`organisation-device-dot ${getDeviceConditionDotClass(
device.techState?.needMaintenance, device.techState?.status,
)}`} )}`}
/> />
{getDeviceConditionLabel( {getDeviceConditionLabel(
device.techState?.needMaintenance, device.techState?.status,
)} )}
</div> </span>
</Tooltip>
</td> </td>
<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 { useEffect, useState } from 'react'
import { flushSync } from 'react-dom'
import type { AppTheme } from './theme' import type { AppTheme } from './theme'
import { import {
applyTheme, applyTheme,
@ -6,6 +7,7 @@ import {
getThemeStorageKey, getThemeStorageKey,
saveTheme, saveTheme,
} from './theme' } from './theme'
import { updateThemeWithTransition } from './themeTransition'
export function useTheme() { export function useTheme() {
const [theme, setTheme] = useState<AppTheme>(() => getPreferredTheme()) const [theme, setTheme] = useState<AppTheme>(() => getPreferredTheme())
@ -30,12 +32,22 @@ export function useTheme() {
} }
}, []) }, [])
function toggleTheme() { function commitTheme(nextTheme: AppTheme) {
const nextTheme = theme === 'dark' ? 'light' : 'dark'
applyTheme(nextTheme) applyTheme(nextTheme)
saveTheme(nextTheme) saveTheme(nextTheme)
flushSync(() => {
setTheme(nextTheme) setTheme(nextTheme)
})
}
function toggleTheme(source?: Element | null) {
const nextTheme = theme === 'dark' ? 'light' : 'dark'
updateThemeWithTransition({
source,
theme: nextTheme,
updateTheme: commitTheme,
})
} }
return { 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 { .navbar__theme-button {
color: $color-primary; width: 32px;
&[aria-pressed='true'] {
background: $color-surface;
box-shadow: $shadow-control;
}
}
.navbar__theme-switch {
width: 72px;
height: 32px; height: 32px;
padding: 0; padding: 0;
border: none; border: none;
border-radius: 999px; border-radius: 12px;
background: transparent; background: transparent;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: $gray50;
cursor: pointer; cursor: pointer;
transition: 0.2s ease;
svg {
flex: 0 0 auto;
transition: transform 0.2s ease;
} }
.navbar__theme-switch-track { &:hover {
position: relative; background: $gray30;
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 { &:active {
content: ''; transform: scale(0.96);
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 { &.is-dark:hover {
position: relative; background: var(--color-surface-hover);
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;
}
.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);
}
.navbar__theme-switch-track::before {
opacity: 0.65;
}
.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);
} }
} }

View File

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

View File

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

View File

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

View File

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