From 07cfc2abecceb7c4d7c28c09bfb5c0b2ce8c9058 Mon Sep 17 00:00:00 2001 From: Carlys17 Date: Thu, 30 Jul 2026 04:03:22 +0200 Subject: [PATCH] feat: add investor portfolio table with restricted and unavailable states - Add InvestorPortfolioTable component showing asset holdings, compliance, transfer eligibility, and lifecycle status via existing AssetCard - Handle empty state with PortfolioEmptyState - Show unavailable assets in a separate amber section when metadata is missing - Wire into PortfolioList replacing inline grid - Add tests covering ready, empty, restricted, and unavailable states Closes #178 --- .../InvestorPortfolioTable.test.tsx | 97 +++++++++++++++++++ .../components/InvestorPortfolioTable.tsx | 70 +++++++++++++ .../investor/components/PortfolioList.tsx | 24 +---- 3 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 src/features/investor/components/InvestorPortfolioTable.test.tsx create mode 100644 src/features/investor/components/InvestorPortfolioTable.tsx diff --git a/src/features/investor/components/InvestorPortfolioTable.test.tsx b/src/features/investor/components/InvestorPortfolioTable.test.tsx new file mode 100644 index 0000000..acbdcc0 --- /dev/null +++ b/src/features/investor/components/InvestorPortfolioTable.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import InvestorPortfolioTable from './InvestorPortfolioTable'; +import type { PortfolioAsset } from '@/lib/aegis/types'; + +function makeAsset(overrides: Partial = {}): PortfolioAsset { + return { + id: 'ny-cre', + name: 'Manhattan Commercial Real Estate', + ticker: 'NY-CRE', + balance: 50.5, + decimals: 2, + metadata: { + assetClass: 'Real Estate', + issuer: 'Aegis Property Holdings LLC', + jurisdiction: 'United States', + description: 'Fractionalized ownership record.', + }, + compliance: { + state: 'compliant', + label: 'Compliant', + detail: 'KYC current.', + }, + transferEligibility: { + state: 'eligible', + reasons: [], + }, + lifecycleStatus: { + current: 'active', + since: '2026-01-15T00:00:00Z', + history: [{ state: 'active', occurredAt: '2026-01-15T00:00:00Z' }], + }, + isDataAvailable: true, + ...overrides, + }; +} + +describe('InvestorPortfolioTable', () => { + it('renders asset cards when assets are available', () => { + render( + , + ); + expect(screen.getByText('Manhattan Commercial Real Estate')).toBeInTheDocument(); + expect(screen.getByText('NY-CRE')).toBeInTheDocument(); + }); + + it('shows empty state when no assets are available', () => { + render( + , + ); + expect(screen.getByText('No holdings yet')).toBeInTheDocument(); + }); + + it('shows restricted compliance badge for restricted assets', () => { + const restricted = makeAsset({ + id: 'restricted-1', + compliance: { + state: 'restricted', + label: 'Restricted', + detail: 'Accreditation required.', + }, + transferEligibility: { + state: 'ineligible', + reasons: ['Restricted to accredited investors only.'], + }, + }); + render( + , + ); + expect(screen.getByText('Restricted')).toBeInTheDocument(); + expect(screen.getByText('Transfer restricted')).toBeInTheDocument(); + }); + + it('shows unavailable section for assets with missing metadata', () => { + const unavailable = makeAsset({ + id: 'unavail-1', + name: 'Unknown Asset', + ticker: 'UNK', + isDataAvailable: false, + }); + render( + , + ); + expect(screen.getByText('Unavailable holdings (1)')).toBeInTheDocument(); + expect(screen.getByText('Metadata temporarily unavailable')).toBeInTheDocument(); + }); + + it('calls onTransferClick when transfer button is clicked', () => { + const onTransferClick = vi.fn(); + render( + , + ); + screen.getByText('Transfer').click(); + expect(onTransferClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/investor/components/InvestorPortfolioTable.tsx b/src/features/investor/components/InvestorPortfolioTable.tsx new file mode 100644 index 0000000..aa33458 --- /dev/null +++ b/src/features/investor/components/InvestorPortfolioTable.tsx @@ -0,0 +1,70 @@ +import { useMemo } from 'react'; +import AssetCard from '@/features/assets/components/AssetCard'; +import PortfolioEmptyState from './PortfolioEmptyState'; +import type { PortfolioAsset } from '@/lib/aegis/types'; + +export interface InvestorPortfolioTableProps { + assets: PortfolioAsset[]; + onTransferClick: (asset: PortfolioAsset) => void; +} + +export default function InvestorPortfolioTable({ + assets, + onTransferClick, +}: InvestorPortfolioTableProps) { + const unavailableAssets = useMemo( + () => assets.filter((asset) => !asset.isDataAvailable), + [assets], + ); + const availableAssets = useMemo( + () => assets.filter((asset) => asset.isDataAvailable), + [assets], + ); + + return ( +
+ {availableAssets.length > 0 ? ( +
+ {availableAssets.map((asset) => ( + onTransferClick(asset)} + /> + ))} +
+ ) : ( + + )} + + {unavailableAssets.length > 0 && ( +
+

+ Unavailable holdings ({unavailableAssets.length}) +

+

+ These assets appear on-chain but metadata/compliance records could not be + resolved. Balances are shown for information only. +

+
+ {unavailableAssets.map((asset) => ( +
+
{asset.name}
+
{asset.ticker}
+
+ {asset.balance.toLocaleString('en-US')} {asset.ticker} +
+
+ Metadata temporarily unavailable +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/features/investor/components/PortfolioList.tsx b/src/features/investor/components/PortfolioList.tsx index c6965ea..094a6ee 100644 --- a/src/features/investor/components/PortfolioList.tsx +++ b/src/features/investor/components/PortfolioList.tsx @@ -7,6 +7,7 @@ import { usePortfolio } from '../hooks/usePortfolio'; import { EmptyState } from '@/components/states'; import PortfolioDisclaimer from './PortfolioDisclaimer'; import TransferModal from './TransferModal'; +import InvestorPortfolioTable from './InvestorPortfolioTable'; const SKELETON_COUNT = 3; @@ -60,31 +61,10 @@ export default function PortfolioList() { ); } - if (assets.length === 0) { - return ( - - ); - } - return (
-
- {assets.map((asset) => ( - setActiveAsset(asset)} /> - ))} -
- + - {activeAsset && setActiveAsset(null)} />}
);