Skip to content

Commit 90ebfda

Browse files
authored
feat: add accounts tab to party detail page (#1291)
* feat: add accounts tab to party detail page Adds a new Accounts tab to the party detail page listing all accounts owned by the party (filtered by orgPartyId) with links to account details. * fix: fetch multiple pages to avoid sparse results from client-side party filter The ListCurrentAccounts API has no partyId filter, requiring client-side filtering by orgPartyId. To avoid returning sparse pages (which misrepresent pagination state), the loader now fetches API pages in a loop until it has collected pageSize matching accounts or all pages are exhausted. * fix: cap sequential API page fetches with MAX_PAGES guard Prevents unbounded API calls when a party owns few accounts in a large dataset. Limits to 10 pages (1000 accounts scanned) per pagination request. * fix: use remaining slots as API batch size to prevent dropping overflow matches When fetching 100 accounts per batch but returning only pageSize, same-page matches exceeding pageSize were silently dropped. Now requests at most (pageSize - collected) accounts per batch so all matches on a single API page fit within the UI page and are never skipped. * test: update E2E party tab layout tests for 8-tab layout Add 'Accounts' to expected tab list and update grid-cols assertion from 7 to 8 to match the new accounts tab. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent f422900 commit 90ebfda

3 files changed

Lines changed: 145 additions & 6 deletions

File tree

frontend/e2e/specs/parties.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ test.describe('Party detail navigation', () => {
9090
})
9191
})
9292

93-
test.describe('Party detail — 7-tab layout', () => {
93+
test.describe('Party detail — 8-tab layout', () => {
9494
test.beforeEach(async ({ authenticatedPage: page }) => {
9595
await navigateTo(page, '/parties')
9696
const rowCount = await page.locator('table tbody tr').count()
@@ -103,14 +103,15 @@ test.describe('Party detail — 7-tab layout', () => {
103103
await expect(page.getByRole('heading', { name: 'Party Details' })).toBeVisible()
104104
})
105105

106-
test('renders all 7 tab triggers', async ({ authenticatedPage: page }) => {
106+
test('renders all 8 tab triggers', async ({ authenticatedPage: page }) => {
107107
const expectedTabs = [
108108
'Overview',
109109
'Demographics',
110110
'References',
111111
'Associations',
112112
'Bank Relations',
113113
'Payment Methods',
114+
'Accounts',
114115
'Audit Trail',
115116
] as const
116117

@@ -119,11 +120,11 @@ test.describe('Party detail — 7-tab layout', () => {
119120
}
120121
})
121122

122-
test('tab list has 7-column grid layout', async ({ authenticatedPage: page }) => {
123+
test('tab list has 8-column grid layout', async ({ authenticatedPage: page }) => {
123124
const tabList = page.getByRole('tablist')
124125
await expect(tabList).toBeVisible()
125-
// Verify the CSS grid class from [partyId].tsx:33
126-
await expect(tabList).toHaveClass(/grid-cols-7/)
126+
// Verify the CSS grid class from [partyId].tsx:34
127+
await expect(tabList).toHaveClass(/grid-cols-8/)
127128
})
128129

129130
test('Overview tab is selected by default', async ({ authenticatedPage: page }) => {

frontend/src/pages/parties/[partyId].tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AssociationsTab } from './tabs/associations-tab'
1010
import { BankRelationsTab } from './tabs/bank-relations-tab'
1111
import { PaymentMethodsTab } from './tabs/payment-methods-tab'
1212
import { AuditTrailTab } from './tabs/audit-trail-tab'
13+
import { AccountsTab } from './tabs/accounts-tab'
1314

1415
export function PartyDetailPage() {
1516
const { partyId } = useParams<{ partyId: string }>()
@@ -30,13 +31,14 @@ export function PartyDetailPage() {
3031

3132
<Card>
3233
<Tabs defaultValue="overview" className="w-full">
33-
<TabsList className="grid w-full grid-cols-7 border-b rounded-none">
34+
<TabsList className="grid w-full grid-cols-8 border-b rounded-none">
3435
<TabsTrigger value="overview">Overview</TabsTrigger>
3536
<TabsTrigger value="demographics">Demographics</TabsTrigger>
3637
<TabsTrigger value="references">References</TabsTrigger>
3738
<TabsTrigger value="associations">Associations</TabsTrigger>
3839
<TabsTrigger value="bank-relations">Bank Relations</TabsTrigger>
3940
<TabsTrigger value="payment-methods">Payment Methods</TabsTrigger>
41+
<TabsTrigger value="accounts">Accounts</TabsTrigger>
4042
<TabsTrigger value="audit-trail">Audit Trail</TabsTrigger>
4143
</TabsList>
4244

@@ -65,6 +67,10 @@ export function PartyDetailPage() {
6567
<PaymentMethodsTab partyId={partyId} />
6668
</TabsContent>
6769

70+
<TabsContent value="accounts" className="mt-0">
71+
<AccountsTab partyId={partyId} />
72+
</TabsContent>
73+
6874
<TabsContent value="audit-trail" className="mt-0">
6975
<AuditTrailTab partyId={partyId} />
7076
</TabsContent>
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import * as React from 'react'
2+
import { useNavigate } from 'react-router-dom'
3+
import type { ColumnDef } from '@tanstack/react-table'
4+
import { DataTable } from '@/components/shared/data-table'
5+
import type { DataTableQueryParams, DataTableResult } from '@/components/shared/data-table'
6+
import { EntityLink } from '@/components/shared/entity-link'
7+
import { StatusBadge } from '@/components/shared/status-badge'
8+
import { TimeDisplay } from '@/components/shared/time-display'
9+
import { useClients } from '@/api/context'
10+
import { useTenantSlug } from '@/hooks/use-tenant-context'
11+
import { tenantKeys } from '@/lib/query-keys'
12+
import { AccountStatus } from '@/api/gen/meridian/current_account/v1/current_account_pb'
13+
14+
interface AccountsTabProps {
15+
partyId: string
16+
}
17+
18+
interface AccountRow {
19+
accountId: string
20+
externalReference: string
21+
status: string
22+
instrumentCode: string
23+
createdAt?: { seconds: number | bigint; nanos?: number }
24+
}
25+
26+
const ACCOUNT_STATUS_NAMES: Record<number, string> = {
27+
[AccountStatus.ACTIVE]: 'ACTIVE',
28+
[AccountStatus.FROZEN]: 'FROZEN',
29+
[AccountStatus.CLOSED]: 'CLOSED',
30+
}
31+
32+
const columns: ColumnDef<AccountRow>[] = [
33+
{
34+
accessorKey: 'accountId',
35+
header: 'Account ID',
36+
cell: ({ row }) => (
37+
<EntityLink type="account" id={row.original.accountId} />
38+
),
39+
},
40+
{
41+
accessorKey: 'externalReference',
42+
header: 'External Ref',
43+
},
44+
{
45+
accessorKey: 'status',
46+
header: 'Status',
47+
cell: ({ row }) => <StatusBadge status={row.original.status} />,
48+
},
49+
{
50+
accessorKey: 'instrumentCode',
51+
header: 'Instrument',
52+
},
53+
{
54+
accessorKey: 'createdAt',
55+
header: 'Created',
56+
cell: ({ row }) => <TimeDisplay timestamp={row.original.createdAt} format="relative" />,
57+
},
58+
]
59+
60+
export function AccountsTab({ partyId }: AccountsTabProps) {
61+
const clients = useClients()
62+
const tenantSlug = useTenantSlug()
63+
const navigate = useNavigate()
64+
65+
const queryKey = React.useMemo(
66+
() => [...tenantKeys.party(tenantSlug ?? '', partyId), 'accounts'],
67+
[tenantSlug, partyId],
68+
)
69+
70+
const queryFn = React.useCallback(
71+
async (params: DataTableQueryParams): Promise<DataTableResult<AccountRow>> => {
72+
if (!tenantSlug) return { items: [] }
73+
74+
// The API does not support filtering by partyId, so we fetch pages and filter
75+
// client-side. We continue fetching until we have enough matching rows to fill
76+
// pageSize, or the API is exhausted. MAX_PAGES caps sequential API calls to
77+
// prevent unbounded fetching when the party owns few accounts in a large dataset.
78+
const MAX_PAGES = 10
79+
const collected: AccountRow[] = []
80+
let cursor = params.pageToken ?? ''
81+
let nextPageToken: string | undefined
82+
let pagesScanned = 0
83+
84+
while (collected.length < params.pageSize && pagesScanned < MAX_PAGES) {
85+
pagesScanned++
86+
// Use remaining slots as batch size to avoid dropping same-page overflow:
87+
// if the batch were larger than remaining slots, we might get more matches
88+
// than pageSize in one batch but nextPageToken would advance past them.
89+
const remaining = Math.max(params.pageSize - collected.length, 1)
90+
const response = await clients.currentAccount.listCurrentAccounts({
91+
pageSize: remaining,
92+
pageToken: cursor,
93+
})
94+
95+
for (const a of response.accounts ?? []) {
96+
if (a.orgPartyId === partyId) {
97+
collected.push({
98+
accountId: a.accountId,
99+
externalReference: a.externalIdentifier ?? '',
100+
status: ACCOUNT_STATUS_NAMES[a.accountStatus] ?? String(a.accountStatus),
101+
instrumentCode: a.instrumentCode || '',
102+
createdAt: a.createdAt ?? undefined,
103+
})
104+
}
105+
}
106+
107+
if (!response.nextPageToken) {
108+
nextPageToken = undefined
109+
break
110+
}
111+
112+
cursor = response.nextPageToken
113+
nextPageToken = response.nextPageToken
114+
}
115+
116+
return {
117+
items: collected.slice(0, params.pageSize),
118+
nextPageToken,
119+
}
120+
},
121+
[tenantSlug, partyId, clients],
122+
)
123+
124+
return (
125+
<DataTable
126+
queryKey={queryKey}
127+
queryFn={queryFn}
128+
columns={columns}
129+
onRowClick={(row) => navigate(`/accounts/${row.accountId}`)}
130+
/>
131+
)
132+
}

0 commit comments

Comments
 (0)