Подключение Apollo, получение устройств

This commit is contained in:
neizbejnoezlo 2026-04-29 17:59:26 +07:00
parent b978875a0b
commit 9a07d006d7
17 changed files with 882 additions and 151 deletions

1
mdm-front/.env Normal file
View File

@ -0,0 +1 @@
VITE_GRAPHQL_API_URL=/graphql

View File

@ -9,4 +9,5 @@
flex-direction: column;
flex: 1;
padding: 20px 36px 36px 0;
max-height: calc(100vh - 56px);
}

View File

@ -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
}
}
}
`

View File

@ -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
}

View File

@ -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
}
}
`

View File

@ -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
}

View File

@ -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;

View File

@ -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>
<RouterProvider router={router} />
<ApolloProvider client={apolloClient}>
<AuthGate>
<RouterProvider router={router} />
</AuthGate>
</ApolloProvider>
</StrictMode>,
)

View File

@ -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">
@ -40,13 +166,13 @@ export function DevicePage() {
return (
<section className="device-page">
<div className="device-breadcrumbs">
<Link to="/devices">
Все устройства
</Link>
<Link to="/devices">
Все устройства
</Link>
<span>/</span>
<span>{device.factoryNumber}</span>
</div>
<span>/</span>
<span>{device.factoryNumber}</span>
</div>
<div className="device-page__grid">
<DeviceMainCard device={device} />

View File

@ -6,6 +6,7 @@ export type Device = {
factoryNumber: string
model?: string
imei: string
imei2: string
serialNumber?: string
workTime: string | null
employee: string | null

View File

@ -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 {
@ -31,12 +58,14 @@
border-collapse: collapse;
table-layout: fixed;
&__row{
&__row {
transition: .2s ease;
cursor: pointer;
&:hover{
&:hover {
background-color: $gray20;
.devices-map-btn{
.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 {

View File

@ -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,170 +13,260 @@ 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">
<table className="devices-table">
<thead>
<tr>
<th>ID</th>
<th>Информация</th>
<th>Состояние</th>
<th>Связь</th>
<th>Статусы</th>
<th />
</tr>
</thead>
<tbody>
{typedDevices.map((device) => (
<tr key={device.id} className="devices-table__row" onClick={() => navigate(`/devices/${device.id}`)}>
<td className="devices-table__id">{device.id}</td>
{!loading && !error && devices.length === 0 && (
<div className="devices-state">Устройства не найдены</div>
)}
<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>
)}
{device.employee && (
<div className="device-info__employee">{device.employee}</div>
)}
</div>
</td>
<td>
<div className="devices-status">
<span className={getDotClass(device.condition)} />
{conditionText[device.condition]}
</div>
</td>
<td>
<div className="devices-status">
<span className={getDotClass(device.connection)} />
{device.connectionText}
</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' : ''
}
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}
/>
</div>
</td>
<td>
<button className="devices-map-btn" type="button">
<Map size={15} />
На карте
</button>
</td>
{!loading && !error && (
<table className="devices-table">
<thead>
<tr>
<th>ID</th>
<th>Информация</th>
<th>Состояние</th>
<th>Связь</th>
<th>Статусы</th>
<th />
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{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.serial || '—'}
</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 ${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 ${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.lastLocation ? '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"
onClick={(event) => {
event.stopPropagation()
navigate(`/devices/${device.id}`)
}}
>
<Map size={15} />
На карте
</button>
</td>
</tr>
))}
</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>

View File

@ -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;
}
}

View File

@ -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>
)
}

View File

@ -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'],
},
},
},
},
}),
})

View File

@ -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;

View File

@ -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,
},
},
},
})