680 lines
34 KiB
TypeScript
680 lines
34 KiB
TypeScript
import React from 'react';
|
|
import { AlertTriangle, Clock3, RefreshCcw, Search, ShieldCheck, Undo2 } from 'lucide-react';
|
|
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;
|
|
batch_id: string;
|
|
profile_id: string;
|
|
symbol: string;
|
|
trade_id: string;
|
|
exchange_order_id?: string | null;
|
|
exchange_client_order_id?: string | null;
|
|
backfill_order_id?: string | null;
|
|
filled_qty?: number | null;
|
|
filled_price?: number | null;
|
|
filled_at?: string | null;
|
|
dry_run: boolean;
|
|
decision: string;
|
|
reason?: string | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
applied_at?: string | null;
|
|
reverted_at?: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
interface ReconciliationBackfillBatchSummary {
|
|
batchId: string;
|
|
firstSeenAt: string;
|
|
lastSeenAt: string;
|
|
profileIds: string[];
|
|
symbols: string[];
|
|
totalRows: number;
|
|
byDecision: Record<string, number>;
|
|
dryRunRows: number;
|
|
appliedRows: number;
|
|
revertedRows: number;
|
|
}
|
|
|
|
interface ReconciliationAuditResponse {
|
|
success: boolean;
|
|
rows?: ReconciliationBackfillAuditRow[];
|
|
pagination?: {
|
|
limit?: number;
|
|
offset?: number;
|
|
totalCount?: number;
|
|
hasMore?: boolean;
|
|
};
|
|
error?: string;
|
|
}
|
|
|
|
interface ReconciliationBatchesResponse {
|
|
success: boolean;
|
|
batches?: ReconciliationBackfillBatchSummary[];
|
|
error?: string;
|
|
}
|
|
|
|
interface ReconciliationFilters {
|
|
profileId: string;
|
|
symbol: string;
|
|
batchId: string;
|
|
decision: string;
|
|
days: number;
|
|
}
|
|
|
|
const PAGE_LIMIT = 50;
|
|
const BATCH_LIMIT = 20;
|
|
const MANUAL_REVIEW_LIMIT = 200;
|
|
const MANUAL_REVIEW_REASON = 'missing_fill_evidence_for_large_remainder';
|
|
const DEFAULT_FILTERS: ReconciliationFilters = {
|
|
profileId: '',
|
|
symbol: '',
|
|
batchId: '',
|
|
decision: '',
|
|
days: 7
|
|
};
|
|
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;
|
|
return payload?.error || payload?.message || `HTTP ${response.status}`;
|
|
};
|
|
|
|
const formatDateTime = (value?: string | null): string => {
|
|
if (!value) return '-';
|
|
const parsed = Date.parse(value);
|
|
if (!Number.isFinite(parsed)) return '-';
|
|
return new Date(parsed).toLocaleString();
|
|
};
|
|
|
|
const formatNumber = (value?: number | null): string => {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) return '-';
|
|
return value.toLocaleString(undefined, { maximumFractionDigits: 8 });
|
|
};
|
|
|
|
const shortId = (value: string): string => {
|
|
if (!value) return '-';
|
|
return value.length > 16 ? `${value.slice(0, 8)}...${value.slice(-6)}` : value;
|
|
};
|
|
|
|
const compactTag = (value?: string): string => {
|
|
const token = String(value || '').trim();
|
|
if (!token) return '-';
|
|
return token.length > 24 ? `${token.slice(0, 12)}...${token.slice(-8)}` : token;
|
|
};
|
|
|
|
const safeCount = (value: unknown): number => {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
};
|
|
|
|
const buildQueryParams = (
|
|
filters: ReconciliationFilters,
|
|
limit: number,
|
|
offset?: number,
|
|
decisionOverride?: string
|
|
): URLSearchParams => {
|
|
const params = new URLSearchParams();
|
|
params.set('limit', String(limit));
|
|
params.set('days', String(filters.days));
|
|
if (typeof offset === 'number') {
|
|
params.set('offset', String(offset));
|
|
}
|
|
|
|
const profileId = filters.profileId.trim();
|
|
const symbol = filters.symbol.trim().toUpperCase();
|
|
const batchId = filters.batchId.trim();
|
|
const decision = decisionOverride ?? filters.decision.trim();
|
|
|
|
if (profileId) params.set('profileId', profileId);
|
|
if (symbol) params.set('symbol', symbol);
|
|
if (batchId) params.set('batchId', batchId);
|
|
if (decision) params.set('decision', decision);
|
|
return params;
|
|
};
|
|
|
|
const readMetadataNumber = (metadata: Record<string, unknown> | null | undefined, key: string): number | null => {
|
|
if (!metadata) return null;
|
|
const parsed = Number(metadata[key]);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
};
|
|
|
|
export const ReconciliationAuditPanel = () => {
|
|
const [draftFilters, setDraftFilters] = React.useState<ReconciliationFilters>(DEFAULT_FILTERS);
|
|
const [filters, setFilters] = React.useState<ReconciliationFilters>(DEFAULT_FILTERS);
|
|
const [offset, setOffset] = React.useState(0);
|
|
const [rows, setRows] = React.useState<ReconciliationBackfillAuditRow[]>([]);
|
|
const [batches, setBatches] = React.useState<ReconciliationBackfillBatchSummary[]>([]);
|
|
const [manualReviewRows, setManualReviewRows] = React.useState<ReconciliationBackfillAuditRow[]>([]);
|
|
const [totalCount, setTotalCount] = React.useState(0);
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [lastLoadedAt, setLastLoadedAt] = React.useState<number | null>(null);
|
|
const [isReverting, setIsReverting] = React.useState<string | null>(null);
|
|
|
|
const loadAuditData = React.useCallback(async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const accessToken = await getPlatformAccessToken();
|
|
|
|
const apiUrl = tradingRuntime.tradingApiUrl;
|
|
const auditParams = buildQueryParams(filters, PAGE_LIMIT, offset);
|
|
const batchParams = buildQueryParams(filters, BATCH_LIMIT);
|
|
const manualReviewParams = buildQueryParams(filters, MANUAL_REVIEW_LIMIT, 0, 'NO_GO');
|
|
|
|
const [auditResponse, batchResponse, manualReviewResponse] = await Promise.all([
|
|
fetch(`${apiUrl}/api/reconciliation/backfill/audit?${auditParams.toString()}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'x-request-id': createRequestId('web-recon')
|
|
}
|
|
}),
|
|
fetch(`${apiUrl}/api/reconciliation/backfill/batches?${batchParams.toString()}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'x-request-id': createRequestId('web-recon')
|
|
}
|
|
}),
|
|
fetch(`${apiUrl}/api/reconciliation/backfill/audit?${manualReviewParams.toString()}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'x-request-id': createRequestId('web-recon')
|
|
}
|
|
})
|
|
]);
|
|
|
|
if (!auditResponse.ok) {
|
|
throw new Error(await parseResponseError(auditResponse));
|
|
}
|
|
if (!batchResponse.ok) {
|
|
throw new Error(await parseResponseError(batchResponse));
|
|
}
|
|
if (!manualReviewResponse.ok) {
|
|
throw new Error(await parseResponseError(manualReviewResponse));
|
|
}
|
|
|
|
const auditPayload = (await auditResponse.json()) as ReconciliationAuditResponse;
|
|
const batchPayload = (await batchResponse.json()) as ReconciliationBatchesResponse;
|
|
const manualReviewPayload = (await manualReviewResponse.json()) as ReconciliationAuditResponse;
|
|
|
|
if (!auditPayload.success) {
|
|
throw new Error(auditPayload.error || 'Failed to load reconciliation audit rows');
|
|
}
|
|
if (!batchPayload.success) {
|
|
throw new Error(batchPayload.error || 'Failed to load reconciliation audit batches');
|
|
}
|
|
if (!manualReviewPayload.success) {
|
|
throw new Error(manualReviewPayload.error || 'Failed to load manual review rows');
|
|
}
|
|
|
|
setRows(Array.isArray(auditPayload.rows) ? auditPayload.rows : []);
|
|
setBatches(Array.isArray(batchPayload.batches) ? batchPayload.batches : []);
|
|
setManualReviewRows(
|
|
(Array.isArray(manualReviewPayload.rows) ? manualReviewPayload.rows : [])
|
|
.filter((row) => String(row.reason || '').trim() === MANUAL_REVIEW_REASON)
|
|
);
|
|
setTotalCount(Math.max(0, safeCount(auditPayload.pagination?.totalCount)));
|
|
setLastLoadedAt(Date.now());
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to load reconciliation audit';
|
|
setError(message);
|
|
setRows([]);
|
|
setBatches([]);
|
|
setManualReviewRows([]);
|
|
setTotalCount(0);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [filters, offset]);
|
|
|
|
React.useEffect(() => {
|
|
void loadAuditData();
|
|
}, [loadAuditData]);
|
|
|
|
const decisionOptions = React.useMemo(() => {
|
|
const values = new Set<string>(DEFAULT_DECISIONS);
|
|
for (const row of rows) {
|
|
if (row.decision) values.add(row.decision);
|
|
}
|
|
for (const batch of batches) {
|
|
for (const decision of Object.keys(batch.byDecision || {})) {
|
|
if (decision) values.add(decision);
|
|
}
|
|
}
|
|
return Array.from(values).sort((a, b) => a.localeCompare(b));
|
|
}, [rows, batches]);
|
|
|
|
const aggregateDecisionCounts = React.useMemo(() => {
|
|
const counts: Record<string, number> = {};
|
|
for (const batch of batches) {
|
|
for (const [decision, count] of Object.entries(batch.byDecision || {})) {
|
|
counts[decision] = (counts[decision] || 0) + safeCount(count);
|
|
}
|
|
}
|
|
return counts;
|
|
}, [batches]);
|
|
|
|
const hasPrevPage = offset > 0;
|
|
const hasNextPage = offset + PAGE_LIMIT < totalCount;
|
|
const pageStart = totalCount === 0 ? 0 : offset + 1;
|
|
const pageEnd = totalCount === 0 ? 0 : Math.min(offset + rows.length, totalCount);
|
|
|
|
const handleRevertBatch = async (batchId: string) => {
|
|
if (!confirm(`Are you sure you want to revert batch ${batchId}? This performs status-only rollback for generated BFILL rows (non-destructive).`)) return;
|
|
setIsReverting(batchId);
|
|
setError(null);
|
|
try {
|
|
const accessToken = await getPlatformAccessToken();
|
|
|
|
const apiUrl = tradingRuntime.tradingApiUrl;
|
|
|
|
const response = await fetch(`${apiUrl}/api/admin/revert-backfill-batch`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'x-request-id': createRequestId('web-recon')
|
|
},
|
|
body: JSON.stringify({ batchId })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await parseResponseError(response));
|
|
}
|
|
|
|
const payload = await response.json();
|
|
if (!payload.success) throw new Error(payload.error || 'Revert failed');
|
|
|
|
void loadAuditData();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to revert batch');
|
|
} finally {
|
|
setIsReverting(null);
|
|
}
|
|
};
|
|
|
|
const handleApplyFilters = () => {
|
|
setOffset(0);
|
|
setFilters({
|
|
profileId: draftFilters.profileId.trim(),
|
|
symbol: draftFilters.symbol.trim().toUpperCase(),
|
|
batchId: draftFilters.batchId.trim(),
|
|
decision: draftFilters.decision.trim(),
|
|
days: draftFilters.days
|
|
});
|
|
};
|
|
|
|
const handleResetFilters = () => {
|
|
setDraftFilters(DEFAULT_FILTERS);
|
|
setFilters(DEFAULT_FILTERS);
|
|
setOffset(0);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<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-[var(--accent)]" />
|
|
<h3 className={sectionTitleClass}>Reconciliation EXIT Backfill Audit</h3>
|
|
</div>
|
|
<Button
|
|
onClick={() => { void loadAuditData(); }}
|
|
disabled={isLoading}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-[10px] uppercase tracking-wider"
|
|
>
|
|
<RefreshCcw size={11} className={isLoading ? 'animate-spin' : ''} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<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="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="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="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="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>
|
|
</Card>
|
|
|
|
<Card className="space-y-4 rounded-2xl p-5">
|
|
<div className="flex items-center gap-2">
|
|
<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
|
|
value={draftFilters.profileId}
|
|
onChange={(event) => setDraftFilters((prev) => ({ ...prev, profileId: event.target.value }))}
|
|
placeholder="Profile ID"
|
|
className="h-10 rounded-lg px-3 py-2 text-xs"
|
|
/>
|
|
<Input
|
|
value={draftFilters.symbol}
|
|
onChange={(event) => setDraftFilters((prev) => ({ ...prev, symbol: event.target.value }))}
|
|
placeholder="Symbol (e.g. SOL/USDT)"
|
|
className="h-10 rounded-lg px-3 py-2 text-xs"
|
|
/>
|
|
<Input
|
|
value={draftFilters.batchId}
|
|
onChange={(event) => setDraftFilters((prev) => ({ ...prev, batchId: event.target.value }))}
|
|
placeholder="Batch ID"
|
|
className="h-10 rounded-lg px-3 py-2 text-xs"
|
|
/>
|
|
<Select
|
|
value={draftFilters.decision}
|
|
onChange={(event) => setDraftFilters((prev) => ({ ...prev, decision: event.target.value }))}
|
|
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
|
|
value={String(draftFilters.days)}
|
|
onChange={(event) => setDraftFilters((prev) => ({ ...prev, days: Number.parseInt(event.target.value, 10) || 7 }))}
|
|
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>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={handleApplyFilters}
|
|
disabled={isLoading}
|
|
size="sm"
|
|
className="text-[10px] uppercase tracking-wider"
|
|
>
|
|
Apply Filters
|
|
</Button>
|
|
<Button
|
|
onClick={handleResetFilters}
|
|
disabled={isLoading}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-[10px] uppercase tracking-wider"
|
|
>
|
|
Reset
|
|
</Button>
|
|
</div>
|
|
</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="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>
|
|
)}
|
|
|
|
<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={tableHeadClass}>
|
|
<tr>
|
|
<th className="px-4 py-2">Time</th>
|
|
<th className="px-4 py-2">Profile</th>
|
|
<th className="px-4 py-2">Symbol</th>
|
|
<th className="px-4 py-2">Trade</th>
|
|
<th className="px-4 py-2">Remaining</th>
|
|
<th className="px-4 py-2">Open Qty</th>
|
|
<th className="px-4 py-2">Dust Threshold</th>
|
|
<th className="px-4 py-2">Evidence</th>
|
|
<th className="px-4 py-2">Batch</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className={tableDividerClass}>
|
|
{manualReviewRows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={9} className={emptyCellClass}>
|
|
{isLoading ? 'Loading manual review queue...' : 'No large remainder manual-review cases for selected filters'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
manualReviewRows.map((row) => {
|
|
const metadata = row.metadata || null;
|
|
const remaining = readMetadataNumber(metadata, 'remaining');
|
|
const openQty = readMetadataNumber(metadata, 'openQty');
|
|
const dustThreshold = readMetadataNumber(metadata, 'dustThreshold');
|
|
const evidenceRows = readMetadataNumber(metadata, 'evidenceRows');
|
|
const evidenceRowsUsed = readMetadataNumber(metadata, 'evidenceRowsUsed');
|
|
const unmatchedEvidenceCount = readMetadataNumber(metadata, 'unmatchedEvidenceCount');
|
|
|
|
return (
|
|
<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="ml-1 text-[10px] text-[var(--muted-foreground)]">unmatched:{formatNumber(unmatchedEvidenceCount)}</span>
|
|
</td>
|
|
<td className={cn('px-4 py-2', monoMutedClass)}>{shortId(row.batch_id || '')}</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
<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={tableHeadClass}>
|
|
<tr>
|
|
<th className="px-4 py-2">Batch</th>
|
|
<th className="px-4 py-2">Window</th>
|
|
<th className="px-4 py-2">Rows</th>
|
|
<th className="px-4 py-2">Decisions</th>
|
|
<th className="px-4 py-2">Profiles</th>
|
|
<th className="px-4 py-2">Symbols</th>
|
|
<th className="px-4 py-2 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className={tableDividerClass}>
|
|
{batches.length === 0 ? (
|
|
<tr>
|
|
<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-[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-[var(--muted-foreground)]">to {formatDateTime(batch.lastSeenAt)}</div>
|
|
</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="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={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
|
|
onClick={() => handleRevertBatch(batch.batchId)}
|
|
disabled={isReverting === batch.batchId || isLoading}
|
|
title="Revert APPLIED BFILL rows in this batch via status-only rollback"
|
|
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>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
<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={tableHeadClass}>
|
|
<tr>
|
|
<th className="px-4 py-2">Time</th>
|
|
<th className="px-4 py-2">Decision</th>
|
|
<th className="px-4 py-2">Profile</th>
|
|
<th className="px-4 py-2">Symbol</th>
|
|
<th className="px-4 py-2">Trade</th>
|
|
<th className="px-4 py-2">Qty / Price</th>
|
|
<th className="px-4 py-2">Reason</th>
|
|
<th className="px-4 py-2">Exchange Tag</th>
|
|
<th className="px-4 py-2">Order IDs</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className={tableDividerClass}>
|
|
{rows.length === 0 ? (
|
|
<tr>
|
|
<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>
|
|
) : (
|
|
rows.map((row) => {
|
|
const metadata = row.metadata || {};
|
|
const matchedBy = typeof metadata.matchedBy === 'string' ? metadata.matchedBy : null;
|
|
const exchangeSubTag = typeof metadata.exchangeSubTag === 'string'
|
|
? metadata.exchangeSubTag
|
|
: '';
|
|
return (
|
|
<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-600 dark:text-emerald-400'
|
|
: row.decision === 'NO_GO'
|
|
? '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-[var(--muted-foreground)]">DRY</span>}
|
|
</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={cn('px-4 py-2', monoMutedClass)}>
|
|
<div>{formatNumber(row.filled_qty)}</div>
|
|
<div className="text-[10px] text-[var(--muted-foreground)]">{formatNumber(row.filled_price)}</div>
|
|
</td>
|
|
<td className={cn('px-4 py-2', mutedTextClass)}>
|
|
<div>{row.reason || '-'}</div>
|
|
{matchedBy && <div className="text-[10px] text-[var(--muted-foreground)]">matchedBy: {matchedBy}</div>}
|
|
</td>
|
|
<td className={cn('px-4 py-2', monoMutedClass)} title={exchangeSubTag || ''}>
|
|
{compactTag(exchangeSubTag)}
|
|
</td>
|
|
<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>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<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}
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 text-[10px] uppercase tracking-wider"
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
onClick={() => setOffset((prev) => prev + PAGE_LIMIT)}
|
|
disabled={!hasNextPage || isLoading}
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 text-[10px] uppercase tracking-wider"
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|