Compare commits
3 Commits
595d059151
...
6f4b46c964
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f4b46c964 | |||
| 0ddd9b5f80 | |||
| 3027b4f0f0 |
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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. При невалидных датах — показ ошибки под полем, запрос не выполняется.
|
||||||
|
|||||||
@ -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 для валидации, форматирования и дефолтов (today–today+7d).
|
||||||
|
- [x] Валидация to >= from с показом ошибки под полем.
|
||||||
- [x] Реализовать summary по выбранному периоду.
|
- [x] Реализовать summary по выбранному периоду.
|
||||||
- [x] Реализовать список событий с признаком `estimate`.
|
- [x] Реализовать список событий с признаком `estimate`.
|
||||||
- [ ] Разделить денежные и неденежные события на уровне представления.
|
- [ ] Разделить денежные и неденежные события на уровне представления.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user