From b8864ea27656e44698d6868820ee57c2d5ab1211 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Tue, 5 May 2026 22:06:05 -0700 Subject: [PATCH] refactor(web): normalize reconciliation audit theme surfaces --- docs/UX_THEME_NORMALIZATION_HANDOFF.md | 2 +- .../ReconciliationAuditPanel.dom.test.tsx | 136 ++++++++++ web/src/tabs/ReconciliationAuditPanel.tsx | 247 ++++++++++-------- 3 files changed, 270 insertions(+), 115 deletions(-) create mode 100644 web/src/tabs/ReconciliationAuditPanel.dom.test.tsx diff --git a/docs/UX_THEME_NORMALIZATION_HANDOFF.md b/docs/UX_THEME_NORMALIZATION_HANDOFF.md index ae83ce0..eedd99d 100644 --- a/docs/UX_THEME_NORMALIZATION_HANDOFF.md +++ b/docs/UX_THEME_NORMALIZATION_HANDOFF.md @@ -97,7 +97,7 @@ Current public bundle: - [x] Normalize `web/src/components/TradeProfileManager.tsx` - [x] Normalize `web/src/components/StrategyWizard.tsx` -- [ ] Normalize `web/src/tabs/ReconciliationAuditPanel.tsx` +- [x] Normalize `web/src/tabs/ReconciliationAuditPanel.tsx` - [ ] Normalize `web/src/components/GlobalConfigManager.tsx` - [ ] Normalize `web/src/components/EntryForm.tsx` - [ ] Normalize remaining old admin/config surfaces diff --git a/web/src/tabs/ReconciliationAuditPanel.dom.test.tsx b/web/src/tabs/ReconciliationAuditPanel.dom.test.tsx new file mode 100644 index 0000000..3c57a7d --- /dev/null +++ b/web/src/tabs/ReconciliationAuditPanel.dom.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReconciliationAuditPanel } from './ReconciliationAuditPanel'; + +const { getPlatformAccessTokenMock } = vi.hoisted(() => ({ + getPlatformAccessTokenMock: vi.fn() +})); + +vi.mock('../lib/authSession', () => ({ + getPlatformAccessToken: getPlatformAccessTokenMock +})); + +vi.mock('../lib/runtime', () => ({ + tradingRuntime: { tradingApiUrl: 'https://trading.test' } +})); + +const jsonResponse = (payload: unknown, ok = true, status = 200) => ({ + ok, + status, + json: vi.fn(async () => payload) +}) as unknown as Response; + +const auditRows = [ + { + id: 1, + batch_id: 'batch-applied-123456', + profile_id: 'profile-123456789', + symbol: 'SOL/USDT', + trade_id: 'trade-1', + backfill_order_id: 'bfill-1', + exchange_order_id: 'exchange-1', + filled_qty: 2, + filled_price: 101.5, + dry_run: false, + decision: 'APPLIED', + reason: 'matched_exchange_fill', + metadata: { matchedBy: 'clientOrderId', exchangeSubTag: 'client-sub-tag-123456789' }, + created_at: '2026-05-05T12:00:00.000Z' + } +]; + +const manualRows = [ + { + id: 2, + batch_id: 'batch-review-123456', + profile_id: 'profile-review-123', + symbol: 'BTC/USDT', + trade_id: 'trade-review', + dry_run: true, + decision: 'NO_GO', + reason: 'missing_fill_evidence_for_large_remainder', + metadata: { + remaining: 1.25, + openQty: 2.5, + dustThreshold: 0.01, + evidenceRows: 3, + evidenceRowsUsed: 1, + unmatchedEvidenceCount: 2 + }, + created_at: '2026-05-05T12:30:00.000Z' + } +]; + +const batches = [ + { + batchId: 'batch-applied-123456', + firstSeenAt: '2026-05-05T12:00:00.000Z', + lastSeenAt: '2026-05-05T12:10:00.000Z', + profileIds: ['profile-123456789'], + symbols: ['SOL/USDT'], + totalRows: 4, + byDecision: { APPLIED: 3, NO_GO: 1 }, + dryRunRows: 0, + appliedRows: 3, + revertedRows: 0 + } +]; + +const mockFetchPayloads = () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + success: true, + rows: auditRows, + pagination: { totalCount: 1, hasMore: false, limit: 50, offset: 0 } + })) + .mockResolvedValueOnce(jsonResponse({ success: true, batches })) + .mockResolvedValueOnce(jsonResponse({ + success: true, + rows: manualRows, + pagination: { totalCount: 1, hasMore: false, limit: 200, offset: 0 } + })) + .mockResolvedValue(jsonResponse({ + success: true, + rows: [], + batches: [], + pagination: { totalCount: 0, hasMore: false, limit: 50, offset: 0 } + })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +}; + +describe('ReconciliationAuditPanel', () => { + beforeEach(() => { + getPlatformAccessTokenMock.mockReset(); + getPlatformAccessTokenMock.mockResolvedValue('token-abc'); + }); + + it('renders audit summaries, filters, manual review rows, and revert action', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetchPayloads(); + vi.stubGlobal('confirm', vi.fn(() => false)); + + render(); + + await waitFor(() => { + expect(screen.getByText('Reconciliation EXIT Backfill Audit')).toBeInTheDocument(); + expect(screen.getByText('Manual Review Queue')).toBeInTheDocument(); + expect(screen.getAllByText('SOL/USDT').length).toBeGreaterThan(0); + expect(screen.getByText('BTC/USDT')).toBeInTheDocument(); + }); + + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('1 large-remainder case')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Revert Batch/i })).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('Symbol (e.g. SOL/USDT)'), 'eth/usdt'); + await user.click(screen.getByRole('button', { name: /Apply Filters/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(6); + }); + expect(String(fetchMock.mock.calls[3][0])).toContain('symbol=ETH%2FUSDT'); + }); +}); diff --git a/web/src/tabs/ReconciliationAuditPanel.tsx b/web/src/tabs/ReconciliationAuditPanel.tsx index 4d151fc..d9860d5 100644 --- a/web/src/tabs/ReconciliationAuditPanel.tsx +++ b/web/src/tabs/ReconciliationAuditPanel.tsx @@ -3,6 +3,11 @@ import { AlertTriangle, Clock3, RefreshCcw, Search, ShieldCheck, Undo2 } from 'l import { tradingRuntime } from '../lib/runtime'; import { getPlatformAccessToken } from '../lib/authSession'; import { createRequestId } from '../../../shared/request-id.js'; +import { Button } from '../components/ui/button'; +import { Card } from '../components/ui/card'; +import { Input } from '../components/ui/input'; +import { Select } from '../components/ui/select'; +import { cn } from '../lib/utils'; interface ReconciliationBackfillAuditRow { id: number; @@ -77,6 +82,12 @@ const DEFAULT_FILTERS: ReconciliationFilters = { }; const DEFAULT_DECISIONS = ['APPLIED', 'DRY_RUN', 'NO_GO', 'SKIP_EXISTING', 'PENDING_APPLY']; const DAY_OPTIONS = [1, 3, 7, 14, 30]; +const sectionTitleClass = 'text-xs font-bold uppercase tracking-wider text-[var(--foreground)]'; +const mutedTextClass = 'text-[var(--muted-foreground)]'; +const tableHeadClass = 'bg-[var(--muted)]/45 text-[var(--muted-foreground)] uppercase tracking-wider text-[10px]'; +const tableDividerClass = 'divide-y divide-[var(--border)]'; +const emptyCellClass = 'px-4 py-8 text-center text-[var(--muted-foreground)]'; +const monoMutedClass = 'font-mono text-[var(--muted-foreground)]'; const parseResponseError = async (response: Response): Promise => { const payload = (await response.json().catch(() => null)) as { error?: string; message?: string } | null; @@ -316,131 +327,133 @@ export const ReconciliationAuditPanel = () => { return (
-
+
- -

Reconciliation EXIT Backfill Audit

+ +

Reconciliation EXIT Backfill Audit

- +
-
-

Rows (Filter Scope)

-

{totalCount}

+
+

Rows (Filter Scope)

+

{totalCount}

-
-

Batches (Latest)

-

{batches.length}

+
+

Batches (Latest)

+

{batches.length}

-
-

Applied

-

{aggregateDecisionCounts.APPLIED || 0}

+
+

Applied

+

{aggregateDecisionCounts.APPLIED || 0}

-
-

No-Go

-

{aggregateDecisionCounts.NO_GO || 0}

+
+

No-Go

+

{aggregateDecisionCounts.NO_GO || 0}

-

+

Last refresh: {lastLoadedAt ? new Date(lastLoadedAt).toLocaleString() : 'Not loaded yet'}

-
+ -
+
- -

Filters

+ +

Filters

- setDraftFilters((prev) => ({ ...prev, profileId: event.target.value }))} placeholder="Profile ID" - className="px-3 py-2 rounded-lg bg-[#0e0f14] border border-white/[0.06] text-xs text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/40" + className="h-10 rounded-lg px-3 py-2 text-xs" /> - setDraftFilters((prev) => ({ ...prev, symbol: event.target.value }))} placeholder="Symbol (e.g. SOL/USDT)" - className="px-3 py-2 rounded-lg bg-[#0e0f14] border border-white/[0.06] text-xs text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/40" + className="h-10 rounded-lg px-3 py-2 text-xs" /> - setDraftFilters((prev) => ({ ...prev, batchId: event.target.value }))} placeholder="Batch ID" - className="px-3 py-2 rounded-lg bg-[#0e0f14] border border-white/[0.06] text-xs text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/40" + className="h-10 rounded-lg px-3 py-2 text-xs" /> - - setDraftFilters((prev) => ({ ...prev, days: Number.parseInt(event.target.value, 10) || 7 }))} - className="px-3 py-2 rounded-lg bg-[#0e0f14] border border-white/[0.06] text-xs text-white focus:outline-none focus:border-cyan-500/40" + className="h-10 rounded-lg px-3 py-2 text-xs" > {DAY_OPTIONS.map((days) => ( ))} - +
- - +
-
+ {error && (
- -

{error}

+ +

{error}

)} -
-
-

Manual Review Queue

- + +
+

Manual Review Queue

+ {manualReviewRows.length} large-remainder case{manualReviewRows.length === 1 ? '' : 's'}
- + @@ -453,10 +466,10 @@ export const ReconciliationAuditPanel = () => { - + {manualReviewRows.length === 0 ? ( - @@ -471,19 +484,19 @@ export const ReconciliationAuditPanel = () => { const unmatchedEvidenceCount = readMetadataNumber(metadata, 'unmatchedEvidenceCount'); return ( - - - - - - - - - + + + + + + + + - + ); }) @@ -491,15 +504,15 @@ export const ReconciliationAuditPanel = () => {
Time ProfileBatch
+ {isLoading ? 'Loading manual review queue...' : 'No large remainder manual-review cases for selected filters'}
{formatDateTime(row.created_at)}{shortId(row.profile_id)}{row.symbol || '-'}{row.trade_id || '-'}{formatNumber(remaining)}{formatNumber(openQty)}{formatNumber(dustThreshold)} +
{formatDateTime(row.created_at)}{shortId(row.profile_id)}{row.symbol || '-'}{row.trade_id || '-'}{formatNumber(remaining)}{formatNumber(openQty)}{formatNumber(dustThreshold)} {formatNumber(evidenceRowsUsed)}/{formatNumber(evidenceRows)} - unmatched:{formatNumber(unmatchedEvidenceCount)} + unmatched:{formatNumber(unmatchedEvidenceCount)} {shortId(row.batch_id || '')}{shortId(row.batch_id || '')}
-
+ -
-
-

Batch Summary

+ +
+

Batch Summary

- + @@ -510,47 +523,49 @@ export const ReconciliationAuditPanel = () => { - + {batches.length === 0 ? ( - ) : ( batches.map((batch) => ( - - - + + - + - - + + @@ -559,18 +574,18 @@ export const ReconciliationAuditPanel = () => {
Batch WindowActions
+ {isLoading ? 'Loading batch summaries...' : 'No backfill batches found for selected filters'}
{shortId(batch.batchId)} +
{shortId(batch.batchId)}
{formatDateTime(batch.firstSeenAt)}
-
to {formatDateTime(batch.lastSeenAt)}
+
to {formatDateTime(batch.lastSeenAt)}
{batch.totalRows}{batch.totalRows}
{Object.entries(batch.byDecision || {}).map(([decision, count]) => ( {decision}:{count} ))}
{batch.profileIds.map(shortId).join(', ') || '-'}{batch.symbols.join(', ') || '-'}{batch.profileIds.map(shortId).join(', ') || '-'}{batch.symbols.join(', ') || '-'} {batch.appliedRows > 0 && ( - + )}
-
+ -
-
-

Audit Rows

- + +
+

Audit Rows

+ Showing {pageStart}-{pageEnd} of {totalCount}
- + @@ -583,10 +598,10 @@ export const ReconciliationAuditPanel = () => { - + {rows.length === 0 ? ( - @@ -598,35 +613,35 @@ export const ReconciliationAuditPanel = () => { ? metadata.exchangeSubTag : ''; return ( - - + + - - - - + + + - - - @@ -638,23 +653,27 @@ export const ReconciliationAuditPanel = () => {
Time DecisionOrder IDs
+ {isLoading ? 'Loading audit rows...' : 'No audit rows for selected filters'}
{formatDateTime(row.created_at)}
{formatDateTime(row.created_at)} {row.decision} - {row.dry_run && DRY} + {row.dry_run && DRY} {shortId(row.profile_id)}{row.symbol || '-'}{row.trade_id || '-'} + {shortId(row.profile_id)}{row.symbol || '-'}{row.trade_id || '-'}
{formatNumber(row.filled_qty)}
-
{formatNumber(row.filled_price)}
+
{formatNumber(row.filled_price)}
+
{row.reason || '-'}
- {matchedBy &&
matchedBy: {matchedBy}
} + {matchedBy &&
matchedBy: {matchedBy}
}
+ {compactTag(exchangeSubTag)} +
bf: {shortId(row.backfill_order_id || '')}
ex: {shortId(row.exchange_order_id || '')}
-
- - +
-
+
); };