Sergey Krylov cfadb2adbe feat: migrate from react-router-dom to @tanstack/react-router (code-first)
- Create code-first route tree in src/app/routing/routeTree.tsx
- Replace ProtectedRoute with beforeLoad auth guards
- Add useSearchParamsCompat for URLSearchParams access
- Update App.tsx, layouts, and all page/widget imports
- Add frontend tooling: biome, prettier, env config
- Update all tests for TanStack Router compatibility
- Remove react-router-dom dependency, @tanstack/router-plugin
- Consolidate biome config at root level
2026-06-23 06:51:48 +03:00

411 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Button, Checkbox, Chip, Heading, Text, TextField } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { useBrokerEvents } from '@/entities/broker-event'
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams'
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
const EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const
type EventType = (typeof EVENT_TYPES)[number]
type Filters = {
from: string
to: string
types: EventType[]
}
const EVENT_TYPE_OPTIONS: { value: EventType; label: string }[] = [
{ value: 'dividend', label: 'Дивиденды' },
{ value: 'coupon', label: 'Купоны' },
{ value: 'maturity', label: 'Погашения' },
{ value: 'offer', label: 'Оферты' },
]
function eventTypeLabel(type: string): string {
switch (type) {
case 'dividend':
return 'Дивиденд'
case 'coupon':
return 'Купон'
case 'maturity':
return 'Погашение'
case 'offer':
return 'Оферта'
default:
return type
}
}
function defaultPeriod(): { from: string; to: string } {
const now = dayjs()
return {
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
to: now.add(7, 'day').format('YYYY-MM-DD'),
}
}
function parseTypes(value: string | null): EventType[] {
if (!value) return [...EVENT_TYPES]
const parsed = value
.split(',')
.map((type) => type.trim())
.filter((type): type is EventType => EVENT_TYPES.includes(type as EventType))
return parsed.length > 0 ? parsed : [...EVENT_TYPES]
}
function filtersFromSearchParams(searchParams: URLSearchParams): Filters {
const def = defaultPeriod()
const from = searchParams.get('from')
const to = searchParams.get('to')
return {
from: from && dayjs(from).isValid() ? from : def.from,
to: to && dayjs(to).isValid() ? to : def.to,
types: parseTypes(searchParams.get('types')),
}
}
function filtersToSearchParams(filters: Filters): URLSearchParams {
const next = new URLSearchParams()
next.set('from', filters.from)
next.set('to', filters.to)
next.set('types', filters.types.join(','))
return next
}
function sourceLabel(source: string): string {
return source === 'actual' ? 'Факт' : 'Прогноз'
}
export function BrokerEventsPage() {
const { accountId } = useBrokerAccountContext()
const [searchParams, setSearchParams] = useSearchParamsCompat()
const [appliedFilters, setAppliedFilters] = useState<Filters>(() =>
filtersFromSearchParams(searchParams),
)
const [draftFilters, setDraftFilters] = useState<Filters>(() =>
filtersFromSearchParams(searchParams),
)
useEffect(() => {
const next = filtersFromSearchParams(searchParams)
setAppliedFilters(next)
setDraftFilters(next)
}, [searchParams])
const from = draftFilters.from
const to = draftFilters.to
const validFrom = dayjs(from)
const validTo = dayjs(to)
const dateError =
from && to && validFrom.isValid() && validTo.isValid() && validTo.isBefore(validFrom)
? '"По" не может быть раньше "С"'
: ''
const typeError = draftFilters.types.length === 0 ? 'Выберите хотя бы один тип события' : ''
const filterError = dateError || typeError
const events = useBrokerEvents(filterError ? undefined : accountId, {
from: appliedFilters.from,
to: appliedFilters.to,
types: appliedFilters.types.join(','),
})
const ev = events.data
function toggleType(type: EventType, checked: boolean) {
setDraftFilters((current) => ({
...current,
types: checked
? [...new Set([...current.types, type])]
: current.types.filter((t) => t !== type),
}))
}
function applyFilters() {
if (filterError) return
setAppliedFilters(draftFilters)
setSearchParams(filtersToSearchParams(draftFilters), { replace: true })
}
return (
<Box component="section" aria-labelledby="broker-events-heading">
<Box
sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }}
>
<Heading level={2} id="broker-events-heading">
События
</Heading>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'flex-end' }}>
<TextField
label="С"
type="date"
value={from}
onChange={(e) => {
setDraftFilters((current) => ({ ...current, from: e.target.value }))
}}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="По"
type="date"
value={to}
onChange={(e) => {
setDraftFilters((current) => ({ ...current, to: e.target.value }))
}}
InputLabelProps={{ shrink: true }}
error={!!dateError}
helperText={dateError}
/>
<Box
sx={{
border: '1px solid',
borderColor: typeError ? 'error.main' : 'divider',
borderRadius: 2,
px: 1.5,
py: 1,
minWidth: 280,
}}
>
<Text variant="caption" tone={typeError ? 'negative' : 'secondary'}>
Типы событий
</Text>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{EVENT_TYPE_OPTIONS.map((option) => (
<Checkbox
key={option.value}
label={option.label}
checked={draftFilters.types.includes(option.value)}
onChange={(checked) => toggleType(option.value, checked)}
error={!!typeError}
/>
))}
</Box>
{typeError && (
<Text variant="caption" tone="negative">
{typeError}
</Text>
)}
</Box>
<Button onClick={applyFilters} disabled={!!filterError}>
Показать
</Button>
</Box>
</Box>
{events.error ? (
<Text component="p" tone="negative" role="alert">
Не удалось загрузить календарь событий
</Text>
) : events.isLoading ? (
<Text component="p" tone="muted">
Загрузка событий
</Text>
) : ev && ev.items.length === 0 ? (
<Text component="p" tone="muted">
В выбранном диапазоне событий нет
</Text>
) : ev ? (
<Box sx={{ display: 'grid', gap: 2 }}>
<Box
sx={{
p: 2,
bgcolor: 'surface.default',
borderRadius: 2,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 2,
}}
>
<Box>
<Text variant="caption" tone="secondary">
Событий
</Text>
<Box sx={{ fontWeight: 700 }}>{ev.summary.eventCount}</Box>
</Box>
<Box>
<Text variant="caption" tone="secondary">
Ближайшее
</Text>
<Box sx={{ fontWeight: 700 }}>
{formatBrokerDate(ev.summary.nearestEventDate) ?? '-'}
</Box>
</Box>
<Box>
<Text variant="caption" tone="secondary">
Прогноз выплат
</Text>
<Box sx={{ fontWeight: 700 }}>
~{formatBrokerCurrencyValue('RUB', ev.summary.forecastEstimatedCashflow)}
</Box>
<Text variant="caption" tone="muted">
оценка*
</Text>
</Box>
<Box>
<Text variant="caption" tone="secondary">
Поступило
</Text>
<Box sx={{ fontWeight: 700, color: 'success.main' }}>
+{formatBrokerCurrencyValue('RUB', ev.summary.actualCashflow)}
</Box>
</Box>
</Box>
<Box sx={{ p: 2, bgcolor: 'surface.default', borderRadius: 2 }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<Box component="thead">
<Box component="tr">
<Box
component="th"
sx={{
textAlign: 'left',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Дата
</Box>
<Box
component="th"
sx={{
textAlign: 'left',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Тип
</Box>
<Box
component="th"
sx={{
textAlign: 'left',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Статус
</Box>
<Box
component="th"
sx={{
textAlign: 'left',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Инструмент
</Box>
<Box
component="th"
sx={{
textAlign: 'right',
borderBottom: '1px solid',
borderColor: 'divider',
p: 1,
color: 'text.secondary',
fontWeight: 600,
}}
>
Сумма
</Box>
</Box>
</Box>
<Box component="tbody">
{ev.items.map((item) => (
<Box component="tr" key={item.id}>
<Box
component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{formatBrokerDate(item.eventDate) ?? '-'}
</Box>
<Box
component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{eventTypeLabel(item.type)}
</Box>
<Box
component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Chip
label={sourceLabel(item.source)}
tone={item.source === 'actual' ? 'success' : 'info'}
/>
</Box>
<Box
component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{item.ticker && <Box sx={{ fontWeight: 700 }}>{item.ticker}</Box>}
{item.name && item.name !== item.ticker && (
<Text variant="caption" tone="secondary">
{item.name}
</Text>
)}
</Box>
<Box
component="td"
sx={{
textAlign: 'right',
p: 1,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{item.source === 'actual' && item.actualAmount != null ? (
<>
<Box sx={{ fontWeight: 700, color: 'success.main' }}>
+{formatBrokerCurrencyValue(item.currency ?? 'RUB', item.actualAmount)}
</Box>
<Text variant="caption" tone="secondary">
Поступило
</Text>
</>
) : item.estimatedAmount != null ? (
<>
<Box sx={{ fontWeight: 700 }}>
~
{formatBrokerCurrencyValue(
item.currency ?? 'RUB',
item.estimatedAmount,
)}
</Box>
<Text variant="caption" tone="muted">
оценка*
</Text>
</>
) : (
<Text tone="muted"></Text>
)}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
</Box>
) : null}
</Box>
)
}