refactor(frontend): migrate entities/portfolio to FSD

This commit is contained in:
Sergey Krylov 2026-06-20 14:05:23 +03:00
parent 9c115f0e16
commit 946383bde0
15 changed files with 309 additions and 271 deletions

View File

@ -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<Portfolio[]>('/api/v1/portfolios');
}
export function getPortfolio(
id: number,
): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<PortfolioDetail>(`/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<Portfolio>('/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<Portfolio>(`/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<null>(`/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<Position>(`/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<Position>(`/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<null>(`/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<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`);
}
export {
getPortfolios,
getPortfolio,
createPortfolio,
updatePortfolio,
deletePortfolio,
addPosition,
updatePosition,
removePosition,
getPortfolioAnalytics,
} from '../entities/portfolio/api/portfolioApi';

View File

@ -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<Portfolio[]>('/api/v1/portfolios');
}
export function getPortfolio(
id: number,
): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<PortfolioDetail>(`/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<Portfolio>('/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<Portfolio>(`/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<null>(`/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<Position>(`/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<Position>(`/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<null>(`/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<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`);
}

View File

@ -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';

View File

@ -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<PortfolioDetail>({
queryKey: ['portfolio', id],
queryFn: async () => {
const res = await getPortfolio(id);
return res.data;
},
staleTime: 900_000,
retry: 2,
refetchOnWindowFocus: false,
enabled: !!id,
});
}

View File

@ -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<AnalyticsResponse>({
queryKey: ['portfolio', portfolioId, 'analytics'],
queryFn: async () => {
const res = await getPortfolioAnalytics(portfolioId);
return res.data;
},
staleTime: 900_000,
retry: 2,
refetchOnWindowFocus: false,
enabled: !!portfolioId,
});
}

View File

@ -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 };
}

View File

@ -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<Portfolio[]>({
queryKey: ['portfolios'],
queryFn: async () => {
const res = await getPortfolios();
return res.data;
},
staleTime: 900_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -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 };
}

View File

@ -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<PortfolioDetail>({
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';

View File

@ -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<AnalyticsResponse>({
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';

View File

@ -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';

View File

@ -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<Portfolio[]>({
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';

View File

@ -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';

View File

@ -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';

View File

@ -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';