- 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
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|