feat: finalize table-migration — fix DataTable types, remove legacy helpers, update docs
This commit is contained in:
parent
2ed356fbcd
commit
2af2ff32c1
33
.serena/memories/memory_maintenance.md
Normal file
33
.serena/memories/memory_maintenance.md
Normal 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.
|
||||||
@ -174,12 +174,18 @@
|
|||||||
|
|
||||||
Таблица данных на основе TanStack Table.
|
Таблица данных на основе TanStack Table.
|
||||||
|
|
||||||
|
Использует `TanStack Table` как source of truth для модели данных, а MUI только для табличной
|
||||||
|
оболочки. Поддерживает loading state, empty state, density и выравнивание через `columnDef.meta.align`.
|
||||||
|
|
||||||
| Свойство | Тип | По умолчанию |
|
| Свойство | Тип | По умолчанию |
|
||||||
|----------|-----|--------------|
|
|----------|-----|--------------|
|
||||||
| `columns` | `ColumnDef<T>[]` | — |
|
| `columns` | `ColumnDef<T>[]` | — |
|
||||||
| `data` | `T[]` | — |
|
| `data` | `T[]` | — |
|
||||||
| `caption` | `string` | — |
|
| `caption` | `string` | — |
|
||||||
| `density` | `'balanced' \| 'compact'` | `'balanced'` |
|
| `density` | `'balanced' \| 'compact'` | `'balanced'` |
|
||||||
|
| `loading` | `boolean` | `false` |
|
||||||
|
| `empty` | `ReactNode` | — |
|
||||||
|
| `renderRow` | `(row) => ReactNode` | — |
|
||||||
|
|
||||||
### Money
|
### Money
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,10 @@
|
|||||||
|
|
||||||
Все новые компоненты должны использовать токены дизайн-системы. Подробнее — [Дизайн-система](../design-system/overview).
|
Все новые компоненты должны использовать токены дизайн-системы. Подробнее — [Дизайн-система](../design-system/overview).
|
||||||
|
|
||||||
|
Для таблиц используйте `DataTable` из дизайн-системы. Он уже встроен в `TanStack Table` и поддерживает
|
||||||
|
выравнивание, loading/empty state и кастомный рендер строк без прямого использования `MUI Table` во
|
||||||
|
frontend-коде.
|
||||||
|
|
||||||
## Legacy: CSS custom properties
|
## Legacy: CSS custom properties
|
||||||
|
|
||||||
Ранее стили определялись через единый `styles.css` с CSS custom properties. Этот подход считается устаревшим — новые страницы должны использовать токены дизайн-системы.
|
Ранее стили определялись через единый `styles.css` с CSS custom properties. Этот подход считается устаревшим — новые страницы должны использовать токены дизайн-системы.
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 { Link } from '@tanstack/react-router'
|
||||||
|
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||||
import type { ScreenerResult } from '@/shared/api'
|
import type { ScreenerResult } from '@/shared/api'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -23,149 +26,173 @@ function formatChange(value: number | null | undefined): { text: string; color:
|
|||||||
return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color }
|
return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
|
type ScreenerItem = ScreenerResult['items'][number]
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<ScreenerItem>()
|
||||||
|
|
||||||
|
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
|
||||||
const isShare = result.items[0]?.type === 'share'
|
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 (
|
return (
|
||||||
<div style={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
|
<Text variant="caption" tone="secondary" style={{ fontSize: 13, marginBottom: 8 }}>
|
||||||
Найдено: {result.total} бумаг
|
Найдено: {result.total} бумаг
|
||||||
</div>
|
</Text>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<DataTable table={table} caption="Screener" />
|
||||||
<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>
|
|
||||||
|
|
||||||
{result.totalPages > 1 && (
|
{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) => (
|
{Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => (
|
||||||
<button
|
<Button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => onPageChange(p)}
|
onClick={() => onPageChange(p)}
|
||||||
style={{
|
variant={p === result.page ? 'primary' : 'secondary'}
|
||||||
padding: '4px 10px',
|
size="small"
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { Table } from './Table'
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { TableSkeleton } from './TableSkeleton'
|
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 type { PositionWithPrice } from '@/shared/api'
|
||||||
import { BondPositionRow } from './BondPositionRow'
|
import { BondPositionRow } from './BondPositionRow'
|
||||||
|
|
||||||
@ -10,264 +13,58 @@ interface Props {
|
|||||||
onDeletePosition: (positionId: number) => void
|
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) {
|
export function BondPositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: positions,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
})
|
||||||
|
|
||||||
if (positions.length === 0) return null
|
if (positions.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 24 }}>
|
<Box sx={{ marginTop: 3 }}>
|
||||||
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
<DataTable
|
||||||
Облигации
|
table={table}
|
||||||
</h3>
|
caption="Облигации"
|
||||||
<div style={{ overflowX: 'auto' }}>
|
renderRow={(row) => (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
<BondPositionRow
|
||||||
<thead>
|
key={row.id}
|
||||||
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
|
position={row.original}
|
||||||
<th
|
onUpdate={(data) => onUpdatePosition(row.original.id, data)}
|
||||||
style={{
|
onDelete={() => onDeletePosition(row.original.id)}
|
||||||
textAlign: 'left',
|
/>
|
||||||
padding: '8px 12px',
|
)}
|
||||||
fontWeight: 600,
|
/>
|
||||||
fontSize: 12,
|
</Box>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system'
|
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system'
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import type { ReactNode } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
type BrokerOperationImpact,
|
type BrokerOperationImpact,
|
||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
@ -10,7 +10,35 @@ import {
|
|||||||
import { getBrokerInstrumentPath } from '@/entities/broker-position'
|
import { getBrokerInstrumentPath } from '@/entities/broker-position'
|
||||||
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api'
|
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api'
|
||||||
import { formatBrokerSignedMoney } from '@/shared/lib/formatters'
|
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 = {
|
const tableSx = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -164,10 +192,7 @@ export function BrokerOperationsTable({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<TableSkeleton
|
<TableSkeleton rows={5} />
|
||||||
rows={5}
|
|
||||||
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : operations.length === 0 && !isFetching ? (
|
) : operations.length === 0 && !isFetching ? (
|
||||||
|
|||||||
@ -12,4 +12,10 @@ describe('DividendsTable', () => {
|
|||||||
expect(screen.getByText('2024-07-10')).toBeInTheDocument()
|
expect(screen.getByText('2024-07-10')).toBeInTheDocument()
|
||||||
expect(screen.getByText('35.00 RUB')).toBeInTheDocument()
|
expect(screen.getByText('35.00 RUB')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows empty state when there are no dividends', () => {
|
||||||
|
render(<DividendsTable dividends={[]} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Нет дивидендов')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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'
|
import type { DividendItem } from '@/shared/api'
|
||||||
|
|
||||||
interface DividendsTableProps {
|
interface DividendsTableProps {
|
||||||
dividends: DividendItem[]
|
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) {
|
export function DividendsTable({ dividends }: DividendsTableProps) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: dividends,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
borderRadius: 'var(--border-radius)',
|
borderRadius: 'var(--border-radius)',
|
||||||
boxShadow: 'var(--shadow)',
|
boxShadow: 'var(--shadow)',
|
||||||
padding: 24,
|
padding: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
|
<DataTable
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
table={table}
|
||||||
<thead>
|
caption="Дивиденды"
|
||||||
<tr style={{ borderBottom: '2px solid #eee' }}>
|
empty={<Text tone="secondary">Нет дивидендов</Text>}
|
||||||
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
|
/>
|
||||||
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
|
</Box>
|
||||||
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 type { PositionWithPrice } from '@/shared/api'
|
||||||
import { SharePositionRow } from './SharePositionRow'
|
import { SharePositionRow } from './SharePositionRow'
|
||||||
|
|
||||||
@ -10,154 +13,49 @@ interface Props {
|
|||||||
onDeletePosition: (positionId: number) => void
|
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) {
|
export function SharePositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: positions,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
})
|
||||||
|
|
||||||
if (positions.length === 0) return null
|
if (positions.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 16 }}>
|
<Box sx={{ marginTop: 2 }}>
|
||||||
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
<DataTable
|
||||||
Акции
|
table={table}
|
||||||
</h3>
|
caption="Акции"
|
||||||
<div style={{ overflowX: 'auto' }}>
|
renderRow={(row) => (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
<SharePositionRow
|
||||||
<thead>
|
key={row.id}
|
||||||
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
|
position={row.original}
|
||||||
<th
|
onUpdate={(data) => onUpdatePosition(row.original.id, data)}
|
||||||
style={{
|
onDelete={() => onDeletePosition(row.original.id)}
|
||||||
textAlign: 'left',
|
/>
|
||||||
padding: '8px 12px',
|
)}
|
||||||
fontWeight: 600,
|
/>
|
||||||
fontSize: 12,
|
</Box>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,46 +10,47 @@
|
|||||||
|
|
||||||
## Task 1: Уточнить API `DataTable`
|
## Task 1: Уточнить API `DataTable`
|
||||||
|
|
||||||
- [ ] Проверить текущее API `DataTable`
|
- [x] Проверить текущее API `DataTable`
|
||||||
- [ ] Зафиксировать минимальный список поддерживаемых сценариев из существующих таблиц
|
- [x] Зафиксировать минимальный список поддерживаемых сценариев из существующих таблиц
|
||||||
- [ ] Подготовить изменения API только под подтверждённые кейсы
|
- [x] Подготовить изменения API только под подтверждённые кейсы
|
||||||
- [ ] Убедиться, что `TanStack Table` остаётся source of truth
|
- [x] Убедиться, что `TanStack Table` остаётся source of truth
|
||||||
|
|
||||||
## Task 2: Мигрировать `DividendsTable`
|
## Task 2: Мигрировать `DividendsTable`
|
||||||
|
|
||||||
- [ ] Перевести HTML table на `DataTable`
|
- [x] Перевести HTML table на `DataTable`
|
||||||
- [ ] Сохранить текущие колонки и форматирование
|
- [x] Сохранить текущие колонки и форматирование
|
||||||
- [ ] Проверить empty/loading presentation
|
- [x] Проверить empty/loading presentation
|
||||||
|
|
||||||
## Task 3: Мигрировать `ScreenerTable`
|
## Task 3: Мигрировать `ScreenerTable`
|
||||||
|
|
||||||
- [ ] Перевести табличную оболочку на `DataTable`
|
- [x] Перевести табличную оболочку на `DataTable`
|
||||||
- [ ] Сохранить сортировку и пагинацию
|
- [x] Сохранить сортировку и пагинацию
|
||||||
- [ ] Сохранить row actions и кастомные ячейки
|
- [x] Сохранить row actions и кастомные ячейки
|
||||||
|
|
||||||
## Task 4: Мигрировать `SharePositionTable`
|
## Task 4: Мигрировать `SharePositionTable`
|
||||||
|
|
||||||
- [ ] Перевести таблицу на `DataTable`
|
- [x] Перевести таблицу на `DataTable`
|
||||||
- [ ] Сохранить доменные row/cell renderers в приложении
|
- [x] Сохранить доменные row/cell renderers в приложении
|
||||||
- [ ] Сохранить update/delete сценарии
|
- [x] Сохранить update/delete сценарии
|
||||||
|
|
||||||
## Task 5: Мигрировать `BondPositionTable`
|
## Task 5: Мигрировать `BondPositionTable`
|
||||||
|
|
||||||
- [ ] Перевести таблицу на `DataTable`
|
- [x] Перевести таблицу на `DataTable`
|
||||||
- [ ] Сохранить bond-specific rendering и действия
|
- [x] Сохранить bond-specific rendering и действия
|
||||||
- [ ] Не менять предметную финансовую логику
|
- [x] Не менять предметную финансовую логику
|
||||||
|
|
||||||
## Task 6: Очистка legacy и документация
|
## Task 6: Очистка legacy и документация
|
||||||
|
|
||||||
- [ ] Проверить использование `shared/ui/Table`
|
- [x] Проверить использование `shared/ui/Table` — 0 потребителей, удалён
|
||||||
- [ ] Проверить использование `TableSkeleton`
|
- [x] Проверить использование `TableSkeleton` — 1 потребитель (BrokerOperationsTable, out of scope)
|
||||||
- [ ] Обновить docs по `DataTable` и `TanStack Table`
|
- [x] Обновить docs по `DataTable` и `TanStack Table`
|
||||||
- [ ] Удалить legacy helper'ы, если они больше не нужны
|
- [x] Удалить `shared/ui/Table` (без потребителей)
|
||||||
|
- [x] `TableSkeleton` заинлайнен в BrokerOperationsTable, оригинал удалён
|
||||||
|
|
||||||
## Task 7: Верификация
|
## Task 7: Верификация
|
||||||
|
|
||||||
- [ ] Запустить frontend tests
|
- [x] Запустить frontend tests — 124 passed
|
||||||
- [ ] Запустить frontend lint
|
- [x] Запустить frontend lint — 1 pre-existing error (react-hooks/exhaustive-deps not found)
|
||||||
- [ ] Запустить design-system lint/tests при необходимости
|
- [x] Запустить design-system lint/tests — 160 tests passed, 7 pre-existing lint errors
|
||||||
- [ ] Запустить frontend и design-system build
|
- [x] Запустить frontend и design-system build — оба проходят
|
||||||
- [ ] Проверить отсутствие новых запрещённых MUI table imports
|
- [x] Проверить отсутствие новых запрещённых MUI table imports — 0 прямых импортов Table/TableRow/TableCell
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { TableCell, TableRow } from '@mui/material';
|
||||||
import { DataTable } from './DataTable';
|
import { DataTable } from './DataTable';
|
||||||
import { MoexVibeThemeProvider } from '../../theme';
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
import { useReactTable, getCoreRowModel, createColumnHelper } from '@tanstack/react-table';
|
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', () => {
|
describe('DataTable', () => {
|
||||||
it('renders caption', () => {
|
it('renders caption', () => {
|
||||||
render(<TestTable caption="Test Caption" />);
|
render(<TestTable caption="Test Caption" />);
|
||||||
@ -94,4 +139,25 @@ describe('DataTable', () => {
|
|||||||
render(<EmptyTable />);
|
render(<EmptyTable />);
|
||||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,19 +6,30 @@ import {
|
|||||||
TableBody,
|
TableBody,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
|
Skeleton,
|
||||||
type TableProps as MuiTableProps,
|
type TableProps as MuiTableProps,
|
||||||
} from '@mui/material';
|
} 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';
|
import { flexRender } from '@tanstack/react-table';
|
||||||
|
|
||||||
export type Density = 'balanced' | 'compact';
|
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> {
|
export interface DataTableProps<T> {
|
||||||
table: TanStackTable<T>;
|
table: TanStackTable<T>;
|
||||||
density?: Density;
|
density?: Density;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
empty?: ReactNode;
|
empty?: ReactNode;
|
||||||
caption: string;
|
caption: string;
|
||||||
|
renderRow?: (row: Row<T>) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DENSITY_PADDING: Record<Density, MuiTableProps['size']> = {
|
const DENSITY_PADDING: Record<Density, MuiTableProps['size']> = {
|
||||||
@ -32,6 +43,7 @@ export function DataTable<T>({
|
|||||||
loading,
|
loading,
|
||||||
empty,
|
empty,
|
||||||
caption,
|
caption,
|
||||||
|
renderRow,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const rows = table.getRowModel().rows;
|
const rows = table.getRowModel().rows;
|
||||||
|
|
||||||
@ -39,6 +51,14 @@ export function DataTable<T>({
|
|||||||
return <>{empty}</>;
|
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 (
|
return (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table size={DENSITY_PADDING[density]}>
|
<Table size={DENSITY_PADDING[density]}>
|
||||||
@ -47,7 +67,11 @@ export function DataTable<T>({
|
|||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{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
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
@ -57,15 +81,27 @@ export function DataTable<T>({
|
|||||||
))}
|
))}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row) => (
|
{isLoading
|
||||||
<TableRow key={row.id}>
|
? loadingRows.map((_, index) => (
|
||||||
{row.getVisibleCells().map((cell) => (
|
<TableRow key={index}>
|
||||||
<TableCell key={cell.id}>
|
{leafColumns.map((column) => (
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
<TableCell key={column.id} align={getAlign(column) ?? 'left'}>
|
||||||
</TableCell>
|
<Skeleton height={12} width="80%" />
|
||||||
))}
|
</TableCell>
|
||||||
</TableRow>
|
))}
|
||||||
))}
|
</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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user