From 49dcbb4e33997d0a553bec79bfcc2935792e04f0 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 20 Jun 2026 20:56:48 +0300 Subject: [PATCH 1/7] docs: define frontend fsd portfolios spec --- docs/features/frontend-fsd-portfolios/spec.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/features/frontend-fsd-portfolios/spec.md diff --git a/docs/features/frontend-fsd-portfolios/spec.md b/docs/features/frontend-fsd-portfolios/spec.md new file mode 100644 index 0000000..bd10f6e --- /dev/null +++ b/docs/features/frontend-fsd-portfolios/spec.md @@ -0,0 +1,82 @@ +# Frontend FSD — Portfolio Pages + +Дата: 2026-06-20 +Статус: черновик + +## Контекст + +FSD-миграция фронтенда выполнена для большинства доменов. Остались 3 домена, +намеренно не затронутые финальной cleanup-фазой: screener, portfolios, auth pages. + +Домен портфелей — второй по величине: entities/portfolio уже мигрирован в FSD, +но UI-компоненты остаются в плоской `components/portfolios/`, а страницы — +в `pages/portfolios/` с импортами из плоской структуры. + +## Цель + +Мигрировать portfolio pages и сопутствующие UI-компоненты в FSD, следуя тому же +паттерну, что и broker domain (entities → widgets → pages). + +## Требования + +1. Все UI-компоненты из `components/portfolios/` размещаются в `widgets/` по принципу + «один виджет — одна композиционная единица» +2. Страницы в `pages/portfolios/` переключаются на импорты из виджетов +3. Никакие внутренние компоненты виджетов не импортируются напрямую из других слоёв — + только через barrel-файл виджета +4. API-хуки и бизнес-логика остаются в `entities/portfolio/` — не дублируются +5. `components/portfolios/` удаляется после миграции всех потребителей + +## Область изменений + +### Мигрируемые файлы + +Из `components/portfolios/` в `widgets/`: + +- `AllocationChart.tsx` → внутренний компонент `widgets/portfolio-summary/ui/` +- `AnalyticsSummary.tsx` → `widgets/portfolio-analytics/ui/AnalyticsSummary.tsx` +- `BondPositionRow.tsx` → внутренний компонент `widgets/bond-positions-table/ui/` +- `BondPositionTable.tsx` → `widgets/bond-positions-table/ui/BondPositionTable.tsx` +- `PortfolioCard.tsx` → `widgets/portfolio-card/ui/PortfolioCard.tsx` +- `PortfolioForm.tsx` → `widgets/portfolio-form/ui/PortfolioForm.tsx` +- `PortfolioSummary.tsx` → `widgets/portfolio-summary/ui/PortfolioSummary.tsx` +- `SharePositionRow.tsx` → внутренний компонент `widgets/share-positions-table/ui/` +- `SharePositionTable.tsx` → `widgets/share-positions-table/ui/SharePositionTable.tsx` + +Обновляемые страницы: + +- `pages/portfolios/PortfoliosListPage.tsx` — импорты из `../../components/portfolios/*` → `@/widgets/portfolio-*` +- `pages/portfolios/PortfolioDetailPage.tsx` — импорты из `../../components/portfolios/*` → `@/widgets/portfolio-*` + +### Удаляемые файлы + +- `components/portfolios/` — весь каталог (9 файлов) + +## Ограничения + +- Изменения ограничены frontend-пакетом. +- `entities/portfolio/` не изменяется (уже FSD). +- Не мигрируется screener и auth pages. +- Не меняется поведение UI, API-контракты, роутинг. +- Не вводятся ESLint import boundaries. + +## Список виджетов + +| Виджет | Компоненты | Назначение | +|--------|-----------|------------| +| `portfolio-card` | PortfolioCard | Карточка портфеля для списка | +| `portfolio-form` | PortfolioForm | Форма создания/редактирования | +| `portfolio-summary` | PortfolioSummary, AllocationChart | Сводка: график + общая стоимость | +| `portfolio-analytics` | AnalyticsSummary | Метрики: инвестировано, P&L, доходность | +| `share-positions-table` | SharePositionTable, SharePositionRow | Таблица позиций-акций | +| `bond-positions-table` | BondPositionTable, BondPositionRow | Таблица позиций-облигаций | + +## Acceptance Criteria + +- Все 6 виджетов созданы в `widgets/` по FSD-структуре (index.ts + ui/) +- `components/portfolios/` удалён +- `pages/portfolios/` импортируют только из `@/widgets/*` и `@/entities/portfolio` +- `npm test -w apps/frontend` — PASS +- `npm run lint -w apps/frontend` — PASS +- `npm run build -w apps/frontend` — PASS +- Deep import check: `rg "components/portfolios" apps/frontend/src` — no matches -- 2.47.2 From 36f39d2d3f11d21642553f68c0372174d253701f Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 20 Jun 2026 20:57:30 +0300 Subject: [PATCH 2/7] docs: add frontend fsd portfolios plan and tasks --- docs/features/frontend-fsd-portfolios/plan.md | 294 ++++++++++++++++++ .../features/frontend-fsd-portfolios/tasks.md | 41 +++ 2 files changed, 335 insertions(+) create mode 100644 docs/features/frontend-fsd-portfolios/plan.md create mode 100644 docs/features/frontend-fsd-portfolios/tasks.md diff --git a/docs/features/frontend-fsd-portfolios/plan.md b/docs/features/frontend-fsd-portfolios/plan.md new file mode 100644 index 0000000..aae9aad --- /dev/null +++ b/docs/features/frontend-fsd-portfolios/plan.md @@ -0,0 +1,294 @@ +# Frontend FSD — Portfolio Pages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate portfolio UI components from flat `components/portfolios/` to `widgets/` per FSD, following the broker domain pattern. + +**Architecture:** 6 widgets created under `widgets/`, each with `index.ts` barrel + `ui/Component.tsx`. Pages in `pages/portfolios/` switch imports to widgets. `components/portfolios/` deleted. `entities/portfolio/` unchanged. + +**Tech Stack:** React 18, TypeScript, Vite, Vitest + +--- + +### Task 1: Create widget directory structure and barrel files + +**Files:** +- Create: `apps/frontend/src/widgets/portfolio-card/index.ts` +- Create: `apps/frontend/src/widgets/portfolio-form/index.ts` +- Create: `apps/frontend/src/widgets/portfolio-summary/index.ts` +- Create: `apps/frontend/src/widgets/portfolio-analytics/index.ts` +- Create: `apps/frontend/src/widgets/share-positions-table/index.ts` +- Create: `apps/frontend/src/widgets/bond-positions-table/index.ts` +- Create: `apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx` +- Create: `apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx` +- Create: `apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx` +- Create: `apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx` +- Create: `apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx` +- Create: `apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx` +- Create: `apps/frontend/src/widgets/share-positions-table/ui/SharePositionRow.tsx` +- Create: `apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx` +- Create: `apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx` + +- [ ] **Step 1: Create all widget barrel files** + +```ts +// widgets/portfolio-card/index.ts +export { PortfolioCard } from './ui/PortfolioCard'; +``` + +```ts +// widgets/portfolio-form/index.ts +export { PortfolioForm } from './ui/PortfolioForm'; +``` + +```ts +// widgets/portfolio-summary/index.ts +export { PortfolioSummary } from './ui/PortfolioSummary'; +``` + +```ts +// widgets/portfolio-analytics/index.ts +export { AnalyticsSummary } from './ui/AnalyticsSummary'; +``` + +```ts +// widgets/share-positions-table/index.ts +export { SharePositionTable } from './ui/SharePositionTable'; +``` + +```ts +// widgets/bond-positions-table/index.ts +export { BondPositionTable } from './ui/BondPositionTable'; +``` + +- [ ] **Step 2: Copy and update imports for each component** + +For each component from `components/portfolios/`, copy to `widgets//ui/.tsx` and fix imports: + +**PortfolioCard.tsx** (copy from `components/portfolios/PortfolioCard.tsx`, no import changes needed): +```tsx +// Same content as original — imports @/shared/api/responses which stays valid +``` + +**PortfolioForm.tsx** (copy from `components/portfolios/PortfolioForm.tsx`, no import changes): +```tsx +// Same content — imports @/shared/api/responses +``` + +**PortfolioSummary.tsx** (copy from `components/portfolios/PortfolioSummary.tsx`, update import path): +```tsx +import { AllocationChart } from './AllocationChart'; +import type { PortfolioDetail } from '@/shared/api/responses'; +// rest identical to original +``` + +**AllocationChart.tsx** (copy from `components/portfolios/AllocationChart.tsx`, no import changes): +```tsx +// Same content — imports @/shared/api/responses +``` + +**AnalyticsSummary.tsx** (copy from `components/portfolios/AnalyticsSummary.tsx`, no import changes): +```tsx +// Same content — imports @/shared/api/responses +``` + +**SharePositionRow.tsx** (copy from `components/portfolios/SharePositionRow.tsx`, no import changes): +```tsx +// Same content — imports @/shared/api/responses +``` + +**SharePositionTable.tsx** (copy from `components/portfolios/SharePositionTable.tsx`, update import path): +```tsx +import { SharePositionRow } from './SharePositionRow'; +import type { PositionWithPrice } from '@/shared/api/responses'; +// rest identical to original +``` + +**BondPositionRow.tsx** (copy from `components/portfolios/BondPositionRow.tsx`, no import changes): +```tsx +// Same content — imports @/shared/api/responses +``` + +**BondPositionTable.tsx** (copy from `components/portfolios/BondPositionTable.tsx`, update import path): +```tsx +import { BondPositionRow } from './BondPositionRow'; +import type { PositionWithPrice } from '@/shared/api/responses'; +// rest identical to original +``` + +- [ ] **Step 3: Verify build** + +Run: `npm run build -w apps/frontend` +Expected: PASS + +- [ ] **Step 4: Run tests** + +Run: `npm test -w apps/frontend` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/widgets/portfolio-card/ \ + apps/frontend/src/widgets/portfolio-form/ \ + apps/frontend/src/widgets/portfolio-summary/ \ + apps/frontend/src/widgets/portfolio-analytics/ \ + apps/frontend/src/widgets/share-positions-table/ \ + apps/frontend/src/widgets/bond-positions-table/ +git commit -m "feat(frontend): create portfolio widgets in FSD structure" +``` + +--- + +### Task 2: Update pages to use widgets + +**Files:** +- Modify: `apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx` +- Modify: `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx` + +- [ ] **Step 1: Update PortfoliosListPage.tsx** + +```tsx +import { useState } from 'react'; +import { usePortfolios, usePortfolioMutations } from '../../entities/portfolio'; +import { PortfolioCard } from '../../widgets/portfolio-card'; +import { PortfolioForm } from '../../widgets/portfolio-form'; +``` + +- [ ] **Step 2: Update PortfolioDetailPage.tsx** + +```tsx +import { useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { + usePortfolio, + usePortfolioMutations, + usePositionMutations, +} from '../../entities/portfolio'; +import { PortfolioForm } from '../../widgets/portfolio-form'; +import { PortfolioSummary } from '../../widgets/portfolio-summary'; +import { AnalyticsSummary } from '../../widgets/portfolio-analytics'; +import { SharePositionTable } from '../../widgets/share-positions-table'; +import { BondPositionTable } from '../../widgets/bond-positions-table'; +``` + +- [ ] **Step 3: Verify build** + +Run: `npm run build -w apps/frontend` +Expected: PASS + +- [ ] **Step 4: Run tests** + +Run: `npm test -w apps/frontend` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/pages/portfolios/ +git commit -m "refactor(frontend): switch pages/portfolios to widget imports" +``` + +--- + +### Task 3: Remove flat components/portfolios/ + +**Files:** +- Delete: `apps/frontend/src/components/portfolios/AllocationChart.tsx` +- Delete: `apps/frontend/src/components/portfolios/AnalyticsSummary.tsx` +- Delete: `apps/frontend/src/components/portfolios/BondPositionRow.tsx` +- Delete: `apps/frontend/src/components/portfolios/BondPositionTable.tsx` +- Delete: `apps/frontend/src/components/portfolios/PortfolioCard.tsx` +- Delete: `apps/frontend/src/components/portfolios/PortfolioForm.tsx` +- Delete: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` +- Delete: `apps/frontend/src/components/portfolios/SharePositionRow.tsx` +- Delete: `apps/frontend/src/components/portfolios/SharePositionTable.tsx` + +- [ ] **Step 1: Delete all files in components/portfolios/** + +Run: `rm apps/frontend/src/components/portfolios/*.tsx` + +- [ ] **Step 2: Check for remaining imports from components/portfolios** + +Run: `rg "components/portfolios" apps/frontend/src` +Expected: no matches + +- [ ] **Step 3: Verify build** + +Run: `npm run build -w apps/frontend` +Expected: PASS + +- [ ] **Step 4: Run tests** + +Run: `npm test -w apps/frontend` +Expected: PASS + +- [ ] **Step 5: Lint check** + +Run: `npm run lint -w apps/frontend` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git rm apps/frontend/src/components/portfolios/*.tsx +git commit -m "refactor(frontend): remove flat components/portfolios" +``` + +--- + +### Task 4: Update documentation + +**Files:** +- Modify: `apps/docs/docs/frontend/hooks.md` +- Modify: `apps/docs/docs/frontend/routes.md` + +- [ ] **Step 1: Update hooks.md** + +Remove any references to legacy `components/portfolios/` paths. +Update import examples to use `@/widgets/portfolio-*` paths where applicable. + +- [ ] **Step 2: Update routes.md** + +Ensure portfolio route documentation reflects FSD structure. + +- [ ] **Step 3: Run doc build** + +Run: `npm run build -w apps/docs` +Expected: PASS + +- [ ] **Step 4: Commit documentation** + +```bash +git add apps/docs/docs/frontend/ +git commit -m "docs: update frontend docs for portfolio FSD structure" +``` + +--- + +### Task 5: Final verification + +- [ ] **Step 1: Test** + +Run: `npm test -w apps/frontend` +Expected: PASS + +- [ ] **Step 2: Lint** + +Run: `npm run lint -w apps/frontend` +Expected: PASS + +- [ ] **Step 3: Build** + +Run: `npm run build -w apps/frontend` +Expected: PASS + +- [ ] **Step 4: Deep import check** + +Run: `rg "components/portfolios" apps/frontend/src` +Expected: no matches + +- [ ] **Step 5: Docs build** + +Run: `npm run build -w apps/docs` +Expected: PASS diff --git a/docs/features/frontend-fsd-portfolios/tasks.md b/docs/features/frontend-fsd-portfolios/tasks.md new file mode 100644 index 0000000..75a06c7 --- /dev/null +++ b/docs/features/frontend-fsd-portfolios/tasks.md @@ -0,0 +1,41 @@ +# Frontend FSD — Portfolio Pages Tasks + +## 1. Create widget directory structure and barrel files + +- [ ] Create 6 widget barrels in `widgets/` +- [ ] Copy components to `widgets/*/ui/` with updated imports +- [ ] `npm run build -w apps/frontend` — PASS +- [ ] `npm test -w apps/frontend` — PASS +- [ ] Commit + +## 2. Update pages to use widgets + +- [ ] Update `PortfoliosListPage.tsx` imports +- [ ] Update `PortfolioDetailPage.tsx` imports +- [ ] `npm run build -w apps/frontend` — PASS +- [ ] `npm test -w apps/frontend` — PASS +- [ ] Commit + +## 3. Remove flat components/portfolios/ + +- [ ] Delete `components/portfolios/*.tsx` +- [ ] No remaining imports from `components/portfolios` +- [ ] `npm run build -w apps/frontend` — PASS +- [ ] `npm test -w apps/frontend` — PASS +- [ ] `npm run lint -w apps/frontend` — PASS +- [ ] Commit + +## 4. Update documentation + +- [ ] Update `apps/docs/docs/frontend/hooks.md` +- [ ] Update `apps/docs/docs/frontend/routes.md` +- [ ] `npm run build -w apps/docs` — PASS +- [ ] Отдельный коммит + +## 5. Final verification + +- [ ] `npm test -w apps/frontend` — PASS +- [ ] `npm run lint -w apps/frontend` — PASS +- [ ] `npm run build -w apps/frontend` — PASS +- [ ] `rg "components/portfolios"` — no matches +- [ ] `npm run build -w apps/docs` — PASS -- 2.47.2 From cfa890fdbea101bf5b364aff373c882f1d6f682a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 20 Jun 2026 20:59:39 +0300 Subject: [PATCH 3/7] feat: add portfolio widgets with barrel files (FSD migration) --- .../src/widgets/bond-positions-table/index.ts | 1 + .../ui/BondPositionRow.tsx | 222 ++++++++++++++ .../ui/BondPositionTable.tsx | 273 ++++++++++++++++++ .../src/widgets/portfolio-analytics/index.ts | 1 + .../ui/AnalyticsSummary.tsx | 68 +++++ .../src/widgets/portfolio-card/index.ts | 1 + .../portfolio-card/ui/PortfolioCard.tsx | 87 ++++++ .../src/widgets/portfolio-form/index.ts | 1 + .../portfolio-form/ui/PortfolioForm.tsx | 119 ++++++++ .../src/widgets/portfolio-summary/index.ts | 1 + .../portfolio-summary/ui/AllocationChart.tsx | 162 +++++++++++ .../portfolio-summary/ui/PortfolioSummary.tsx | 46 +++ .../widgets/share-positions-table/index.ts | 1 + .../ui/SharePositionRow.tsx | 203 +++++++++++++ .../ui/SharePositionTable.tsx | 163 +++++++++++ 15 files changed, 1349 insertions(+) create mode 100644 apps/frontend/src/widgets/bond-positions-table/index.ts create mode 100644 apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx create mode 100644 apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx create mode 100644 apps/frontend/src/widgets/portfolio-analytics/index.ts create mode 100644 apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx create mode 100644 apps/frontend/src/widgets/portfolio-card/index.ts create mode 100644 apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx create mode 100644 apps/frontend/src/widgets/portfolio-form/index.ts create mode 100644 apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx create mode 100644 apps/frontend/src/widgets/portfolio-summary/index.ts create mode 100644 apps/frontend/src/widgets/portfolio-summary/ui/AllocationChart.tsx create mode 100644 apps/frontend/src/widgets/portfolio-summary/ui/PortfolioSummary.tsx create mode 100644 apps/frontend/src/widgets/share-positions-table/index.ts create mode 100644 apps/frontend/src/widgets/share-positions-table/ui/SharePositionRow.tsx create mode 100644 apps/frontend/src/widgets/share-positions-table/ui/SharePositionTable.tsx diff --git a/apps/frontend/src/widgets/bond-positions-table/index.ts b/apps/frontend/src/widgets/bond-positions-table/index.ts new file mode 100644 index 0000000..add2bda --- /dev/null +++ b/apps/frontend/src/widgets/bond-positions-table/index.ts @@ -0,0 +1 @@ +export { BondPositionTable } from './ui/BondPositionTable'; diff --git a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx new file mode 100644 index 0000000..e1ae9b6 --- /dev/null +++ b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionRow.tsx @@ -0,0 +1,222 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import type { PositionWithPrice } from '@/shared/api/responses'; + +interface Props { + position: PositionWithPrice; + onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; + onDelete: () => void; +} + +export function BondPositionRow({ position, onUpdate, onDelete }: Props) { + const [editingQty, setEditingQty] = useState(false); + const [editingPrice, setEditingPrice] = useState(false); + const [qty, setQty] = useState(String(position.quantity)); + const [price, setPrice] = useState(String(position.buyPrice ?? '')); + + function handleSaveQty() { + const num = parseInt(qty, 10); + if (!isNaN(num) && num >= 0 && num !== position.quantity) { + onUpdate({ quantity: num }); + } + setEditingQty(false); + } + + function handleSavePrice() { + const num = parseFloat(price); + if (!isNaN(num) && num >= 0 && num !== position.buyPrice) { + onUpdate({ buyPrice: num }); + } else if (price === '' && position.buyPrice !== null) { + onUpdate({ buyPrice: undefined }); + } + setEditingPrice(false); + } + + function formatDate(dateStr: string | null | undefined): string { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString('ru-RU'); + } + + function formatPct(value: number | null | undefined): string { + return value != null ? `${value.toFixed(2)}%` : '—'; + } + + function formatRubles(value: number | null | undefined): string { + return value != null + ? value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : '—'; + } + + const totalAccrued = position.accruedInt != null ? position.accruedInt * position.quantity : null; + + return ( + + + + {position.secid} + + + + {position.shortName ?? '—'} + + + {position.bondType ? {position.bondType} : '—'} + + + {editingQty ? ( + setQty(e.target.value)} + onBlur={handleSaveQty} + onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()} + autoFocus + style={{ + width: 80, + padding: '4px 8px', + border: '1px solid var(--color-primary)', + borderRadius: 'var(--border-radius)', + fontSize: 14, + }} + /> + ) : ( + { + setQty(String(position.quantity)); + setEditingQty(true); + }} + style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }} + > + {position.quantity.toLocaleString('ru-RU')} + + )} + + + {editingPrice ? ( + setPrice(e.target.value)} + onBlur={handleSavePrice} + onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()} + autoFocus + style={{ + width: 90, + padding: '4px 8px', + border: '1px solid var(--color-primary)', + borderRadius: 'var(--border-radius)', + fontSize: 14, + textAlign: 'right', + }} + /> + ) : ( + { + setPrice(String(position.buyPrice ?? '')); + setEditingPrice(true); + }} + style={{ + cursor: 'pointer', + padding: '4px 0', + display: 'inline-block', + color: position.buyPrice === null ? 'var(--color-text-secondary)' : 'inherit', + }} + > + {formatPct(position.buyPrice)} + + )} + + + {formatPct(position.currentPrice)} + + {formatPct(position.bid)} + {formatPct(position.offer)} + + {formatPct(position.yieldToMaturity)} + + + {position.duration != null ? `${position.duration.toFixed(2)}г` : '—'} + + + {formatRubles(position.couponValue)} + + + {formatPct(position.couponPercent)} + + + {position.couponPeriod ? `${position.couponPeriod}д` : '—'} + + + {totalAccrued != null + ? totalAccrued.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : '—'} + + + {position.totalCost !== null + ? position.totalCost.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : '—'} + + + {position.pnl !== null ? ( + = 0 ? 'var(--color-positive)' : 'var(--color-negative)' }} + > + {position.pnl >= 0 ? '+' : ''} + {position.pnl.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + ) : ( + '—' + )} + + + {position.pnlPercent !== null ? ( + = 0 ? 'var(--color-positive)' : 'var(--color-negative)', + }} + > + {position.pnlPercent >= 0 ? '+' : ''} + {position.pnlPercent.toFixed(2)}% + + ) : ( + '—' + )} + + + {formatDate(position.nextCouponDate)} + + {formatDate(position.matDate)} + {formatDate(position.offerDate)} + + {position.weightPercent.toFixed(1)}% + + + + + + ); +} diff --git a/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx new file mode 100644 index 0000000..c1dfc2b --- /dev/null +++ b/apps/frontend/src/widgets/bond-positions-table/ui/BondPositionTable.tsx @@ -0,0 +1,273 @@ +import { BondPositionRow } from './BondPositionRow'; +import type { PositionWithPrice } from '@/shared/api/responses'; + +interface Props { + positions: PositionWithPrice[]; + onUpdatePosition: ( + positionId: number, + data: { quantity?: number; buyPrice?: number; buyDate?: string }, + ) => void; + onDeletePosition: (positionId: number) => void; +} + +export function BondPositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) { + if (positions.length === 0) return null; + + return ( +
+

+ Облигации +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {positions.map((pos) => ( + onUpdatePosition(pos.id, data)} + onDelete={() => onDeletePosition(pos.id)} + /> + ))} + +
+ Тикер + + Название + + Тип + + Количество + + Цена пок. + + Цена + + Бид + + Оффер + + Доходность + + Дюрация + + Купон + + Куп. % + + Период + + НКД + + Затраты + + P&L + + P&L % + + След. купон + + Погашение + + Оферта + + Доля +
+
+
+ ); +} diff --git a/apps/frontend/src/widgets/portfolio-analytics/index.ts b/apps/frontend/src/widgets/portfolio-analytics/index.ts new file mode 100644 index 0000000..e89a5f5 --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-analytics/index.ts @@ -0,0 +1 @@ +export { AnalyticsSummary } from './ui/AnalyticsSummary'; diff --git a/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx new file mode 100644 index 0000000..b2139ba --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx @@ -0,0 +1,68 @@ +import type { PortfolioSummary } from '@/shared/api/responses'; + +export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) { + const formatRub = (val: number | null) => + val != null + ? val.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : '—'; + + const formatPct = (val: number | null) => (val != null ? `${val.toFixed(2)}%` : '—'); + + const pnlColor = + summary.totalPnl > 0 + ? 'var(--color-positive)' + : summary.totalPnl < 0 + ? 'var(--color-negative)' + : 'inherit'; + + return ( +
+
+
+ Инвестировано +
+
{formatRub(summary.totalInvested)}
+
+ +
+
+ Текущая стоимость +
+
{formatRub(summary.totalValue)}
+
+ +
+
+ Прибыль/Убыток +
+
+ {summary.totalPnl > 0 ? '+' : ''} + {formatRub(summary.totalPnl)} + + ({formatPct(summary.totalPnlPercent)}) + +
+
+ +
+
+ Доходность (weighted) +
+
+ {formatPct(summary.weightedYield)} +
+
+
+ ); +} diff --git a/apps/frontend/src/widgets/portfolio-card/index.ts b/apps/frontend/src/widgets/portfolio-card/index.ts new file mode 100644 index 0000000..e2914ab --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-card/index.ts @@ -0,0 +1 @@ +export { PortfolioCard } from './ui/PortfolioCard'; diff --git a/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx b/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx new file mode 100644 index 0000000..9c36320 --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-card/ui/PortfolioCard.tsx @@ -0,0 +1,87 @@ +import { Link } from 'react-router-dom'; +import type { Portfolio } from '@/shared/api/responses'; + +export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { + const chipStyle = (bg: string): React.CSSProperties => ({ + background: bg, + padding: '4px 10px', + borderRadius: 6, + fontSize: 12, + color: '#fff', + fontWeight: 500, + }); + + return ( + (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')} + onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} + > +
+

{portfolio.name}

+
+
+ {portfolio.totalValue?.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +
+
+ {portfolio.currency} +
+
+
+ + {portfolio.description && ( +

+ {portfolio.description} +

+ )} + +
+ {portfolio.shareCount > 0 && ( + + {portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')} + + )} + {portfolio.bondCount > 0 && ( + + {portfolio.bondCount}{' '} + {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')} + + )} + + {portfolio.positionCount}{' '} + {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')} + +
+ + + обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} + + + ); +} + +function pluralize(n: number, one: string, few: string, many: string): string { + if (n % 10 === 1 && n % 100 !== 11) return one; + if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few; + return many; +} diff --git a/apps/frontend/src/widgets/portfolio-form/index.ts b/apps/frontend/src/widgets/portfolio-form/index.ts new file mode 100644 index 0000000..a030012 --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-form/index.ts @@ -0,0 +1 @@ +export { PortfolioForm } from './ui/PortfolioForm'; diff --git a/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx new file mode 100644 index 0000000..870f288 --- /dev/null +++ b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import type { Portfolio } from '@/shared/api/responses'; + +interface Props { + initial?: Portfolio; + onSave: (data: { name: string; description?: string; currency?: string }) => void; + onCancel: () => void; + isLoading?: boolean; +} + +const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN']; + +export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) { + const [name, setName] = useState(initial?.name || ''); + const [description, setDescription] = useState(initial?.description || ''); + const [currency, setCurrency] = useState(initial?.currency || 'RUB'); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim()) return; + onSave({ name: name.trim(), description: description.trim() || undefined, currency }); + } + + return ( +
+
+ + setName(e.target.value)} + required + maxLength={100} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, + }} + /> +
+
+ +