From 946383bde086831084e3234351fc34bd51305aa0 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 20 Jun 2026 14:05:23 +0300 Subject: [PATCH] refactor(frontend): migrate entities/portfolio to FSD --- apps/frontend/src/api/portfolio.ts | 104 ++---------------- .../entities/portfolio/api/portfolioApi.ts | 98 +++++++++++++++++ apps/frontend/src/entities/portfolio/index.ts | 16 +++ .../entities/portfolio/model/usePortfolio.ts | 17 +++ .../portfolio/model/usePortfolioAnalytics.ts | 17 +++ .../portfolio/model/usePortfolioMutations.ts | 45 ++++++++ .../entities/portfolio/model/usePortfolios.ts | 16 +++ .../portfolio/model/usePositionMutations.ts | 78 +++++++++++++ apps/frontend/src/hooks/usePortfolio.ts | 18 +-- .../src/hooks/usePortfolioAnalytics.ts | 18 +-- .../src/hooks/usePortfolioMutations.ts | 46 +------- apps/frontend/src/hooks/usePortfolios.ts | 17 +-- .../src/hooks/usePositionMutations.ts | 79 +------------ .../pages/portfolios/PortfolioDetailPage.tsx | 8 +- .../pages/portfolios/PortfoliosListPage.tsx | 3 +- 15 files changed, 309 insertions(+), 271 deletions(-) create mode 100644 apps/frontend/src/entities/portfolio/api/portfolioApi.ts create mode 100644 apps/frontend/src/entities/portfolio/index.ts create mode 100644 apps/frontend/src/entities/portfolio/model/usePortfolio.ts create mode 100644 apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts create mode 100644 apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts create mode 100644 apps/frontend/src/entities/portfolio/model/usePortfolios.ts create mode 100644 apps/frontend/src/entities/portfolio/model/usePositionMutations.ts diff --git a/apps/frontend/src/api/portfolio.ts b/apps/frontend/src/api/portfolio.ts index 6474965..b16ddbd 100644 --- a/apps/frontend/src/api/portfolio.ts +++ b/apps/frontend/src/api/portfolio.ts @@ -1,93 +1,11 @@ -import { request } from './client'; -import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from './responses'; - -export function getPortfolios(): Promise<{ - data: Portfolio[]; - meta: { cachedAt: string | null; fromCache: boolean }; -}> { - return request('/api/v1/portfolios'); -} - -export function getPortfolio( - id: number, -): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request(`/api/v1/portfolios/${id}`); -} - -export function createPortfolio(data: { - name: string; - description?: string; - currency?: string; -}): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request('/api/v1/portfolios', undefined, { - method: 'POST', - body: data, - }); -} - -export function updatePortfolio( - id: number, - data: { name?: string; description?: string; currency?: string }, -): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request(`/api/v1/portfolios/${id}`, undefined, { - method: 'PATCH', - body: data, - }); -} - -export function deletePortfolio( - id: number, -): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request(`/api/v1/portfolios/${id}`, undefined, { - method: 'DELETE', - }); -} - -export function addPosition( - portfolioId: number, - data: { - secid: string; - quantity: number; - buyPrice?: number; - buyDate?: string; - notes?: string; - tags?: string[]; - }, -): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request(`/api/v1/portfolios/${portfolioId}/positions`, undefined, { - method: 'POST', - body: data, - }); -} - -export function updatePosition( - portfolioId: number, - positionId: number, - data: { - quantity?: number; - buyPrice?: number; - buyDate?: string; - notes?: string; - tags?: string[]; - }, -): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { - method: 'PATCH', - body: data, - }); -} - -export function removePosition( - portfolioId: number, - positionId: number, -): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { - method: 'DELETE', - }); -} - -export function getPortfolioAnalytics( - portfolioId: number, -): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> { - return request(`/api/v1/portfolios/${portfolioId}/analytics`); -} +export { + getPortfolios, + getPortfolio, + createPortfolio, + updatePortfolio, + deletePortfolio, + addPosition, + updatePosition, + removePosition, + getPortfolioAnalytics, +} from '../entities/portfolio/api/portfolioApi'; diff --git a/apps/frontend/src/entities/portfolio/api/portfolioApi.ts b/apps/frontend/src/entities/portfolio/api/portfolioApi.ts new file mode 100644 index 0000000..d3ed6fa --- /dev/null +++ b/apps/frontend/src/entities/portfolio/api/portfolioApi.ts @@ -0,0 +1,98 @@ +import { request } from '@/shared/api/client'; +import type { + AnalyticsResponse, + Portfolio, + PortfolioDetail, + Position, +} from '@/shared/api/responses'; + +export function getPortfolios(): Promise<{ + data: Portfolio[]; + meta: { cachedAt: string | null; fromCache: boolean }; +}> { + return request('/api/v1/portfolios'); +} + +export function getPortfolio( + id: number, +): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${id}`); +} + +export function createPortfolio(data: { + name: string; + description?: string; + currency?: string; +}): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request('/api/v1/portfolios', undefined, { + method: 'POST', + body: data, + }); +} + +export function updatePortfolio( + id: number, + data: { name?: string; description?: string; currency?: string }, +): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${id}`, undefined, { + method: 'PATCH', + body: data, + }); +} + +export function deletePortfolio( + id: number, +): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${id}`, undefined, { + method: 'DELETE', + }); +} + +export function addPosition( + portfolioId: number, + data: { + secid: string; + quantity: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }, +): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${portfolioId}/positions`, undefined, { + method: 'POST', + body: data, + }); +} + +export function updatePosition( + portfolioId: number, + positionId: number, + data: { + quantity?: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }, +): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { + method: 'PATCH', + body: data, + }); +} + +export function removePosition( + portfolioId: number, + positionId: number, +): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { + method: 'DELETE', + }); +} + +export function getPortfolioAnalytics( + portfolioId: number, +): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/api/v1/portfolios/${portfolioId}/analytics`); +} diff --git a/apps/frontend/src/entities/portfolio/index.ts b/apps/frontend/src/entities/portfolio/index.ts new file mode 100644 index 0000000..48ccebb --- /dev/null +++ b/apps/frontend/src/entities/portfolio/index.ts @@ -0,0 +1,16 @@ +export { usePortfolio } from './model/usePortfolio'; +export { usePortfolios } from './model/usePortfolios'; +export { usePortfolioAnalytics } from './model/usePortfolioAnalytics'; +export { usePortfolioMutations } from './model/usePortfolioMutations'; +export { usePositionMutations } from './model/usePositionMutations'; +export { + getPortfolios, + getPortfolio, + createPortfolio, + updatePortfolio, + deletePortfolio, + addPosition, + updatePosition, + removePosition, + getPortfolioAnalytics, +} from './api/portfolioApi'; diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolio.ts b/apps/frontend/src/entities/portfolio/model/usePortfolio.ts new file mode 100644 index 0000000..e2d6be8 --- /dev/null +++ b/apps/frontend/src/entities/portfolio/model/usePortfolio.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPortfolio } from '../api/portfolioApi'; +import type { PortfolioDetail } from '@/shared/api/responses'; + +export function usePortfolio(id: number) { + return useQuery({ + queryKey: ['portfolio', id], + queryFn: async () => { + const res = await getPortfolio(id); + return res.data; + }, + staleTime: 900_000, + retry: 2, + refetchOnWindowFocus: false, + enabled: !!id, + }); +} diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts b/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts new file mode 100644 index 0000000..f74d169 --- /dev/null +++ b/apps/frontend/src/entities/portfolio/model/usePortfolioAnalytics.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPortfolioAnalytics } from '../api/portfolioApi'; +import type { AnalyticsResponse } from '@/shared/api/responses'; + +export function usePortfolioAnalytics(portfolioId: number) { + return useQuery({ + queryKey: ['portfolio', portfolioId, 'analytics'], + queryFn: async () => { + const res = await getPortfolioAnalytics(portfolioId); + return res.data; + }, + staleTime: 900_000, + retry: 2, + refetchOnWindowFocus: false, + enabled: !!portfolioId, + }); +} diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts b/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts new file mode 100644 index 0000000..f5965ee --- /dev/null +++ b/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts @@ -0,0 +1,45 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolioApi'; +import { useNavigate } from 'react-router-dom'; + +export function usePortfolioMutations() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const create = useMutation({ + mutationFn: (data: { name: string; description?: string; currency?: string }) => + createPortfolio(data), + onSuccess: (res) => { + queryClient.invalidateQueries({ queryKey: ['portfolios'] }); + navigate(`/portfolios/${res.data.id}`); + }, + }); + + const update = useMutation({ + mutationFn: ({ + id, + data, + }: { + id: number; + data: { + name?: string; + description?: string; + currency?: string; + }; + }) => updatePortfolio(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['portfolios'] }); + queryClient.invalidateQueries({ queryKey: ['portfolio', id] }); + }, + }); + + const remove = useMutation({ + mutationFn: (id: number) => deletePortfolio(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portfolios'] }); + navigate('/portfolios'); + }, + }); + + return { create, update, remove }; +} diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolios.ts b/apps/frontend/src/entities/portfolio/model/usePortfolios.ts new file mode 100644 index 0000000..781a63d --- /dev/null +++ b/apps/frontend/src/entities/portfolio/model/usePortfolios.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPortfolios } from '../api/portfolioApi'; +import type { Portfolio } from '@/shared/api/responses'; + +export function usePortfolios() { + return useQuery({ + queryKey: ['portfolios'], + queryFn: async () => { + const res = await getPortfolios(); + return res.data; + }, + staleTime: 900_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts b/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts new file mode 100644 index 0000000..30ed785 --- /dev/null +++ b/apps/frontend/src/entities/portfolio/model/usePositionMutations.ts @@ -0,0 +1,78 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { addPosition, updatePosition, removePosition } from '../api/portfolioApi'; +import type { PortfolioDetail } from '@/shared/api/responses'; + +export function usePositionMutations(portfolioId: number) { + const queryClient = useQueryClient(); + + const add = useMutation({ + mutationFn: (data: { + secid: string; + quantity: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }) => addPosition(portfolioId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); + }, + }); + + const update = useMutation({ + mutationFn: ({ + positionId, + data, + }: { + positionId: number; + data: { + quantity?: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }; + }) => updatePosition(portfolioId, positionId, data), + onMutate: async ({ positionId, data }) => { + await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] }); + const previous = queryClient.getQueryData<{ data: PortfolioDetail }>([ + 'portfolio', + portfolioId, + ]); + queryClient.setQueryData(['portfolio', portfolioId], (old: any) => { + if (!old) return old; + return { + ...old, + positions: old.positions.map((p: any) => + p.id === positionId + ? { + ...p, + ...(data.quantity !== undefined ? { quantity: data.quantity } : {}), + ...(data.buyPrice !== undefined ? { buyPrice: data.buyPrice } : {}), + ...(data.buyDate !== undefined ? { buyDate: data.buyDate } : {}), + } + : p, + ), + }; + }); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(['portfolio', portfolioId], context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); + }, + }); + + const remove = useMutation({ + mutationFn: (positionId: number) => removePosition(portfolioId, positionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); + }, + }); + + return { add, update, remove }; +} diff --git a/apps/frontend/src/hooks/usePortfolio.ts b/apps/frontend/src/hooks/usePortfolio.ts index be02636..91dac4d 100644 --- a/apps/frontend/src/hooks/usePortfolio.ts +++ b/apps/frontend/src/hooks/usePortfolio.ts @@ -1,17 +1 @@ -import { useQuery } from '@tanstack/react-query'; -import { getPortfolio } from '../api/portfolio'; -import type { PortfolioDetail } from '@/shared/api/responses'; - -export function usePortfolio(id: number) { - return useQuery({ - queryKey: ['portfolio', id], - queryFn: async () => { - const res = await getPortfolio(id); - return res.data; - }, - staleTime: 900_000, - retry: 2, - refetchOnWindowFocus: false, - enabled: !!id, - }); -} +export { usePortfolio } from '../entities/portfolio/model/usePortfolio'; diff --git a/apps/frontend/src/hooks/usePortfolioAnalytics.ts b/apps/frontend/src/hooks/usePortfolioAnalytics.ts index f4087ba..dba65a5 100644 --- a/apps/frontend/src/hooks/usePortfolioAnalytics.ts +++ b/apps/frontend/src/hooks/usePortfolioAnalytics.ts @@ -1,17 +1 @@ -import { useQuery } from '@tanstack/react-query'; -import { getPortfolioAnalytics } from '../api/portfolio'; -import type { AnalyticsResponse } from '@/shared/api/responses'; - -export function usePortfolioAnalytics(portfolioId: number) { - return useQuery({ - queryKey: ['portfolio', portfolioId, 'analytics'], - queryFn: async () => { - const res = await getPortfolioAnalytics(portfolioId); - return res.data; - }, - staleTime: 900_000, - retry: 2, - refetchOnWindowFocus: false, - enabled: !!portfolioId, - }); -} +export { usePortfolioAnalytics } from '../entities/portfolio/model/usePortfolioAnalytics'; diff --git a/apps/frontend/src/hooks/usePortfolioMutations.ts b/apps/frontend/src/hooks/usePortfolioMutations.ts index 5a60eef..8bfac3d 100644 --- a/apps/frontend/src/hooks/usePortfolioMutations.ts +++ b/apps/frontend/src/hooks/usePortfolioMutations.ts @@ -1,45 +1 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolio'; -import { useNavigate } from 'react-router-dom'; - -export function usePortfolioMutations() { - const queryClient = useQueryClient(); - const navigate = useNavigate(); - - const create = useMutation({ - mutationFn: (data: { name: string; description?: string; currency?: string }) => - createPortfolio(data), - onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: ['portfolios'] }); - navigate(`/portfolios/${res.data.id}`); - }, - }); - - const update = useMutation({ - mutationFn: ({ - id, - data, - }: { - id: number; - data: { - name?: string; - description?: string; - currency?: string; - }; - }) => updatePortfolio(id, data), - onSuccess: (_, { id }) => { - queryClient.invalidateQueries({ queryKey: ['portfolios'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio', id] }); - }, - }); - - const remove = useMutation({ - mutationFn: (id: number) => deletePortfolio(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['portfolios'] }); - navigate('/portfolios'); - }, - }); - - return { create, update, remove }; -} +export { usePortfolioMutations } from '../entities/portfolio/model/usePortfolioMutations'; diff --git a/apps/frontend/src/hooks/usePortfolios.ts b/apps/frontend/src/hooks/usePortfolios.ts index f4daa18..b76b239 100644 --- a/apps/frontend/src/hooks/usePortfolios.ts +++ b/apps/frontend/src/hooks/usePortfolios.ts @@ -1,16 +1 @@ -import { useQuery } from '@tanstack/react-query'; -import { getPortfolios } from '../api/portfolio'; -import type { Portfolio } from '@/shared/api/responses'; - -export function usePortfolios() { - return useQuery({ - queryKey: ['portfolios'], - queryFn: async () => { - const res = await getPortfolios(); - return res.data; - }, - staleTime: 900_000, - retry: 2, - refetchOnWindowFocus: false, - }); -} +export { usePortfolios } from '../entities/portfolio/model/usePortfolios'; diff --git a/apps/frontend/src/hooks/usePositionMutations.ts b/apps/frontend/src/hooks/usePositionMutations.ts index fc98187..f8289a5 100644 --- a/apps/frontend/src/hooks/usePositionMutations.ts +++ b/apps/frontend/src/hooks/usePositionMutations.ts @@ -1,78 +1 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { addPosition, updatePosition, removePosition } from '../api/portfolio'; -import type { PortfolioDetail } from '@/shared/api/responses'; - -export function usePositionMutations(portfolioId: number) { - const queryClient = useQueryClient(); - - const add = useMutation({ - mutationFn: (data: { - secid: string; - quantity: number; - buyPrice?: number; - buyDate?: string; - notes?: string; - tags?: string[]; - }) => addPosition(portfolioId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); - }, - }); - - const update = useMutation({ - mutationFn: ({ - positionId, - data, - }: { - positionId: number; - data: { - quantity?: number; - buyPrice?: number; - buyDate?: string; - notes?: string; - tags?: string[]; - }; - }) => updatePosition(portfolioId, positionId, data), - onMutate: async ({ positionId, data }) => { - await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] }); - const previous = queryClient.getQueryData<{ data: PortfolioDetail }>([ - 'portfolio', - portfolioId, - ]); - queryClient.setQueryData(['portfolio', portfolioId], (old: any) => { - if (!old) return old; - return { - ...old, - positions: old.positions.map((p: any) => - p.id === positionId - ? { - ...p, - ...(data.quantity !== undefined ? { quantity: data.quantity } : {}), - ...(data.buyPrice !== undefined ? { buyPrice: data.buyPrice } : {}), - ...(data.buyDate !== undefined ? { buyDate: data.buyDate } : {}), - } - : p, - ), - }; - }); - return { previous }; - }, - onError: (_err, _vars, context) => { - if (context?.previous) { - queryClient.setQueryData(['portfolio', portfolioId], context.previous); - } - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); - }, - }); - - const remove = useMutation({ - mutationFn: (positionId: number) => removePosition(portfolioId, positionId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); - }, - }); - - return { add, update, remove }; -} +export { usePositionMutations } from '../entities/portfolio/model/usePositionMutations'; diff --git a/apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx b/apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx index cfcfe22..96f2ab6 100644 --- a/apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx +++ b/apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { usePortfolio } from '../../hooks/usePortfolio'; -import { usePortfolioMutations } from '../../hooks/usePortfolioMutations'; -import { usePositionMutations } from '../../hooks/usePositionMutations'; +import { + usePortfolio, + usePortfolioMutations, + usePositionMutations, +} from '../../entities/portfolio'; import { PortfolioForm } from '../../components/portfolios/PortfolioForm'; import { PortfolioSummary } from '../../components/portfolios/PortfolioSummary'; import { AnalyticsSummary } from '../../components/portfolios/AnalyticsSummary'; diff --git a/apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx b/apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx index a77a12b..22eb49e 100644 --- a/apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx +++ b/apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; -import { usePortfolios } from '../../hooks/usePortfolios'; -import { usePortfolioMutations } from '../../hooks/usePortfolioMutations'; +import { usePortfolios, usePortfolioMutations } from '../../entities/portfolio'; import { PortfolioCard } from '../../components/portfolios/PortfolioCard'; import { PortfolioForm } from '../../components/portfolios/PortfolioForm';