Подключение Apollo, получение устройств
This commit is contained in:
parent
b978875a0b
commit
9a07d006d7
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_GRAPHQL_API_URL=/graphql
|
||||||
|
|
@ -9,4 +9,5 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 20px 36px 36px 0;
|
padding: 20px 36px 36px 0;
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { gql } from '@apollo/client'
|
||||||
|
|
||||||
|
export const GET_PHONES_PAGE_QUERY = gql`
|
||||||
|
query GetPhonesPage($key: String) {
|
||||||
|
getPhonesPage(key: $key) {
|
||||||
|
page {
|
||||||
|
id
|
||||||
|
imei
|
||||||
|
imei2
|
||||||
|
serial
|
||||||
|
lastLocation {
|
||||||
|
alt
|
||||||
|
date
|
||||||
|
lat
|
||||||
|
lng
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_PHONE_QUERY = gql`
|
||||||
|
query GetPhone($id: Int!) {
|
||||||
|
getPhone(id: $id) {
|
||||||
|
id
|
||||||
|
imei
|
||||||
|
imei2
|
||||||
|
serial
|
||||||
|
lastLocation {
|
||||||
|
alt
|
||||||
|
date
|
||||||
|
lat
|
||||||
|
lng
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
export type DeviceLocation = {
|
||||||
|
alt: number
|
||||||
|
date: number
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Device = {
|
||||||
|
id: number
|
||||||
|
imei: string
|
||||||
|
imei2: string
|
||||||
|
serial: string
|
||||||
|
lastLocation: DeviceLocation | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhonesPageData = {
|
||||||
|
getPhonesPage: {
|
||||||
|
page: Device[]
|
||||||
|
nextKey: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhonesPageVariables = {
|
||||||
|
key?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhoneData = {
|
||||||
|
getPhone: Device | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPhoneVariables = {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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
|
||||||
|
role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CURRENT_USER_QUERY = gql`
|
||||||
|
query CurrentUser {
|
||||||
|
currentUser {
|
||||||
|
id
|
||||||
|
role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
|
||||||
|
import { CURRENT_USER_QUERY } from '../api/auth.graphql'
|
||||||
|
import { LoginPage } from '../../../pages/LoginPage/LoginPage'
|
||||||
|
|
||||||
|
type CurrentUser = {
|
||||||
|
id: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurrentUserQueryData = {
|
||||||
|
currentUser: CurrentUser | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthGateProps = {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGate({ children }: AuthGateProps) {
|
||||||
|
const [authVersion, setAuthVersion] = useState(0)
|
||||||
|
|
||||||
|
const { data, loading, error, refetch } = useQuery<CurrentUserQueryData>(
|
||||||
|
CURRENT_USER_QUERY,
|
||||||
|
{
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleLogout() {
|
||||||
|
setAuthVersion((prev) => prev + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('auth:logout', handleLogout)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('auth:logout', handleLogout)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: 24 }}>Проверка авторизации...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data?.currentUser) {
|
||||||
|
return (
|
||||||
|
<LoginPage
|
||||||
|
key={authVersion}
|
||||||
|
onSuccess={() => {
|
||||||
|
refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
letter-spacing: 0.18px;
|
letter-spacing: 0.18px;
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: $color-bg;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,19 @@ import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { RouterProvider } from 'react-router-dom'
|
import { RouterProvider } from 'react-router-dom'
|
||||||
|
import { ApolloProvider } from '@apollo/client/react'
|
||||||
|
import { apolloClient } from './shared/api/apolloClient'
|
||||||
import { router } from './app/router/router.tsx'
|
import { router } from './app/router/router.tsx'
|
||||||
import 'react-day-picker/style.css'
|
import 'react-day-picker/style.css'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import { AuthGate } from './features/auth/ui/AuthGate'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ApolloProvider client={apolloClient}>
|
||||||
|
<AuthGate>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
</AuthGate>
|
||||||
|
</ApolloProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Link } from 'react-router-dom'
|
import { useQuery } from '@apollo/client/react'
|
||||||
|
|
||||||
import devices from '../DevicesPage/devices.mock.json'
|
import type { Device as PageDevice } from './types'
|
||||||
import type { Device } from './types'
|
|
||||||
|
import { GET_PHONE_QUERY } from '../../entities/device/api/device.graphql'
|
||||||
|
import type {
|
||||||
|
GetPhoneData,
|
||||||
|
GetPhoneVariables,
|
||||||
|
Device as ApiDevice,
|
||||||
|
} from '../../entities/device/model/types'
|
||||||
|
|
||||||
import { DeviceMainCard } from './components/DeviceMainCard/DeviceMainCard'
|
import { DeviceMainCard } from './components/DeviceMainCard/DeviceMainCard'
|
||||||
import { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard'
|
import { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard'
|
||||||
|
|
@ -13,17 +19,137 @@ import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards
|
||||||
|
|
||||||
import './DevicePage.scss'
|
import './DevicePage.scss'
|
||||||
|
|
||||||
const typedDevices = devices as Device[]
|
function formatLocationDate(timestamp?: number) {
|
||||||
|
if (!timestamp) return 'Нет данных'
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(timestamp * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiDeviceToPageDevice(device: ApiDevice): PageDevice {
|
||||||
|
const location = device.lastLocation
|
||||||
|
? {
|
||||||
|
lat: device.lastLocation.lat,
|
||||||
|
lng: device.lastLocation.lng,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: device.id,
|
||||||
|
|
||||||
|
model: 'АРМАФОН S3.3+',
|
||||||
|
factoryNumber: device.serial || 'Заводской номер не указан',
|
||||||
|
imei: device.imei || 'IMEI не указан',
|
||||||
|
imei2: device.imei2 || 'IMEI 2 не указан',
|
||||||
|
serialNumber: device.serial || undefined,
|
||||||
|
|
||||||
|
employee: null,
|
||||||
|
|
||||||
|
condition: 'ok',
|
||||||
|
connection: device.lastLocation ? 'online' : 'offline',
|
||||||
|
connectionText: device.lastLocation ? 'В сети' : 'Нет геопозиции',
|
||||||
|
|
||||||
|
workTime: null,
|
||||||
|
registeredAt: undefined,
|
||||||
|
|
||||||
|
image: undefined,
|
||||||
|
|
||||||
|
location,
|
||||||
|
lastLocationAt: device.lastLocation
|
||||||
|
? formatLocationDate(device.lastLocation.date)
|
||||||
|
: 'Нет данных',
|
||||||
|
|
||||||
|
route: device.lastLocation
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
lat: device.lastLocation.lat,
|
||||||
|
lng: device.lastLocation.lng,
|
||||||
|
time: formatLocationDate(device.lastLocation.date),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
|
||||||
|
permissions: {
|
||||||
|
wifi: true,
|
||||||
|
bluetooth: true,
|
||||||
|
gps: Boolean(device.lastLocation),
|
||||||
|
camera: true,
|
||||||
|
sim: true,
|
||||||
|
speaker: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
statusIcons: {
|
||||||
|
gps: Boolean(device.lastLocation),
|
||||||
|
wifi: true,
|
||||||
|
bluetooth: true,
|
||||||
|
lock: false,
|
||||||
|
camera: true,
|
||||||
|
sim: true,
|
||||||
|
sound: true,
|
||||||
|
kiosk: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
battery: undefined,
|
||||||
|
batteryMaxCapacity: undefined,
|
||||||
|
chargeCycles: undefined,
|
||||||
|
totalWorkTime: undefined,
|
||||||
|
mediumImpacts: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function DevicePage() {
|
export function DevicePage() {
|
||||||
const { deviceId } = useParams()
|
const { deviceId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const device = useMemo(() => {
|
const phoneId = Number(deviceId)
|
||||||
return typedDevices.find((item) => String(item.id) === deviceId)
|
|
||||||
}, [deviceId])
|
|
||||||
|
|
||||||
if (!device) {
|
const { data, loading, error } = useQuery<GetPhoneData, GetPhoneVariables>(
|
||||||
|
GET_PHONE_QUERY,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
id: phoneId,
|
||||||
|
},
|
||||||
|
skip: !phoneId,
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const device = useMemo(() => {
|
||||||
|
if (!data?.getPhone) return null
|
||||||
|
|
||||||
|
return mapApiDeviceToPageDevice(data.getPhone)
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
if (!phoneId) {
|
||||||
|
return (
|
||||||
|
<section className="device-page">
|
||||||
|
<div className="device-page__empty">
|
||||||
|
<h2>Некорректный ID устройства</h2>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => navigate('/devices')}>
|
||||||
|
Вернуться к списку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !device) {
|
||||||
|
return (
|
||||||
|
<section className="device-page">
|
||||||
|
<div className="device-page__empty">
|
||||||
|
<h2>Загрузка устройства...</h2>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !device) {
|
||||||
return (
|
return (
|
||||||
<section className="device-page">
|
<section className="device-page">
|
||||||
<div className="device-page__empty">
|
<div className="device-page__empty">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export type Device = {
|
||||||
factoryNumber: string
|
factoryNumber: string
|
||||||
model?: string
|
model?: string
|
||||||
imei: string
|
imei: string
|
||||||
|
imei2: string
|
||||||
serialNumber?: string
|
serialNumber?: string
|
||||||
workTime: string | null
|
workTime: string | null
|
||||||
employee: string | null
|
employee: string | null
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
//overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-table-container {
|
.devices-table-container {
|
||||||
|
|
@ -17,6 +20,10 @@
|
||||||
.devices-table-filter-container {
|
.devices-table-filter-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-table-card {
|
.devices-table-card {
|
||||||
|
|
@ -24,6 +31,26 @@
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: $gray50 transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: $gray30;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: $gray50;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-table {
|
.devices-table {
|
||||||
|
|
@ -34,8 +61,10 @@
|
||||||
&__row {
|
&__row {
|
||||||
transition: .2s ease;
|
transition: .2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $gray20;
|
background-color: $gray20;
|
||||||
|
|
||||||
.devices-map-btn {
|
.devices-map-btn {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
@ -135,10 +164,24 @@
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
font-weight: 400;
|
padding: 0px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 550;
|
||||||
|
&--green{
|
||||||
|
background-color: hsla(128, 56%, 45%, 0.15);
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
&--gray{
|
||||||
|
background-color: $color-bg;
|
||||||
|
color: $gray50;
|
||||||
|
}
|
||||||
|
&--red{
|
||||||
|
background-color: hsla(0, 100%, 43%, 0.15);
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-dot {
|
.devices-dot {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useApolloClient, useQuery } from '@apollo/client/react'
|
||||||
import {
|
import {
|
||||||
Bluetooth,
|
Bluetooth,
|
||||||
Camera,
|
Camera,
|
||||||
|
|
@ -12,67 +13,130 @@ import {
|
||||||
Store,
|
Store,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import devices from './devices.mock.json'
|
|
||||||
import './DevicesPage.scss'
|
import './DevicesPage.scss'
|
||||||
|
|
||||||
import { DevicesTabs } from './components/DevicesTabs/DevicesTabs'
|
import { DevicesTabs } from './components/DevicesTabs/DevicesTabs'
|
||||||
import { DevicesToolbar } from './components/DevicesToolbar/DevicesToolbar'
|
import { DevicesToolbar } from './components/DevicesToolbar/DevicesToolbar'
|
||||||
import { DevicesFiltersPanel } from './components/DevicesFiltersPanel/DevicesFiltersPanel'
|
import { DevicesFiltersPanel } from './components/DevicesFiltersPanel/DevicesFiltersPanel'
|
||||||
|
|
||||||
type DeviceCondition = 'ok' | 'inspection'
|
import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
||||||
type DeviceConnection = 'online' | 'offline' | 'offlineDanger'
|
import type { Device, GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types'
|
||||||
|
|
||||||
type Device = {
|
function formatLocationDate(timestamp: number) {
|
||||||
id: number
|
if (!timestamp) return 'Нет данных'
|
||||||
factoryNumber: string
|
|
||||||
imei: string
|
|
||||||
workTime: string | null
|
|
||||||
employee: string | null
|
|
||||||
condition: DeviceCondition
|
|
||||||
connection: DeviceConnection
|
|
||||||
connectionText: string
|
|
||||||
statusIcons: {
|
|
||||||
gps: boolean
|
|
||||||
wifi: boolean
|
|
||||||
bluetooth: boolean
|
|
||||||
lock: boolean
|
|
||||||
camera: boolean
|
|
||||||
sim: boolean
|
|
||||||
sound: boolean
|
|
||||||
kiosk: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const typedDevices = devices as Device[]
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
const conditionText: Record<DeviceCondition, string> = {
|
month: '2-digit',
|
||||||
ok: 'Исправно',
|
year: 'numeric',
|
||||||
inspection: 'Требует осмотра',
|
hour: '2-digit',
|
||||||
}
|
minute: '2-digit',
|
||||||
|
}).format(new Date(timestamp * 1000))
|
||||||
function getDotClass(status: DeviceCondition | DeviceConnection) {
|
|
||||||
if (status === 'ok' || status === 'online') return 'devices-dot devices-dot--green'
|
|
||||||
if (status === 'inspection' || status === 'offlineDanger') return 'devices-dot devices-dot--red'
|
|
||||||
|
|
||||||
return 'devices-dot devices-dot--gray'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevicesPage() {
|
export function DevicesPage() {
|
||||||
|
|
||||||
const [isFiltersOpen, setIsFiltersOpen] = useState(true)
|
const [isFiltersOpen, setIsFiltersOpen] = useState(true)
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const client = useApolloClient()
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [loadedPages, setLoadedPages] = useState<Device[][]>([])
|
||||||
|
const [loadedNextKeys, setLoadedNextKeys] = useState<Array<string | null>>([])
|
||||||
|
const [isPageLoading, setIsPageLoading] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: firstPageData,
|
||||||
|
loading: isFirstPageLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery<GetPhonesPageData, GetPhonesPageVariables>(
|
||||||
|
GET_PHONES_PAGE_QUERY,
|
||||||
|
{
|
||||||
|
variables: {},
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const firstPageDevices = firstPageData?.getPhonesPage.page ?? []
|
||||||
|
const firstPageNextKey = firstPageData?.getPhonesPage.nextKey ?? null
|
||||||
|
|
||||||
|
const pages = [firstPageDevices, ...loadedPages]
|
||||||
|
|
||||||
|
const devices = pages[currentPage - 1] ?? []
|
||||||
|
|
||||||
|
const nextKey =
|
||||||
|
currentPage === 1
|
||||||
|
? firstPageNextKey
|
||||||
|
: loadedNextKeys[currentPage - 2] ?? null
|
||||||
|
|
||||||
|
const loading = isFirstPageLoading || isPageLoading
|
||||||
|
|
||||||
|
async function handleNextPage() {
|
||||||
|
const alreadyLoadedNextPage = pages[currentPage]
|
||||||
|
|
||||||
|
if (alreadyLoadedNextPage) {
|
||||||
|
setCurrentPage((prev) => prev + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextKey) return
|
||||||
|
|
||||||
|
setIsPageLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.query<GetPhonesPageData, GetPhonesPageVariables>({
|
||||||
|
query: GET_PHONES_PAGE_QUERY,
|
||||||
|
variables: {
|
||||||
|
key: nextKey,
|
||||||
|
},
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextPageData = result.data?.getPhonesPage
|
||||||
|
|
||||||
|
if (!nextPageData) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadedPages((prev) => [
|
||||||
|
...prev,
|
||||||
|
nextPageData.page,
|
||||||
|
])
|
||||||
|
|
||||||
|
setLoadedNextKeys((prev) => [
|
||||||
|
...prev,
|
||||||
|
nextPageData.nextKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
setCurrentPage((prev) => prev + 1)
|
||||||
|
} finally {
|
||||||
|
setIsPageLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrevPage() {
|
||||||
|
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="devices-page">
|
<section className="devices-page">
|
||||||
|
|
||||||
<DevicesTabs />
|
<DevicesTabs />
|
||||||
|
|
||||||
<DevicesToolbar
|
<DevicesToolbar
|
||||||
isFiltersOpen={isFiltersOpen}
|
isFiltersOpen={isFiltersOpen}
|
||||||
onToggleFilters={() => setIsFiltersOpen((prev) => !prev)}
|
onToggleFilters={() => setIsFiltersOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
<div className='devices-table-filter-container'>
|
|
||||||
<div className='devices-table-container'>
|
<div className="devices-table-filter-container">
|
||||||
|
<div className="devices-table-container">
|
||||||
<div className="devices-table-card">
|
<div className="devices-table-card">
|
||||||
|
|
||||||
|
{!loading && !error && devices.length === 0 && (
|
||||||
|
<div className="devices-state">Устройства не найдены</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
<table className="devices-table">
|
<table className="devices-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -86,71 +150,85 @@ export function DevicesPage() {
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{typedDevices.map((device) => (
|
{devices.map((device) => (
|
||||||
<tr key={device.id} className="devices-table__row" onClick={() => navigate(`/devices/${device.id}`)}>
|
<tr
|
||||||
|
key={device.id}
|
||||||
|
className="devices-table__row"
|
||||||
|
onClick={() => navigate(`/devices/${device.id}`)}
|
||||||
|
>
|
||||||
<td className="devices-table__id">{device.id}</td>
|
<td className="devices-table__id">{device.id}</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div className="device-info">
|
<div className="device-info">
|
||||||
<div className="device-info__number">{device.factoryNumber}</div>
|
<div className="device-info__number">
|
||||||
<div className="device-info__imei">{device.imei}</div>
|
{device.serial || '—'}
|
||||||
|
|
||||||
{device.workTime && (
|
|
||||||
<div className="device-info__work">
|
|
||||||
<span className={getDotClass(device.condition)} />
|
|
||||||
В работе: {device.workTime}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{device.employee && (
|
<div className="device-info__imei">
|
||||||
<div className="device-info__employee">{device.employee}</div>
|
IMEI: {device.imei || '—'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="device-info__imei">
|
||||||
|
IMEI 2: {device.imei2 || '—'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{device.lastLocation && (
|
||||||
|
<div className="device-info__employee">
|
||||||
|
Последняя геопозиция:{' '}
|
||||||
|
{formatLocationDate(device.lastLocation.date)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div className="devices-status">
|
<div className={`devices-status ${device.lastLocation? 'devices-status--green' : 'devices-status--red'}`}>
|
||||||
<span className={getDotClass(device.condition)} />
|
<span className={`devices-dot ${device.lastLocation? 'devices-dot--green' : 'devices-dot--red'}`} />
|
||||||
{conditionText[device.condition]}
|
|
||||||
|
{device.lastLocation? 'Исправно' : 'Требует ТО'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div className="devices-status">
|
<div className={`devices-status ${device.lastLocation? 'devices-status--green' : 'devices-status--gray'}`}>
|
||||||
<span className={getDotClass(device.connection)} />
|
<span
|
||||||
{device.connectionText}
|
className={
|
||||||
|
device.lastLocation
|
||||||
|
? 'devices-dot devices-dot--green'
|
||||||
|
: 'devices-dot devices-dot--gray'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{device.lastLocation ? 'В сети' : 'Нет данных'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div className="device-icons">
|
<div className="device-icons">
|
||||||
<MapPin className={device.statusIcons.gps ? 'is-active' : ''} size={16} />
|
<MapPin
|
||||||
<Wifi className={device.statusIcons.wifi ? 'is-active' : ''} size={16} />
|
className={device.lastLocation ? 'is-active' : ''}
|
||||||
<Bluetooth
|
|
||||||
className={device.statusIcons.bluetooth ? 'is-active' : ''}
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
<Lock
|
|
||||||
className={
|
|
||||||
device.statusIcons.lock ? 'is-danger is-active' : ''
|
|
||||||
}
|
|
||||||
size={16}
|
size={16}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Camera className={device.statusIcons.camera ? 'is-active' : ''} size={16} />
|
<Wifi className="is-active" size={16} />
|
||||||
<SlidersHorizontal className={device.statusIcons.sim ? 'is-active' : ''} size={16} />
|
<Bluetooth className="is-active" size={16} />
|
||||||
<Volume2 className={device.statusIcons.sound ? 'is-active' : ''} size={16} />
|
<Lock size={16} />
|
||||||
<Store
|
|
||||||
className={
|
<Camera className="is-active" size={16} />
|
||||||
device.statusIcons.kiosk ? 'is-active' : ''
|
<SlidersHorizontal className="is-active" size={16} />
|
||||||
}
|
<Volume2 size={16} />
|
||||||
size={16}
|
<Store size={16} />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<button className="devices-map-btn" type="button">
|
<button
|
||||||
|
className="devices-map-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
navigate(`/devices/${device.id}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Map size={15} />
|
<Map size={15} />
|
||||||
На карте
|
На карте
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -159,23 +237,36 @@ export function DevicesPage() {
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="devices-pagination">
|
<div className="devices-pagination">
|
||||||
<span>1 из 10</span>
|
<span>Страница {currentPage}</span>
|
||||||
|
|
||||||
<div className="devices-pagination__controls">
|
<div className="devices-pagination__controls">
|
||||||
<button type="button" disabled>
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={currentPage === 1 || loading}
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
>
|
||||||
Назад
|
Назад
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="is-active" type="button">
|
<button className="is-active" type="button">
|
||||||
1
|
{currentPage}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!nextKey || loading}
|
||||||
|
onClick={handleNextPage}
|
||||||
|
>
|
||||||
|
Вперед
|
||||||
</button>
|
</button>
|
||||||
<button type="button">2</button>
|
|
||||||
<button type="button">Вперед</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DevicesFiltersPanel isOpen={isFiltersOpen} />
|
<DevicesFiltersPanel isOpen={isFiltersOpen} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
@use '../../shared/styles/variables' as *;
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $color-bg;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 32px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: $gray50;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 450;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #30394b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #dfe5ef;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0 14px;
|
||||||
|
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $blue;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffecec;
|
||||||
|
color: $red;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 450;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: $blue;
|
||||||
|
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { SubmitEvent } from 'react'
|
||||||
|
import { useMutation } from '@apollo/client/react'
|
||||||
|
import { SIGN_IN_MUTATION, CURRENT_USER_QUERY } from '../../features/auth/api/auth.graphql'
|
||||||
|
|
||||||
|
import './LoginPage.scss'
|
||||||
|
|
||||||
|
type LoginPageProps = {
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage({ onSuccess }: LoginPageProps) {
|
||||||
|
const [username, setUsername] = useState('User1')
|
||||||
|
const [password, setPassword] = useState('123456')
|
||||||
|
|
||||||
|
const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, {
|
||||||
|
refetchQueries: [CURRENT_USER_QUERY],
|
||||||
|
onCompleted: () => {
|
||||||
|
onSuccess()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
signIn({
|
||||||
|
variables: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<form className="login-card" onSubmit={handleSubmit}>
|
||||||
|
<div className="login-card__header">
|
||||||
|
<img src='/favicon.svg' height={56} width={56} alt='logo'></img>
|
||||||
|
<p>Авторизуйтесь для доступа к панели управления устройствами</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Логин</span>
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Введите логин"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Пароль</span>
|
||||||
|
<input
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="login-error">
|
||||||
|
Не удалось войти. Проверьте логин и пароль.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="login-button" type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Вход...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import {
|
||||||
|
ApolloClient,
|
||||||
|
from,
|
||||||
|
HttpLink,
|
||||||
|
InMemoryCache,
|
||||||
|
Observable,
|
||||||
|
} from '@apollo/client'
|
||||||
|
import { ErrorLink } from '@apollo/client/link/error'
|
||||||
|
import { CombinedGraphQLErrors } from '@apollo/client/errors'
|
||||||
|
|
||||||
|
const httpLink = new HttpLink({
|
||||||
|
uri: import.meta.env.VITE_GRAPHQL_API_URL,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let pendingRequests: Array<() => void> = []
|
||||||
|
|
||||||
|
function resolvePendingRequests() {
|
||||||
|
pendingRequests.forEach((callback) => callback())
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnauthorizedError(error: unknown) {
|
||||||
|
if (!CombinedGraphQLErrors.is(error)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.errors.some((graphQLError) => {
|
||||||
|
return graphQLError.extensions?.code === 'Unauthorized'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSession() {
|
||||||
|
const response = await fetch(import.meta.env.VITE_GRAPHQL_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
mutation RefreshSession {
|
||||||
|
refreshSession {
|
||||||
|
id
|
||||||
|
role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.errors?.length || !result.data?.refreshSession) {
|
||||||
|
throw new Error('Refresh session failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.refreshSession
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorLink = new ErrorLink(({ error, operation, forward }) => {
|
||||||
|
const isUnauthorized = isUnauthorizedError(error)
|
||||||
|
|
||||||
|
if (!isUnauthorized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Важно: если Unauthorized прилетел уже на refreshSession,
|
||||||
|
* повторять refresh нельзя, иначе можно уйти в цикл.
|
||||||
|
*/
|
||||||
|
if (operation.operationName === 'RefreshSession') {
|
||||||
|
window.dispatchEvent(new Event('auth:logout'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Observable((observer) => {
|
||||||
|
const retryRequest = () => {
|
||||||
|
forward(operation).subscribe({
|
||||||
|
next: observer.next.bind(observer),
|
||||||
|
error: observer.error.bind(observer),
|
||||||
|
complete: observer.complete.bind(observer),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
pendingRequests.push(retryRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
refreshSession()
|
||||||
|
.then(() => {
|
||||||
|
isRefreshing = false
|
||||||
|
resolvePendingRequests()
|
||||||
|
retryRequest()
|
||||||
|
})
|
||||||
|
.catch((refreshError) => {
|
||||||
|
isRefreshing = false
|
||||||
|
pendingRequests = []
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event('auth:logout'))
|
||||||
|
|
||||||
|
observer.error(refreshError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const apolloClient = new ApolloClient({
|
||||||
|
link: from([errorLink, httpLink]),
|
||||||
|
cache: new InMemoryCache({
|
||||||
|
typePolicies: {
|
||||||
|
Query: {
|
||||||
|
fields: {
|
||||||
|
getPhonesPage: {
|
||||||
|
keyArgs: ['key'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
.sidebar__nav {
|
.sidebar__nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
//gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__link {
|
.sidebar__link {
|
||||||
|
|
@ -119,6 +119,7 @@
|
||||||
.sidebar__label {
|
.sidebar__label {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
line-height: 1.1;
|
||||||
transition:
|
transition:
|
||||||
width 0.2s ease,
|
width 0.2s ease,
|
||||||
opacity 0.2s ease;
|
opacity 0.2s ease;
|
||||||
|
|
@ -140,6 +141,7 @@
|
||||||
.wrap-btn__text {
|
.wrap-btn__text {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
line-height: 1.1;
|
||||||
transition:
|
transition:
|
||||||
width 0.2s ease,
|
width 0.2s ease,
|
||||||
opacity 0.2s ease;
|
opacity 0.2s ease;
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react'
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/graphql': {
|
||||||
|
target: 'http://192.168.1.179:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue