feat: add all-accounts portfolio pages

Reuse account sections and group securities with per-account holdings.

Use Mantine Select to preserve filters and reset pagination on scope changes.

Document component reuse guardrails and cover routes, grouping and scope switching.
This commit is contained in:
2026-09-09 20:36:49 +03:00
parent c906dfa2e2
commit daf1dca666
36 changed files with 1962 additions and 117 deletions
+53 -17
View File
@@ -26,9 +26,14 @@ type AccountPagePosition = Portfolio["positions"][number] & {
logoSrc: string | null;
};
type AccountPagePositionGroup = {
key: string;
positions: AccountPagePosition[];
};
type AccountPagePositionSection = {
kind: PositionSectionKind;
positions: AccountPagePosition[];
groups: AccountPagePositionGroup[];
};
type AccountPageRecentOperations =
@@ -76,8 +81,9 @@ type AccountPageViewModel =
| AccountPageEmptyViewModel
| AccountPageLoadedViewModel;
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, {
limit: 5,
@@ -177,9 +183,7 @@ function toAccountPagePositionSections(
const stocks = toSection("stocks", portfolio, denominators);
const bonds = toSection("bonds", portfolio, denominators);
const other = toSection("other", portfolio, denominators);
return [stocks, bonds, other].filter(
(section) => section.positions.length > 0,
);
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
}
function toSection(
@@ -189,20 +193,51 @@ function toSection(
): AccountPagePositionSection {
return {
kind,
positions: portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
if (kind === "bonds") return position.type === "BOND";
return (
position.type !== "STOCK" &&
position.type !== "BOND" &&
position.type !== "CURRENCY"
);
})
.map((position) => toPosition(position, denominators, kind)),
groups: groupPositions(
portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
if (kind === "bonds") return position.type === "BOND";
return (
position.type !== "STOCK" &&
position.type !== "BOND" &&
position.type !== "CURRENCY"
);
})
.map((position) => toPosition(position, denominators, kind)),
),
};
}
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = position.figi?.trim()
? ["figi", position.figi.trim()]
: position.isin?.trim()
? ["isin", position.isin.trim()]
: null;
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key =
position.account && identity
? JSON.stringify([position.type, ...identity])
: rowKey;
const group = groups.get(key);
if (
group &&
!group.positions.some((item) => item.account?.id === position.account?.id)
) {
group.positions.push(position);
} else {
const uniqueKey = group ? rowKey : key;
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
}
}
return [...groups.values()];
}
function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
@@ -237,6 +272,7 @@ function toPosition(
export {
type AccountPageIncomeSummary,
type AccountPagePosition,
type AccountPagePositionGroup,
type AccountPagePositionSection,
type AccountPageRecentEvents,
type AccountPageRecentOperations,