Подключение 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: 1;
|
||||
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;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
background: $color-bg;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,19 @@ import { StrictMode } from 'react'
|
|||
import { createRoot } from 'react-dom/client'
|
||||
import './index.scss'
|
||||
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 'react-day-picker/style.css'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { AuthGate } from './features/auth/ui/AuthGate'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<AuthGate>
|
||||
<RouterProvider router={router} />
|
||||
</AuthGate>
|
||||
</ApolloProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@apollo/client/react'
|
||||
|
||||
import devices from '../DevicesPage/devices.mock.json'
|
||||
import type { Device } from './types'
|
||||
import type { Device as PageDevice } 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 { DeviceMapCard } from './components/DeviceMapCard/DeviceMapCard'
|
||||
|
|
@ -13,17 +19,137 @@ import { DeviceStatsCards } from './components/DeviceStatsCards/DeviceStatsCards
|
|||
|
||||
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() {
|
||||
const { deviceId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const device = useMemo(() => {
|
||||
return typedDevices.find((item) => String(item.id) === deviceId)
|
||||
}, [deviceId])
|
||||
const phoneId = Number(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 (
|
||||
<section className="device-page">
|
||||
<div className="device-page__empty">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export type Device = {
|
|||
factoryNumber: string
|
||||
model?: string
|
||||
imei: string
|
||||
imei2: string
|
||||
serialNumber?: string
|
||||
workTime: string | null
|
||||
employee: string | null
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
//overflow: hidden;
|
||||
}
|
||||
|
||||
.devices-table-container {
|
||||
|
|
@ -17,6 +20,10 @@
|
|||
.devices-table-filter-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.devices-table-card {
|
||||
|
|
@ -24,6 +31,26 @@
|
|||
overflow: auto;
|
||||
border-radius: 20px;
|
||||
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 {
|
||||
|
|
@ -34,8 +61,10 @@
|
|||
&__row {
|
||||
transition: .2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray20;
|
||||
|
||||
.devices-map-btn {
|
||||
background-color: white;
|
||||
}
|
||||
|
|
@ -135,10 +164,24 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
text-transform: uppercase;
|
||||
color: #111827;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useApolloClient, useQuery } from '@apollo/client/react'
|
||||
import {
|
||||
Bluetooth,
|
||||
Camera,
|
||||
|
|
@ -12,67 +13,130 @@ import {
|
|||
Store,
|
||||
} from 'lucide-react'
|
||||
|
||||
import devices from './devices.mock.json'
|
||||
import './DevicesPage.scss'
|
||||
|
||||
import { DevicesTabs } from './components/DevicesTabs/DevicesTabs'
|
||||
import { DevicesToolbar } from './components/DevicesToolbar/DevicesToolbar'
|
||||
import { DevicesFiltersPanel } from './components/DevicesFiltersPanel/DevicesFiltersPanel'
|
||||
|
||||
type DeviceCondition = 'ok' | 'inspection'
|
||||
type DeviceConnection = 'online' | 'offline' | 'offlineDanger'
|
||||
import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql'
|
||||
import type { Device, GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types'
|
||||
|
||||
type Device = {
|
||||
id: number
|
||||
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
|
||||
}
|
||||
}
|
||||
function formatLocationDate(timestamp: number) {
|
||||
if (!timestamp) return 'Нет данных'
|
||||
|
||||
const typedDevices = devices as Device[]
|
||||
|
||||
const conditionText: Record<DeviceCondition, string> = {
|
||||
ok: 'Исправно',
|
||||
inspection: 'Требует осмотра',
|
||||
}
|
||||
|
||||
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'
|
||||
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))
|
||||
}
|
||||
|
||||
export function DevicesPage() {
|
||||
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState(true)
|
||||
|
||||
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 (
|
||||
<section className="devices-page">
|
||||
|
||||
<DevicesTabs />
|
||||
|
||||
<DevicesToolbar
|
||||
isFiltersOpen={isFiltersOpen}
|
||||
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">
|
||||
|
||||
{!loading && !error && devices.length === 0 && (
|
||||
<div className="devices-state">Устройства не найдены</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<table className="devices-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -86,71 +150,85 @@ export function DevicesPage() {
|
|||
</thead>
|
||||
|
||||
<tbody>
|
||||
{typedDevices.map((device) => (
|
||||
<tr key={device.id} className="devices-table__row" onClick={() => navigate(`/devices/${device.id}`)}>
|
||||
{devices.map((device) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="devices-table__row"
|
||||
onClick={() => navigate(`/devices/${device.id}`)}
|
||||
>
|
||||
<td className="devices-table__id">{device.id}</td>
|
||||
|
||||
<td>
|
||||
<div className="device-info">
|
||||
<div className="device-info__number">{device.factoryNumber}</div>
|
||||
<div className="device-info__imei">{device.imei}</div>
|
||||
|
||||
{device.workTime && (
|
||||
<div className="device-info__work">
|
||||
<span className={getDotClass(device.condition)} />
|
||||
В работе: {device.workTime}
|
||||
<div className="device-info__number">
|
||||
{device.serial || '—'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.employee && (
|
||||
<div className="device-info__employee">{device.employee}</div>
|
||||
<div className="device-info__imei">
|
||||
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>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div className="devices-status">
|
||||
<span className={getDotClass(device.condition)} />
|
||||
{conditionText[device.condition]}
|
||||
<div className={`devices-status ${device.lastLocation? 'devices-status--green' : 'devices-status--red'}`}>
|
||||
<span className={`devices-dot ${device.lastLocation? 'devices-dot--green' : 'devices-dot--red'}`} />
|
||||
|
||||
{device.lastLocation? 'Исправно' : 'Требует ТО'}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div className="devices-status">
|
||||
<span className={getDotClass(device.connection)} />
|
||||
{device.connectionText}
|
||||
<div className={`devices-status ${device.lastLocation? 'devices-status--green' : 'devices-status--gray'}`}>
|
||||
<span
|
||||
className={
|
||||
device.lastLocation
|
||||
? 'devices-dot devices-dot--green'
|
||||
: 'devices-dot devices-dot--gray'
|
||||
}
|
||||
/>
|
||||
{device.lastLocation ? 'В сети' : 'Нет данных'}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div className="device-icons">
|
||||
<MapPin className={device.statusIcons.gps ? 'is-active' : ''} size={16} />
|
||||
<Wifi className={device.statusIcons.wifi ? 'is-active' : ''} size={16} />
|
||||
<Bluetooth
|
||||
className={device.statusIcons.bluetooth ? 'is-active' : ''}
|
||||
size={16}
|
||||
/>
|
||||
<Lock
|
||||
className={
|
||||
device.statusIcons.lock ? 'is-danger is-active' : ''
|
||||
}
|
||||
<MapPin
|
||||
className={device.lastLocation ? 'is-active' : ''}
|
||||
size={16}
|
||||
/>
|
||||
|
||||
<Camera className={device.statusIcons.camera ? 'is-active' : ''} size={16} />
|
||||
<SlidersHorizontal className={device.statusIcons.sim ? 'is-active' : ''} size={16} />
|
||||
<Volume2 className={device.statusIcons.sound ? 'is-active' : ''} size={16} />
|
||||
<Store
|
||||
className={
|
||||
device.statusIcons.kiosk ? 'is-active' : ''
|
||||
}
|
||||
size={16}
|
||||
/>
|
||||
<Wifi className="is-active" size={16} />
|
||||
<Bluetooth className="is-active" size={16} />
|
||||
<Lock size={16} />
|
||||
|
||||
<Camera className="is-active" size={16} />
|
||||
<SlidersHorizontal className="is-active" size={16} />
|
||||
<Volume2 size={16} />
|
||||
<Store size={16} />
|
||||
</div>
|
||||
</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} />
|
||||
На карте
|
||||
</button>
|
||||
|
|
@ -159,23 +237,36 @@ export function DevicesPage() {
|
|||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="devices-pagination">
|
||||
<span>1 из 10</span>
|
||||
<span>Страница {currentPage}</span>
|
||||
|
||||
<div className="devices-pagination__controls">
|
||||
<button type="button" disabled>
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === 1 || loading}
|
||||
onClick={handlePrevPage}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
|
||||
<button className="is-active" type="button">
|
||||
1
|
||||
{currentPage}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!nextKey || loading}
|
||||
onClick={handleNextPage}
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
<button type="button">2</button>
|
||||
<button type="button">Вперед</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DevicesFiltersPanel isOpen={isFiltersOpen} />
|
||||
</div>
|
||||
</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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
//gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar__link {
|
||||
|
|
@ -119,6 +119,7 @@
|
|||
.sidebar__label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
line-height: 1.1;
|
||||
transition:
|
||||
width 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
|
|
@ -140,6 +141,7 @@
|
|||
.wrap-btn__text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
line-height: 1.1;
|
||||
transition:
|
||||
width 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
|
|
|
|||
|
|
@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react'
|
|||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/graphql': {
|
||||
target: 'http://192.168.1.179:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue