Добавлен интерфейс технического обслуживания устройств
This commit is contained in:
parent
ee339bf301
commit
4cf0b0d542
|
|
@ -0,0 +1,2 @@
|
|||
# Separate repository
|
||||
/Arma_mdm/
|
||||
|
|
@ -12,6 +12,16 @@ dist
|
|||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local working files
|
||||
.artifact-third-sheet-work/
|
||||
/*.xlsx
|
||||
/public/photo_2025-03-29_20-05-51.jpg
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -4,8 +4,10 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 192.168.1.181",
|
||||
"dev": "vite --host 192.168.1.91",
|
||||
"build": "tsc -b && vite build",
|
||||
"codegen": "graphql-codegen --config codegen.ts",
|
||||
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
|
@ -41,6 +43,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@graphql-codegen/cli": "^7.2.0",
|
||||
"@graphql-codegen/client-preset": "^6.1.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,390 +1,17 @@
|
|||
import { gql } from '@apollo/client'
|
||||
|
||||
export const GET_PHONES_QUERY = gql`
|
||||
query GetPhones(
|
||||
$page: Int!
|
||||
$query: String
|
||||
$locked: Boolean
|
||||
$needMaintenance: Boolean
|
||||
$networkStatus: [PhoneNetworkStatus!]
|
||||
$orgs: [ID!]
|
||||
$sortDirection: SortDirection!
|
||||
$sortField: PhoneSortField!
|
||||
) {
|
||||
getPhones(
|
||||
page: $page
|
||||
query: $query
|
||||
locked: $locked
|
||||
needMaintenance: $needMaintenance
|
||||
networkStatus: $networkStatus
|
||||
orgs: $orgs
|
||||
sortDirection: $sortDirection
|
||||
sortField: $sortField
|
||||
) {
|
||||
page {
|
||||
id
|
||||
imei
|
||||
imei2
|
||||
serial
|
||||
networkStatus
|
||||
|
||||
lastLocation {
|
||||
alt
|
||||
date
|
||||
lat
|
||||
lng
|
||||
}
|
||||
|
||||
org {
|
||||
id
|
||||
logoUrl
|
||||
name
|
||||
|
||||
policy {
|
||||
bluetooth
|
||||
bluetoothEditable
|
||||
camera
|
||||
cameraEditable
|
||||
GPS
|
||||
gpsEditable
|
||||
sim
|
||||
simEditable
|
||||
offlineTimeThreshold
|
||||
}
|
||||
}
|
||||
|
||||
orgId
|
||||
|
||||
policy {
|
||||
bluetooth
|
||||
camera
|
||||
GPS
|
||||
sim
|
||||
locked
|
||||
}
|
||||
|
||||
techState {
|
||||
batteryLevel
|
||||
needMaintenance
|
||||
temperature
|
||||
}
|
||||
}
|
||||
|
||||
totalElements
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_PHONES_TABS_STATS_QUERY = gql`
|
||||
query GetPhonesTabsStats($query: String, $orgs: [ID!]) {
|
||||
totalPhones: getPhones(
|
||||
page: 0
|
||||
query: $query
|
||||
orgs: $orgs
|
||||
sortDirection: DESC
|
||||
sortField: ID
|
||||
) {
|
||||
totalElements
|
||||
}
|
||||
|
||||
onlinePhones: getPhones(
|
||||
page: 0
|
||||
query: $query
|
||||
networkStatus: [Online]
|
||||
orgs: $orgs
|
||||
sortDirection: DESC
|
||||
sortField: ID
|
||||
) {
|
||||
totalElements
|
||||
}
|
||||
|
||||
lockedPhones: getPhones(
|
||||
page: 0
|
||||
query: $query
|
||||
locked: true
|
||||
orgs: $orgs
|
||||
sortDirection: DESC
|
||||
sortField: ID
|
||||
) {
|
||||
totalElements
|
||||
}
|
||||
|
||||
lostPhones: getPhones(
|
||||
page: 0
|
||||
query: $query
|
||||
networkStatus: [Lost]
|
||||
orgs: $orgs
|
||||
sortDirection: DESC
|
||||
sortField: ID
|
||||
) {
|
||||
totalElements
|
||||
}
|
||||
|
||||
maintenancePhones: getPhones(
|
||||
page: 0
|
||||
query: $query
|
||||
needMaintenance: true
|
||||
orgs: $orgs
|
||||
sortDirection: DESC
|
||||
sortField: ID
|
||||
) {
|
||||
totalElements
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_PHONE_QUERY = gql`
|
||||
query GetPhone($id: Int!) {
|
||||
getPhone(id: $id) {
|
||||
id
|
||||
imei
|
||||
imei2
|
||||
networkStatus
|
||||
|
||||
lastLocation {
|
||||
alt
|
||||
date
|
||||
lat
|
||||
lng
|
||||
}
|
||||
|
||||
org {
|
||||
creationDate
|
||||
id
|
||||
name
|
||||
policy {
|
||||
bluetooth
|
||||
bluetoothEditable
|
||||
camera
|
||||
cameraEditable
|
||||
GPS
|
||||
gpsEditable
|
||||
sim
|
||||
simEditable
|
||||
offlineTimeThreshold
|
||||
}
|
||||
}
|
||||
|
||||
orgId
|
||||
|
||||
policy {
|
||||
bluetooth
|
||||
camera
|
||||
GPS
|
||||
sim
|
||||
locked
|
||||
}
|
||||
|
||||
registerDate
|
||||
serial
|
||||
|
||||
techState {
|
||||
batteryCycles
|
||||
batteryLevel
|
||||
batteryRemainingCapacity
|
||||
hits
|
||||
needMaintenance
|
||||
overheats
|
||||
worktime
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_PHONE_GPS_TRACK_QUERY = gql`
|
||||
query GetPhoneGpsTrack($phoneId: ID!, $startDate: Float!, $endDate: Float) {
|
||||
getPhoneGpsTrack(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
|
||||
alt
|
||||
date
|
||||
lat
|
||||
lng
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_PHONE_PACKAGES_QUERY = gql`
|
||||
query GetPhonePackages($phoneId: ID!) {
|
||||
getPhonePackages(phoneId: $phoneId) {
|
||||
category
|
||||
iconUrl
|
||||
installDate
|
||||
name
|
||||
packageName
|
||||
version
|
||||
versionName
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_TELEMETRY_QUERY = gql`
|
||||
query GetTelemetry(
|
||||
$phoneId: Int!
|
||||
$packagesPhoneId: ID!
|
||||
$startDate: Float!
|
||||
$endDate: Float
|
||||
) {
|
||||
getTelemetry(phoneId: $phoneId, startDate: $startDate, endDate: $endDate) {
|
||||
batteryCapacity
|
||||
batteryCycles
|
||||
batteryLevel
|
||||
date
|
||||
temperature
|
||||
}
|
||||
|
||||
getPhoneStateEvents(phoneId: $packagesPhoneId, startDate: $startDate, endDate: $endDate) {
|
||||
date
|
||||
phoneId
|
||||
type
|
||||
}
|
||||
|
||||
getPhonePackagesUseEvents(
|
||||
phoneId: $packagesPhoneId
|
||||
startDate: $startDate
|
||||
endDate: $endDate
|
||||
) {
|
||||
events {
|
||||
type
|
||||
packageName
|
||||
date
|
||||
}
|
||||
|
||||
phonePackage {
|
||||
category
|
||||
iconUrl
|
||||
installDate
|
||||
name
|
||||
packageName
|
||||
version
|
||||
versionName
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_DEVICE_PAGE_QUERY = gql`
|
||||
query GetDevicePage(
|
||||
$id: Int!
|
||||
$phoneId: ID!
|
||||
$telemetryStartDate: Float!
|
||||
$telemetryEndDate: Float
|
||||
$gpsStartDate: Float!
|
||||
$gpsEndDate: Float
|
||||
) {
|
||||
getPhone(id: $id) {
|
||||
id
|
||||
imei
|
||||
imei2
|
||||
networkStatus
|
||||
|
||||
lastLocation {
|
||||
alt
|
||||
date
|
||||
lat
|
||||
lng
|
||||
}
|
||||
|
||||
org {
|
||||
creationDate
|
||||
id
|
||||
name
|
||||
policy {
|
||||
bluetooth
|
||||
bluetoothEditable
|
||||
camera
|
||||
cameraEditable
|
||||
GPS
|
||||
gpsEditable
|
||||
sim
|
||||
simEditable
|
||||
offlineTimeThreshold
|
||||
}
|
||||
}
|
||||
|
||||
orgId
|
||||
|
||||
policy {
|
||||
bluetooth
|
||||
camera
|
||||
GPS
|
||||
sim
|
||||
locked
|
||||
}
|
||||
|
||||
registerDate
|
||||
serial
|
||||
|
||||
techState {
|
||||
batteryCycles
|
||||
batteryLevel
|
||||
batteryRemainingCapacity
|
||||
hits
|
||||
needMaintenance
|
||||
overheats
|
||||
temperature
|
||||
worktime
|
||||
}
|
||||
}
|
||||
|
||||
getTelemetry(
|
||||
phoneId: $id
|
||||
startDate: $telemetryStartDate
|
||||
endDate: $telemetryEndDate
|
||||
) {
|
||||
batteryCapacity
|
||||
batteryCycles
|
||||
batteryLevel
|
||||
date
|
||||
temperature
|
||||
}
|
||||
|
||||
getPhoneGpsTrack(
|
||||
phoneId: $phoneId
|
||||
startDate: $gpsStartDate
|
||||
endDate: $gpsEndDate
|
||||
) {
|
||||
alt
|
||||
date
|
||||
lat
|
||||
lng
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_PHONE_REGISTRATION_TOKEN_MUTATION = gql`
|
||||
mutation CreatePhoneRegistrationToken {
|
||||
createPhoneRegistrationToken {
|
||||
address
|
||||
expiresIn
|
||||
token
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CHANGE_PHONE_POLICY_MUTATION = gql`
|
||||
mutation ChangePhonePolicy(
|
||||
$id: ID!
|
||||
$bluetooth: ServiceUseOption!
|
||||
$camera: ComponentUseOption!
|
||||
$GPS: ServiceUseOption!
|
||||
$sim: ServiceUseOption!
|
||||
$locked: Boolean!
|
||||
) {
|
||||
changePhonePolicy(
|
||||
id: $id
|
||||
policy: {
|
||||
bluetooth: $bluetooth
|
||||
camera: $camera
|
||||
GPS: $GPS
|
||||
sim: $sim
|
||||
locked: $locked
|
||||
}
|
||||
) {
|
||||
bluetooth
|
||||
camera
|
||||
GPS
|
||||
sim
|
||||
locked
|
||||
}
|
||||
}
|
||||
`
|
||||
export {
|
||||
GetPhonesDocument as GET_PHONES_QUERY,
|
||||
GetPhonesTabsStatsDocument as GET_PHONES_TABS_STATS_QUERY,
|
||||
GetPhoneDocument as GET_PHONE_QUERY,
|
||||
GetPhoneGpsTrackDocument as GET_PHONE_GPS_TRACK_QUERY,
|
||||
GetPhonePackagesDocument as GET_PHONE_PACKAGES_QUERY,
|
||||
GetTelemetryDocument as GET_TELEMETRY_QUERY,
|
||||
GetDevicePageDocument as GET_DEVICE_PAGE_QUERY,
|
||||
CreatePhoneRegistrationTokenDocument as CREATE_PHONE_REGISTRATION_TOKEN_MUTATION,
|
||||
ChangePhonePolicyDocument as CHANGE_PHONE_POLICY_MUTATION,
|
||||
GetActivePhoneMaintenanceCaseDocument as GET_ACTIVE_PHONE_MAINTENANCE_CASE_QUERY,
|
||||
GetPhoneMaintenanceHistoryDocument as GET_PHONE_MAINTENANCE_HISTORY_QUERY,
|
||||
SetPhoneMaintenanceRequiredDocument as SET_PHONE_MAINTENANCE_REQUIRED_MUTATION,
|
||||
MarkPhoneHealthyDocument as MARK_PHONE_HEALTHY_MUTATION,
|
||||
SendPhoneToMaintenanceDocument as SEND_PHONE_TO_MAINTENANCE_MUTATION,
|
||||
ReturnPhoneFromMaintenanceDocument as RETURN_PHONE_FROM_MAINTENANCE_MUTATION,
|
||||
} from '../../../shared/api/generated/graphql'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,42 @@
|
|||
import type {
|
||||
ChangePhonePolicyMutation,
|
||||
ChangePhonePolicyMutationVariables,
|
||||
ComponentUseOption as GeneratedComponentUseOption,
|
||||
CreatePhoneRegistrationTokenMutation,
|
||||
CreatePhoneRegistrationTokenMutationVariables,
|
||||
GetDevicePageQuery,
|
||||
GetDevicePageQueryVariables,
|
||||
GetActivePhoneMaintenanceCaseQuery,
|
||||
GetActivePhoneMaintenanceCaseQueryVariables,
|
||||
GetPhoneGpsTrackQuery,
|
||||
GetPhoneGpsTrackQueryVariables,
|
||||
GetPhoneMaintenanceHistoryQuery,
|
||||
GetPhoneMaintenanceHistoryQueryVariables,
|
||||
GetPhonePackagesQuery,
|
||||
GetPhonePackagesQueryVariables,
|
||||
GetPhoneQuery,
|
||||
GetPhoneQueryVariables,
|
||||
GetPhonesQuery,
|
||||
GetPhonesQueryVariables,
|
||||
GetPhonesTabsStatsQuery,
|
||||
GetPhonesTabsStatsQueryVariables,
|
||||
GetTelemetryQuery,
|
||||
GetTelemetryQueryVariables,
|
||||
MarkPhoneHealthyMutation,
|
||||
MarkPhoneHealthyMutationVariables,
|
||||
PhoneMaintenanceCaseStatus as GeneratedPhoneMaintenanceCaseStatus,
|
||||
PhoneMalfunction as GeneratedPhoneMalfunction,
|
||||
PhoneNetworkStatus,
|
||||
PhoneTechnicalStatus as GeneratedPhoneTechnicalStatus,
|
||||
ReturnPhoneFromMaintenanceMutation,
|
||||
ReturnPhoneFromMaintenanceMutationVariables,
|
||||
SendPhoneToMaintenanceMutation,
|
||||
SendPhoneToMaintenanceMutationVariables,
|
||||
ServiceUseOption as GeneratedServiceUseOption,
|
||||
SetPhoneMaintenanceRequiredMutation,
|
||||
SetPhoneMaintenanceRequiredMutationVariables,
|
||||
} from '../../../shared/api/generated/graphql'
|
||||
|
||||
export type DeviceLocation = {
|
||||
alt: number
|
||||
date: number
|
||||
|
|
@ -5,14 +44,9 @@ export type DeviceLocation = {
|
|||
lng: number
|
||||
}
|
||||
|
||||
export type ServiceUseOption =
|
||||
| 'Disabled'
|
||||
| 'Enabled'
|
||||
| 'Unspecified'
|
||||
export type ServiceUseOption = GeneratedServiceUseOption
|
||||
|
||||
export type ComponentUseOption =
|
||||
| 'Allowed'
|
||||
| 'Disallowed'
|
||||
export type ComponentUseOption = GeneratedComponentUseOption
|
||||
|
||||
export type PhoneUsePolicyOption = ServiceUseOption | ComponentUseOption
|
||||
|
||||
|
|
@ -47,17 +81,23 @@ export type DevicePolicy = {
|
|||
}
|
||||
|
||||
export type DeviceTechState = {
|
||||
batteryCycles: number | null
|
||||
batteryLevel: number | null
|
||||
batteryRemainingCapacity: number | null
|
||||
hits: number | null
|
||||
needMaintenance: boolean
|
||||
overheats: number | null
|
||||
batteryCycles?: number | null
|
||||
batteryLevel?: number | null
|
||||
batteryRemainingCapacity?: number | null
|
||||
hits?: number | null
|
||||
malfunctions?: DeviceMalfunction[]
|
||||
malfunctionComment?: string | null
|
||||
needMaintenance?: boolean
|
||||
overheats?: number | null
|
||||
status?: DeviceTechnicalStatus
|
||||
temperature?: number | null
|
||||
worktime: number | null
|
||||
worktime?: number | null
|
||||
}
|
||||
|
||||
export type DeviceNetworkStatus = 'Online' | 'Offline' | 'Lost'
|
||||
export type DeviceNetworkStatus = PhoneNetworkStatus
|
||||
export type DeviceTechnicalStatus = GeneratedPhoneTechnicalStatus
|
||||
export type DeviceMalfunction = GeneratedPhoneMalfunction
|
||||
export type DeviceMaintenanceCaseStatus = GeneratedPhoneMaintenanceCaseStatus
|
||||
|
||||
export type DeviceOrganisation = {
|
||||
creationDate?: number
|
||||
|
|
@ -70,60 +110,23 @@ export type DeviceOrganisation = {
|
|||
export type Device = {
|
||||
id: number
|
||||
imei: string
|
||||
imei2: string
|
||||
imei2: string | null
|
||||
serial: string
|
||||
networkStatus?: DeviceNetworkStatus
|
||||
networkStatus: DeviceNetworkStatus
|
||||
orgId: number
|
||||
registerDate: number
|
||||
registerDate?: number
|
||||
org: DeviceOrganisation | null
|
||||
policy: DevicePolicy | null
|
||||
techState: DeviceTechState | null
|
||||
lastLocation: DeviceLocation | null
|
||||
}
|
||||
|
||||
export type GetPhonesData = {
|
||||
getPhones: {
|
||||
page: Device[]
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export type GetPhonesVariables = {
|
||||
page: number
|
||||
query?: string
|
||||
locked?: boolean
|
||||
needMaintenance?: boolean
|
||||
networkStatus?: DeviceNetworkStatus[]
|
||||
orgs?: string[]
|
||||
sortDirection: 'ASC' | 'DESC'
|
||||
sortField: 'ID' | 'Serial' | 'Date'
|
||||
}
|
||||
|
||||
type GetPhonesStatsPage = {
|
||||
totalElements: number
|
||||
}
|
||||
|
||||
export type GetPhonesTabsStatsData = {
|
||||
totalPhones: GetPhonesStatsPage
|
||||
onlinePhones: GetPhonesStatsPage
|
||||
lockedPhones: GetPhonesStatsPage
|
||||
lostPhones: GetPhonesStatsPage
|
||||
maintenancePhones: GetPhonesStatsPage
|
||||
}
|
||||
|
||||
export type GetPhonesTabsStatsVariables = {
|
||||
query?: string
|
||||
orgs?: string[]
|
||||
}
|
||||
|
||||
export type GetPhoneData = {
|
||||
getPhone: Device | null
|
||||
}
|
||||
|
||||
export type GetPhoneVariables = {
|
||||
id: number
|
||||
}
|
||||
export type GetPhonesData = GetPhonesQuery
|
||||
export type GetPhonesVariables = GetPhonesQueryVariables
|
||||
export type GetPhonesTabsStatsData = GetPhonesTabsStatsQuery
|
||||
export type GetPhonesTabsStatsVariables = GetPhonesTabsStatsQueryVariables
|
||||
export type GetPhoneData = GetPhoneQuery
|
||||
export type GetPhoneVariables = GetPhoneQueryVariables
|
||||
|
||||
export type DeviceTelemetryItem = {
|
||||
batteryCapacity: number | null
|
||||
|
|
@ -174,13 +177,8 @@ export type DevicePackage = {
|
|||
versionName: string | null
|
||||
}
|
||||
|
||||
export type GetPhonePackagesData = {
|
||||
getPhonePackages: DevicePackage[]
|
||||
}
|
||||
|
||||
export type GetPhonePackagesVariables = {
|
||||
phoneId: string
|
||||
}
|
||||
export type GetPhonePackagesData = GetPhonePackagesQuery
|
||||
export type GetPhonePackagesVariables = GetPhonePackagesQueryVariables
|
||||
|
||||
export type DevicePackageUseEventType =
|
||||
| 'ResumeActivity'
|
||||
|
|
@ -199,18 +197,8 @@ export type DevicePackageUseEventsGroup = {
|
|||
phonePackage: DevicePackage | null
|
||||
}
|
||||
|
||||
export type GetTelemetryData = {
|
||||
getTelemetry: DeviceTelemetryItem[]
|
||||
getPhoneStateEvents: DevicePhoneStateEvent[]
|
||||
getPhonePackagesUseEvents: DevicePackageUseEventsGroup[]
|
||||
}
|
||||
|
||||
export type GetTelemetryVariables = {
|
||||
phoneId: number
|
||||
packagesPhoneId: string
|
||||
startDate: number
|
||||
endDate?: number
|
||||
}
|
||||
export type GetTelemetryData = GetTelemetryQuery
|
||||
export type GetTelemetryVariables = GetTelemetryQueryVariables
|
||||
|
||||
export type DeviceGpsTrackPoint = {
|
||||
alt: number
|
||||
|
|
@ -219,50 +207,33 @@ export type DeviceGpsTrackPoint = {
|
|||
lng: number
|
||||
}
|
||||
|
||||
export type GetPhoneGpsTrackData = {
|
||||
getPhoneGpsTrack: DeviceGpsTrackPoint[]
|
||||
}
|
||||
|
||||
export type GetPhoneGpsTrackVariables = {
|
||||
phoneId: string
|
||||
startDate: number
|
||||
endDate?: number
|
||||
}
|
||||
|
||||
export type GetDevicePageData = {
|
||||
getPhone: Device | null
|
||||
getTelemetry: DeviceTelemetryItem[]
|
||||
getPhoneGpsTrack: DeviceGpsTrackPoint[]
|
||||
}
|
||||
|
||||
export type GetDevicePageVariables = {
|
||||
id: number
|
||||
phoneId: string
|
||||
telemetryStartDate: number
|
||||
telemetryEndDate?: number
|
||||
gpsStartDate: number
|
||||
gpsEndDate?: number
|
||||
}
|
||||
|
||||
export type ChangePhonePolicyData = {
|
||||
changePhonePolicy: DevicePolicy
|
||||
}
|
||||
|
||||
export type ChangePhonePolicyVariables = {
|
||||
id: string
|
||||
bluetooth: ServiceUseOption
|
||||
camera: ComponentUseOption
|
||||
GPS: ServiceUseOption
|
||||
sim: ServiceUseOption
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export type CreatePhoneRegistrationTokenData = {
|
||||
createPhoneRegistrationToken: {
|
||||
address: string
|
||||
expiresIn: number
|
||||
token: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CreatePhoneRegistrationTokenVariables = Record<string, never>
|
||||
export type GetPhoneGpsTrackData = GetPhoneGpsTrackQuery
|
||||
export type GetPhoneGpsTrackVariables = GetPhoneGpsTrackQueryVariables
|
||||
export type GetDevicePageData = GetDevicePageQuery
|
||||
export type GetDevicePageVariables = GetDevicePageQueryVariables
|
||||
export type ChangePhonePolicyData = ChangePhonePolicyMutation
|
||||
export type ChangePhonePolicyVariables = ChangePhonePolicyMutationVariables
|
||||
export type CreatePhoneRegistrationTokenData =
|
||||
CreatePhoneRegistrationTokenMutation
|
||||
export type CreatePhoneRegistrationTokenVariables =
|
||||
CreatePhoneRegistrationTokenMutationVariables
|
||||
export type GetPhoneMaintenanceHistoryData = GetPhoneMaintenanceHistoryQuery
|
||||
export type GetPhoneMaintenanceHistoryVariables =
|
||||
GetPhoneMaintenanceHistoryQueryVariables
|
||||
export type GetActivePhoneMaintenanceCaseData =
|
||||
GetActivePhoneMaintenanceCaseQuery
|
||||
export type GetActivePhoneMaintenanceCaseVariables =
|
||||
GetActivePhoneMaintenanceCaseQueryVariables
|
||||
export type SetPhoneMaintenanceRequiredData =
|
||||
SetPhoneMaintenanceRequiredMutation
|
||||
export type SetPhoneMaintenanceRequiredVariables =
|
||||
SetPhoneMaintenanceRequiredMutationVariables
|
||||
export type MarkPhoneHealthyData = MarkPhoneHealthyMutation
|
||||
export type MarkPhoneHealthyVariables = MarkPhoneHealthyMutationVariables
|
||||
export type SendPhoneToMaintenanceData = SendPhoneToMaintenanceMutation
|
||||
export type SendPhoneToMaintenanceVariables =
|
||||
SendPhoneToMaintenanceMutationVariables
|
||||
export type ReturnPhoneFromMaintenanceData =
|
||||
ReturnPhoneFromMaintenanceMutation
|
||||
export type ReturnPhoneFromMaintenanceVariables =
|
||||
ReturnPhoneFromMaintenanceMutationVariables
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,325 +1,14 @@
|
|||
import { gql } from '@apollo/client'
|
||||
|
||||
export const GET_USERS_QUERY = gql`
|
||||
query GetUsers(
|
||||
$page: Int!
|
||||
$query: String
|
||||
$orgs: [ID!]
|
||||
$roles: [Role!]
|
||||
$sortDirection: SortDirection!
|
||||
$sortField: UserSortField!
|
||||
) {
|
||||
getUsers(
|
||||
page: $page
|
||||
query: $query
|
||||
orgs: $orgs
|
||||
roles: $roles
|
||||
sortDirection: $sortDirection
|
||||
sortField: $sortField
|
||||
) {
|
||||
totalPages
|
||||
totalElements
|
||||
page {
|
||||
avatarUrl
|
||||
firstName
|
||||
id
|
||||
lastName
|
||||
middleName
|
||||
org {
|
||||
creationDate
|
||||
id
|
||||
logoUrl
|
||||
name
|
||||
}
|
||||
orgId
|
||||
role
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_ORGANISATION_QUERY = gql`
|
||||
query GetOrganisation($id: ID!) {
|
||||
getOrganisation(id: $id) {
|
||||
creationDate
|
||||
id
|
||||
name
|
||||
logoUrl
|
||||
policy {
|
||||
bluetooth
|
||||
bluetoothEditable
|
||||
camera
|
||||
cameraEditable
|
||||
GPS
|
||||
gpsEditable
|
||||
sim
|
||||
simEditable
|
||||
offlineTimeThreshold
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_ORGANISATION_MUTATION = gql`
|
||||
mutation DeleteOrganisation($id: ID!) {
|
||||
deleteOrganisation(id: $id)
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_ORGANISATIONS_QUERY = gql`
|
||||
query GetOrganisations(
|
||||
$page: Int!
|
||||
$query: String
|
||||
$sortDirection: SortDirection!
|
||||
$sortField: OrganisationSortField!
|
||||
) {
|
||||
getOrganisations(
|
||||
page: $page
|
||||
query: $query
|
||||
sortDirection: $sortDirection
|
||||
sortField: $sortField
|
||||
) {
|
||||
totalPages
|
||||
totalElements
|
||||
page {
|
||||
creationDate
|
||||
id
|
||||
name
|
||||
logoUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_ORGANISATION_MUTATION = gql`
|
||||
mutation CreateOrganisation(
|
||||
$name: String!
|
||||
$bluetooth: ServiceUseOption!
|
||||
$bluetoothEditable: Boolean!
|
||||
$camera: ComponentUseOption!
|
||||
$cameraEditable: Boolean!
|
||||
$GPS: ServiceUseOption!
|
||||
$gpsEditable: Boolean!
|
||||
$sim: ServiceUseOption!
|
||||
$simEditable: Boolean!
|
||||
$offlineTimeThreshold: Float!
|
||||
) {
|
||||
createOrganisation(
|
||||
name: $name
|
||||
policy: {
|
||||
bluetooth: $bluetooth
|
||||
bluetoothEditable: $bluetoothEditable
|
||||
camera: $camera
|
||||
cameraEditable: $cameraEditable
|
||||
GPS: $GPS
|
||||
gpsEditable: $gpsEditable
|
||||
sim: $sim
|
||||
simEditable: $simEditable
|
||||
offlineTimeThreshold: $offlineTimeThreshold
|
||||
}
|
||||
) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CHANGE_ORGANISATION_MUTATION = gql`
|
||||
mutation ChangeOrganisation(
|
||||
$id: ID!
|
||||
$name: String!
|
||||
$bluetooth: ServiceUseOption!
|
||||
$bluetoothEditable: Boolean!
|
||||
$camera: ComponentUseOption!
|
||||
$cameraEditable: Boolean!
|
||||
$GPS: ServiceUseOption!
|
||||
$gpsEditable: Boolean!
|
||||
$sim: ServiceUseOption!
|
||||
$simEditable: Boolean!
|
||||
$offlineTimeThreshold: Float!
|
||||
) {
|
||||
changeOrganisation(
|
||||
id: $id
|
||||
name: $name
|
||||
policy: {
|
||||
bluetooth: $bluetooth
|
||||
bluetoothEditable: $bluetoothEditable
|
||||
camera: $camera
|
||||
cameraEditable: $cameraEditable
|
||||
GPS: $GPS
|
||||
gpsEditable: $gpsEditable
|
||||
sim: $sim
|
||||
simEditable: $simEditable
|
||||
offlineTimeThreshold: $offlineTimeThreshold
|
||||
}
|
||||
) {
|
||||
creationDate
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CHANGE_ORGANISATION_POLICY_MUTATION = gql`
|
||||
mutation ChangeOrganisationPolicy(
|
||||
$id: ID!
|
||||
$name: String!
|
||||
$bluetooth: ServiceUseOption!
|
||||
$bluetoothEditable: Boolean!
|
||||
$camera: ComponentUseOption!
|
||||
$cameraEditable: Boolean!
|
||||
$GPS: ServiceUseOption!
|
||||
$gpsEditable: Boolean!
|
||||
$sim: ServiceUseOption!
|
||||
$simEditable: Boolean!
|
||||
$offlineTimeThreshold: Float!
|
||||
) {
|
||||
changeOrganisation(
|
||||
id: $id
|
||||
name: $name
|
||||
policy: {
|
||||
bluetooth: $bluetooth
|
||||
bluetoothEditable: $bluetoothEditable
|
||||
camera: $camera
|
||||
cameraEditable: $cameraEditable
|
||||
GPS: $GPS
|
||||
gpsEditable: $gpsEditable
|
||||
sim: $sim
|
||||
simEditable: $simEditable
|
||||
offlineTimeThreshold: $offlineTimeThreshold
|
||||
}
|
||||
) {
|
||||
creationDate
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_USER_MUTATION = gql`
|
||||
mutation createUser(
|
||||
$orgId: ID!
|
||||
$firstName: String!
|
||||
$lastName: String!
|
||||
$middleName: String!
|
||||
$username: String!
|
||||
$password: String!
|
||||
$role: Role!
|
||||
) {
|
||||
createUser(
|
||||
payload: {
|
||||
orgId: $orgId
|
||||
firstName: $firstName
|
||||
lastName: $lastName
|
||||
middleName: $middleName
|
||||
username: $username
|
||||
password: $password
|
||||
role: $role
|
||||
}
|
||||
) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
middleName
|
||||
orgId
|
||||
role
|
||||
org {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CHANGE_USER_MUTATION = gql`
|
||||
mutation ChangeUser(
|
||||
$userId: ID!
|
||||
$orgId: ID!
|
||||
$firstName: String!
|
||||
$lastName: String!
|
||||
$middleName: String!
|
||||
$username: String!
|
||||
$role: Role!
|
||||
) {
|
||||
changeUser(
|
||||
userId: $userId
|
||||
form: {
|
||||
orgId: $orgId
|
||||
firstName: $firstName
|
||||
lastName: $lastName
|
||||
middleName: $middleName
|
||||
username: $username
|
||||
role: $role
|
||||
}
|
||||
) {
|
||||
avatarUrl
|
||||
firstName
|
||||
id
|
||||
lastName
|
||||
middleName
|
||||
org {
|
||||
creationDate
|
||||
id
|
||||
logoUrl
|
||||
name
|
||||
}
|
||||
orgId
|
||||
role
|
||||
username
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CHANGE_USER_WITH_PASSWORD_MUTATION = gql`
|
||||
mutation ChangeUserWithPassword(
|
||||
$userId: ID!
|
||||
$orgId: ID!
|
||||
$firstName: String!
|
||||
$lastName: String!
|
||||
$middleName: String!
|
||||
$username: String!
|
||||
$password: String!
|
||||
$role: Role!
|
||||
) {
|
||||
changeUser(
|
||||
userId: $userId
|
||||
form: {
|
||||
orgId: $orgId
|
||||
firstName: $firstName
|
||||
lastName: $lastName
|
||||
middleName: $middleName
|
||||
username: $username
|
||||
password: $password
|
||||
role: $role
|
||||
}
|
||||
) {
|
||||
avatarUrl
|
||||
firstName
|
||||
id
|
||||
lastName
|
||||
middleName
|
||||
org {
|
||||
creationDate
|
||||
id
|
||||
logoUrl
|
||||
name
|
||||
}
|
||||
orgId
|
||||
role
|
||||
username
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_UPLOAD_USER_AVATAR_URL_MUTATION = gql`
|
||||
mutation CreateUploadUserAvatarUrl($userId: ID!) {
|
||||
createUploadUserAvatarUrl(userId: $userId)
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION = gql`
|
||||
mutation CreateUploadOrganisationLogoUrl($orgId: ID!) {
|
||||
createUploadOrganisationLogoUrl(orgId: $orgId)
|
||||
}
|
||||
`
|
||||
export {
|
||||
GetUsersDocument as GET_USERS_QUERY,
|
||||
GetOrganisationDocument as GET_ORGANISATION_QUERY,
|
||||
DeleteOrganisationDocument as DELETE_ORGANISATION_MUTATION,
|
||||
GetOrganisationsDocument as GET_ORGANISATIONS_QUERY,
|
||||
CreateOrganisationDocument as CREATE_ORGANISATION_MUTATION,
|
||||
ChangeOrganisationDocument as CHANGE_ORGANISATION_MUTATION,
|
||||
ChangeOrganisationPolicyDocument as CHANGE_ORGANISATION_POLICY_MUTATION,
|
||||
CreateUserDocument as CREATE_USER_MUTATION,
|
||||
ChangeUserDocument as CHANGE_USER_MUTATION,
|
||||
ChangeUserWithPasswordDocument as CHANGE_USER_WITH_PASSWORD_MUTATION,
|
||||
CreateUploadUserAvatarUrlDocument as CREATE_UPLOAD_USER_AVATAR_URL_MUTATION,
|
||||
CreateUploadOrganisationLogoUrlDocument as CREATE_UPLOAD_ORGANISATION_LOGO_URL_MUTATION,
|
||||
} from '../../../shared/api/generated/graphql'
|
||||
|
|
|
|||
|
|
@ -1,11 +1,42 @@
|
|||
export type EmployeeRole = 'User' | 'Admin' | string
|
||||
import type {
|
||||
ChangeOrganisationMutation,
|
||||
ChangeOrganisationMutationVariables,
|
||||
ChangeOrganisationPolicyMutationVariables,
|
||||
ChangeUserMutation,
|
||||
ChangeUserMutationVariables,
|
||||
ChangeUserWithPasswordMutationVariables,
|
||||
ComponentUseOption as GeneratedComponentUseOption,
|
||||
CreateOrganisationMutation,
|
||||
CreateOrganisationMutationVariables,
|
||||
CreateUploadOrganisationLogoUrlMutation,
|
||||
CreateUploadOrganisationLogoUrlMutationVariables,
|
||||
CreateUploadUserAvatarUrlMutation,
|
||||
CreateUploadUserAvatarUrlMutationVariables,
|
||||
CreateUserMutation,
|
||||
CreateUserMutationVariables,
|
||||
DeleteOrganisationMutation,
|
||||
DeleteOrganisationMutationVariables,
|
||||
GetOrganisationQuery,
|
||||
GetOrganisationQueryVariables,
|
||||
GetOrganisationsQuery,
|
||||
GetOrganisationsQueryVariables,
|
||||
GetUsersQuery,
|
||||
GetUsersQueryVariables,
|
||||
OrganisationSortField as GeneratedOrganisationSortField,
|
||||
Role,
|
||||
ServiceUseOption as GeneratedServiceUseOption,
|
||||
SortDirection,
|
||||
UserSortField as GeneratedUserSortField,
|
||||
} from '../../../shared/api/generated/graphql'
|
||||
|
||||
export type EmployeeRole = Role
|
||||
|
||||
export type Employee = {
|
||||
avatarUrl?: string | null
|
||||
id: number
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName: string
|
||||
middleName: string | null
|
||||
username?: string
|
||||
orgId: number
|
||||
role: EmployeeRole
|
||||
|
|
@ -17,41 +48,19 @@ export type Employee = {
|
|||
} | null
|
||||
}
|
||||
|
||||
export type UserSortDirection = 'ASC' | 'DESC'
|
||||
export type UserSortDirection = SortDirection
|
||||
|
||||
export type UserSortField = 'ID' | 'Name' | 'Date'
|
||||
export type UserSortField = GeneratedUserSortField
|
||||
|
||||
export type ServiceUseOption =
|
||||
| 'Disabled'
|
||||
| 'Enabled'
|
||||
| 'Unspecified'
|
||||
export type ServiceUseOption = GeneratedServiceUseOption
|
||||
|
||||
export type ComponentUseOption =
|
||||
| 'Allowed'
|
||||
| 'Disallowed'
|
||||
export type ComponentUseOption = GeneratedComponentUseOption
|
||||
|
||||
export type GroupUsePolicyOption =
|
||||
| 'Allowed'
|
||||
| 'Disallowed'
|
||||
| 'Changeable'
|
||||
|
||||
export type GetUsersData = {
|
||||
getUsers: {
|
||||
totalPages: number
|
||||
totalElements: number
|
||||
page: Employee[]
|
||||
}
|
||||
}
|
||||
|
||||
export type GetUsersVariables = {
|
||||
page: number
|
||||
query?: string
|
||||
orgs?: string[]
|
||||
roles?: EmployeeRole[]
|
||||
sortDirection: UserSortDirection
|
||||
sortField: UserSortField
|
||||
}
|
||||
|
||||
export type OrganisationPolicy = {
|
||||
bluetooth?: ServiceUseOption | GroupUsePolicyOption
|
||||
bluetoothEditable?: boolean
|
||||
|
|
@ -77,161 +86,33 @@ export type Organisation = {
|
|||
logoUrl?: string | null
|
||||
}
|
||||
|
||||
export type GetOrganisationData = {
|
||||
getOrganisation: Organisation | null
|
||||
}
|
||||
|
||||
export type GetOrganisationVariables = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type ChangeOrganisationData = {
|
||||
changeOrganisation: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ChangeOrganisationVariables = {
|
||||
id: string
|
||||
name: string
|
||||
bluetooth: ServiceUseOption
|
||||
bluetoothEditable: boolean
|
||||
camera: ComponentUseOption
|
||||
cameraEditable: boolean
|
||||
GPS: ServiceUseOption
|
||||
gpsEditable: boolean
|
||||
sim: ServiceUseOption
|
||||
simEditable: boolean
|
||||
offlineTimeThreshold: number
|
||||
}
|
||||
|
||||
export type ChangeOrganisationPolicyVariables = {
|
||||
id: string
|
||||
name: string
|
||||
bluetooth: ServiceUseOption
|
||||
bluetoothEditable: boolean
|
||||
camera: ComponentUseOption
|
||||
cameraEditable: boolean
|
||||
GPS: ServiceUseOption
|
||||
gpsEditable: boolean
|
||||
sim: ServiceUseOption
|
||||
simEditable: boolean
|
||||
offlineTimeThreshold: number
|
||||
}
|
||||
|
||||
export type GetUsersPageData = {
|
||||
getUsersPage: {
|
||||
page: Employee[]
|
||||
nextKey: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type GetUsersPageVariables = {
|
||||
key?: string
|
||||
}
|
||||
|
||||
export type GetOrganisationsData = {
|
||||
getOrganisations: {
|
||||
totalPages: number
|
||||
totalElements: number
|
||||
page: Organisation[]
|
||||
}
|
||||
}
|
||||
|
||||
export type GetOrganisationsVariables = {
|
||||
page: number
|
||||
query?: string
|
||||
sortDirection: OrganisationSortDirection
|
||||
sortField: OrganisationSortField
|
||||
}
|
||||
|
||||
export type DeleteOrganisationData = {
|
||||
deleteOrganisation: boolean
|
||||
}
|
||||
|
||||
export type DeleteOrganisationVariables = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type CreateUserData = {
|
||||
createUser: Employee
|
||||
}
|
||||
|
||||
export type CreateUserVariables = {
|
||||
orgId: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName: string
|
||||
username: string
|
||||
password: string
|
||||
role: EmployeeRole
|
||||
}
|
||||
|
||||
export type ChangeUserData = {
|
||||
changeUser: Employee
|
||||
}
|
||||
|
||||
export type ChangeUserVariables = {
|
||||
userId: string
|
||||
orgId: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName: string
|
||||
username: string
|
||||
role: EmployeeRole
|
||||
}
|
||||
|
||||
export type ChangeUserWithPasswordVariables = ChangeUserVariables & {
|
||||
password: string
|
||||
}
|
||||
|
||||
export type SignUpVariables = {
|
||||
orgId: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName: string
|
||||
username: string
|
||||
password: string
|
||||
role: EmployeeRole
|
||||
}
|
||||
|
||||
export type CreateOrganisationData = {
|
||||
createOrganisation: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateOrganisationVariables = {
|
||||
name: string
|
||||
bluetooth: ServiceUseOption
|
||||
bluetoothEditable: boolean
|
||||
camera: ComponentUseOption
|
||||
cameraEditable: boolean
|
||||
GPS: ServiceUseOption
|
||||
gpsEditable: boolean
|
||||
sim: ServiceUseOption
|
||||
simEditable: boolean
|
||||
offlineTimeThreshold: number
|
||||
}
|
||||
|
||||
export type OrganisationSortDirection = 'ASC' | 'DESC'
|
||||
|
||||
export type OrganisationSortField = 'ID' | 'Name' | 'Date'
|
||||
|
||||
export type CreateUploadUserAvatarUrlData = {
|
||||
createUploadUserAvatarUrl: string
|
||||
}
|
||||
|
||||
export type CreateUploadUserAvatarUrlVariables = {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export type CreateUploadOrganisationLogoUrlData = {
|
||||
createUploadOrganisationLogoUrl: string
|
||||
}
|
||||
|
||||
export type CreateUploadOrganisationLogoUrlVariables = {
|
||||
orgId: string
|
||||
}
|
||||
export type GetUsersData = GetUsersQuery
|
||||
export type GetUsersVariables = GetUsersQueryVariables
|
||||
export type GetOrganisationData = GetOrganisationQuery
|
||||
export type GetOrganisationVariables = GetOrganisationQueryVariables
|
||||
export type ChangeOrganisationData = ChangeOrganisationMutation
|
||||
export type ChangeOrganisationVariables = ChangeOrganisationMutationVariables
|
||||
export type ChangeOrganisationPolicyVariables =
|
||||
ChangeOrganisationPolicyMutationVariables
|
||||
export type GetOrganisationsData = GetOrganisationsQuery
|
||||
export type GetOrganisationsVariables = GetOrganisationsQueryVariables
|
||||
export type DeleteOrganisationData = DeleteOrganisationMutation
|
||||
export type DeleteOrganisationVariables = DeleteOrganisationMutationVariables
|
||||
export type CreateUserData = CreateUserMutation
|
||||
export type CreateUserVariables = CreateUserMutationVariables
|
||||
export type ChangeUserData = ChangeUserMutation
|
||||
export type ChangeUserVariables = ChangeUserMutationVariables
|
||||
export type ChangeUserWithPasswordVariables =
|
||||
ChangeUserWithPasswordMutationVariables
|
||||
export type CreateOrganisationData = CreateOrganisationMutation
|
||||
export type CreateOrganisationVariables = CreateOrganisationMutationVariables
|
||||
export type OrganisationSortDirection = SortDirection
|
||||
export type OrganisationSortField = GeneratedOrganisationSortField
|
||||
export type CreateUploadUserAvatarUrlData =
|
||||
CreateUploadUserAvatarUrlMutation
|
||||
export type CreateUploadUserAvatarUrlVariables =
|
||||
CreateUploadUserAvatarUrlMutationVariables
|
||||
export type CreateUploadOrganisationLogoUrlData =
|
||||
CreateUploadOrganisationLogoUrlMutation
|
||||
export type CreateUploadOrganisationLogoUrlVariables =
|
||||
CreateUploadOrganisationLogoUrlMutationVariables
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +1,5 @@
|
|||
import { gql } from '@apollo/client'
|
||||
|
||||
export const SIGN_IN_MUTATION = gql`
|
||||
mutation SignIn($username: String!, $password: String!) {
|
||||
signIn(username: $username, password: $password) {
|
||||
id
|
||||
role
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const REFRESH_SESSION_MUTATION = gql`
|
||||
mutation RefreshSession {
|
||||
refreshSession {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
middleName
|
||||
orgId
|
||||
role
|
||||
org {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CURRENT_USER_QUERY = gql`
|
||||
query CurrentUser {
|
||||
currentUser {
|
||||
avatarUrl
|
||||
id
|
||||
role
|
||||
firstName
|
||||
middleName
|
||||
lastName
|
||||
username
|
||||
org {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
export {
|
||||
SignInDocument as SIGN_IN_MUTATION,
|
||||
RefreshSessionDocument as REFRESH_SESSION_MUTATION,
|
||||
CurrentUserDocument as CURRENT_USER_QUERY,
|
||||
} from '../../../shared/api/generated/graphql'
|
||||
|
|
|
|||
|
|
@ -8,28 +8,6 @@ import {
|
|||
} from '../api/auth.graphql'
|
||||
import { LoginPage } from '../../../pages/LoginPage/LoginPage'
|
||||
|
||||
type CurrentUser = {
|
||||
id: string
|
||||
role: string
|
||||
avatarUrl: string
|
||||
firstName: string
|
||||
middleName: string
|
||||
lastName: string
|
||||
username: string
|
||||
org: {
|
||||
id: number
|
||||
name: string
|
||||
} | null
|
||||
}
|
||||
|
||||
type CurrentUserQueryData = {
|
||||
currentUser: CurrentUser | null
|
||||
}
|
||||
|
||||
type RefreshSessionData = {
|
||||
refreshSession: CurrentUser | null
|
||||
}
|
||||
|
||||
type AuthGateProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
|
@ -38,7 +16,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||
const [isForcedLogout, setIsForcedLogout] = useState(false)
|
||||
const [isRefreshFailed, setIsRefreshFailed] = useState(false)
|
||||
|
||||
const { data, loading, error, refetch } = useQuery<CurrentUserQueryData>(
|
||||
const { data, loading, error, refetch } = useQuery(
|
||||
CURRENT_USER_QUERY,
|
||||
{
|
||||
fetchPolicy: 'network-only',
|
||||
|
|
@ -47,7 +25,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||
)
|
||||
|
||||
const [refreshSession, { loading: isRefreshing }] =
|
||||
useMutation<RefreshSessionData>(REFRESH_SESSION_MUTATION, {
|
||||
useMutation(REFRESH_SESSION_MUTATION, {
|
||||
onCompleted: async (result) => {
|
||||
if (!result.refreshSession) {
|
||||
setIsRefreshFailed(true)
|
||||
|
|
@ -112,4 +90,4 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||
}
|
||||
|
||||
return children
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,18 @@ html{
|
|||
background: $color-bg;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
html.theme-transition-no-css-transitions *,
|
||||
html.theme-transition-no-css-transitions *::before,
|
||||
html.theme-transition-no-css-transitions *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
button{
|
||||
font-family: inherit;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,35 @@
|
|||
padding: 20px 20px 36px 20px;
|
||||
}
|
||||
|
||||
.device-page--apps {
|
||||
flex: 1 1 0;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow: visible;
|
||||
|
||||
.device-page__motion-view {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.device-page__apps-motion {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-apps-view {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.device-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 340px 340px minmax(360px, 1fr);
|
||||
|
|
@ -30,7 +59,7 @@
|
|||
|
||||
.device-card {
|
||||
border-radius: 20px;
|
||||
background: rgba($color-surface-rgb, .5);
|
||||
background: $color-surface;
|
||||
backdrop-filter: blur(22px);
|
||||
padding: 20px;
|
||||
box-shadow: $shadow-card;
|
||||
|
|
@ -116,35 +145,153 @@
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
gap: 2px;
|
||||
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-18;
|
||||
font-weight: 500;
|
||||
font-size: $font-size-14;
|
||||
font-weight: 550;
|
||||
text-transform: uppercase;
|
||||
|
||||
padding-right: 12px;
|
||||
border-radius: 12px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
|
||||
svg {
|
||||
padding: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 12px;
|
||||
background: $color-bg;
|
||||
}
|
||||
|
||||
&.is-success svg {
|
||||
&.is-success {
|
||||
color: $green;
|
||||
background: $color-success-bg;
|
||||
background-color: var(--color-success-bg);
|
||||
|
||||
svg {
|
||||
color: $green;
|
||||
background: $color-success-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-danger svg {
|
||||
color: $red;
|
||||
&.is-danger {
|
||||
background: $color-danger-bg;
|
||||
color: $red;
|
||||
|
||||
svg {
|
||||
color: $red;
|
||||
background: $color-danger-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-muted svg {
|
||||
&.is-muted {
|
||||
background: var(--color-bg);
|
||||
color: $gray50;
|
||||
|
||||
svg {
|
||||
color: $gray50;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-service {
|
||||
background: $color-primary-muted;
|
||||
color: $blue;
|
||||
|
||||
svg {
|
||||
color: $blue;
|
||||
background: $color-primary-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.device-status--interactive {
|
||||
border: 0;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.device-status-menu__trigger {
|
||||
width: auto;
|
||||
aspect-ratio: 1/1;
|
||||
height: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: $gray50;
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
|
||||
&:hover,
|
||||
&[data-state='open'] {
|
||||
background: $color-bg;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.device-status-menu {
|
||||
z-index: 600;
|
||||
min-width: 228px;
|
||||
border: 1px solid $color-border-muted;
|
||||
border-radius: 12px;
|
||||
background: $color-surface;
|
||||
padding: 6px;
|
||||
box-shadow: $shadow-popover;
|
||||
animation: device-status-menu-in 0.16s ease;
|
||||
}
|
||||
|
||||
.device-status-menu__item {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
outline: none;
|
||||
color: $color-text-strong;
|
||||
font-size: $font-size-13;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
color: $gray50;
|
||||
}
|
||||
|
||||
&[data-highlighted] {
|
||||
background: $color-surface-hover;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes device-status-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -744,7 +891,8 @@
|
|||
}
|
||||
|
||||
.device-page__grid {
|
||||
grid-template-columns: 540px minmax(360px, 1fr);;
|
||||
grid-template-columns: 540px minmax(360px, 1fr);
|
||||
;
|
||||
}
|
||||
|
||||
.device-card--main {
|
||||
|
|
@ -761,19 +909,23 @@
|
|||
height: calc(100% - 36px);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.device-card-stats{
|
||||
|
||||
.device-card-stats {
|
||||
height: calc(100%);
|
||||
}
|
||||
.device-impacts{
|
||||
|
||||
.device-impacts {
|
||||
flex: 0;
|
||||
}
|
||||
.device-battery{
|
||||
|
||||
.device-battery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
}
|
||||
.device-permissions{
|
||||
|
||||
.device-permissions {
|
||||
height: calc(100% - 12px);
|
||||
padding: 8px 8px 4px 18px;
|
||||
}
|
||||
|
|
@ -789,22 +941,27 @@
|
|||
.device-map {
|
||||
min-height: 252px;
|
||||
}
|
||||
.history-period-tabs{
|
||||
|
||||
.history-period-tabs {
|
||||
display: none;
|
||||
}
|
||||
.device-card__title{
|
||||
|
||||
.device-card__title {
|
||||
gap: 8px;
|
||||
svg{
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
h3{
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-16);
|
||||
}
|
||||
}
|
||||
.device-card__header{
|
||||
p{
|
||||
font-size: var(--font-size-14);
|
||||
|
||||
.device-card__header {
|
||||
p {
|
||||
font-size: var(--font-size-14);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
|
|
@ -21,6 +22,7 @@ import { GET_DEVICE_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
|||
import type {
|
||||
Device as ApiDevice,
|
||||
DeviceNetworkStatus,
|
||||
DeviceTechnicalStatus,
|
||||
GetPhoneGpsTrackData,
|
||||
GetTelemetryData,
|
||||
GetDevicePageData,
|
||||
|
|
@ -42,8 +44,10 @@ import { DevicePageSkeleton } from './components/DevicePageSkeleton/DevicePageSk
|
|||
import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout'
|
||||
import {
|
||||
preloadOnIdle,
|
||||
useDeferredModalPayload,
|
||||
useLazyMount,
|
||||
} from '../../shared/lib/lazyMount'
|
||||
import { isPresent } from '../../shared/lib/isPresent'
|
||||
|
||||
const loadDeviceHistoryModal = () =>
|
||||
import('./components/DeviceHistoryModal/DeviceHistoryModal').then(
|
||||
|
|
@ -54,6 +58,26 @@ const loadDeviceHistoryModal = () =>
|
|||
|
||||
const DeviceHistoryModal = lazy(loadDeviceHistoryModal)
|
||||
|
||||
const loadDeviceMaintenanceModal = () =>
|
||||
import('./components/DeviceMaintenanceModal/DeviceMaintenanceModal').then(
|
||||
(module) => ({
|
||||
default: module.DeviceMaintenanceModal,
|
||||
}),
|
||||
)
|
||||
|
||||
const DeviceMaintenanceModal = lazy(loadDeviceMaintenanceModal)
|
||||
|
||||
const loadDeviceMaintenanceHistoryModal = () =>
|
||||
import(
|
||||
'./components/DeviceMaintenanceHistoryModal/DeviceMaintenanceHistoryModal'
|
||||
).then((module) => ({
|
||||
default: module.DeviceMaintenanceHistoryModal,
|
||||
}))
|
||||
|
||||
const DeviceMaintenanceHistoryModal = lazy(
|
||||
loadDeviceMaintenanceHistoryModal,
|
||||
)
|
||||
|
||||
function formatLocationDate(timestamp: number) {
|
||||
if (!timestamp) return 'Нет данных'
|
||||
|
||||
|
|
@ -77,7 +101,7 @@ function getLatestTelemetryItem(telemetry: GetTelemetryData['getTelemetry']) {
|
|||
}
|
||||
|
||||
function getSortedGpsTrack(track: GetPhoneGpsTrackData['getPhoneGpsTrack']) {
|
||||
return [...track].sort((a, b) => a.date - b.date)
|
||||
return track.filter(isPresent).sort((a, b) => a.date - b.date)
|
||||
}
|
||||
|
||||
function getDeviceNetworkConnection(status?: DeviceNetworkStatus) {
|
||||
|
|
@ -94,6 +118,13 @@ function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
|
|||
return 'Не в сети'
|
||||
}
|
||||
|
||||
function getDeviceCondition(status?: DeviceTechnicalStatus) {
|
||||
if (status === 'InService') return 'service'
|
||||
if (status === 'NeedsMaintenance') return 'inspection'
|
||||
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function mapApiDeviceToPageDevice(
|
||||
device: ApiDevice,
|
||||
batteryLevel?: number,
|
||||
|
|
@ -103,7 +134,6 @@ function mapApiDeviceToPageDevice(
|
|||
const sortedGpsTrack = getSortedGpsTrack(gpsTrack)
|
||||
const policy = device.policy
|
||||
const techState = device.techState
|
||||
const needMaintenance = techState?.needMaintenance ?? false
|
||||
const bluetoothEnabled = isPhonePolicyOptionEnabled(
|
||||
policy?.bluetooth ?? policy?.canUseBluetooth,
|
||||
)
|
||||
|
|
@ -157,7 +187,10 @@ function mapApiDeviceToPageDevice(
|
|||
policy,
|
||||
organisationPolicy: device.org?.policy ?? null,
|
||||
|
||||
condition: needMaintenance ? 'inspection' : 'ok',
|
||||
condition: getDeviceCondition(techState?.status),
|
||||
technicalStatus: techState?.status ?? 'Healthy',
|
||||
malfunctions: techState?.malfunctions ?? [],
|
||||
malfunctionComment: techState?.malfunctionComment,
|
||||
connection: getDeviceNetworkConnection(device.networkStatus),
|
||||
connectionText: getDeviceNetworkLabel(device.networkStatus),
|
||||
|
||||
|
|
@ -217,12 +250,35 @@ export function DevicePage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||
const shouldMountHistoryModal = useLazyMount(isHistoryOpen)
|
||||
const [maintenanceMode, setMaintenanceMode] = useState<
|
||||
'view' | 'manage' | null
|
||||
>(null)
|
||||
const renderMaintenanceMode = useDeferredModalPayload(maintenanceMode)
|
||||
const shouldMountMaintenanceModal = useLazyMount(maintenanceMode !== null)
|
||||
const [isMaintenanceHistoryOpen, setIsMaintenanceHistoryOpen] =
|
||||
useState(false)
|
||||
const shouldMountMaintenanceHistoryModal = useLazyMount(
|
||||
isMaintenanceHistoryOpen,
|
||||
)
|
||||
const maintenanceHistoryTimerRef = useRef<number | null>(null)
|
||||
|
||||
const numericDeviceId = Number(deviceId)
|
||||
const isAppsView = searchParams.get('view') === 'apps'
|
||||
|
||||
useEffect(() => {
|
||||
return preloadOnIdle([loadDeviceHistoryModal])
|
||||
return preloadOnIdle([
|
||||
loadDeviceHistoryModal,
|
||||
loadDeviceMaintenanceModal,
|
||||
loadDeviceMaintenanceHistoryModal,
|
||||
])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (maintenanceHistoryTimerRef.current !== null) {
|
||||
window.clearTimeout(maintenanceHistoryTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const {
|
||||
|
|
@ -315,6 +371,35 @@ export function DevicePage() {
|
|||
setIsHistoryOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleOpenMaintenanceDetails = useCallback(() => {
|
||||
setMaintenanceMode('view')
|
||||
}, [])
|
||||
|
||||
const handleManageMaintenance = useCallback(() => {
|
||||
setMaintenanceMode('manage')
|
||||
}, [])
|
||||
|
||||
const handleOpenMaintenanceHistory = useCallback(() => {
|
||||
setMaintenanceMode(null)
|
||||
|
||||
if (maintenanceHistoryTimerRef.current !== null) {
|
||||
window.clearTimeout(maintenanceHistoryTimerRef.current)
|
||||
}
|
||||
|
||||
maintenanceHistoryTimerRef.current = window.setTimeout(() => {
|
||||
setIsMaintenanceHistoryOpen(true)
|
||||
maintenanceHistoryTimerRef.current = null
|
||||
}, 220)
|
||||
}, [])
|
||||
|
||||
const handleOpenMaintenanceHistoryFromCard = useCallback(() => {
|
||||
setIsMaintenanceHistoryOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleMaintenanceUpdated = useCallback(async () => {
|
||||
await refetch()
|
||||
}, [refetch])
|
||||
|
||||
const handleRefetchPolicy = useCallback(async () => {
|
||||
const result = await refetch()
|
||||
|
||||
|
|
@ -372,12 +457,12 @@ export function DevicePage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<section className="device-page">
|
||||
<section className={`device-page ${isAppsView ? 'device-page--apps' : ''}`}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isAppsView ? (
|
||||
<m.div
|
||||
key="apps"
|
||||
className="device-page__motion-view"
|
||||
className="device-page__motion-view device-page__apps-motion"
|
||||
variants={pageSwapVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
|
|
@ -404,6 +489,9 @@ export function DevicePage() {
|
|||
<DeviceMainCard
|
||||
device={device}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
onOpenMaintenanceDetails={handleOpenMaintenanceDetails}
|
||||
onManageMaintenance={handleManageMaintenance}
|
||||
onOpenMaintenanceHistory={handleOpenMaintenanceHistoryFromCard}
|
||||
/>
|
||||
<DeviceMapCard device={device} />
|
||||
|
||||
|
|
@ -433,6 +521,29 @@ export function DevicePage() {
|
|||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{shouldMountMaintenanceModal && renderMaintenanceMode && (
|
||||
<Suspense fallback={null}>
|
||||
<DeviceMaintenanceModal
|
||||
open={maintenanceMode !== null}
|
||||
mode={renderMaintenanceMode}
|
||||
device={device}
|
||||
onOpenHistory={handleOpenMaintenanceHistory}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setMaintenanceMode(null)
|
||||
}}
|
||||
onUpdated={handleMaintenanceUpdated}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{shouldMountMaintenanceHistoryModal && (
|
||||
<Suspense fallback={null}>
|
||||
<DeviceMaintenanceHistoryModal
|
||||
open={isMaintenanceHistoryOpen}
|
||||
device={device}
|
||||
onOpenChange={setIsMaintenanceHistoryOpen}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
@use '../../../../shared/styles/variables' as *;
|
||||
|
||||
.device-apps-view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
|
|
@ -237,16 +240,43 @@
|
|||
}
|
||||
|
||||
.device-apps-view__panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
flex: 1 1 0;
|
||||
border-radius: 20px;
|
||||
background: $color-surface;
|
||||
box-shadow: 5px 5px 40px -10px rgba($gray50-rgb, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-apps-view__scroll {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 20px;
|
||||
background: $color-surface;
|
||||
|
||||
.simplebar-wrapper,
|
||||
.simplebar-mask,
|
||||
.simplebar-offset,
|
||||
.simplebar-content-wrapper {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.simplebar-content-wrapper {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.simplebar-content {
|
||||
min-height: 100%;
|
||||
|
|
@ -254,18 +284,29 @@
|
|||
|
||||
.simplebar-track.simplebar-vertical {
|
||||
top: 14px;
|
||||
right: 6px;
|
||||
right: -7px;
|
||||
bottom: 14px;
|
||||
width: 8px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.simplebar-scrollbar::before {
|
||||
background: $gray50;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--gray50) 85%, transparent);
|
||||
border-radius: 6px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.device-apps-view__reveal {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
|
||||
.reveal-content__fallback,
|
||||
.reveal-content__content {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.device-apps-view__state {
|
||||
min-height: 220px;
|
||||
padding: 24px;
|
||||
|
|
|
|||
|
|
@ -483,7 +483,11 @@ export function DeviceAppsView({
|
|||
|
||||
<div className="device-apps-view__panel">
|
||||
<SimpleBar className="device-apps-view__scroll">
|
||||
<RevealContent loading={loading} fallback={<ListSkeleton />}>
|
||||
<RevealContent
|
||||
className="device-apps-view__reveal"
|
||||
loading={loading}
|
||||
fallback={<ListSkeleton />}
|
||||
>
|
||||
{error && (
|
||||
<div className="device-apps-view__state is-error">
|
||||
Не удалось загрузить список приложений
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
DevicePackageUseEventsGroup,
|
||||
DevicePhoneStateEventType
|
||||
} from '../../../../entities/device/model/types'
|
||||
import { isPresent } from '../../../../shared/lib/isPresent'
|
||||
|
||||
import {
|
||||
DeviceHistoryPeriodControl,
|
||||
|
|
@ -194,7 +195,7 @@ export function DeviceHistoryModal({
|
|||
return [
|
||||
...telemetryData.getTelemetry,
|
||||
|
||||
...telemetryData.getPhoneStateEvents.map(
|
||||
...telemetryData.getPhoneStateEvents.filter(isPresent).map(
|
||||
(event): DeviceHistoryChartItem => ({
|
||||
date: event.date,
|
||||
type: 'event',
|
||||
|
|
|
|||
|
|
@ -1,23 +1,35 @@
|
|||
import { memo, useState } from 'react'
|
||||
import { AnimatePresence, m, useReducedMotion } from 'framer-motion'
|
||||
import { Link } from 'react-router-dom'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import {
|
||||
Building2,
|
||||
CircleAlert,
|
||||
Eye,
|
||||
History,
|
||||
Lock,
|
||||
Menu,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
Signal,
|
||||
Smartphone,
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { Device } from '../../types'
|
||||
import { conditionText, connectionText, getStatusClass } from '../../types'
|
||||
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
||||
import { useDeferredModalPayload } from '../../../../shared/lib/lazyMount'
|
||||
import { getDeviceMalfunctionSummary } from '../../../../entities/device/lib/maintenance'
|
||||
import { Tooltip } from '../../../../shared/ui/Tooltip/Tooltip'
|
||||
|
||||
type DeviceMainCardProps = {
|
||||
device: Device
|
||||
onOpenHistory: () => void
|
||||
onOpenMaintenanceDetails: () => void
|
||||
onManageMaintenance: () => void
|
||||
onOpenMaintenanceHistory: () => void
|
||||
}
|
||||
|
||||
function getDeviceFullName(device: Device) {
|
||||
|
|
@ -30,6 +42,9 @@ function areDeviceMainCardPropsEqual(
|
|||
) {
|
||||
return (
|
||||
prev.onOpenHistory === next.onOpenHistory &&
|
||||
prev.onOpenMaintenanceDetails === next.onOpenMaintenanceDetails &&
|
||||
prev.onManageMaintenance === next.onManageMaintenance &&
|
||||
prev.onOpenMaintenanceHistory === next.onOpenMaintenanceHistory &&
|
||||
prev.device.id === next.device.id &&
|
||||
prev.device.image === next.device.image &&
|
||||
prev.device.model === next.device.model &&
|
||||
|
|
@ -38,6 +53,9 @@ function areDeviceMainCardPropsEqual(
|
|||
prev.device.imei === next.device.imei &&
|
||||
prev.device.imei2 === next.device.imei2 &&
|
||||
prev.device.condition === next.device.condition &&
|
||||
prev.device.technicalStatus === next.device.technicalStatus &&
|
||||
prev.device.malfunctionComment === next.device.malfunctionComment &&
|
||||
prev.device.malfunctions.join(',') === next.device.malfunctions.join(',') &&
|
||||
prev.device.connection === next.device.connection &&
|
||||
prev.device.connectionText === next.device.connectionText &&
|
||||
prev.device.permissions?.locked === next.device.permissions?.locked &&
|
||||
|
|
@ -51,6 +69,9 @@ function areDeviceMainCardPropsEqual(
|
|||
export const DeviceMainCard = memo(function DeviceMainCard({
|
||||
device,
|
||||
onOpenHistory,
|
||||
onOpenMaintenanceDetails,
|
||||
onManageMaintenance,
|
||||
onOpenMaintenanceHistory,
|
||||
}: DeviceMainCardProps) {
|
||||
const [deletingDevice, setDeletingDevice] = useState<Device | null>(null)
|
||||
const renderDeletingDevice = useDeferredModalPayload(deletingDevice)
|
||||
|
|
@ -61,12 +82,32 @@ export const DeviceMainCard = memo(function DeviceMainCard({
|
|||
duration: 0.24,
|
||||
ease: [0.34, 1.42, 0.64, 1] as [number, number, number, number],
|
||||
}
|
||||
const statusItems = [
|
||||
const statusItems: Array<{
|
||||
key: string
|
||||
className: string
|
||||
icon: React.ReactNode
|
||||
content: string
|
||||
title?: string
|
||||
isTechnical?: boolean
|
||||
}> = [
|
||||
{
|
||||
key: `condition-${device.condition}`,
|
||||
className: getStatusClass(device.condition),
|
||||
icon: <ShieldCheck size={17} />,
|
||||
icon:
|
||||
device.condition === 'ok' ? (
|
||||
<ShieldCheck size={17} />
|
||||
) : device.condition === 'service' ? (
|
||||
<Wrench size={17} />
|
||||
) : (
|
||||
<CircleAlert size={17} />
|
||||
),
|
||||
content: conditionText[device.condition],
|
||||
title:
|
||||
getDeviceMalfunctionSummary(
|
||||
device.malfunctions,
|
||||
device.malfunctionComment,
|
||||
) || undefined,
|
||||
isTechnical: true,
|
||||
},
|
||||
{
|
||||
key: `connection-${device.connection}-${device.connectionText ?? ''}`,
|
||||
|
|
@ -133,7 +174,7 @@ export const DeviceMainCard = memo(function DeviceMainCard({
|
|||
<m.div
|
||||
key={status.key}
|
||||
layout={!shouldReduceMotion}
|
||||
className={`device-status ${status.className}`}
|
||||
className="device-status-row"
|
||||
initial={{ opacity: 0, x: shouldReduceMotion ? 0 : -8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
|
|
@ -149,8 +190,67 @@ export const DeviceMainCard = memo(function DeviceMainCard({
|
|||
transition: statusTransition,
|
||||
}}
|
||||
>
|
||||
{status.icon}
|
||||
{status.content}
|
||||
<Tooltip content={status.title}>
|
||||
{status.isTechnical ? (
|
||||
<button
|
||||
className={`device-status device-status--interactive ${status.className}`}
|
||||
type="button"
|
||||
onClick={onOpenMaintenanceDetails}
|
||||
>
|
||||
{status.icon}
|
||||
{status.content}
|
||||
</button>
|
||||
) : (
|
||||
<span className={`device-status ${status.className}`}>
|
||||
{status.icon}
|
||||
{status.content}
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{status.isTechnical && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
className="device-status-menu__trigger"
|
||||
type="button"
|
||||
aria-label="Действия с техническим состоянием"
|
||||
>
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="device-status-menu"
|
||||
align="start"
|
||||
sideOffset={7}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
className="device-status-menu__item"
|
||||
onSelect={onOpenMaintenanceDetails}
|
||||
>
|
||||
<Eye size={16} />
|
||||
Просмотреть состояние
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="device-status-menu__item"
|
||||
onSelect={onManageMaintenance}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Изменить состояние
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="device-status-menu__item"
|
||||
onSelect={onOpenMaintenanceHistory}
|
||||
>
|
||||
<History size={16} />
|
||||
Журнал обслуживания
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
</m.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
GetPhoneGpsTrackData,
|
||||
GetPhoneGpsTrackVariables,
|
||||
} from '../../../../entities/device/model/types'
|
||||
import { isPresent } from '../../../../shared/lib/isPresent'
|
||||
import { FullscreenControl } from '../../../../widgets/FullscreenControlLeaflet/FullscreenControl'
|
||||
import {
|
||||
MapTrackPeriodControl,
|
||||
|
|
@ -249,7 +250,9 @@ export function DeviceMapCard({
|
|||
})
|
||||
|
||||
const sortedGpsTrack = useMemo(() => {
|
||||
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
||||
return sortGpsTrack(
|
||||
(gpsTrackData?.getPhoneGpsTrack ?? []).filter(isPresent),
|
||||
)
|
||||
}, [gpsTrackData])
|
||||
|
||||
const telemetry = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import type {
|
||||
DeviceMalfunction,
|
||||
DevicePolicy,
|
||||
DeviceTelemetryItem,
|
||||
DeviceTechnicalStatus,
|
||||
GroupUsePolicy,
|
||||
} from '../../entities/device/model/types'
|
||||
|
||||
export type DeviceCondition = 'ok' | 'inspection'
|
||||
export type DeviceCondition = 'ok' | 'inspection' | 'service'
|
||||
export type DeviceConnection = 'online' | 'offline' | 'offlineDanger'
|
||||
|
||||
export type Device = {
|
||||
|
|
@ -21,6 +23,9 @@ export type Device = {
|
|||
workTime: string | null
|
||||
employee: string | null
|
||||
condition: DeviceCondition
|
||||
technicalStatus: DeviceTechnicalStatus
|
||||
malfunctions: DeviceMalfunction[]
|
||||
malfunctionComment?: string | null
|
||||
connection: DeviceConnection
|
||||
connectionText: string
|
||||
registeredAt?: string
|
||||
|
|
@ -67,6 +72,7 @@ export type Device = {
|
|||
export const conditionText: Record<DeviceCondition, string> = {
|
||||
ok: 'Исправно',
|
||||
inspection: 'Требует ТО',
|
||||
service: 'На обслуживании',
|
||||
}
|
||||
|
||||
export const connectionText: Record<DeviceConnection, string> = {
|
||||
|
|
@ -78,6 +84,7 @@ export const connectionText: Record<DeviceConnection, string> = {
|
|||
export function getStatusClass(status: DeviceCondition | DeviceConnection) {
|
||||
if (status === 'ok' || status === 'online') return 'is-success'
|
||||
if (status === 'inspection' || status === 'offlineDanger') return 'is-danger'
|
||||
if (status === 'service') return 'is-service'
|
||||
|
||||
return 'is-muted'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -387,6 +387,11 @@
|
|||
background-color: $color-danger-status-bg;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background-color: $color-primary-muted;
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
.devices-dot {
|
||||
|
|
@ -408,6 +413,10 @@
|
|||
background: $gray50;
|
||||
}
|
||||
|
||||
.devices-dot--blue {
|
||||
background: $blue;
|
||||
}
|
||||
|
||||
.device-icons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 20px);
|
||||
|
|
@ -514,7 +523,7 @@
|
|||
}
|
||||
|
||||
.device-icons svg:not(.is-active):not(.is-danger) {
|
||||
background-color: $color-surface-active;
|
||||
background-color: rgba($gray50-rgb, .15);
|
||||
}
|
||||
|
||||
.devices-map-btn:hover {
|
||||
|
|
|
|||
|
|
@ -42,9 +42,14 @@ import { GET_PHONES_QUERY } from '../../entities/device/api/device.graphql'
|
|||
|
||||
import type {
|
||||
DeviceNetworkStatus,
|
||||
DeviceTechnicalStatus,
|
||||
GetPhonesData,
|
||||
GetPhonesVariables,
|
||||
} from '../../entities/device/model/types'
|
||||
import {
|
||||
getDeviceMalfunctionSummary,
|
||||
deviceTechnicalStatusLabels,
|
||||
} from '../../entities/device/lib/maintenance'
|
||||
|
||||
import type { Device as PageDevice } from '../DevicePage/types'
|
||||
import type { Device as ApiDevice } from '../../entities/device/model/types'
|
||||
|
|
@ -58,6 +63,7 @@ import {
|
|||
import { RevealContent } from '../../shared/ui/RevealContent/RevealContent'
|
||||
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
|
||||
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
|
||||
import { Tooltip } from '../../shared/ui/Tooltip/Tooltip'
|
||||
import {
|
||||
preloadOnIdle,
|
||||
useLazyMount,
|
||||
|
|
@ -95,6 +101,12 @@ const DEVICE_NETWORK_STATUSES: DeviceNetworkStatus[] = [
|
|||
'Lost',
|
||||
]
|
||||
|
||||
const DEVICE_TECHNICAL_STATUSES: DeviceTechnicalStatus[] = [
|
||||
'Healthy',
|
||||
'NeedsMaintenance',
|
||||
'InService',
|
||||
]
|
||||
|
||||
const FILTERS_DRAWER_MEDIA_QUERY = '(max-width: 1600px)'
|
||||
|
||||
function getIsFiltersDrawerViewport() {
|
||||
|
|
@ -109,6 +121,12 @@ function isDeviceNetworkStatus(
|
|||
return DEVICE_NETWORK_STATUSES.includes(value as DeviceNetworkStatus)
|
||||
}
|
||||
|
||||
function isDeviceTechnicalStatus(
|
||||
value: string,
|
||||
): value is DeviceTechnicalStatus {
|
||||
return DEVICE_TECHNICAL_STATUSES.includes(value as DeviceTechnicalStatus)
|
||||
}
|
||||
|
||||
function formatDateTime(timestamp: number) {
|
||||
if (!timestamp) return 'Нет данных'
|
||||
|
||||
|
|
@ -121,16 +139,22 @@ function formatDateTime(timestamp: number) {
|
|||
}).format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function getDeviceConditionLabel(needMaintenance?: boolean) {
|
||||
return needMaintenance ? 'Требует ТО' : 'Исправно'
|
||||
function getDeviceConditionLabel(status?: DeviceTechnicalStatus) {
|
||||
return deviceTechnicalStatusLabels[status ?? 'Healthy']
|
||||
}
|
||||
|
||||
function getDeviceConditionClass(needMaintenance?: boolean) {
|
||||
return needMaintenance ? 'devices-status--red' : 'devices-status--green'
|
||||
function getDeviceConditionClass(status?: DeviceTechnicalStatus) {
|
||||
if (status === 'NeedsMaintenance') return 'devices-status--red'
|
||||
if (status === 'InService') return 'devices-status--blue'
|
||||
|
||||
return 'devices-status--green'
|
||||
}
|
||||
|
||||
function getDeviceConditionDotClass(needMaintenance?: boolean) {
|
||||
return needMaintenance ? 'devices-dot--red' : 'devices-dot--green'
|
||||
function getDeviceConditionDotClass(status?: DeviceTechnicalStatus) {
|
||||
if (status === 'NeedsMaintenance') return 'devices-dot--red'
|
||||
if (status === 'InService') return 'devices-dot--blue'
|
||||
|
||||
return 'devices-dot--green'
|
||||
}
|
||||
|
||||
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
|
||||
|
|
@ -215,19 +239,26 @@ const DevicesTableRow = memo(function DevicesTableRow({
|
|||
</td>
|
||||
|
||||
<td>
|
||||
<div
|
||||
className={`devices-status ${getDeviceConditionClass(
|
||||
device.techState?.needMaintenance,
|
||||
)}`}
|
||||
<Tooltip
|
||||
content={getDeviceMalfunctionSummary(
|
||||
device.techState?.malfunctions,
|
||||
device.techState?.malfunctionComment,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={`devices-dot ${getDeviceConditionDotClass(
|
||||
device.techState?.needMaintenance,
|
||||
className={`devices-status ${getDeviceConditionClass(
|
||||
device.techState?.status,
|
||||
)}`}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
className={`devices-dot ${getDeviceConditionDotClass(
|
||||
device.techState?.status,
|
||||
)}`}
|
||||
/>
|
||||
|
||||
{getDeviceConditionLabel(device.techState?.needMaintenance)}
|
||||
</div>
|
||||
{getDeviceConditionLabel(device.techState?.status)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
|
@ -396,11 +427,9 @@ export function DevicesPage() {
|
|||
|
||||
const selectedNetworkStatuses = useMemo(
|
||||
() =>
|
||||
networkStatusParam === 'none'
|
||||
? []
|
||||
: networkStatusParam
|
||||
?.split(',')
|
||||
.filter(isDeviceNetworkStatus) ?? [],
|
||||
networkStatusParam
|
||||
?.split(',')
|
||||
.filter(isDeviceNetworkStatus) ?? [],
|
||||
[networkStatusParam],
|
||||
)
|
||||
|
||||
|
|
@ -412,6 +441,15 @@ export function DevicesPage() {
|
|||
? false
|
||||
: undefined
|
||||
|
||||
const technicalStatusParam = searchParams.get('dTechnicalStatus')
|
||||
const selectedTechnicalStatuses = useMemo(
|
||||
() =>
|
||||
technicalStatusParam
|
||||
?.split(',')
|
||||
.filter(isDeviceTechnicalStatus) ?? [],
|
||||
[technicalStatusParam],
|
||||
)
|
||||
|
||||
const lockedParam = searchParams.get('dLocked')
|
||||
const locked =
|
||||
lockedParam === 'true'
|
||||
|
|
@ -488,21 +526,26 @@ export function DevicesPage() {
|
|||
: undefined,
|
||||
locked,
|
||||
networkStatus:
|
||||
networkStatusParam
|
||||
selectedNetworkStatuses.length > 0
|
||||
? selectedNetworkStatuses
|
||||
: undefined,
|
||||
needMaintenance,
|
||||
technicalStatus:
|
||||
selectedTechnicalStatuses.length > 0
|
||||
? selectedTechnicalStatuses
|
||||
: undefined,
|
||||
}),
|
||||
[
|
||||
currentPage,
|
||||
debouncedDeviceSearch,
|
||||
locked,
|
||||
needMaintenance,
|
||||
networkStatusParam,
|
||||
selectedTechnicalStatuses,
|
||||
selectedNetworkStatuses,
|
||||
selectedOrganisationIds,
|
||||
sortDirection,
|
||||
sortField,
|
||||
technicalStatusParam,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -534,8 +577,9 @@ export function DevicesPage() {
|
|||
const hasAppliedFilters = Boolean(
|
||||
debouncedDeviceSearch ||
|
||||
selectedOrganisationIds.length > 0 ||
|
||||
networkStatusParam ||
|
||||
selectedNetworkStatuses.length > 0 ||
|
||||
needMaintenance !== undefined ||
|
||||
selectedTechnicalStatuses.length > 0 ||
|
||||
locked !== undefined,
|
||||
)
|
||||
const shouldShowEmptyState = !loading && !error && !hasDevices
|
||||
|
|
@ -628,8 +672,7 @@ export function DevicesPage() {
|
|||
)
|
||||
const isLocked = policy?.locked ?? false
|
||||
|
||||
const needMaintenance =
|
||||
device.techState?.needMaintenance ?? false
|
||||
const technicalStatus = device.techState?.status ?? 'Healthy'
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
|
|
@ -647,9 +690,15 @@ export function DevicesPage() {
|
|||
policy,
|
||||
organisationPolicy: device.org?.policy ?? null,
|
||||
|
||||
condition: needMaintenance
|
||||
? 'inspection'
|
||||
: 'ok',
|
||||
condition:
|
||||
technicalStatus === 'InService'
|
||||
? 'service'
|
||||
: technicalStatus === 'NeedsMaintenance'
|
||||
? 'inspection'
|
||||
: 'ok',
|
||||
technicalStatus,
|
||||
malfunctions: device.techState?.malfunctions ?? [],
|
||||
malfunctionComment: device.techState?.malfunctionComment,
|
||||
|
||||
connection: getDeviceNetworkConnection(device.networkStatus),
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import * as Accordion from '@radix-ui/react-accordion'
|
|||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
import type { DeviceNetworkStatus } from '../../../../entities/device/model/types'
|
||||
import type {
|
||||
DeviceNetworkStatus,
|
||||
DeviceTechnicalStatus,
|
||||
} from '../../../../entities/device/model/types'
|
||||
import {
|
||||
DevicesDateRangePicker,
|
||||
type DevicesDateRangePickerValue,
|
||||
|
|
@ -31,21 +34,32 @@ const networkStatusOptions: {
|
|||
},
|
||||
]
|
||||
|
||||
const maintenanceOptions = [
|
||||
const technicalStatusOptions: Array<{
|
||||
value: DeviceTechnicalStatus
|
||||
label: string
|
||||
}> = [
|
||||
{
|
||||
value: false,
|
||||
value: 'Healthy',
|
||||
label: 'Исправно',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
value: 'NeedsMaintenance',
|
||||
label: 'Требует ТО',
|
||||
},
|
||||
{
|
||||
value: 'InService',
|
||||
label: 'На обслуживании',
|
||||
},
|
||||
]
|
||||
|
||||
function isDeviceNetworkStatus(value: string): value is DeviceNetworkStatus {
|
||||
return networkStatusOptions.some((option) => option.value === value)
|
||||
}
|
||||
|
||||
function isDeviceTechnicalStatus(value: string): value is DeviceTechnicalStatus {
|
||||
return technicalStatusOptions.some((option) => option.value === value)
|
||||
}
|
||||
|
||||
type DevicesFiltersPanelProps = {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
|
@ -63,24 +77,31 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
|
|||
const networkStatusParam = searchParams.get('dNetwork')
|
||||
const selectedNetworkStatuses = useMemo<DeviceNetworkStatus[]>(
|
||||
() =>
|
||||
networkStatusParam === 'none'
|
||||
? []
|
||||
: networkStatusParam
|
||||
?.split(',')
|
||||
.filter(isDeviceNetworkStatus) ??
|
||||
networkStatusOptions.map((option) => option.value),
|
||||
networkStatusParam
|
||||
?.split(',')
|
||||
.filter(isDeviceNetworkStatus) ?? [],
|
||||
[networkStatusParam],
|
||||
)
|
||||
|
||||
const technicalStatusParam = searchParams.get('dTechnicalStatus')
|
||||
const needMaintenanceParam = searchParams.get('dNeedMaintenance')
|
||||
const selectedMaintenanceStates = useMemo(
|
||||
() =>
|
||||
needMaintenanceParam === 'true'
|
||||
? [true]
|
||||
: needMaintenanceParam === 'false'
|
||||
? [false]
|
||||
: maintenanceOptions.map((option) => option.value),
|
||||
[needMaintenanceParam],
|
||||
const selectedTechnicalStatuses = useMemo<DeviceTechnicalStatus[]>(
|
||||
() => {
|
||||
if (technicalStatusParam) {
|
||||
return technicalStatusParam
|
||||
.split(',')
|
||||
.filter(isDeviceTechnicalStatus)
|
||||
}
|
||||
|
||||
if (needMaintenanceParam === 'true') {
|
||||
return ['NeedsMaintenance', 'InService']
|
||||
}
|
||||
|
||||
if (needMaintenanceParam === 'false') return ['Healthy']
|
||||
|
||||
return []
|
||||
},
|
||||
[needMaintenanceParam, technicalStatusParam],
|
||||
)
|
||||
|
||||
const [workPeriod, setWorkPeriod] = useState<DevicesDateRangePickerValue>({
|
||||
|
|
@ -123,17 +144,18 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
|
|||
|
||||
updateSearchParams({
|
||||
dNetwork:
|
||||
nextStatuses.length === networkStatusOptions.length
|
||||
? null
|
||||
: nextStatuses.length === 0
|
||||
? 'none'
|
||||
: nextStatuses.join(','),
|
||||
nextStatuses.length > 0
|
||||
? nextStatuses.join(',')
|
||||
: null,
|
||||
dPage: '0',
|
||||
})
|
||||
}, [selectedNetworkStatuses, updateSearchParams])
|
||||
|
||||
const handleMaintenanceChange = useCallback((value: boolean, checked: boolean) => {
|
||||
const currentStates = new Set(selectedMaintenanceStates)
|
||||
const handleTechnicalStatusChange = useCallback((
|
||||
value: DeviceTechnicalStatus,
|
||||
checked: boolean,
|
||||
) => {
|
||||
const currentStates = new Set(selectedTechnicalStatuses)
|
||||
|
||||
if (checked) {
|
||||
currentStates.add(value)
|
||||
|
|
@ -141,20 +163,19 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
|
|||
currentStates.delete(value)
|
||||
}
|
||||
|
||||
if (currentStates.size === 0) return
|
||||
|
||||
const nextStates = maintenanceOptions
|
||||
const nextStates = technicalStatusOptions
|
||||
.map((option) => option.value)
|
||||
.filter((optionValue) => currentStates.has(optionValue))
|
||||
|
||||
updateSearchParams({
|
||||
dNeedMaintenance:
|
||||
nextStates.length === maintenanceOptions.length
|
||||
? null
|
||||
: String(nextStates[0]),
|
||||
dTechnicalStatus:
|
||||
nextStates.length > 0
|
||||
? nextStates.join(',')
|
||||
: null,
|
||||
dNeedMaintenance: null,
|
||||
dPage: '0',
|
||||
})
|
||||
}, [selectedMaintenanceStates, updateSearchParams])
|
||||
}, [selectedTechnicalStatuses, updateSearchParams])
|
||||
|
||||
const handleOrganisationsChange = useCallback((ids: string[]) => {
|
||||
updateSearchParams({
|
||||
|
|
@ -173,7 +194,7 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
|
|||
type="multiple"
|
||||
defaultValue={['work-period']}
|
||||
>
|
||||
<Accordion.Item className="devices-filter-item" value="work-period">
|
||||
{/* <Accordion.Item className="devices-filter-item" value="work-period">
|
||||
<Accordion.Header className="devices-filter-item__header">
|
||||
<Accordion.Trigger className="devices-filter-item__trigger">
|
||||
<span>Период работы</span>
|
||||
|
|
@ -194,7 +215,7 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
|
|||
</div>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Item> */}
|
||||
|
||||
<Accordion.Item
|
||||
className="devices-filter-item"
|
||||
|
|
@ -269,13 +290,13 @@ export const DevicesFiltersPanel = memo(function DevicesFiltersPanel({
|
|||
<Accordion.Content className="devices-filter-item__content">
|
||||
<div className="devices-filter-item__inner">
|
||||
<div className="devices-checkbox-list">
|
||||
{maintenanceOptions.map((option) => (
|
||||
<label className="devices-checkbox" key={String(option.value)}>
|
||||
{technicalStatusOptions.map((option) => (
|
||||
<label className="devices-checkbox" key={option.value}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaintenanceStates.includes(option.value)}
|
||||
checked={selectedTechnicalStatuses.includes(option.value)}
|
||||
onChange={(event) => {
|
||||
handleMaintenanceChange(
|
||||
handleTechnicalStatusChange(
|
||||
option.value,
|
||||
event.target.checked,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery } from '@apollo/client/react'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
|
|
@ -14,15 +14,18 @@ import {
|
|||
type PageTabItem,
|
||||
} from '../../../../shared/ui/PageTabs/PageTabs'
|
||||
|
||||
type DevicesTab = 'working' | 'lost' | 'locked' | 'maintenance'
|
||||
type DevicesTab = 'working' | 'lost' | 'locked' | 'maintenance' | 'service'
|
||||
type DevicesTabsValue = DevicesTab | 'none'
|
||||
|
||||
function getActiveTab(searchParams: URLSearchParams): DevicesTabsValue {
|
||||
const locked = searchParams.get('dLocked')
|
||||
const needMaintenance = searchParams.get('dNeedMaintenance')
|
||||
const networkStatus = searchParams.get('dNetwork')
|
||||
const technicalStatus = searchParams.get('dTechnicalStatus')
|
||||
|
||||
if (locked === 'true') return 'locked'
|
||||
if (technicalStatus === 'NeedsMaintenance') return 'maintenance'
|
||||
if (technicalStatus === 'InService') return 'service'
|
||||
if (needMaintenance === 'true') return 'maintenance'
|
||||
if (networkStatus === 'Lost') return 'lost'
|
||||
if (networkStatus === 'Online') return 'working'
|
||||
|
|
@ -40,6 +43,27 @@ export const DevicesTabs = memo(function DevicesTabs() {
|
|||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const activeTab = useMemo(() => getActiveTab(searchParams), [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
const legacyNeedMaintenance = searchParams.get('dNeedMaintenance')
|
||||
|
||||
if (
|
||||
searchParams.has('dTechnicalStatus') ||
|
||||
(legacyNeedMaintenance !== 'true' && legacyNeedMaintenance !== 'false')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
|
||||
nextParams.delete('dNeedMaintenance')
|
||||
nextParams.set(
|
||||
'dTechnicalStatus',
|
||||
legacyNeedMaintenance === 'true' ? 'NeedsMaintenance' : 'Healthy',
|
||||
)
|
||||
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
const query = searchParams.get('dQuery')?.trim() || undefined
|
||||
const selectedOrganisationIds = useMemo(() => {
|
||||
return searchParams.get('dOrgs')?.split(',').filter(Boolean) ?? []
|
||||
|
|
@ -70,6 +94,7 @@ export const DevicesTabs = memo(function DevicesTabs() {
|
|||
const lockedCount = stats?.lockedPhones.totalElements
|
||||
const lostCount = stats?.lostPhones.totalElements
|
||||
const maintenanceCount = stats?.maintenancePhones.totalElements
|
||||
const serviceCount = stats?.servicePhones.totalElements
|
||||
|
||||
const tabs = useMemo<Array<PageTabItem<DevicesTabsValue>>>(() => {
|
||||
const nextTabs: Array<PageTabItem<DevicesTabsValue>> = [
|
||||
|
|
@ -119,8 +144,19 @@ export const DevicesTabs = memo(function DevicesTabs() {
|
|||
})
|
||||
}
|
||||
|
||||
if ((serviceCount ?? 0) > 0) {
|
||||
nextTabs.push({
|
||||
value: 'service',
|
||||
label: (
|
||||
<>
|
||||
На обслуживании: <b>{formatCount(serviceCount)}</b>
|
||||
</>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return nextTabs
|
||||
}, [lockedCount, lostCount, maintenanceCount, onlineCount, totalCount])
|
||||
}, [lockedCount, lostCount, maintenanceCount, onlineCount, serviceCount, totalCount])
|
||||
|
||||
const applyQuickFilter = useCallback((tab: DevicesTabsValue) => {
|
||||
if (tab === 'none') return
|
||||
|
|
@ -133,6 +169,7 @@ export const DevicesTabs = memo(function DevicesTabs() {
|
|||
nextParams.delete('dLocked')
|
||||
nextParams.delete('dNetwork')
|
||||
nextParams.delete('dNeedMaintenance')
|
||||
nextParams.delete('dTechnicalStatus')
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
return
|
||||
}
|
||||
|
|
@ -141,24 +178,35 @@ export const DevicesTabs = memo(function DevicesTabs() {
|
|||
nextParams.set('dNetwork', 'Online')
|
||||
nextParams.delete('dLocked')
|
||||
nextParams.delete('dNeedMaintenance')
|
||||
nextParams.delete('dTechnicalStatus')
|
||||
}
|
||||
|
||||
if (tab === 'locked') {
|
||||
nextParams.set('dLocked', 'true')
|
||||
nextParams.delete('dNetwork')
|
||||
nextParams.delete('dNeedMaintenance')
|
||||
nextParams.delete('dTechnicalStatus')
|
||||
}
|
||||
|
||||
if (tab === 'lost') {
|
||||
nextParams.set('dNetwork', 'Lost')
|
||||
nextParams.delete('dLocked')
|
||||
nextParams.delete('dNeedMaintenance')
|
||||
nextParams.delete('dTechnicalStatus')
|
||||
}
|
||||
|
||||
if (tab === 'maintenance') {
|
||||
nextParams.delete('dLocked')
|
||||
nextParams.delete('dNetwork')
|
||||
nextParams.set('dNeedMaintenance', 'true')
|
||||
nextParams.delete('dNeedMaintenance')
|
||||
nextParams.set('dTechnicalStatus', 'NeedsMaintenance')
|
||||
}
|
||||
|
||||
if (tab === 'service') {
|
||||
nextParams.delete('dLocked')
|
||||
nextParams.delete('dNetwork')
|
||||
nextParams.delete('dNeedMaintenance')
|
||||
nextParams.set('dTechnicalStatus', 'InService')
|
||||
}
|
||||
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@
|
|||
.add-organisation-modal--with-policy {
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
}
|
||||
.add-organisation-modal--without-policy{
|
||||
width: min(460px, calc(100vw - 32px));
|
||||
.avatar-upload{
|
||||
width: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.add-organisation-modal__header {
|
||||
margin-bottom: 22px;
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function AddOrganisationModal({
|
|||
const modalClassName = useMemo(
|
||||
() =>
|
||||
`add-organisation-modal ${
|
||||
!isEditMode ? 'add-organisation-modal--with-policy' : ''
|
||||
!isEditMode ? 'add-organisation-modal--with-policy' : 'add-organisation-modal--without-policy'
|
||||
}`,
|
||||
[isEditMode],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -57,154 +57,60 @@
|
|||
}
|
||||
}
|
||||
|
||||
.login-theme-switch {
|
||||
.login-theme-button {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 2;
|
||||
|
||||
width: 72px;
|
||||
height: 32px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border-radius: 14px;
|
||||
background: $color-surface-glass;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
inset 0 0 1px 1px rgba($gray50-rgb, 0.18),
|
||||
0 10px 28px rgba($color-shadow-rgb, 0.12);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-theme-switch__track {
|
||||
position: relative;
|
||||
width: 62px;
|
||||
height: 26px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
|
||||
background: $color-surface-glass;
|
||||
box-shadow:
|
||||
inset 0 0 1px 1px rgba($gray50-rgb, 0.24),
|
||||
0 10px 28px rgba($color-shadow-rgb, 0.12);
|
||||
overflow: hidden;
|
||||
|
||||
transition:
|
||||
background 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.login-theme-switch__track::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
|
||||
background:
|
||||
radial-gradient(circle at 25% 35%, rgba($orange, 0.22), transparent 34%),
|
||||
radial-gradient(circle at 78% 65%, rgba($blue, 0.2), transparent 38%);
|
||||
|
||||
opacity: 0.92;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.login-theme-switch__thumb {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background: $white;
|
||||
color: $orange;
|
||||
box-shadow: 0 1px 8px -2px rgba($gray50-rgb, 0.8);
|
||||
transform: translateX(0);
|
||||
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
background 0.25s ease,
|
||||
color 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
}
|
||||
transform 0.18s ease,
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
|
||||
.login-theme-switch__icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
color: $color-text-muted;
|
||||
opacity: 0.55;
|
||||
transform: translateY(-50%);
|
||||
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
color 0.25s ease;
|
||||
}
|
||||
|
||||
.login-theme-switch__icon--sun {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.login-theme-switch__icon--moon {
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.login-theme-switch.is-light {
|
||||
.login-theme-switch__icon--sun {
|
||||
opacity: 0;
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.login-theme-switch__icon--moon {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: $color-surface;
|
||||
color: $blue;
|
||||
transform: translateY(-1px);
|
||||
|
||||
.login-theme-switch.is-dark {
|
||||
.login-theme-switch__track {
|
||||
background: $color-surface-glass;
|
||||
svg {
|
||||
transform: rotate(-8deg) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
&.is-dark {
|
||||
color: $blue;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba($white, 0.08),
|
||||
0 12px 30px rgba($black, 0.24);
|
||||
}
|
||||
|
||||
.login-theme-switch__track::before {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.login-theme-switch__thumb {
|
||||
transform: translateX(36px);
|
||||
background: $color-surface;
|
||||
color: $blue;
|
||||
box-shadow: 0 1px 6px -2px $gray50;
|
||||
}
|
||||
|
||||
.login-theme-switch__icon--sun {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.login-theme-switch__icon--moon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-theme-switch:active {
|
||||
.login-theme-switch__thumb {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
&.is-dark .login-theme-switch__thumb {
|
||||
transform: translateX(36px) scale(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
|
|
@ -410,7 +316,7 @@
|
|||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-theme-switch {
|
||||
.login-theme-button {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,25 +54,13 @@ export function LoginPage({ onSuccess }: LoginPageProps) {
|
|||
return (
|
||||
<main className="login-page">
|
||||
<button
|
||||
className={`login-theme-switch ${isDark ? 'is-dark' : 'is-light'}`}
|
||||
className={`login-theme-button ${isDark ? 'is-dark' : 'is-light'}`}
|
||||
type="button"
|
||||
aria-label={isDark ? 'Включить светлую тему' : 'Включить тёмную тему'}
|
||||
aria-pressed={isDark}
|
||||
onClick={toggleTheme}
|
||||
onClick={(event) => toggleTheme(event.currentTarget)}
|
||||
>
|
||||
<span className="login-theme-switch__track">
|
||||
<span className="login-theme-switch__icon login-theme-switch__icon--sun">
|
||||
<Sun size={14} />
|
||||
</span>
|
||||
|
||||
<span className="login-theme-switch__icon login-theme-switch__icon--moon">
|
||||
<Moon size={14} />
|
||||
</span>
|
||||
|
||||
<span className="login-theme-switch__thumb">
|
||||
{isDark ? <Moon size={15} /> : <Sun size={15} />}
|
||||
</span>
|
||||
</span>
|
||||
{isDark ? <Moon size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
type MapTrackPeriodValue,
|
||||
} from './components/MapTrackPeriodControl/MapTrackPeriodControl'
|
||||
import { popoverVariants, uiFadeTransition } from '../../shared/lib/motion'
|
||||
import { isPresent } from '../../shared/lib/isPresent'
|
||||
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './MapPage.scss'
|
||||
|
|
@ -51,6 +52,12 @@ type MapInfoPoint = {
|
|||
lng: number
|
||||
}
|
||||
|
||||
type MapDevice = GetPhonesData['getPhones']['page'][number]
|
||||
|
||||
type MapDeviceWithLocation = MapDevice & {
|
||||
lastLocation: NonNullable<MapDevice['lastLocation']>
|
||||
}
|
||||
|
||||
function getNetworkStatusClass(status?: DeviceNetworkStatus) {
|
||||
if (status === 'Online') return 'is-online'
|
||||
if (status === 'Lost') return 'is-lost'
|
||||
|
|
@ -348,9 +355,8 @@ export function MapPage() {
|
|||
|
||||
const devicesWithLocation = useMemo(() => {
|
||||
return devices.filter(
|
||||
(device): device is Device & {
|
||||
lastLocation: NonNullable<Device['lastLocation']>
|
||||
} => Boolean(device.lastLocation),
|
||||
(device): device is MapDeviceWithLocation =>
|
||||
device.lastLocation !== null,
|
||||
)
|
||||
}, [devices])
|
||||
const shouldShowPhonesLoadState =
|
||||
|
|
@ -359,7 +365,9 @@ export function MapPage() {
|
|||
(isPhonesLoading && devicesWithLocation.length === 0))
|
||||
|
||||
const sortedGpsTrack = useMemo(() => {
|
||||
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
|
||||
return sortGpsTrack(
|
||||
(gpsTrackData?.getPhoneGpsTrack ?? []).filter(isPresent),
|
||||
)
|
||||
}, [gpsTrackData])
|
||||
|
||||
const selectedLastLocation = selectedDevice?.lastLocation ?? null
|
||||
|
|
|
|||
|
|
@ -604,6 +604,11 @@
|
|||
background-color: $color-danger-status-bg;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
&.devices-status--blue {
|
||||
background-color: $color-primary-muted;
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
.organisation-device-dot {
|
||||
|
|
@ -623,6 +628,10 @@
|
|||
&.devices-dot--gray {
|
||||
background: $gray50;
|
||||
}
|
||||
|
||||
&.devices-dot--blue {
|
||||
background: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
.organisation-device-icons {
|
||||
|
|
@ -1069,4 +1078,4 @@
|
|||
.organisation-devices-table {
|
||||
min-width: 800px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,9 +39,14 @@ import type {
|
|||
import type {
|
||||
Device as OrganisationDevice,
|
||||
DeviceNetworkStatus,
|
||||
DeviceTechnicalStatus,
|
||||
GetPhonesData,
|
||||
GetPhonesVariables,
|
||||
} from '../../entities/device/model/types'
|
||||
import {
|
||||
getDeviceMalfunctionSummary,
|
||||
deviceTechnicalStatusLabels,
|
||||
} from '../../entities/device/lib/maintenance'
|
||||
import { ConfirmDangerDialog } from '../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
|
||||
import { EmployeesPagination } from '../EmployeesPage/components/EmployeesPagination/EmployeesPagination'
|
||||
import {
|
||||
|
|
@ -67,6 +72,7 @@ import {
|
|||
import { RevealContent } from '../../shared/ui/RevealContent/RevealContent'
|
||||
import { isPhonePolicyOptionEnabled } from '../../entities/device/lib/phonePolicy'
|
||||
import { EmptyState } from '../../shared/ui/EmptyState/EmptyState'
|
||||
import { Tooltip } from '../../shared/ui/Tooltip/Tooltip'
|
||||
import type { AppLayoutOutletContext } from '../../app/layouts/AppLayout'
|
||||
import {
|
||||
useDeferredModalPayload,
|
||||
|
|
@ -108,16 +114,22 @@ function getEmployeeRoleLabel(role: string) {
|
|||
return role
|
||||
}
|
||||
|
||||
function getDeviceConditionLabel(needMaintenance?: boolean) {
|
||||
return needMaintenance ? 'Требует ТО' : 'Исправно'
|
||||
function getDeviceConditionLabel(status?: DeviceTechnicalStatus) {
|
||||
return deviceTechnicalStatusLabels[status ?? 'Healthy']
|
||||
}
|
||||
|
||||
function getDeviceConditionClass(needMaintenance?: boolean) {
|
||||
return needMaintenance ? 'devices-status--red' : 'devices-status--green'
|
||||
function getDeviceConditionClass(status?: DeviceTechnicalStatus) {
|
||||
if (status === 'NeedsMaintenance') return 'devices-status--red'
|
||||
if (status === 'InService') return 'devices-status--blue'
|
||||
|
||||
return 'devices-status--green'
|
||||
}
|
||||
|
||||
function getDeviceConditionDotClass(needMaintenance?: boolean) {
|
||||
return needMaintenance ? 'devices-dot--red' : 'devices-dot--green'
|
||||
function getDeviceConditionDotClass(status?: DeviceTechnicalStatus) {
|
||||
if (status === 'NeedsMaintenance') return 'devices-dot--red'
|
||||
if (status === 'InService') return 'devices-dot--blue'
|
||||
|
||||
return 'devices-dot--green'
|
||||
}
|
||||
|
||||
function getDeviceNetworkLabel(status?: DeviceNetworkStatus) {
|
||||
|
|
@ -941,21 +953,28 @@ export function OrganisationPage() {
|
|||
</td>
|
||||
|
||||
<td>
|
||||
<div
|
||||
className={`organisation-device-status ${getDeviceConditionClass(
|
||||
device.techState?.needMaintenance,
|
||||
)}`}
|
||||
<Tooltip
|
||||
content={getDeviceMalfunctionSummary(
|
||||
device.techState?.malfunctions,
|
||||
device.techState?.malfunctionComment,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={`organisation-device-dot ${getDeviceConditionDotClass(
|
||||
device.techState?.needMaintenance,
|
||||
className={`organisation-device-status ${getDeviceConditionClass(
|
||||
device.techState?.status,
|
||||
)}`}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
className={`organisation-device-dot ${getDeviceConditionDotClass(
|
||||
device.techState?.status,
|
||||
)}`}
|
||||
/>
|
||||
|
||||
{getDeviceConditionLabel(
|
||||
device.techState?.needMaintenance,
|
||||
)}
|
||||
</div>
|
||||
{getDeviceConditionLabel(
|
||||
device.techState?.status,
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./fragment-masking";
|
||||
export * from "./gql";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export function isPresent<T>(
|
||||
value: T | null | undefined,
|
||||
): value is T {
|
||||
return value !== null && value !== undefined
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import type { AppTheme } from './theme'
|
||||
import {
|
||||
applyTheme,
|
||||
|
|
@ -6,6 +7,7 @@ import {
|
|||
getThemeStorageKey,
|
||||
saveTheme,
|
||||
} from './theme'
|
||||
import { updateThemeWithTransition } from './themeTransition'
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<AppTheme>(() => getPreferredTheme())
|
||||
|
|
@ -30,12 +32,22 @@ export function useTheme() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
function toggleTheme() {
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark'
|
||||
|
||||
function commitTheme(nextTheme: AppTheme) {
|
||||
applyTheme(nextTheme)
|
||||
saveTheme(nextTheme)
|
||||
setTheme(nextTheme)
|
||||
flushSync(() => {
|
||||
setTheme(nextTheme)
|
||||
})
|
||||
}
|
||||
|
||||
function toggleTheme(source?: Element | null) {
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark'
|
||||
|
||||
updateThemeWithTransition({
|
||||
source,
|
||||
theme: nextTheme,
|
||||
updateTheme: commitTheme,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -169,166 +169,37 @@
|
|||
}
|
||||
}
|
||||
|
||||
.navbar__theme-btn {
|
||||
color: $color-primary;
|
||||
|
||||
&[aria-pressed='true'] {
|
||||
background: $color-surface;
|
||||
box-shadow: $shadow-control;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar__theme-switch {
|
||||
width: 72px;
|
||||
.navbar__theme-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
color: $gray50;
|
||||
cursor: pointer;
|
||||
}
|
||||
transition: 0.2s ease;
|
||||
|
||||
.navbar__theme-switch-track {
|
||||
position: relative;
|
||||
width: 62px;
|
||||
height: 26px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
|
||||
background: $color-surface;
|
||||
box-shadow:
|
||||
inset 0 0 1px 1px rgba(var(--gray50-rgb), 0.3),
|
||||
0 6px 20px -6px rgba(var(--gray50-rgb), 0.3);
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
transition:
|
||||
background 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.navbar__theme-switch-track::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
|
||||
background:
|
||||
radial-gradient(circle at 25% 35%, rgba($orange, 0.2), transparent 32%),
|
||||
radial-gradient(circle at 78% 65%, rgba($blue, 0.18), transparent 36%);
|
||||
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.navbar__theme-switch-thumb {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background: #ffffff;
|
||||
color: $orange;
|
||||
|
||||
box-shadow: 0px 1px 8px -2px rgba(var(--gray50-rgb), 0.8);
|
||||
|
||||
transform: translateX(0);
|
||||
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
background 0.25s ease,
|
||||
color 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.navbar__theme-switch-icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
color: $color-text-muted;
|
||||
opacity: 0.55;
|
||||
|
||||
transform: translateY(-50%);
|
||||
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
color 0.25s ease;
|
||||
}
|
||||
|
||||
.navbar__theme-switch-icon--sun {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.navbar__theme-switch-icon--moon {
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.navbar__theme-switch.is-light {
|
||||
.navbar__theme-switch-icon--sun {
|
||||
opacity: 0;
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar__theme-switch-icon--moon {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar__theme-switch.is-dark {
|
||||
.navbar__theme-switch-track {
|
||||
background: $color-surface;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08), 0 8px 22px rgba(0, 0, 0, 0.22);
|
||||
&:hover {
|
||||
background: $gray30;
|
||||
}
|
||||
|
||||
.navbar__theme-switch-track::before {
|
||||
opacity: 0.65;
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.navbar__theme-switch-thumb {
|
||||
transform: translateX(36px);
|
||||
background: $color-surface;
|
||||
color: $blue;
|
||||
|
||||
box-shadow: 0px 1px 6px -2px $gray50;
|
||||
}
|
||||
|
||||
.navbar__theme-switch-icon--sun {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.navbar__theme-switch-icon--moon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar__theme-switch:hover {
|
||||
.navbar__theme-switch-track {
|
||||
//box-shadow: inset 0 0 0 1px rgba($blue, 0.35), 0 8px 20px rgba($blue, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.navbar__theme-switch:active {
|
||||
.navbar__theme-switch-thumb {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
&.is-dark .navbar__theme-switch-thumb {
|
||||
transform: translateX(24px) scale(0.94);
|
||||
&.is-dark:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -520,4 +391,4 @@
|
|||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
useDeferredModalPayload,
|
||||
} from '../../shared/lib/lazyMount'
|
||||
import { AddEmployeeModal } from '../../pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal'
|
||||
import type { CurrentUserQuery } from '../../shared/api/generated/graphql'
|
||||
|
||||
function clearAuthCookies() {
|
||||
document.cookie = 'Access-token=; Max-Age=0; path=/'
|
||||
|
|
@ -24,22 +25,6 @@ async function handleLogout() {
|
|||
window.dispatchEvent(new Event('auth:logout'))
|
||||
}
|
||||
|
||||
type CurrentUserData = {
|
||||
currentUser: {
|
||||
id: string
|
||||
role: string
|
||||
avatarUrl: string
|
||||
firstName: string
|
||||
middleName: string
|
||||
lastName: string
|
||||
username: string
|
||||
org: {
|
||||
id: number
|
||||
name: string
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
function getPageTitle(location: ReturnType<typeof useLocation>) {
|
||||
const { pathname, search } = location
|
||||
const searchParams = new URLSearchParams(search)
|
||||
|
|
@ -76,10 +61,10 @@ function getPageTitleLink(location: ReturnType<typeof useLocation>) {
|
|||
}
|
||||
|
||||
function mapCurrentUserToEmployee(
|
||||
user: NonNullable<CurrentUserData['currentUser']>,
|
||||
user: CurrentUserQuery['currentUser'],
|
||||
): Employee {
|
||||
return {
|
||||
id: Number(user.id),
|
||||
id: user.id,
|
||||
avatarUrl: user.avatarUrl,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
|
|
@ -109,7 +94,7 @@ export function Navbar({
|
|||
}: NavbarProps) {
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
|
||||
const { data, refetch } = useQuery<CurrentUserData>(CURRENT_USER_QUERY, {
|
||||
const { data, refetch } = useQuery(CURRENT_USER_QUERY, {
|
||||
fetchPolicy: 'cache-first',
|
||||
})
|
||||
|
||||
|
|
@ -180,25 +165,13 @@ export function Navbar({
|
|||
</button> */}
|
||||
|
||||
<button
|
||||
className={`navbar__theme-switch ${isDark ? 'is-dark' : 'is-light'}`}
|
||||
className={`navbar__theme-button ${isDark ? 'is-dark' : 'is-light'}`}
|
||||
type="button"
|
||||
aria-label={isDark ? 'Включить светлую тему' : 'Включить тёмную тему'}
|
||||
aria-pressed={isDark}
|
||||
onClick={toggleTheme}
|
||||
onClick={(event) => toggleTheme(event.currentTarget)}
|
||||
>
|
||||
<span className="navbar__theme-switch-track">
|
||||
<span className="navbar__theme-switch-icon navbar__theme-switch-icon--sun">
|
||||
<Sun size={14} />
|
||||
</span>
|
||||
|
||||
<span className="navbar__theme-switch-icon navbar__theme-switch-icon--moon">
|
||||
<Moon size={14} />
|
||||
</span>
|
||||
|
||||
<span className="navbar__theme-switch-thumb">
|
||||
{isDark ? <Moon size={15} /> : <Sun size={15} />}
|
||||
</span>
|
||||
</span>
|
||||
{isDark ? <Moon size={17} /> : <Sun size={17} />}
|
||||
</button>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
.notification {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 300;
|
||||
right: max(24px, env(safe-area-inset-right));
|
||||
bottom: max(24px, env(safe-area-inset-bottom));
|
||||
z-index: 1200;
|
||||
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
//min-height: 76px;
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.98);
|
||||
pointer-events: none;
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { CheckCircle2, X, XCircle } from 'lucide-react'
|
||||
import { m } from 'framer-motion'
|
||||
|
||||
|
|
@ -49,7 +50,9 @@ export function Notification({
|
|||
|
||||
const Icon = variant === 'success' ? CheckCircle2 : XCircle
|
||||
|
||||
return (
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<m.div
|
||||
className={`notification notification--${variant}`}
|
||||
role="status"
|
||||
|
|
@ -92,6 +95,7 @@ export function Notification({
|
|||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</m.div>
|
||||
</m.div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,20 +9,20 @@ export default defineConfig({
|
|||
host: true,
|
||||
proxy: {
|
||||
'/graphql': {
|
||||
target: 'http://192.168.1.179:8080',
|
||||
target: 'http://192.168.1.91:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/user': {
|
||||
target: 'http://192.168.1.179:8080',
|
||||
target: 'http://192.168.1.91:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/org': {
|
||||
target: 'http://192.168.1.179:8080',
|
||||
target: 'http://192.168.1.91:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/phone': {
|
||||
target: 'http://192.168.1.179:8080',
|
||||
target: 'http://192.168.1.91:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue