codex/broker-dashboard-redesign #49
@ -24,6 +24,7 @@
|
||||
"@moex-vibe/design-system": "*",
|
||||
"@mui/icons-material": "^6.5.0",
|
||||
"@mui/material": "^6.5.0",
|
||||
"@mui/x-date-pickers": "^7.29.4",
|
||||
"@tanstack/react-query": "^5.20.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
@ -43,7 +44,6 @@
|
||||
"@biomejs/biome": "^2.5.0",
|
||||
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
||||
"@tanstack/router-devtools": "^1.167.0",
|
||||
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
@ -18,7 +18,7 @@ export function defaultEventsFilters(): DashboardFilterState<DashboardEventType>
|
||||
const now = dayjs()
|
||||
return {
|
||||
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
||||
to: now.add(7, 'day').format('YYYY-MM-DD'),
|
||||
to: now.format('YYYY-MM-DD'),
|
||||
types: [...DASHBOARD_EVENT_TYPES],
|
||||
preset: '7d',
|
||||
}
|
||||
@ -27,10 +27,10 @@ export function defaultEventsFilters(): DashboardFilterState<DashboardEventType>
|
||||
export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType> {
|
||||
const now = dayjs()
|
||||
return {
|
||||
from: now.startOf('year').format('YYYY-MM-DD'),
|
||||
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
||||
to: now.format('YYYY-MM-DD'),
|
||||
types: [...DASHBOARD_INCOME_TYPES],
|
||||
preset: '1y',
|
||||
preset: '7d',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { BrokerDashboard } from './BrokerDashboard'
|
||||
@ -67,6 +70,10 @@ const portfolio: BrokerPortfolio = {
|
||||
asOf: '2026-06-26T00:00:00.000Z',
|
||||
}
|
||||
|
||||
function renderWithProviders(ui: ReactNode) {
|
||||
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>)
|
||||
}
|
||||
|
||||
describe('BrokerDashboard', () => {
|
||||
beforeEach(() => {
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
@ -88,7 +95,7 @@ describe('BrokerDashboard', () => {
|
||||
})
|
||||
|
||||
it('renders the dashboard sections', () => {
|
||||
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.getByText('Основной счёт')).toBeInTheDocument()
|
||||
expect(screen.getByText('События')).toBeInTheDocument()
|
||||
@ -99,7 +106,7 @@ describe('BrokerDashboard', () => {
|
||||
|
||||
it('applies event type chips immediately to useBrokerEvents', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
||||
await user.click(couponChips[0])
|
||||
@ -115,7 +122,7 @@ describe('BrokerDashboard', () => {
|
||||
|
||||
it('applies income type chips immediately to useBrokerOperations', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
||||
await user.click(couponChips[1])
|
||||
@ -131,14 +138,17 @@ describe('BrokerDashboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows date filter toggle button and expandable panel', async () => {
|
||||
it('shows date filter toggle button with apply action', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const filterButtons = screen.getAllByRole('button', { name: /Фильтр дат/ })
|
||||
expect(filterButtons).toHaveLength(2)
|
||||
const applyButtons = screen.getAllByRole('button', { name: /Применить период/ })
|
||||
expect(applyButtons).toHaveLength(2)
|
||||
|
||||
await user.click(filterButtons[0])
|
||||
const toggleButtons = screen.getAllByRole('button', { name: /📅/ })
|
||||
expect(toggleButtons).toHaveLength(2)
|
||||
|
||||
await user.click(toggleButtons[0])
|
||||
|
||||
expect(screen.getByText('7д')).toBeInTheDocument()
|
||||
expect(screen.getByText('30д')).toBeInTheDocument()
|
||||
@ -147,4 +157,36 @@ describe('BrokerDashboard', () => {
|
||||
expect(screen.getByText('Всё')).toBeInTheDocument()
|
||||
expect(screen.getByText('Сбросить')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows skeleton table while events are loading', () => {
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows skeleton table while income operations are loading', () => {
|
||||
hookMocks.useBrokerOperations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('does not show skeleton when data is loaded', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -39,12 +39,10 @@ export function BrokerDashboard({
|
||||
}) {
|
||||
const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters)
|
||||
const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters)
|
||||
const [eventFilterPanelOpen, setEventFilterPanelOpen] = useState(false)
|
||||
const [eventPage, setEventPage] = useState(1)
|
||||
|
||||
const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const [incomeFilterPanelOpen, setIncomeFilterPanelOpen] = useState(false)
|
||||
const incomePagination = useCursorPagination()
|
||||
|
||||
const analytics = useBrokerAnalytics(accountId)
|
||||
@ -199,8 +197,6 @@ export function BrokerDashboard({
|
||||
onDraftToChange={handleDraftEventToChange}
|
||||
onApplyFilters={applyEventFilters}
|
||||
onResetFilters={resetEventFilters}
|
||||
dateFilterOpen={eventFilterPanelOpen}
|
||||
onToggleDateFilter={() => setEventFilterPanelOpen((v) => !v)}
|
||||
hasDraftTypes={hasDraftEventTypes}
|
||||
page={eventPage}
|
||||
canGoBack={eventPage > 1}
|
||||
@ -224,8 +220,6 @@ export function BrokerDashboard({
|
||||
onDraftToChange={handleDraftIncomeToChange}
|
||||
onApplyFilters={applyIncomeFilters}
|
||||
onResetFilters={resetIncomeFilters}
|
||||
dateFilterOpen={incomeFilterPanelOpen}
|
||||
onToggleDateFilter={() => setIncomeFilterPanelOpen((v) => !v)}
|
||||
hasDraftTypes={hasDraftIncomeTypes}
|
||||
pageNumber={incomePagination.pageNumber}
|
||||
canGoBack={incomePagination.pageNumber > 1}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { Button, Chip, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { Chip, Text } from '@moex-vibe/design-system'
|
||||
import { Box, Popover } from '@mui/material'
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { DashboardDatePreset } from '../lib/dashboardFilters'
|
||||
|
||||
const PRESETS: { key: DashboardDatePreset; label: string }[] = [
|
||||
@ -21,8 +26,6 @@ type BrokerDashboardDateFilterProps = {
|
||||
onToChange: (value: string) => void
|
||||
onReset: () => void
|
||||
onApply: () => void
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
export function BrokerDashboardDateFilter({
|
||||
@ -36,15 +39,63 @@ export function BrokerDashboardDateFilter({
|
||||
onToChange,
|
||||
onReset,
|
||||
onApply,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: BrokerDashboardDateFilterProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||
const [selectingStage, setSelectingStage] = useState<'from' | 'to'>('from')
|
||||
const isOpen = Boolean(anchorEl)
|
||||
|
||||
const handleToggle = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl((prev) => {
|
||||
if (prev) return null
|
||||
return e.currentTarget
|
||||
})
|
||||
setSelectingStage('from')
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null)
|
||||
setSelectingStage('from')
|
||||
}, [])
|
||||
|
||||
const handleCalendarChange = useCallback(
|
||||
(date: dayjs.Dayjs | null) => {
|
||||
if (!date) return
|
||||
if (selectingStage === 'from') {
|
||||
onFromChange(date.format('YYYY-MM-DD'))
|
||||
setSelectingStage('to')
|
||||
} else {
|
||||
if (draftFrom && date.isBefore(dayjs(draftFrom))) {
|
||||
onFromChange(date.format('YYYY-MM-DD'))
|
||||
onToChange('')
|
||||
} else {
|
||||
onToChange(date.format('YYYY-MM-DD'))
|
||||
}
|
||||
setSelectingStage('from')
|
||||
}
|
||||
},
|
||||
[selectingStage, draftFrom, onFromChange, onToChange],
|
||||
)
|
||||
|
||||
function handlePresetClick(p: { key: DashboardDatePreset }) {
|
||||
onPresetChange(p.key)
|
||||
setSelectingStage('from')
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
onReset()
|
||||
setSelectingStage('from')
|
||||
}
|
||||
|
||||
const selectingLabel =
|
||||
selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода'
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
onClick={handleToggle}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@ -60,7 +111,7 @@ export function BrokerDashboardDateFilter({
|
||||
'&:hover': { bgcolor: 'grey.700' },
|
||||
}}
|
||||
>
|
||||
📅 Фильтр дат
|
||||
📅
|
||||
{appliedLabel && (
|
||||
<Box
|
||||
sx={{
|
||||
@ -76,22 +127,38 @@ export function BrokerDashboardDateFilter({
|
||||
{appliedLabel}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ fontSize: 10, ml: 0.25, color: 'grey.400' }}>{isOpen ? '▲' : '▼'}</Box>
|
||||
</Box>
|
||||
<Button variant="primary" size="small" onClick={onApply} disabled={!hasDraftTypes}>
|
||||
Показать
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onApply}
|
||||
disabled={!hasDraftTypes}
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 0.5,
|
||||
p: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
bgcolor: 'grey.50',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: hasDraftTypes ? 'primary.main' : 'grey.400',
|
||||
color: 'common.white',
|
||||
border: 'none',
|
||||
borderRadius: 1.5,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
fontSize: 13,
|
||||
cursor: hasDraftTypes ? 'pointer' : 'default',
|
||||
'&:hover': hasDraftTypes ? { bgcolor: 'primary.dark' } : {},
|
||||
}}
|
||||
>
|
||||
Применить период
|
||||
</Box>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<Box sx={{ p: 2, minWidth: 320 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
|
||||
{PRESETS.map((p) => (
|
||||
<Chip
|
||||
@ -99,42 +166,34 @@ export function BrokerDashboardDateFilter({
|
||||
label={p.label}
|
||||
tone={p.key === preset ? 'primary' : 'neutral'}
|
||||
selected={p.key === preset}
|
||||
onClick={() => onPresetChange(p.key)}
|
||||
onClick={() => handlePresetClick(p)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="date"
|
||||
value={draftFrom}
|
||||
onChange={(e) => onFromChange(e.target.value)}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
fontSize: 13,
|
||||
flex: '0 1 auto',
|
||||
}}
|
||||
/>
|
||||
<Text variant="body" tone="muted">
|
||||
—
|
||||
<Text variant="caption" tone="secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
{selectingLabel}
|
||||
</Text>
|
||||
<input
|
||||
type="date"
|
||||
value={draftTo}
|
||||
onChange={(e) => onToChange(e.target.value)}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
fontSize: 13,
|
||||
flex: '0 1 auto',
|
||||
<DateCalendar
|
||||
value={draftFrom ? dayjs(draftFrom) : null}
|
||||
onChange={handleCalendarChange}
|
||||
sx={{
|
||||
'& .MuiPickersDay-root': {
|
||||
...(draftFrom && draftTo
|
||||
? {
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'common.white',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
onClick={handleReset}
|
||||
sx={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
@ -150,7 +209,8 @@ export function BrokerDashboardDateFilter({
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Popover>
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFi
|
||||
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
||||
|
||||
const EVENT_FILTERS: Array<{
|
||||
type: DashboardEventType
|
||||
@ -35,8 +36,6 @@ type BrokerDashboardEventsCardProps = {
|
||||
onDraftToChange: (value: string) => void
|
||||
onApplyFilters: () => void
|
||||
onResetFilters: () => void
|
||||
dateFilterOpen: boolean
|
||||
onToggleDateFilter: () => void
|
||||
hasDraftTypes: boolean
|
||||
page: number
|
||||
onPreviousPage: () => void
|
||||
@ -68,8 +67,6 @@ export function BrokerDashboardEventsCard({
|
||||
onDraftToChange,
|
||||
onApplyFilters,
|
||||
onResetFilters,
|
||||
dateFilterOpen,
|
||||
onToggleDateFilter,
|
||||
hasDraftTypes,
|
||||
page,
|
||||
onPreviousPage,
|
||||
@ -107,8 +104,6 @@ export function BrokerDashboardEventsCard({
|
||||
onToChange={onDraftToChange}
|
||||
onReset={onResetFilters}
|
||||
onApply={onApplyFilters}
|
||||
isOpen={dateFilterOpen}
|
||||
onToggle={onToggleDateFilter}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
@ -118,7 +113,7 @@ export function BrokerDashboardEventsCard({
|
||||
) : isError ? (
|
||||
<Text tone="negative">Не удалось загрузить события</Text>
|
||||
) : isLoading ? (
|
||||
<Text tone="muted">Загрузка событий…</Text>
|
||||
<BrokerDashboardTableSkeleton rows={5} columns={5} />
|
||||
) : events.length === 0 ? (
|
||||
<Text tone="muted">В ближайшем периоде событий нет</Text>
|
||||
) : (
|
||||
|
||||
@ -7,6 +7,7 @@ import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardF
|
||||
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
||||
|
||||
const INCOME_FILTERS: Array<{
|
||||
type: DashboardIncomeType
|
||||
@ -33,8 +34,6 @@ type BrokerDashboardIncomeCardProps = {
|
||||
onDraftToChange: (value: string) => void
|
||||
onApplyFilters: () => void
|
||||
onResetFilters: () => void
|
||||
dateFilterOpen: boolean
|
||||
onToggleDateFilter: () => void
|
||||
hasDraftTypes: boolean
|
||||
pageNumber: number
|
||||
canGoBack: boolean
|
||||
@ -59,8 +58,6 @@ export function BrokerDashboardIncomeCard({
|
||||
onDraftToChange,
|
||||
onApplyFilters,
|
||||
onResetFilters,
|
||||
dateFilterOpen,
|
||||
onToggleDateFilter,
|
||||
hasDraftTypes,
|
||||
pageNumber,
|
||||
canGoBack,
|
||||
@ -99,8 +96,6 @@ export function BrokerDashboardIncomeCard({
|
||||
onToChange={onDraftToChange}
|
||||
onReset={onResetFilters}
|
||||
onApply={onApplyFilters}
|
||||
isOpen={dateFilterOpen}
|
||||
onToggle={onToggleDateFilter}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
@ -110,7 +105,7 @@ export function BrokerDashboardIncomeCard({
|
||||
) : isError ? (
|
||||
<Text tone="negative">Не удалось загрузить доходные операции</Text>
|
||||
) : isLoading ? (
|
||||
<Text tone="muted">Загрузка доходов…</Text>
|
||||
<BrokerDashboardTableSkeleton rows={5} columns={4} />
|
||||
) : rows.length === 0 ? (
|
||||
<Text tone="muted">Дивидендов и купонов в последних операциях нет</Text>
|
||||
) : (
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { Skeleton } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
|
||||
type BrokerDashboardTableSkeletonProps = {
|
||||
rows?: number
|
||||
columns?: number
|
||||
}
|
||||
|
||||
export function BrokerDashboardTableSkeleton({
|
||||
rows = 10,
|
||||
columns = 5,
|
||||
}: BrokerDashboardTableSkeletonProps) {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 1.5, py: 1 }} data-testid="dashboard-table-skeleton">
|
||||
{Array.from({ length: rows }, (_, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
{Array.from({ length: columns }, (_, j) => (
|
||||
<Skeleton
|
||||
key={j}
|
||||
height={16}
|
||||
width={j === 0 ? 80 : j === columns - 1 ? 60 : 100}
|
||||
shape="rounded"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
**Архитектура:** маршрут и FSD-границы остаются прежними. Композиция собирается в `widgets/broker-dashboard`, данные продолжают приходить из существующих `entities/*` hooks. Навигация счёта остаётся в `BrokerAccountLayout`, а dashboard использует только локальные presentation/helpers без выноса брокерской логики в design system.
|
||||
|
||||
**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, Vitest, Testing Library.
|
||||
**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, MUI X DateCalendar community (`@mui/x-date-pickers`) с Day.js, Vitest, Testing Library.
|
||||
|
||||
---
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — hero KPI с fallback-значениями и выравниванием `Metric`.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — локальный паттерн карточки.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — карточка событий.
|
||||
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемый фильтр дат с draft/apply поведением.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемое управление периодом с draft/apply поведением.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — карточка доходов.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — карточка аналитики доходности.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — карточка аллокации с горизонтальными барами.
|
||||
@ -31,6 +31,7 @@
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels.
|
||||
- Modify: `apps/frontend/package.json` — добавить community-пакет `@mui/x-date-pickers`, если зависимость ещё не подключена; `@mui/x-date-pickers-pro` не добавлять.
|
||||
- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения.
|
||||
|
||||
### Источники данных
|
||||
@ -59,16 +60,26 @@
|
||||
### 3. События
|
||||
|
||||
- Типы событий переключаются chip-фильтрами с немедленным применением.
|
||||
- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по `Показать`.
|
||||
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`.
|
||||
- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода.
|
||||
- В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты.
|
||||
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
|
||||
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
|
||||
- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение.
|
||||
- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров.
|
||||
- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус.
|
||||
|
||||
### 4. Доходы
|
||||
|
||||
- Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
|
||||
- Типы доходов переключаются chip-фильтрами с немедленным применением.
|
||||
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`, чтобы не загружать большой объём операций на первом открытии.
|
||||
- Диапазон дат использует тот же draft/apply паттерн, что и события.
|
||||
- В шапке управления периодом не используется слово `Фильтр`; действие применения периода не называется `Показать`.
|
||||
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
|
||||
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
|
||||
- Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров.
|
||||
- При загрузке карточка показывает skeleton таблицы доходов с колонками дата, инструмент, тип, сумма.
|
||||
|
||||
### 5. Аналитика и аллокация
|
||||
|
||||
@ -118,21 +129,32 @@
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
||||
- `apps/frontend/package.json`
|
||||
|
||||
- [ ] Добавить переиспользуемый `BrokerDashboardDateFilter` с preset chips, полями `from/to`, действиями `Сбросить` и `Показать`.
|
||||
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по `Показать`.
|
||||
- [ ] Добавить или подтвердить зависимость community-пакета `@mui/x-date-pickers` и использовать Day.js adapter.
|
||||
- [ ] Не добавлять `@mui/x-date-pickers-pro` и не использовать `DateRangePicker`.
|
||||
- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку.
|
||||
- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`.
|
||||
- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты.
|
||||
- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня`.
|
||||
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода.
|
||||
- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров.
|
||||
- [ ] Оставить локальные loading/error/empty states внутри карточки.
|
||||
- [ ] Заменить текстовую загрузку событий на skeleton таблицы.
|
||||
- [ ] Оставить локальные error/empty states внутри карточки.
|
||||
|
||||
### Задача 4: Карточка доходов
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
||||
|
||||
- [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state.
|
||||
- [ ] Настроить default range доходов на `сегодня - 7 дней` / `сегодня` вместо периода с начала года.
|
||||
- [ ] Оставить chip-фильтры типов доходов с немедленным применением.
|
||||
- [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров.
|
||||
- [ ] Заменить текстовую загрузку доходов на skeleton таблицы.
|
||||
- [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи.
|
||||
|
||||
### Задача 5: Карточки аналитики и аллокации
|
||||
@ -152,6 +174,9 @@
|
||||
- `docs/features/broker-dashboard-redesign/tasks.md`
|
||||
|
||||
- [ ] Обновить компонентные тесты dashboard так, чтобы они покрывали текущую композицию, фильтры и основные empty/error states.
|
||||
- [ ] Обновить тесты, которые ищут `Фильтр дат` или `Показать`, под новые тексты и accessible labels единого поля периода.
|
||||
- [ ] Добавить/обновить тесты default weekly range для событий и доходов.
|
||||
- [ ] Добавить/обновить тесты skeleton-таблиц для loading state событий и доходов.
|
||||
- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`.
|
||||
- [ ] Прогнать `rtk npm run test:frontend`.
|
||||
- [ ] Прогнать `rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`.
|
||||
@ -165,5 +190,6 @@
|
||||
- Hero KPI и equal-height `Metric`: задача 2.
|
||||
- События с chip filters, date filters и локальной пагинацией: задача 3.
|
||||
- Доходы с chip filters, date filters и cursor pagination: задача 4.
|
||||
- Community DateCalendar range control, weekly default range и skeleton loading tables: задачи 3, 4 и 6.
|
||||
- Аналитика и аллокация с горизонтальными барами: задача 5.
|
||||
- Ошибки, empty states, тесты и ручная верификация: задача 6.
|
||||
|
||||
@ -89,7 +89,7 @@ Hero показывает:
|
||||
### 4. Блок `События`
|
||||
|
||||
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
|
||||
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
|
||||
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня` и типы
|
||||
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
|
||||
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
|
||||
- Пользователь может выбрать несколько типов событий.
|
||||
@ -98,8 +98,17 @@ Hero показывает:
|
||||
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
|
||||
страницу.
|
||||
- Блок содержит фильтр периода `from` / `to`.
|
||||
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
|
||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
|
||||
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`.
|
||||
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
|
||||
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
|
||||
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
|
||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
|
||||
основное действие применения периода с более понятным текстом, чем `Показать`.
|
||||
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`.
|
||||
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
|
||||
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
|
||||
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
|
||||
- Native `<input type="date">` не используется.
|
||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
||||
обязанностью подробной вкладки `События`.
|
||||
- В dashboard отображается не более 10 событий на странице.
|
||||
@ -124,10 +133,18 @@ Hero показывает:
|
||||
валидационное сообщение.
|
||||
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
|
||||
- Блок содержит фильтр периода `from` / `to`.
|
||||
- По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе
|
||||
операций.
|
||||
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
|
||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
|
||||
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`, чтобы dashboard не загружал
|
||||
слишком много операций при первом открытии.
|
||||
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
|
||||
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
|
||||
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
|
||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
|
||||
основное действие применения периода с более понятным текстом, чем `Показать`.
|
||||
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`.
|
||||
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
|
||||
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
|
||||
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
|
||||
- Native `<input type="date">` не используется.
|
||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
||||
обязанностью подробных разделов.
|
||||
- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10.
|
||||
@ -162,6 +179,8 @@ Hero показывает:
|
||||
- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы.
|
||||
- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного
|
||||
контекста.
|
||||
- Загрузка событий и доходов внутри карточек показывает skeleton таблицы соответствующей структуры, а не
|
||||
только текстовую строку загрузки.
|
||||
- Ошибка событий, доходов или analytics отображается внутри соответствующей карточки.
|
||||
- Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения.
|
||||
- Недоступные отдельные значения отображаются как `—`, не подменяются нулём.
|
||||
@ -194,8 +213,15 @@ Hero показывает:
|
||||
- Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса.
|
||||
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
|
||||
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
|
||||
- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
|
||||
периода с popover-календарём на базе community `DateCalendar`.
|
||||
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
|
||||
- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
|
||||
- Блок `Доходы` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
|
||||
периода с popover-календарём на базе community `DateCalendar`.
|
||||
- В шапке управления периодом не отображается слово `Фильтр`, а действие применения периода не называется
|
||||
`Показать`.
|
||||
- Загрузка событий и доходов отображается skeleton-таблицей.
|
||||
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
|
||||
- Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также
|
||||
итоговую стоимость портфеля.
|
||||
|
||||
@ -40,15 +40,23 @@
|
||||
- [x] Добавить unit/component tests для helpers и базовой dashboard composition.
|
||||
- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
|
||||
- [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`.
|
||||
- [x] Убрать слово `Фильтр` из пользовательского текста шапки периода в dashboard-карточках.
|
||||
- [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров.
|
||||
- [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`.
|
||||
- [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`.
|
||||
- [x] Настроить default range событий и доходов на `сегодня - 7 дней` / `сегодня`.
|
||||
- [x] Заменить текстовую загрузку `События` на skeleton таблицы.
|
||||
- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы.
|
||||
- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states.
|
||||
- [ ] Проверить desktop layout `/broker/2084014113`.
|
||||
- [ ] Проверить mobile layout `/broker/2084014113`.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] `rtk npm run test:frontend` проходит.
|
||||
- [ ] `rtk npm run test:design-system` проходит.
|
||||
- [ ] `rtk npm run lint -w apps/frontend` проходит.
|
||||
- [ ] `rtk npm run build:frontend` проходит.
|
||||
- [x] `rtk npm run test:frontend` проходит.
|
||||
- [x] `rtk npm run test:design-system` проходит.
|
||||
- [x] `rtk npm run lint -w apps/frontend` проходит.
|
||||
- [x] `rtk npm run build:frontend` проходит.
|
||||
- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
|
||||
- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
|
||||
- [ ] `graphify update .` выполнен после code changes.
|
||||
- [x] `graphify update .` выполнен после code changes.
|
||||
|
||||
87
package-lock.json
generated
87
package-lock.json
generated
@ -92,6 +92,7 @@
|
||||
"@moex-vibe/design-system": "*",
|
||||
"@mui/icons-material": "^6.5.0",
|
||||
"@mui/material": "^6.5.0",
|
||||
"@mui/x-date-pickers": "^7.29.4",
|
||||
"@tanstack/react-query": "^5.20.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
@ -7426,6 +7427,92 @@
|
||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mui/x-date-pickers": {
|
||||
"version": "7.29.4",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz",
|
||||
"integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.7",
|
||||
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0",
|
||||
"@mui/x-internals": "7.29.0",
|
||||
"@types/react-transition-group": "^4.4.11",
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.9.0",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
|
||||
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
|
||||
"dayjs": "^1.10.7",
|
||||
"luxon": "^3.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"moment-hijri": "^2.1.2 || ^3.0.0",
|
||||
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@emotion/styled": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns-jalali": {
|
||||
"optional": true
|
||||
},
|
||||
"dayjs": {
|
||||
"optional": true
|
||||
},
|
||||
"luxon": {
|
||||
"optional": true
|
||||
},
|
||||
"moment": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-hijri": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-jalaali": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-internals": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz",
|
||||
"integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.7",
|
||||
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user