refactor(web): normalize reconciliation audit theme surfaces
This commit is contained in:
parent
5d0f138cd1
commit
b8864ea276
@ -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
|
||||
|
||||
136
web/src/tabs/ReconciliationAuditPanel.dom.test.tsx
Normal file
136
web/src/tabs/ReconciliationAuditPanel.dom.test.tsx
Normal file
@ -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(<ReconciliationAuditPanel />);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -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<string> => {
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string; message?: string } | null;
|
||||
@ -316,131 +327,133 @@ export const ReconciliationAuditPanel = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="bg-[#12131a] border border-white/[0.04] rounded-2xl p-5 space-y-4">
|
||||
<Card className="space-y-4 rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck size={14} className="text-cyan-400" />
|
||||
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Reconciliation EXIT Backfill Audit</h3>
|
||||
<ShieldCheck size={14} className="text-[var(--accent)]" />
|
||||
<h3 className={sectionTitleClass}>Reconciliation EXIT Backfill Audit</h3>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => { void loadAuditData(); }}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider border transition-colors ${isLoading
|
||||
? 'bg-zinc-800 border-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-cyan-500/10 border-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20'
|
||||
}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-[10px] uppercase tracking-wider"
|
||||
>
|
||||
<RefreshCcw size={11} className={isLoading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="px-3 py-2 rounded-lg bg-[#0e0f14] border border-white/[0.04]">
|
||||
<p className="text-[9px] text-zinc-500 uppercase tracking-wider">Rows (Filter Scope)</p>
|
||||
<p className="text-sm font-mono text-white mt-1">{totalCount}</p>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card-elevated)] px-3 py-2">
|
||||
<p className="text-[9px] uppercase tracking-wider text-[var(--muted-foreground)]">Rows (Filter Scope)</p>
|
||||
<p className="mt-1 font-mono text-sm text-[var(--foreground)]">{totalCount}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-lg bg-[#0e0f14] border border-white/[0.04]">
|
||||
<p className="text-[9px] text-zinc-500 uppercase tracking-wider">Batches (Latest)</p>
|
||||
<p className="text-sm font-mono text-white mt-1">{batches.length}</p>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card-elevated)] px-3 py-2">
|
||||
<p className="text-[9px] uppercase tracking-wider text-[var(--muted-foreground)]">Batches (Latest)</p>
|
||||
<p className="mt-1 font-mono text-sm text-[var(--foreground)]">{batches.length}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-lg bg-[#0e0f14] border border-white/[0.04]">
|
||||
<p className="text-[9px] text-zinc-500 uppercase tracking-wider">Applied</p>
|
||||
<p className="text-sm font-mono text-emerald-400 mt-1">{aggregateDecisionCounts.APPLIED || 0}</p>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card-elevated)] px-3 py-2">
|
||||
<p className="text-[9px] uppercase tracking-wider text-[var(--muted-foreground)]">Applied</p>
|
||||
<p className="mt-1 font-mono text-sm text-emerald-600 dark:text-emerald-400">{aggregateDecisionCounts.APPLIED || 0}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-lg bg-[#0e0f14] border border-white/[0.04]">
|
||||
<p className="text-[9px] text-zinc-500 uppercase tracking-wider">No-Go</p>
|
||||
<p className="text-sm font-mono text-orange-400 mt-1">{aggregateDecisionCounts.NO_GO || 0}</p>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card-elevated)] px-3 py-2">
|
||||
<p className="text-[9px] uppercase tracking-wider text-[var(--muted-foreground)]">No-Go</p>
|
||||
<p className="mt-1 font-mono text-sm text-orange-600 dark:text-orange-400">{aggregateDecisionCounts.NO_GO || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-zinc-500 flex items-center gap-1.5">
|
||||
<p className="flex items-center gap-1.5 text-[10px] text-[var(--muted-foreground)]">
|
||||
<Clock3 size={11} />
|
||||
Last refresh: {lastLoadedAt ? new Date(lastLoadedAt).toLocaleString() : 'Not loaded yet'}
|
||||
</p>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section className="bg-[#12131a] border border-white/[0.04] rounded-2xl p-5 space-y-4">
|
||||
<Card className="space-y-4 rounded-2xl p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={13} className="text-zinc-400" />
|
||||
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Filters</h3>
|
||||
<Search size={13} className="text-[var(--muted-foreground)]" />
|
||||
<h3 className={sectionTitleClass}>Filters</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-3">
|
||||
<input
|
||||
<Input
|
||||
value={draftFilters.profileId}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
value={draftFilters.symbol}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
value={draftFilters.batchId}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={draftFilters.decision}
|
||||
onChange={(event) => setDraftFilters((prev) => ({ ...prev, decision: event.target.value }))}
|
||||
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"
|
||||
>
|
||||
<option value="">All Decisions</option>
|
||||
{decisionOptions.map((decision) => (
|
||||
<option key={decision} value={decision}>{decision}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
</Select>
|
||||
<Select
|
||||
value={String(draftFilters.days)}
|
||||
onChange={(event) => 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) => (
|
||||
<option key={days} value={String(days)}>{days} day{days === 1 ? '' : 's'}</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
onClick={handleApplyFilters}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-2 rounded-lg bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 text-[10px] font-bold uppercase tracking-wider hover:bg-cyan-500/20 disabled:opacity-50"
|
||||
size="sm"
|
||||
className="text-[10px] uppercase tracking-wider"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleResetFilters}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-300 text-[10px] font-bold uppercase tracking-wider hover:bg-zinc-700 disabled:opacity-50"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-[10px] uppercase tracking-wider"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-4 rounded-xl bg-red-500/[0.06] border border-red-500/20">
|
||||
<AlertTriangle size={14} className="text-red-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600 dark:text-red-400" />
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="bg-[#12131a] border border-white/[0.04] rounded-2xl overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-white/[0.04] flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Manual Review Queue</h3>
|
||||
<span className="text-[10px] text-orange-400 font-mono">
|
||||
<Card className="overflow-hidden rounded-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-3">
|
||||
<h3 className={sectionTitleClass}>Manual Review Queue</h3>
|
||||
<span className="font-mono text-[10px] text-orange-600 dark:text-orange-400">
|
||||
{manualReviewRows.length} large-remainder case{manualReviewRows.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-[#0e0f14] text-zinc-500 uppercase tracking-wider text-[10px]">
|
||||
<thead className={tableHeadClass}>
|
||||
<tr>
|
||||
<th className="px-4 py-2">Time</th>
|
||||
<th className="px-4 py-2">Profile</th>
|
||||
@ -453,10 +466,10 @@ export const ReconciliationAuditPanel = () => {
|
||||
<th className="px-4 py-2">Batch</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.03]">
|
||||
<tbody className={tableDividerClass}>
|
||||
{manualReviewRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-zinc-600">
|
||||
<td colSpan={9} className={emptyCellClass}>
|
||||
{isLoading ? 'Loading manual review queue...' : 'No large remainder manual-review cases for selected filters'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -471,19 +484,19 @@ export const ReconciliationAuditPanel = () => {
|
||||
const unmatchedEvidenceCount = readMetadataNumber(metadata, 'unmatchedEvidenceCount');
|
||||
|
||||
return (
|
||||
<tr key={`manual-${row.id}`} className="hover:bg-white/[0.01]">
|
||||
<td className="px-4 py-2 text-zinc-400 font-mono">{formatDateTime(row.created_at)}</td>
|
||||
<td className="px-4 py-2 text-zinc-300 font-mono">{shortId(row.profile_id)}</td>
|
||||
<td className="px-4 py-2 text-zinc-300">{row.symbol || '-'}</td>
|
||||
<td className="px-4 py-2 text-zinc-400 font-mono">{row.trade_id || '-'}</td>
|
||||
<td className="px-4 py-2 text-orange-300 font-mono">{formatNumber(remaining)}</td>
|
||||
<td className="px-4 py-2 text-zinc-400 font-mono">{formatNumber(openQty)}</td>
|
||||
<td className="px-4 py-2 text-zinc-400 font-mono">{formatNumber(dustThreshold)}</td>
|
||||
<td className="px-4 py-2 text-zinc-400 font-mono">
|
||||
<tr key={`manual-${row.id}`} className="hover:bg-[var(--muted)]/20">
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>{formatDateTime(row.created_at)}</td>
|
||||
<td className="px-4 py-2 font-mono text-[var(--foreground)]">{shortId(row.profile_id)}</td>
|
||||
<td className="px-4 py-2 text-[var(--foreground)]">{row.symbol || '-'}</td>
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>{row.trade_id || '-'}</td>
|
||||
<td className="px-4 py-2 font-mono text-orange-700 dark:text-orange-300">{formatNumber(remaining)}</td>
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>{formatNumber(openQty)}</td>
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>{formatNumber(dustThreshold)}</td>
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>
|
||||
{formatNumber(evidenceRowsUsed)}/{formatNumber(evidenceRows)}
|
||||
<span className="text-[10px] text-zinc-600 ml-1">unmatched:{formatNumber(unmatchedEvidenceCount)}</span>
|
||||
<span className="ml-1 text-[10px] text-[var(--muted-foreground)]">unmatched:{formatNumber(unmatchedEvidenceCount)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-500 font-mono">{shortId(row.batch_id || '')}</td>
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>{shortId(row.batch_id || '')}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
@ -491,15 +504,15 @@ export const ReconciliationAuditPanel = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section className="bg-[#12131a] border border-white/[0.04] rounded-2xl overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-white/[0.04]">
|
||||
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Batch Summary</h3>
|
||||
<Card className="overflow-hidden rounded-2xl">
|
||||
<div className="border-b border-[var(--border)] px-5 py-3">
|
||||
<h3 className={sectionTitleClass}>Batch Summary</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-[#0e0f14] text-zinc-500 uppercase tracking-wider text-[10px]">
|
||||
<thead className={tableHeadClass}>
|
||||
<tr>
|
||||
<th className="px-4 py-2">Batch</th>
|
||||
<th className="px-4 py-2">Window</th>
|
||||
@ -510,47 +523,49 @@ export const ReconciliationAuditPanel = () => {
|
||||
<th className="px-4 py-2 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.03]">
|
||||
<tbody className={tableDividerClass}>
|
||||
{batches.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-zinc-600">
|
||||
<td colSpan={7} className={emptyCellClass}>
|
||||
{isLoading ? 'Loading batch summaries...' : 'No backfill batches found for selected filters'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
batches.map((batch) => (
|
||||
<tr key={batch.batchId} className="hover:bg-white/[0.01]">
|
||||
<td className="px-4 py-2 font-mono text-zinc-300">{shortId(batch.batchId)}</td>
|
||||
<td className="px-4 py-2 text-zinc-400">
|
||||
<tr key={batch.batchId} className="hover:bg-[var(--muted)]/20">
|
||||
<td className="px-4 py-2 font-mono text-[var(--foreground)]">{shortId(batch.batchId)}</td>
|
||||
<td className={cn('px-4 py-2', mutedTextClass)}>
|
||||
<div>{formatDateTime(batch.firstSeenAt)}</div>
|
||||
<div className="text-[10px] text-zinc-600">to {formatDateTime(batch.lastSeenAt)}</div>
|
||||
<div className="text-[10px] text-[var(--muted-foreground)]">to {formatDateTime(batch.lastSeenAt)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white font-mono">{batch.totalRows}</td>
|
||||
<td className="px-4 py-2 font-mono text-[var(--foreground)]">{batch.totalRows}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(batch.byDecision || {}).map(([decision, count]) => (
|
||||
<span
|
||||
key={`${batch.batchId}-${decision}`}
|
||||
className="px-1.5 py-0.5 rounded bg-white/[0.04] text-zinc-300 text-[10px] font-mono"
|
||||
className="rounded border border-[var(--border)] bg-[var(--muted)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--foreground)]"
|
||||
>
|
||||
{decision}:{count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-400">{batch.profileIds.map(shortId).join(', ') || '-'}</td>
|
||||
<td className="px-4 py-2 text-zinc-400">{batch.symbols.join(', ') || '-'}</td>
|
||||
<td className={cn('px-4 py-2', mutedTextClass)}>{batch.profileIds.map(shortId).join(', ') || '-'}</td>
|
||||
<td className={cn('px-4 py-2', mutedTextClass)}>{batch.symbols.join(', ') || '-'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{batch.appliedRows > 0 && (
|
||||
<button
|
||||
<Button
|
||||
onClick={() => handleRevertBatch(batch.batchId)}
|
||||
disabled={isReverting === batch.batchId || isLoading}
|
||||
title="Revert APPLIED BFILL rows in this batch via status-only rollback"
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] font-bold uppercase hover:bg-red-500/20 disabled:opacity-50"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 rounded px-2 text-[10px] uppercase"
|
||||
>
|
||||
<Undo2 size={10} className={isReverting === batch.batchId ? 'animate-spin' : ''} />
|
||||
Revert Batch
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@ -559,18 +574,18 @@ export const ReconciliationAuditPanel = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section className="bg-[#12131a] border border-white/[0.04] rounded-2xl overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-white/[0.04] flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold text-zinc-300 uppercase tracking-wider">Audit Rows</h3>
|
||||
<span className="text-[10px] text-zinc-500 font-mono">
|
||||
<Card className="overflow-hidden rounded-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-3">
|
||||
<h3 className={sectionTitleClass}>Audit Rows</h3>
|
||||
<span className="font-mono text-[10px] text-[var(--muted-foreground)]">
|
||||
Showing {pageStart}-{pageEnd} of {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-[#0e0f14] text-zinc-500 uppercase tracking-wider text-[10px]">
|
||||
<thead className={tableHeadClass}>
|
||||
<tr>
|
||||
<th className="px-4 py-2">Time</th>
|
||||
<th className="px-4 py-2">Decision</th>
|
||||
@ -583,10 +598,10 @@ export const ReconciliationAuditPanel = () => {
|
||||
<th className="px-4 py-2">Order IDs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.03]">
|
||||
<tbody className={tableDividerClass}>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-10 text-center text-zinc-600">
|
||||
<td colSpan={9} className="px-4 py-10 text-center text-[var(--muted-foreground)]">
|
||||
{isLoading ? 'Loading audit rows...' : 'No audit rows for selected filters'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -598,35 +613,35 @@ export const ReconciliationAuditPanel = () => {
|
||||
? metadata.exchangeSubTag
|
||||
: '';
|
||||
return (
|
||||
<tr key={row.id} className="hover:bg-white/[0.01]">
|
||||
<td className="px-4 py-2 text-zinc-400 font-mono">{formatDateTime(row.created_at)}</td>
|
||||
<tr key={row.id} className="hover:bg-[var(--muted)]/20">
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>{formatDateTime(row.created_at)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${row.decision === 'APPLIED'
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||
: row.decision === 'NO_GO'
|
||||
? 'bg-orange-500/10 text-orange-400'
|
||||
: 'bg-zinc-700/40 text-zinc-300'
|
||||
? 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
|
||||
: 'bg-[var(--muted)] text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
{row.decision}
|
||||
</span>
|
||||
{row.dry_run && <span className="ml-1 text-[10px] text-zinc-500">DRY</span>}
|
||||
{row.dry_run && <span className="ml-1 text-[10px] text-[var(--muted-foreground)]">DRY</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-300 font-mono">{shortId(row.profile_id)}</td>
|
||||
<td className="px-4 py-2 text-zinc-300">{row.symbol || '-'}</td>
|
||||
<td className="px-4 py-2 text-zinc-400 font-mono">{row.trade_id || '-'}</td>
|
||||
<td className="px-4 py-2 text-zinc-400 font-mono">
|
||||
<td className="px-4 py-2 font-mono text-[var(--foreground)]">{shortId(row.profile_id)}</td>
|
||||
<td className="px-4 py-2 text-[var(--foreground)]">{row.symbol || '-'}</td>
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>{row.trade_id || '-'}</td>
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>
|
||||
<div>{formatNumber(row.filled_qty)}</div>
|
||||
<div className="text-[10px] text-zinc-600">{formatNumber(row.filled_price)}</div>
|
||||
<div className="text-[10px] text-[var(--muted-foreground)]">{formatNumber(row.filled_price)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-400">
|
||||
<td className={cn('px-4 py-2', mutedTextClass)}>
|
||||
<div>{row.reason || '-'}</div>
|
||||
{matchedBy && <div className="text-[10px] text-zinc-600">matchedBy: {matchedBy}</div>}
|
||||
{matchedBy && <div className="text-[10px] text-[var(--muted-foreground)]">matchedBy: {matchedBy}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-500 font-mono" title={exchangeSubTag || ''}>
|
||||
<td className={cn('px-4 py-2', monoMutedClass)} title={exchangeSubTag || ''}>
|
||||
{compactTag(exchangeSubTag)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-500 font-mono">
|
||||
<td className={cn('px-4 py-2', monoMutedClass)}>
|
||||
<div>bf: {shortId(row.backfill_order_id || '')}</div>
|
||||
<div className="text-[10px]">ex: {shortId(row.exchange_order_id || '')}</div>
|
||||
</td>
|
||||
@ -638,23 +653,27 @@ export const ReconciliationAuditPanel = () => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-white/[0.04] flex items-center justify-end gap-2">
|
||||
<button
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--border)] px-5 py-3">
|
||||
<Button
|
||||
onClick={() => setOffset((prev) => Math.max(0, prev - PAGE_LIMIT))}
|
||||
disabled={!hasPrevPage || isLoading}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-[10px] text-zinc-300 uppercase tracking-wider disabled:opacity-40"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-[10px] uppercase tracking-wider"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setOffset((prev) => prev + PAGE_LIMIT)}
|
||||
disabled={!hasNextPage || isLoading}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-[10px] text-zinc-300 uppercase tracking-wider disabled:opacity-40"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-[10px] uppercase tracking-wider"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user