diff --git a/.serena/memories/memory_maintenance.md b/.serena/memories/memory_maintenance.md new file mode 100644 index 0000000..6f84514 --- /dev/null +++ b/.serena/memories/memory_maintenance.md @@ -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. \ No newline at end of file diff --git a/apps/docs/docs/design-system/components.md b/apps/docs/docs/design-system/components.md index 73b98cb..7d06e1c 100644 --- a/apps/docs/docs/design-system/components.md +++ b/apps/docs/docs/design-system/components.md @@ -174,12 +174,18 @@ Таблица данных на основе TanStack Table. +Использует `TanStack Table` как source of truth для модели данных, а MUI только для табличной +оболочки. Поддерживает loading state, empty state, density и выравнивание через `columnDef.meta.align`. + | Свойство | Тип | По умолчанию | |----------|-----|--------------| | `columns` | `ColumnDef[]` | — | | `data` | `T[]` | — | | `caption` | `string` | — | | `density` | `'balanced' \| 'compact'` | `'balanced'` | +| `loading` | `boolean` | `false` | +| `empty` | `ReactNode` | — | +| `renderRow` | `(row) => ReactNode` | — | ### Money diff --git a/apps/docs/docs/frontend/styling.md b/apps/docs/docs/frontend/styling.md index 77f4f36..7bae885 100644 --- a/apps/docs/docs/frontend/styling.md +++ b/apps/docs/docs/frontend/styling.md @@ -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. Этот подход считается устаревшим — новые страницы должны использовать токены дизайн-системы. diff --git a/apps/frontend/src/features/screener/ui/ScreenerTable.test.tsx b/apps/frontend/src/features/screener/ui/ScreenerTable.test.tsx new file mode 100644 index 0000000..98d8b35 --- /dev/null +++ b/apps/frontend/src/features/screener/ui/ScreenerTable.test.tsx @@ -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 }) => {children}, +})) + +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( + , + ) + + 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( + undefined} + />, + ) + + screen.getByRole('button', { name: /Цена/ }).click() + + expect(onSort).toHaveBeenCalledWith('price') + }) + + it('renders bond columns', () => { + render( + undefined} + onPageChange={() => undefined} + />, + ) + + expect(screen.getByText('YTM')).toBeInTheDocument() + expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument() + }) +}) diff --git a/apps/frontend/src/features/screener/ui/ScreenerTable.tsx b/apps/frontend/src/features/screener/ui/ScreenerTable.tsx index d5394ab..1f42646 100644 --- a/apps/frontend/src/features/screener/ui/ScreenerTable.tsx +++ b/apps/frontend/src/features/screener/ui/ScreenerTable.tsx @@ -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 ( - 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' ? '▲' : '▼') : ''} - - ) - } +type ScreenerItem = ScreenerResult['items'][number] +const columnHelper = createColumnHelper() + +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 ( + + {info.getValue()} + + ) + }, + }), + columnHelper.accessor('shortName', { header: 'Название', cell: (info) => info.getValue() }), + columnHelper.accessor('price', { + header: () => ( + onSort('price')} + sx={{ all: 'unset', cursor: 'pointer' }} + > + Цена {sortBy === 'price' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} + + ), + meta: { align: 'right' }, + }), + columnHelper.accessor('changePercent', { + header: () => ( + onSort('changePercent')} + sx={{ all: 'unset', cursor: 'pointer' }} + > + Изм. {sortBy === 'changePercent' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} + + ), + meta: { align: 'right' }, + cell: (info) => { + const change = formatChange(info.getValue()) + return {change.text} + }, + }), + columnHelper.accessor('volume', { + header: () => ( + onSort('volume')} + sx={{ all: 'unset', cursor: 'pointer' }} + > + Объём {sortBy === 'volume' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} + + ), + 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: () => ( + onSort('yieldToMaturity')} + sx={{ all: 'unset', cursor: 'pointer' }} + > + YTM {sortBy === 'yieldToMaturity' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} + + ), + meta: { align: 'right' }, + cell: (info) => formatNum(info.getValue()), + }), + columnHelper.accessor('duration', { + header: () => ( + onSort('duration')} + sx={{ all: 'unset', cursor: 'pointer' }} + > + Дюрация {sortBy === 'duration' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} + + ), + meta: { align: 'right' }, + cell: (info) => { + const v = info.getValue() + return v != null ? `${v.toFixed(2)}г` : '—' + }, + }), + columnHelper.accessor('couponValue', { + header: () => ( + onSort('couponValue')} + sx={{ all: 'unset', cursor: 'pointer' }} + > + Купон {sortBy === 'couponValue' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} + + ), + meta: { align: 'right' }, + cell: (info) => formatNum(info.getValue()), + }), + columnHelper.accessor('couponPercent', { + header: () => ( + onSort('couponPercent')} + sx={{ all: 'unset', cursor: 'pointer' }} + > + Куп. % {sortBy === 'couponPercent' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} + + ), + meta: { align: 'right' }, + cell: (info) => formatNum(info.getValue()), + }), + ]), + ] + + const table = useReactTable({ + data: result.items, + columns, + getCoreRowModel: getCoreRowModel(), + }) + return ( -
-
+ + Найдено: {result.total} бумаг -
-
- - - - - - Цена - Изм. - Объём - {isShare ? ( - Капитализация - ) : ( - <> - YTM - Дюрация - Купон - Куп. % - - )} - - - - {result.items.map((item) => { - const change = formatChange(item.changePercent) - const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}` - return ( - - - - - - - {isShare ? ( - - ) : ( - <> - - - - - - )} - - ) - })} - -
- Тикер - - Название -
- - {item.secid} - - - {item.shortName} - - {formatNum(item.price)} - - {change.text} - - {item.volume.toLocaleString('ru-RU')} - - {item.capitalization != null - ? item.capitalization.toLocaleString('ru-RU') - : '—'} - - {formatNum(item.yieldToMaturity)} - - {item.duration != null ? `${item.duration.toFixed(2)}г` : '—'} - - {formatNum(item.couponValue)} - - {formatNum(item.couponPercent)} -
-
+ + {result.totalPages > 1 && ( -
+ {Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => ( - + ))} -
+ )} -
+ ) } diff --git a/apps/frontend/src/shared/ui/Table/Table.tsx b/apps/frontend/src/shared/ui/Table/Table.tsx deleted file mode 100644 index 89c6777..0000000 --- a/apps/frontend/src/shared/ui/Table/Table.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { flexRender, type Table as TanStackTable } from '@tanstack/react-table' - -interface TableProps { - table: TanStackTable -} - -export function Table({ table }: TableProps) { - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - {{ asc: ' ▲', desc: ' ▼' }[header.column.getIsSorted() as string] ?? null} -
{flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- ) -} diff --git a/apps/frontend/src/shared/ui/Table/index.ts b/apps/frontend/src/shared/ui/Table/index.ts deleted file mode 100644 index 3be5c81..0000000 --- a/apps/frontend/src/shared/ui/Table/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Table } from './Table' diff --git a/apps/frontend/src/shared/ui/TableSkeleton.tsx b/apps/frontend/src/shared/ui/TableSkeleton.tsx deleted file mode 100644 index d079ee0..0000000 --- a/apps/frontend/src/shared/ui/TableSkeleton.tsx +++ /dev/null @@ -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 ( - - {Array.from({ length: rows }).map((_, i) => ( - - {columns.map((col, j) => ( - - - - ))} - - ))} - - ) -} diff --git a/apps/frontend/src/shared/ui/index.ts b/apps/frontend/src/shared/ui/index.ts deleted file mode 100644 index fc7204a..0000000 --- a/apps/frontend/src/shared/ui/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TableSkeleton } from './TableSkeleton' diff --git a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.test.tsx b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.test.tsx new file mode 100644 index 0000000..9b8ca95 --- /dev/null +++ b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.test.tsx @@ -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 }) => {children}, +})) + +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( + 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( + undefined} + onDeletePosition={() => undefined} + />, + ) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx index e6592a6..e55664f 100644 --- a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx +++ b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx @@ -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() + +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 ( -
-

- Облигации -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - {positions.map((pos) => ( - onUpdatePosition(pos.id, data)} - onDelete={() => onDeletePosition(pos.id)} - /> - ))} - -
- Тикер - - Название - - Тип - - Количество - - Цена пок. - - Цена - - Бид - - Оффер - - Доходность - - Дюрация - - Купон - - Куп. % - - Период - - НКД - - Затраты - - P&L - - P&L % - - След. купон - - Погашение - - Оферта - - Доля -
-
-
+ + ( + onUpdatePosition(row.original.id, data)} + onDelete={() => onDeletePosition(row.original.id)} + /> + )} + /> + ) } diff --git a/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx b/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx index 50aa765..bbe97ee 100644 --- a/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx +++ b/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx @@ -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 ( + + {Array.from({ length: rows }).map((_, i) => ( + + {TABLE_SKELETON_COLUMNS.map((col, j) => ( + + + + ))} + + ))} + + ) +} const tableSx = { width: '100%', @@ -164,10 +192,7 @@ export function BrokerOperationsTable({ - + ) : operations.length === 0 && !isFetching ? ( diff --git a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx index 1e25c7d..bc3a732 100644 --- a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx +++ b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx @@ -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() + + expect(screen.getByText('Нет дивидендов')).toBeInTheDocument() + }) }) diff --git a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx index bfb4194..0d78554 100644 --- a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx +++ b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx @@ -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() + +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 ( -
-

Дивиденды

- - - - - - - - - {dividends.map((d, i) => ( - - - - - ))} - -
Дата закрытия реестраСумма
{d.registryCloseDate} - {d.value.toFixed(2)} {d.currency} -
-
+ Нет дивидендов} + /> + ) } diff --git a/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.test.tsx b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.test.tsx new file mode 100644 index 0000000..2865b4a --- /dev/null +++ b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.test.tsx @@ -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 }) => {children}, +})) + +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( + 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( + undefined} + onDeletePosition={() => undefined} + />, + ) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx index 51281e0..42e2742 100644 --- a/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx +++ b/apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx @@ -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() + +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 ( -
-

- Акции -

-
- - - - - - - - - - - - - - - - - - - {positions.map((pos) => ( - onUpdatePosition(pos.id, data)} - onDelete={() => onDeletePosition(pos.id)} - /> - ))} - -
- Тикер - - Название - - Количество - - Цена пок. - - Цена - - Изм. - - Стоимость - - Затраты - - P&L - - P&L % - - Доля -
-
-
+ + ( + onUpdatePosition(row.original.id, data)} + onDelete={() => onDeletePosition(row.original.id)} + /> + )} + /> + ) } diff --git a/docs/features/table-migration/tasks.md b/docs/features/table-migration/tasks.md index 8678de0..5804b07 100644 --- a/docs/features/table-migration/tasks.md +++ b/docs/features/table-migration/tasks.md @@ -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 diff --git a/packages/design-system/src/components/DataTable/DataTable.test.tsx b/packages/design-system/src/components/DataTable/DataTable.test.tsx index 0787eca..85c6dfa 100644 --- a/packages/design-system/src/components/DataTable/DataTable.test.tsx +++ b/packages/design-system/src/components/DataTable/DataTable.test.tsx @@ -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 ( + + + + ); +} + +function CustomRowTable() { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + ( + + {row.original.name} + + )} + /> + + ); +} + describe('DataTable', () => { it('renders caption', () => { render(); @@ -94,4 +139,25 @@ describe('DataTable', () => { render(); expect(screen.getByText('No data')).toBeInTheDocument(); }); + + it('shows loading rows instead of data when loading', () => { + render(); + + expect(screen.queryByText('AAPL')).not.toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(6); + }); + + it('applies column alignment from meta', () => { + render(); + + expect(screen.getByText('Price').closest('th')).toHaveClass('MuiTableCell-alignRight'); + }); + + it('renders custom rows when provided', () => { + render(); + + expect(screen.getByTestId('custom-row-0')).toBeInTheDocument(); + expect(screen.getByTestId('custom-row-1')).toBeInTheDocument(); + expect(screen.getByText('AAPL')).toBeInTheDocument(); + }); }); diff --git a/packages/design-system/src/components/DataTable/DataTable.tsx b/packages/design-system/src/components/DataTable/DataTable.tsx index 911e109..587b1e2 100644 --- a/packages/design-system/src/components/DataTable/DataTable.tsx +++ b/packages/design-system/src/components/DataTable/DataTable.tsx @@ -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 { + align?: CellAlign; + } +} + export interface DataTableProps { table: TanStackTable; density?: Density; loading?: boolean; empty?: ReactNode; caption: string; + renderRow?: (row: Row) => ReactNode; } const DENSITY_PADDING: Record = { @@ -32,6 +43,7 @@ export function DataTable({ loading, empty, caption, + renderRow, }: DataTableProps) { const rows = table.getRowModel().rows; @@ -39,6 +51,14 @@ export function DataTable({ return <>{empty}; } + const isLoading = Boolean(loading); + const loadingRows = Array.from({ length: 5 }); + const leafColumns = table.getVisibleLeafColumns(); + + function getAlign(column: Column) { + return column.columnDef.meta?.align; + } + return ( @@ -47,7 +67,11 @@ export function DataTable({ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} @@ -57,15 +81,27 @@ export function DataTable({ ))} - {rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} + {isLoading + ? loadingRows.map((_, index) => ( + + {leafColumns.map((column) => ( + + + + ))} + + )) + : renderRow + ? rows.map((row) => renderRow(row)) + : rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))}