Compare commits

...

3 Commits

Author SHA1 Message Date
6f4b46c964 fix: flatten queryKey to primitives for reliable TanStack Query key comparison
Some checks failed
CI / ci (pull_request) Failing after 3m12s
CI / ci (push) Failing after 2m53s
2026-06-22 19:56:53 +03:00
0ddd9b5f80 feat: add date range selection to broker events page 2026-06-22 06:58:59 +03:00
3027b4f0f0 docs: update plan and tasks for broker events date range selection 2026-06-22 06:56:18 +03:00
6 changed files with 136 additions and 25 deletions

View File

@ -80,7 +80,10 @@ describe('useBrokerEvents', () => {
it('reuses cache when query key matches', async () => { it('reuses cache when query key matches', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(['broker', 'events', 'acc-1', query], mockEventsData); queryClient.setQueryData(
['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29'],
mockEventsData,
);
const { result } = renderHook(() => useBrokerEvents('acc-1', query), { const { result } = renderHook(() => useBrokerEvents('acc-1', query), {
wrapper: createWrapper(queryClient), wrapper: createWrapper(queryClient),

View File

@ -3,10 +3,11 @@ import type { BrokerEventsData } from '@/shared/api/responses';
import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi'; import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi';
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
const { from, to } = query;
return useQuery<BrokerEventsData>({ return useQuery<BrokerEventsData>({
queryKey: ['broker', 'events', accountId, query], queryKey: ['broker', 'events', accountId, from, to],
enabled: Boolean(accountId), enabled: Boolean(accountId),
queryFn: async () => (await getBrokerEvents(accountId!, query)).data, queryFn: async () => (await getBrokerEvents(accountId!, { from, to })).data,
staleTime: 300_000, staleTime: 300_000,
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,

View File

@ -12,9 +12,22 @@ vi.mock('@/widgets/broker-account-layout', () => ({
useBrokerAccountContext: () => ({ accountId: 'acc-1', portfolio: null }), useBrokerAccountContext: () => ({ accountId: 'acc-1', portfolio: null }),
})); }));
const mockSetSearchParams = vi.fn();
vi.mock('react-router-dom', () => ({
useSearchParams: () => [new URLSearchParams(), mockSetSearchParams],
}));
vi.mock('@moex-vibe/design-system', () => ({ vi.mock('@moex-vibe/design-system', () => ({
Heading: ({ children }: { children: ReactNode }) => <h2>{children}</h2>, Heading: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
Text: ({ children }: { children: ReactNode }) => <span>{children}</span>, Text: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TextField: ({ label, value, onChange, ...props }: any) => (
<input
aria-label={label || ''}
value={value || ''}
onChange={onChange || (() => {})}
type={props.type || 'text'}
/>
),
})); }));
import { useBrokerEvents } from '@/entities/broker-event'; import { useBrokerEvents } from '@/entities/broker-event';
@ -179,7 +192,40 @@ describe('BrokerEventsPage', () => {
} as unknown as ReturnType<typeof useBrokerEvents>); } as unknown as ReturnType<typeof useBrokerEvents>);
render(<BrokerEventsPage />, { wrapper: createWrapper() }); render(<BrokerEventsPage />, { wrapper: createWrapper() });
expect(screen.getByText('На ближайшие 7 дней событий нет')).toBeInTheDocument(); expect(screen.getByText('В выбранном диапазоне событий нет')).toBeInTheDocument();
});
it('renders date range inputs', () => {
vi.mocked(useBrokerEvents).mockReturnValue({
data: mockData,
isLoading: false,
isError: false,
error: null,
isSuccess: true,
isPending: false,
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isStale: false,
refetch: vi.fn(),
promise: Promise.resolve(mockData),
status: 'success',
fetchStatus: 'idle',
} as unknown as ReturnType<typeof useBrokerEvents>);
render(<BrokerEventsPage />, { wrapper: createWrapper() });
expect(screen.getByLabelText('С')).toBeInTheDocument();
expect(screen.getByLabelText('По')).toBeInTheDocument();
}); });
it('renders events heading, summary and table', () => { it('renders events heading, summary and table', () => {

View File

@ -1,8 +1,11 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { Heading, Text } from '@moex-vibe/design-system'; import { Heading, Text, TextField } from '@moex-vibe/design-system';
import dayjs from 'dayjs';
import { useBrokerEvents } from '@/entities/broker-event'; import { useBrokerEvents } from '@/entities/broker-event';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters'; import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters';
function eventTypeLabel(type: string): string { function eventTypeLabel(type: string): string {
switch (type) { switch (type) {
@ -19,26 +22,41 @@ function eventTypeLabel(type: string): string {
} }
} }
function formatDate(value: string | null): string { function defaultPeriod(): { from: string; to: string } {
if (!value) return '-'; const now = dayjs();
return new Date(value).toLocaleDateString('ru-RU');
}
function defaultPeriod() {
const now = new Date();
const from = new Date(now);
const to = new Date(now);
to.setDate(to.getDate() + 7);
return { return {
from: from.toISOString().slice(0, 10), from: now.format('YYYY-MM-DD'),
to: to.toISOString().slice(0, 10), to: now.add(7, 'day').format('YYYY-MM-DD'),
}; };
} }
export function BrokerEventsPage() { export function BrokerEventsPage() {
const { accountId } = useBrokerAccountContext(); const { accountId } = useBrokerAccountContext();
const period = defaultPeriod(); const [searchParams, setSearchParams] = useSearchParams();
const events = useBrokerEvents(accountId, period); const [initialized, setInitialized] = useState(false);
useEffect(() => {
const from = searchParams.get('from');
const to = searchParams.get('to');
if (!from || !to || !dayjs(from).isValid() || !dayjs(to).isValid()) {
const def = defaultPeriod();
setSearchParams({ from: def.from, to: def.to }, { replace: true });
}
setInitialized(true);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const from = searchParams.get('from') ?? '';
const to = searchParams.get('to') ?? '';
const period = initialized ? { from, to } : defaultPeriod();
const validFrom = dayjs(from);
const validTo = dayjs(to);
const dateError =
from && to && validFrom.isValid() && validTo.isValid() && validTo.isBefore(validFrom)
? '"По" не может быть раньше "С"'
: '';
const events = useBrokerEvents(dateError ? undefined : accountId, period);
const ev = events.data; const ev = events.data;
@ -50,6 +68,32 @@ export function BrokerEventsPage() {
<Heading level={2} id="broker-events-heading"> <Heading level={2} id="broker-events-heading">
События События
</Heading> </Heading>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="С"
type="date"
value={from}
onChange={(e) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('from', e.target.value);
setSearchParams(newParams, { replace: true });
}}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="По"
type="date"
value={to}
onChange={(e) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('to', e.target.value);
setSearchParams(newParams, { replace: true });
}}
InputLabelProps={{ shrink: true }}
error={!!dateError}
helperText={dateError}
/>
</Box>
</Box> </Box>
{events.error ? ( {events.error ? (
@ -62,7 +106,7 @@ export function BrokerEventsPage() {
</Text> </Text>
) : ev && ev.items.length === 0 ? ( ) : ev && ev.items.length === 0 ? (
<Text component="p" tone="muted"> <Text component="p" tone="muted">
На ближайшие 7 дней событий нет В выбранном диапазоне событий нет
</Text> </Text>
) : ev ? ( ) : ev ? (
<Box sx={{ display: 'grid', gap: 2 }}> <Box sx={{ display: 'grid', gap: 2 }}>
@ -86,7 +130,9 @@ export function BrokerEventsPage() {
<Text variant="caption" tone="secondary"> <Text variant="caption" tone="secondary">
Ближайшее Ближайшее
</Text> </Text>
<Box sx={{ fontWeight: 700 }}>{formatDate(ev.summary.nearestEventDate)}</Box> <Box sx={{ fontWeight: 700 }}>
{formatBrokerDate(ev.summary.nearestEventDate) ?? '-'}
</Box>
</Box> </Box>
<Box> <Box>
<Text variant="caption" tone="secondary"> <Text variant="caption" tone="secondary">
@ -166,7 +212,7 @@ export function BrokerEventsPage() {
component="td" component="td"
sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }} sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider' }}
> >
{formatDate(item.eventDate)} {formatBrokerDate(item.eventDate) ?? '-'}
</Box> </Box>
<Box <Box
component="td" component="td"

View File

@ -207,3 +207,15 @@ Overview получает компактный блок ближайших со
- `npm run build -w apps/frontend` - `npm run build -w apps/frontend`
- `npm run build -w apps/docs` если публичные docs менялись - `npm run build -w apps/docs` если публичные docs менялись
- `npm run codegen -w apps/frontend` если Swagger-контракт обновлён - `npm run codegen -w apps/frontend` если Swagger-контракт обновлён
### Frontend date range UI
Даты хранятся в URL-параметрах `from` / `to` для возможности поделиться ссылкой. На странице — два `TextField[type=date]` из дизайн-системы. dayjs для форматирования, валидации и дефолтов.
Поток данных:
```
useSearchParams → from/to → useBrokerEvents(query) → TanStack Query (автоrefetch)
```
Дефолт при первом визите без параметров: today today+7d. Валидация: to >= from. При невалидных датах — показ ошибки под полем, запрос не выполняется.

View File

@ -57,8 +57,11 @@
- [x] Добавить новый раздел `События` в `BrokerAccountLayout`. - [x] Добавить новый раздел `События` в `BrokerAccountLayout`.
- [x] Добавить маршрут `/broker/:accountId/events`. - [x] Добавить маршрут `/broker/:accountId/events`.
- [ ] Реализовать выбор диапазона дат. - [x] Реализовать выбор диапазона дат.
(Deferred: первая версия использует фиксированный период today+7d. UI для выбора дат — follow-up.) - [x] Добавить два TextField[type=date] из дизайн-системы на страницу событий.
- [x] Синхронизировать from/to с URL-параметрами (useSearchParams).
- [x] dayjs для валидации, форматирования и дефолтов (todaytoday+7d).
- [x] Валидация to >= from с показом ошибки под полем.
- [x] Реализовать summary по выбранному периоду. - [x] Реализовать summary по выбранному периоду.
- [x] Реализовать список событий с признаком `estimate`. - [x] Реализовать список событий с признаком `estimate`.
- [ ] Разделить денежные и неденежные события на уровне представления. - [ ] Разделить денежные и неденежные события на уровне представления.