codex/mcp-tools-setup #43

Merged
ksv741 merged 2 commits from codex/mcp-tools-setup into main 2026-06-24 15:25:37 +03:00
19 changed files with 748 additions and 664 deletions
Showing only changes of commit 2af2ff32c1 - Show all commits

View File

@ -0,0 +1,33 @@
# Memory Maintenance
## Discovery Model
- Core principle: progressive discovery through references, building a graph of memories.
- Initially, agents are provided with the list of all memories (names only).
- Agents should read `mem:core` as the top-level entry point (graph root).
This memory should contain references to other memories covering major project domains.
The referenced memories shall, in turn, shall contain references to even more specific memories, and so on.
The depth of the graph shall depend on the project complexity.
- Use topics/folders to group related memories in order to make the content structure explicit.
Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc.
- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`.
The surrounding text should clearly indicate when to read the memory/which content to expect.
The text should provide more precise guidance than the memory name alone,
i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered.
- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory.
## Style
Dense agent notes, not prose docs. Prefer invariants, terse bullets.
Avoid obvious context, rationale, and examples unless they prevent likely mistakes.
Keep guidance durable and generalizable, not task-local.
## Add/update threshold
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
## Maintenance Actions
- Renaming memories: References are updated automatically if handled via Serena's memory rename tool.
- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report.

View File

@ -174,12 +174,18 @@
Таблица данных на основе TanStack Table.
Использует `TanStack Table` как source of truth для модели данных, а MUI только для табличной
оболочки. Поддерживает loading state, empty state, density и выравнивание через `columnDef.meta.align`.
| Свойство | Тип | По умолчанию |
|----------|-----|--------------|
| `columns` | `ColumnDef<T>[]` | — |
| `data` | `T[]` | — |
| `caption` | `string` | — |
| `density` | `'balanced' \| 'compact'` | `'balanced'` |
| `loading` | `boolean` | `false` |
| `empty` | `ReactNode` | — |
| `renderRow` | `(row) => ReactNode` | — |
### Money

View File

@ -10,6 +10,10 @@
Все новые компоненты должны использовать токены дизайн-системы. Подробнее — [Дизайн-система](../design-system/overview).
Для таблиц используйте `DataTable` из дизайн-системы. Он уже встроен в `TanStack Table` и поддерживает
выравнивание, loading/empty state и кастомный рендер строк без прямого использования `MUI Table` во
frontend-коде.
## Legacy: CSS custom properties
Ранее стили определялись через единый `styles.css` с CSS custom properties. Этот подход считается устаревшим — новые страницы должны использовать токены дизайн-системы.

View File

@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ScreenerTable } from './ScreenerTable'
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: React.ReactNode }) => <a href="/mock">{children}</a>,
}))
const shareResult = {
total: 2,
page: 1,
pageSize: 10,
totalPages: 2,
items: [
{
secid: 'SBER',
shortName: 'Сбер',
type: 'share',
price: 289.5,
changePercent: 0.87,
volume: 1000,
capitalization: 625000000,
},
],
} as const
const bondResult = {
...shareResult,
items: [
{
secid: 'SU26238RMFS5',
shortName: 'ОФЗ 26238',
type: 'bond',
price: 98.5,
changePercent: -0.5,
volume: 500,
yieldToMaturity: 8.2,
duration: 3.5,
couponValue: 36.9,
couponPercent: 7.5,
},
],
} as const
describe('ScreenerTable', () => {
it('renders share columns and pagination', () => {
const onSort = vi.fn()
const onPageChange = vi.fn()
render(
<ScreenerTable
result={shareResult as never}
sortBy="price"
sortOrder="asc"
onSort={onSort}
onPageChange={onPageChange}
/>,
)
expect(screen.getByText('Найдено: 2 бумаг')).toBeInTheDocument()
expect(screen.getByText('Капитализация')).toBeInTheDocument()
expect(screen.getByText('SBER')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument()
expect(onSort).not.toHaveBeenCalled()
})
it('calls onSort when a sortable header is clicked', () => {
const onSort = vi.fn()
render(
<ScreenerTable
result={shareResult as never}
sortBy="price"
sortOrder="asc"
onSort={onSort}
onPageChange={() => undefined}
/>,
)
screen.getByRole('button', { name: /Цена/ }).click()
expect(onSort).toHaveBeenCalledWith('price')
})
it('renders bond columns', () => {
render(
<ScreenerTable
result={bondResult as never}
sortBy="price"
sortOrder="desc"
onSort={() => undefined}
onPageChange={() => undefined}
/>,
)
expect(screen.getByText('YTM')).toBeInTheDocument()
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument()
})
})

View File

@ -1,4 +1,7 @@
import { Button, DataTable, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router'
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { ScreenerResult } from '@/shared/api'
interface Props {
@ -23,149 +26,173 @@ function formatChange(value: number | null | undefined): { text: string; color:
return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color }
}
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
function SortHeader({ field, children }: { field: string; children: string }) {
const isActive = sortBy === field
return (
<th
onClick={() => onSort(field)}
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
cursor: 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
{children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
)
}
type ScreenerItem = ScreenerResult['items'][number]
const columnHelper = createColumnHelper<ScreenerItem>()
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
const isShare = result.items[0]?.type === 'share'
const columns = [
columnHelper.accessor('secid', {
header: 'Тикер',
cell: (info) => {
const link = isShare
? `/stocks/${info.row.original.secid}`
: `/bonds/${info.row.original.secid}`
return (
<Link to={link} style={{ color: 'inherit', textDecoration: 'none' }}>
{info.getValue()}
</Link>
)
},
}),
columnHelper.accessor('shortName', { header: 'Название', cell: (info) => info.getValue() }),
columnHelper.accessor('price', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('price')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Цена {sortBy === 'price' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
}),
columnHelper.accessor('changePercent', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('changePercent')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Изм. {sortBy === 'changePercent' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => {
const change = formatChange(info.getValue())
return <span style={{ color: change.color }}>{change.text}</span>
},
}),
columnHelper.accessor('volume', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('volume')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Объём {sortBy === 'volume' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => info.getValue().toLocaleString('ru-RU'),
}),
...(isShare
? [
columnHelper.accessor('capitalization', {
header: 'Капитализация',
meta: { align: 'right' },
cell: (info) => {
const v = info.getValue()
return v != null ? v.toLocaleString('ru-RU') : '—'
},
}),
]
: [
columnHelper.accessor('yieldToMaturity', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('yieldToMaturity')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
YTM {sortBy === 'yieldToMaturity' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => formatNum(info.getValue()),
}),
columnHelper.accessor('duration', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('duration')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Дюрация {sortBy === 'duration' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => {
const v = info.getValue()
return v != null ? `${v.toFixed(2)}г` : '—'
},
}),
columnHelper.accessor('couponValue', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('couponValue')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Купон {sortBy === 'couponValue' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => formatNum(info.getValue()),
}),
columnHelper.accessor('couponPercent', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('couponPercent')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Куп. % {sortBy === 'couponPercent' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => formatNum(info.getValue()),
}),
]),
]
const table = useReactTable({
data: result.items,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
<Box sx={{ flex: 1 }}>
<Text variant="caption" tone="secondary" style={{ fontSize: 13, marginBottom: 8 }}>
Найдено: {result.total} бумаг
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тикер
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Название
</th>
<SortHeader field="price">Цена</SortHeader>
<SortHeader field="changePercent">Изм.</SortHeader>
<SortHeader field="volume">Объём</SortHeader>
{isShare ? (
<SortHeader field="capitalization">Капитализация</SortHeader>
) : (
<>
<SortHeader field="yieldToMaturity">YTM</SortHeader>
<SortHeader field="duration">Дюрация</SortHeader>
<SortHeader field="couponValue">Купон</SortHeader>
<SortHeader field="couponPercent">Куп. %</SortHeader>
</>
)}
</tr>
</thead>
<tbody>
{result.items.map((item) => {
const change = formatChange(item.changePercent)
const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`
return (
<tr key={item.secid} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
<Link to={link} style={{ color: 'inherit', textDecoration: 'none' }}>
{item.secid}
</Link>
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
{item.shortName}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.price)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right', color: change.color }}>
{change.text}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.volume.toLocaleString('ru-RU')}
</td>
{isShare ? (
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.capitalization != null
? item.capitalization.toLocaleString('ru-RU')
: '—'}
</td>
) : (
<>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.yieldToMaturity)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.duration != null ? `${item.duration.toFixed(2)}г` : '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.couponValue)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.couponPercent)}
</td>
</>
)}
</tr>
)
})}
</tbody>
</table>
</div>
</Text>
<DataTable table={table} caption="Screener" />
{result.totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2 }}>
{Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => (
<button
<Button
key={p}
onClick={() => onPageChange(p)}
style={{
padding: '4px 10px',
background: p === result.page ? 'var(--color-primary)' : 'transparent',
color: p === result.page ? '#fff' : 'var(--color-text)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
cursor: 'pointer',
}}
variant={p === result.page ? 'primary' : 'secondary'}
size="small"
>
{p}
</button>
</Button>
))}
</div>
</Box>
)}
</div>
</Box>
)
}

View File

@ -1,42 +0,0 @@
import { flexRender, type Table as TanStackTable } from '@tanstack/react-table'
interface TableProps<TData> {
table: TanStackTable<TData>
}
export function Table<TData>({ table }: TableProps<TData>) {
return (
<div className="table-container">
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
colSpan={header.colSpan}
style={{ cursor: header.column.getCanSort() ? 'pointer' : undefined }}
onClick={header.column.getToggleSortingHandler()}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ▲', desc: ' ▼' }[header.column.getIsSorted() as string] ?? null}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -1 +0,0 @@
export { Table } from './Table'

View File

@ -1,25 +0,0 @@
import { Skeleton } from '@moex-vibe/design-system'
const tdStyle = {
borderBottom: '1px solid #eeeeee',
padding: '10px 8px',
verticalAlign: 'top',
} satisfies React.CSSProperties
type Column = { width: string }
export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) {
return (
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
{columns.map((col, j) => (
<td key={j} style={tdStyle}>
<Skeleton height={12} width={col.width} shape="text" />
</td>
))}
</tr>
))}
</tbody>
)
}

View File

@ -1 +0,0 @@
export { TableSkeleton } from './TableSkeleton'

View File

@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { PositionWithPrice } from '@/shared/api'
import { BondPositionTable } from './BondPositionTable'
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: React.ReactNode }) => <a href="/mock">{children}</a>,
}))
const positions: PositionWithPrice[] = [
{
id: 1,
secid: 'SU26238RMFS5',
shortName: 'ОФЗ 26238',
type: 'bond',
quantity: 5,
buyPrice: 980,
buyDate: null,
notes: null,
tags: null,
currentPrice: 985,
totalCost: 4900,
currentValue: 4925,
weightPercent: 7.5,
pnl: 25,
pnlPercent: 0.5,
dividendIncome: null,
totalReturn: null,
totalReturnPercent: null,
change: 5,
changePercent: 0.5,
yieldToMaturity: 8.2,
duration: 3.5,
couponValue: 36.9,
couponPercent: 7.5,
nextCouponDate: '2024-07-15',
matDate: '2027-05-15',
accruedInt: 8.45,
bid: 984,
offer: 986,
couponPeriod: 182,
bondType: 'ОФЗ',
offerDate: null,
},
]
describe('BondPositionTable', () => {
it('renders section title and bond row', () => {
render(
<BondPositionTable
positions={positions}
onUpdatePosition={() => undefined}
onDeletePosition={() => undefined}
/>,
)
expect(screen.getByText('Облигации')).toBeInTheDocument()
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument()
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument()
})
it('renders nothing when there are no positions', () => {
const { container } = render(
<BondPositionTable
positions={[]}
onUpdatePosition={() => undefined}
onDeletePosition={() => undefined}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -1,3 +1,6 @@
import { DataTable } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { PositionWithPrice } from '@/shared/api'
import { BondPositionRow } from './BondPositionRow'
@ -10,264 +13,58 @@ interface Props {
onDeletePosition: (positionId: number) => void
}
const columnHelper = createColumnHelper<PositionWithPrice>()
const columns = [
columnHelper.accessor('secid', { header: 'Тикер' }),
columnHelper.accessor('shortName', {
header: 'Название',
cell: (info) => info.getValue() ?? '—',
}),
columnHelper.accessor('bondType', { header: 'Тип', cell: (info) => info.getValue() ?? '—' }),
columnHelper.accessor('quantity', { header: 'Количество' }),
columnHelper.accessor('buyPrice', { header: 'Цена пок.', meta: { align: 'right' } }),
columnHelper.accessor('currentPrice', { header: 'Цена', meta: { align: 'right' } }),
columnHelper.accessor('bid', { header: 'Бид', meta: { align: 'right' } }),
columnHelper.accessor('offer', { header: 'Оффер', meta: { align: 'right' } }),
columnHelper.accessor('yieldToMaturity', { header: 'Доходность', meta: { align: 'right' } }),
columnHelper.accessor('duration', { header: 'Дюрация', meta: { align: 'right' } }),
columnHelper.accessor('couponValue', { header: 'Купон', meta: { align: 'right' } }),
columnHelper.accessor('couponPercent', { header: 'Куп. %', meta: { align: 'right' } }),
columnHelper.accessor('couponPeriod', { header: 'Период', meta: { align: 'right' } }),
columnHelper.accessor('accruedInt', { header: 'НКД', meta: { align: 'right' } }),
columnHelper.accessor('totalCost', { header: 'Затраты', meta: { align: 'right' } }),
columnHelper.accessor('pnl', { header: 'P&L', meta: { align: 'right' } }),
columnHelper.accessor('pnlPercent', { header: 'P&L %', meta: { align: 'right' } }),
columnHelper.accessor('nextCouponDate', { header: 'След. купон', meta: { align: 'right' } }),
columnHelper.accessor('matDate', { header: 'Погашение', meta: { align: 'right' } }),
columnHelper.accessor('offerDate', { header: 'Оферта', meta: { align: 'right' } }),
columnHelper.accessor('weightPercent', { header: 'Доля', meta: { align: 'right' } }),
]
export function BondPositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
const table = useReactTable({
data: positions,
columns,
getCoreRowModel: getCoreRowModel(),
})
if (positions.length === 0) return null
return (
<div style={{ marginTop: 24 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Облигации
</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тикер
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Название
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тип
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Количество
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена пок.
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Бид
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Оффер
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Доходность
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Дюрация
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Купон
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Куп. %
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Период
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
НКД
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Затраты
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L %
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
След. купон
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Погашение
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Оферта
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Доля
</th>
<th style={{ padding: '8px 12px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{positions.map((pos) => (
<BondPositionRow
key={pos.id}
position={pos}
onUpdate={(data) => onUpdatePosition(pos.id, data)}
onDelete={() => onDeletePosition(pos.id)}
/>
))}
</tbody>
</table>
</div>
</div>
<Box sx={{ marginTop: 3 }}>
<DataTable
table={table}
caption="Облигации"
renderRow={(row) => (
<BondPositionRow
key={row.id}
position={row.original}
onUpdate={(data) => onUpdatePosition(row.original.id, data)}
onDelete={() => onDeletePosition(row.original.id)}
/>
)}
/>
</Box>
)
}

View File

@ -1,7 +1,7 @@
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import {
type BrokerOperationImpact,
getBrokerOperationImpact,
@ -10,7 +10,35 @@ import {
import { getBrokerInstrumentPath } from '@/entities/broker-position'
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api'
import { formatBrokerSignedMoney } from '@/shared/lib/formatters'
import { TableSkeleton } from '@/shared/ui/TableSkeleton'
const TABLE_SKELETON_COLUMNS = [
{ width: '35%' },
{ width: '30%' },
{ width: '40%' },
{ width: '25%' },
] as const
const tdSkeletonStyle: CSSProperties = {
borderBottom: '1px solid #eeeeee',
padding: '10px 8px',
verticalAlign: 'top',
}
function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
{TABLE_SKELETON_COLUMNS.map((col, j) => (
<td key={j} style={tdSkeletonStyle}>
<Skeleton height={12} width={col.width} shape="text" />
</td>
))}
</tr>
))}
</tbody>
)
}
const tableSx = {
width: '100%',
@ -164,10 +192,7 @@ export function BrokerOperationsTable({
</Box>
</Box>
</Box>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
<TableSkeleton rows={5} />
</Box>
</Box>
) : operations.length === 0 && !isFetching ? (

View File

@ -12,4 +12,10 @@ describe('DividendsTable', () => {
expect(screen.getByText('2024-07-10')).toBeInTheDocument()
expect(screen.getByText('35.00 RUB')).toBeInTheDocument()
})
it('shows empty state when there are no dividends', () => {
render(<DividendsTable dividends={[]} />)
expect(screen.getByText('Нет дивидендов')).toBeInTheDocument()
})
})

View File

@ -1,38 +1,47 @@
import { DataTable, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { DividendItem } from '@/shared/api'
interface DividendsTableProps {
dividends: DividendItem[]
}
const columnHelper = createColumnHelper<DividendItem>()
const columns = [
columnHelper.accessor('registryCloseDate', {
header: 'Дата закрытия реестра',
cell: (info) => info.getValue(),
}),
columnHelper.accessor('value', {
header: 'Сумма',
meta: { align: 'right' },
cell: (info) => `${info.getValue().toFixed(2)} ${info.row.original.currency}`,
}),
]
export function DividendsTable({ dividends }: DividendsTableProps) {
const table = useReactTable({
data: dividends,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<div
style={{
<Box
sx={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
padding: 3,
}}
>
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #eee' }}>
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
</tr>
</thead>
<tbody>
{dividends.map((d, i) => (
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
<td style={{ textAlign: 'right', padding: 8 }}>
{d.value.toFixed(2)} {d.currency}
</td>
</tr>
))}
</tbody>
</table>
</div>
<DataTable
table={table}
caption="Дивиденды"
empty={<Text tone="secondary">Нет дивидендов</Text>}
/>
</Box>
)
}

View File

@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { PositionWithPrice } from '@/shared/api'
import { SharePositionTable } from './SharePositionTable'
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: React.ReactNode }) => <a href="/mock">{children}</a>,
}))
const positions: PositionWithPrice[] = [
{
id: 1,
secid: 'SBER',
shortName: 'Сбер',
type: 'share',
quantity: 10,
buyPrice: 250,
buyDate: null,
notes: null,
tags: null,
currentPrice: 260,
totalCost: 2500,
currentValue: 2600,
weightPercent: 12.5,
pnl: 100,
pnlPercent: 4,
dividendIncome: null,
totalReturn: null,
totalReturnPercent: null,
change: 10,
changePercent: 4,
yieldToMaturity: null,
duration: null,
couponValue: null,
couponPercent: null,
nextCouponDate: null,
matDate: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
offerDate: null,
},
]
describe('SharePositionTable', () => {
it('renders section title and position row', () => {
render(
<SharePositionTable
positions={positions}
onUpdatePosition={() => undefined}
onDeletePosition={() => undefined}
/>,
)
expect(screen.getByText('Акции')).toBeInTheDocument()
expect(screen.getByText('SBER')).toBeInTheDocument()
expect(screen.getByText('Сбер')).toBeInTheDocument()
})
it('renders nothing when there are no positions', () => {
const { container } = render(
<SharePositionTable
positions={[]}
onUpdatePosition={() => undefined}
onDeletePosition={() => undefined}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -1,3 +1,6 @@
import { DataTable } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { PositionWithPrice } from '@/shared/api'
import { SharePositionRow } from './SharePositionRow'
@ -10,154 +13,49 @@ interface Props {
onDeletePosition: (positionId: number) => void
}
const columnHelper = createColumnHelper<PositionWithPrice>()
const columns = [
columnHelper.accessor('secid', { header: 'Тикер' }),
columnHelper.accessor('shortName', {
header: 'Название',
cell: (info) => info.getValue() ?? '—',
}),
columnHelper.accessor('quantity', { header: 'Количество' }),
columnHelper.accessor('buyPrice', { header: 'Цена пок.', meta: { align: 'right' } }),
columnHelper.accessor('currentPrice', { header: 'Цена', meta: { align: 'right' } }),
columnHelper.accessor('change', { header: 'Изм.', meta: { align: 'right' } }),
columnHelper.accessor('currentValue', { header: 'Стоимость', meta: { align: 'right' } }),
columnHelper.accessor('totalCost', { header: 'Затраты', meta: { align: 'right' } }),
columnHelper.accessor('pnl', { header: 'P&L', meta: { align: 'right' } }),
columnHelper.accessor('pnlPercent', { header: 'P&L %', meta: { align: 'right' } }),
columnHelper.accessor('weightPercent', { header: 'Доля', meta: { align: 'right' } }),
columnHelper.display({ id: 'actions', header: '', meta: { align: 'left' } }),
]
export function SharePositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
const table = useReactTable({
data: positions,
columns,
getCoreRowModel: getCoreRowModel(),
})
if (positions.length === 0) return null
return (
<div style={{ marginTop: 16 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Акции
</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тикер
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Название
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Количество
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена пок.
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Изм.
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Стоимость
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Затраты
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L %
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Доля
</th>
<th style={{ padding: '8px 12px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{positions.map((pos) => (
<SharePositionRow
key={pos.id}
position={pos}
onUpdate={(data) => onUpdatePosition(pos.id, data)}
onDelete={() => onDeletePosition(pos.id)}
/>
))}
</tbody>
</table>
</div>
</div>
<Box sx={{ marginTop: 2 }}>
<DataTable
table={table}
caption="Акции"
renderRow={(row) => (
<SharePositionRow
key={row.id}
position={row.original}
onUpdate={(data) => onUpdatePosition(row.original.id, data)}
onDelete={() => onDeletePosition(row.original.id)}
/>
)}
/>
</Box>
)
}

View File

@ -10,46 +10,47 @@
## Task 1: Уточнить API `DataTable`
- [ ] Проверить текущее API `DataTable`
- [ ] Зафиксировать минимальный список поддерживаемых сценариев из существующих таблиц
- [ ] Подготовить изменения API только под подтверждённые кейсы
- [ ] Убедиться, что `TanStack Table` остаётся source of truth
- [x] Проверить текущее API `DataTable`
- [x] Зафиксировать минимальный список поддерживаемых сценариев из существующих таблиц
- [x] Подготовить изменения API только под подтверждённые кейсы
- [x] Убедиться, что `TanStack Table` остаётся source of truth
## Task 2: Мигрировать `DividendsTable`
- [ ] Перевести HTML table на `DataTable`
- [ ] Сохранить текущие колонки и форматирование
- [ ] Проверить empty/loading presentation
- [x] Перевести HTML table на `DataTable`
- [x] Сохранить текущие колонки и форматирование
- [x] Проверить empty/loading presentation
## Task 3: Мигрировать `ScreenerTable`
- [ ] Перевести табличную оболочку на `DataTable`
- [ ] Сохранить сортировку и пагинацию
- [ ] Сохранить row actions и кастомные ячейки
- [x] Перевести табличную оболочку на `DataTable`
- [x] Сохранить сортировку и пагинацию
- [x] Сохранить row actions и кастомные ячейки
## Task 4: Мигрировать `SharePositionTable`
- [ ] Перевести таблицу на `DataTable`
- [ ] Сохранить доменные row/cell renderers в приложении
- [ ] Сохранить update/delete сценарии
- [x] Перевести таблицу на `DataTable`
- [x] Сохранить доменные row/cell renderers в приложении
- [x] Сохранить update/delete сценарии
## Task 5: Мигрировать `BondPositionTable`
- [ ] Перевести таблицу на `DataTable`
- [ ] Сохранить bond-specific rendering и действия
- [ ] Не менять предметную финансовую логику
- [x] Перевести таблицу на `DataTable`
- [x] Сохранить bond-specific rendering и действия
- [x] Не менять предметную финансовую логику
## Task 6: Очистка legacy и документация
- [ ] Проверить использование `shared/ui/Table`
- [ ] Проверить использование `TableSkeleton`
- [ ] Обновить docs по `DataTable` и `TanStack Table`
- [ ] Удалить legacy helper'ы, если они больше не нужны
- [x] Проверить использование `shared/ui/Table` — 0 потребителей, удалён
- [x] Проверить использование `TableSkeleton` — 1 потребитель (BrokerOperationsTable, out of scope)
- [x] Обновить docs по `DataTable` и `TanStack Table`
- [x] Удалить `shared/ui/Table` (без потребителей)
- [x] `TableSkeleton` заинлайнен в BrokerOperationsTable, оригинал удалён
## Task 7: Верификация
- [ ] Запустить frontend tests
- [ ] Запустить frontend lint
- [ ] Запустить design-system lint/tests при необходимости
- [ ] Запустить frontend и design-system build
- [ ] Проверить отсутствие новых запрещённых MUI table imports
- [x] Запустить frontend tests — 124 passed
- [x] Запустить frontend lint — 1 pre-existing error (react-hooks/exhaustive-deps not found)
- [x] Запустить design-system lint/tests — 160 tests passed, 7 pre-existing lint errors
- [x] Запустить frontend и design-system buildоба проходят
- [x] Проверить отсутствие новых запрещённых MUI table imports — 0 прямых импортов Table/TableRow/TableCell

View File

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TableCell, TableRow } from '@mui/material';
import { DataTable } from './DataTable';
import { MoexVibeThemeProvider } from '../../theme';
import { useReactTable, getCoreRowModel, createColumnHelper } from '@tanstack/react-table';
@ -65,6 +66,50 @@ function EmptyTable() {
);
}
function AlignedTable() {
const alignedColumns = [
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('price', {
header: 'Price',
meta: { align: 'right' } as any,
}),
];
const table = useReactTable({
data,
columns: alignedColumns,
getCoreRowModel: getCoreRowModel(),
});
return (
<MoexVibeThemeProvider>
<DataTable table={table} caption="Aligned" />
</MoexVibeThemeProvider>
);
}
function CustomRowTable() {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<MoexVibeThemeProvider>
<DataTable
table={table}
caption="Custom"
renderRow={(row) => (
<TableRow key={row.id} data-testid={`custom-row-${row.id}`}>
<TableCell colSpan={2}>{row.original.name}</TableCell>
</TableRow>
)}
/>
</MoexVibeThemeProvider>
);
}
describe('DataTable', () => {
it('renders caption', () => {
render(<TestTable caption="Test Caption" />);
@ -94,4 +139,25 @@ describe('DataTable', () => {
render(<EmptyTable />);
expect(screen.getByText('No data')).toBeInTheDocument();
});
it('shows loading rows instead of data when loading', () => {
render(<TestTableWithProps caption="Stocks" loading />);
expect(screen.queryByText('AAPL')).not.toBeInTheDocument();
expect(screen.getAllByRole('row')).toHaveLength(6);
});
it('applies column alignment from meta', () => {
render(<AlignedTable />);
expect(screen.getByText('Price').closest('th')).toHaveClass('MuiTableCell-alignRight');
});
it('renders custom rows when provided', () => {
render(<CustomRowTable />);
expect(screen.getByTestId('custom-row-0')).toBeInTheDocument();
expect(screen.getByTestId('custom-row-1')).toBeInTheDocument();
expect(screen.getByText('AAPL')).toBeInTheDocument();
});
});

View File

@ -6,19 +6,30 @@ import {
TableBody,
TableRow,
TableCell,
Skeleton,
type TableProps as MuiTableProps,
} from '@mui/material';
import type { Table as TanStackTable } from '@tanstack/react-table';
import type { Column, Row, RowData, Table as TanStackTable } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
export type Density = 'balanced' | 'compact';
type CellAlign = 'left' | 'center' | 'right';
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
align?: CellAlign;
}
}
export interface DataTableProps<T> {
table: TanStackTable<T>;
density?: Density;
loading?: boolean;
empty?: ReactNode;
caption: string;
renderRow?: (row: Row<T>) => ReactNode;
}
const DENSITY_PADDING: Record<Density, MuiTableProps['size']> = {
@ -32,6 +43,7 @@ export function DataTable<T>({
loading,
empty,
caption,
renderRow,
}: DataTableProps<T>) {
const rows = table.getRowModel().rows;
@ -39,6 +51,14 @@ export function DataTable<T>({
return <>{empty}</>;
}
const isLoading = Boolean(loading);
const loadingRows = Array.from({ length: 5 });
const leafColumns = table.getVisibleLeafColumns();
function getAlign(column: Column<T, unknown>) {
return column.columnDef.meta?.align;
}
return (
<TableContainer>
<Table size={DENSITY_PADDING[density]}>
@ -47,7 +67,11 @@ export function DataTable<T>({
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableCell key={header.id} sortDirection={header.column.getIsSorted() || false}>
<TableCell
key={header.id}
align={header.column.columnDef.meta?.align ?? 'left'}
sortDirection={header.column.getIsSorted() || false}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
@ -57,15 +81,27 @@ export function DataTable<T>({
))}
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
{isLoading
? loadingRows.map((_, index) => (
<TableRow key={index}>
{leafColumns.map((column) => (
<TableCell key={column.id} align={getAlign(column) ?? 'left'}>
<Skeleton height={12} width="80%" />
</TableCell>
))}
</TableRow>
))
: renderRow
? rows.map((row) => renderRow(row))
: rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} align={cell.column.columnDef.meta?.align ?? 'left'}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>