424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { DataTable } from '@/components/ui';
|
|
import { PlusIcon, XMarkIcon, CheckIcon } from '@heroicons/react/24/outline';
|
|
import { ColumnDef } from '@tanstack/react-table';
|
|
import { useCallback, useMemo, useState, useEffect } from 'react';
|
|
import { useExchanges } from '../hooks/useExchanges';
|
|
import { Exchange, ProviderMapping } from '../types';
|
|
import { AddProviderMappingDialog } from './AddProviderMappingDialog';
|
|
|
|
export function ExchangesTable() {
|
|
const {
|
|
exchanges,
|
|
loading,
|
|
error,
|
|
updateExchange,
|
|
fetchExchangeDetails,
|
|
fetchProviderMappings,
|
|
updateProviderMapping,
|
|
createProviderMapping,
|
|
refetch
|
|
} = useExchanges();
|
|
const [editingCell, setEditingCell] = useState<{ id: string; field: string } | null>(null);
|
|
const [editValue, setEditValue] = useState('');
|
|
const [addProviderDialog, setAddProviderDialog] = useState<{
|
|
exchangeId: string;
|
|
exchangeName: string;
|
|
} | null>(null);
|
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
|
const [expandedRowData, setExpandedRowData] = useState<Record<string, ProviderMapping[]>>({});
|
|
|
|
const handleCellEdit = useCallback(
|
|
async (id: string, field: string, value: string) => {
|
|
if (field === 'name') {
|
|
await updateExchange(id, { name: value });
|
|
}
|
|
setEditingCell(null);
|
|
setEditValue('');
|
|
},
|
|
[updateExchange]
|
|
);
|
|
|
|
const handleToggleActive = useCallback(
|
|
async (id: string, currentStatus: boolean) => {
|
|
await updateExchange(id, { active: !currentStatus });
|
|
},
|
|
[updateExchange]
|
|
);
|
|
|
|
const handleAddProviderMapping = useCallback(async (exchangeId: string, exchangeName: string) => {
|
|
setAddProviderDialog({ exchangeId, exchangeName });
|
|
}, []);
|
|
|
|
const handleToggleProviderMapping = useCallback(
|
|
async (mappingId: string, currentStatus: boolean) => {
|
|
const success = await updateProviderMapping(mappingId, { active: !currentStatus });
|
|
if (success) {
|
|
refetch();
|
|
}
|
|
},
|
|
[updateProviderMapping, refetch]
|
|
);
|
|
|
|
const handleToggleExpandRow = useCallback(async (rowId: string) => {
|
|
setExpandedRows(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(rowId)) {
|
|
next.delete(rowId);
|
|
} else {
|
|
next.add(rowId);
|
|
// Load provider mappings for this exchange
|
|
if (!expandedRowData[rowId]) {
|
|
fetchExchangeDetails(rowId).then(details => {
|
|
if (details) {
|
|
setExpandedRowData(prev => ({
|
|
...prev,
|
|
[rowId]: details.provider_mappings
|
|
}));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}, [fetchExchangeDetails, expandedRowData]);
|
|
|
|
const columns = useMemo<ColumnDef<Exchange>[]>(() => {
|
|
return [
|
|
{
|
|
id: 'expand',
|
|
header: '',
|
|
size: 30,
|
|
enableResizing: false,
|
|
cell: ({ row }) => {
|
|
const isExpanded = expandedRows.has(row.original.id);
|
|
return (
|
|
<button
|
|
onClick={() => handleToggleExpandRow(row.original.id)}
|
|
className="text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
{isExpanded ? '▼' : '▶'}
|
|
</button>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'id',
|
|
header: 'ID',
|
|
accessorKey: 'id',
|
|
size: 50,
|
|
enableResizing: false,
|
|
cell: ({ getValue, cell }) => (
|
|
<span
|
|
className="font-mono text-primary-400 text-xs"
|
|
style={{ width: cell.column.getSize() }}
|
|
>
|
|
{getValue() as string}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'code',
|
|
header: 'Code',
|
|
accessorKey: 'code',
|
|
size: 80,
|
|
enableResizing: false,
|
|
cell: ({ getValue, cell }) => (
|
|
<span
|
|
className="font-mono text-text-primary text-sm font-medium"
|
|
style={{ width: cell.column.getSize() }}
|
|
>
|
|
{getValue() as string}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'name',
|
|
header: 'Name',
|
|
accessorKey: 'name',
|
|
size: 200,
|
|
maxSize: 300,
|
|
enableResizing: true,
|
|
cell: ({ getValue, row, cell }) => {
|
|
const isEditing =
|
|
editingCell?.id === row.original.id && editingCell?.field === 'name';
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<input
|
|
type="text"
|
|
style={{ width: cell.column.getSize() }}
|
|
value={editValue}
|
|
onChange={e => setEditValue(e.target.value)}
|
|
onBlur={() => handleCellEdit(row.original.id, 'name', editValue)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') {
|
|
handleCellEdit(row.original.id, 'name', editValue);
|
|
} else if (e.key === 'Escape') {
|
|
setEditingCell(null);
|
|
setEditValue('');
|
|
}
|
|
}}
|
|
className="w-full bg-surface border border-border rounded px-2 py-1 text-sm"
|
|
autoFocus
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="cursor-pointer hover:bg-surface-secondary rounded px-2 py-1 transition-colors text-sm"
|
|
onClick={() => {
|
|
setEditingCell({ id: row.original.id, field: 'name' });
|
|
setEditValue(getValue() as string);
|
|
}}
|
|
>
|
|
{getValue() as string}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'country',
|
|
header: 'Country',
|
|
accessorKey: 'country',
|
|
size: 80,
|
|
maxSize: 80,
|
|
cell: ({ getValue }) => (
|
|
<span className="text-text-secondary text-sm">{getValue() as string}</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'currency',
|
|
header: 'Currency',
|
|
accessorKey: 'currency',
|
|
size: 70,
|
|
cell: ({ getValue, cell }) => (
|
|
<span
|
|
className="font-mono text-text-secondary text-sm"
|
|
style={{ width: cell.column.getSize() }}
|
|
>
|
|
{getValue() as string}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'active',
|
|
header: 'Active',
|
|
accessorKey: 'active',
|
|
size: 80,
|
|
maxSize: 80,
|
|
cell: ({ getValue, row, cell }) => {
|
|
const isActive = getValue() as boolean;
|
|
return (
|
|
<label
|
|
className="relative inline-flex items-center cursor-pointer"
|
|
style={{ width: cell.column.getSize() }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isActive}
|
|
onChange={() => handleToggleActive(row.original.id, isActive)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-9 h-5 bg-surface-secondary peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500"></div>
|
|
</label>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'provider_mappings',
|
|
header: 'Provider Mappings',
|
|
accessorKey: 'provider_mapping_count',
|
|
size: 150,
|
|
cell: ({ getValue, row }) => {
|
|
const totalMappings = parseInt(getValue() as string) || 0;
|
|
const activeMappings = parseInt(row.original.active_mapping_count) || 0;
|
|
const verifiedMappings = parseInt(row.original.verified_mapping_count) || 0;
|
|
const providers = row.original.providers;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-sm">
|
|
<span className="text-text-primary font-medium">{totalMappings}</span>
|
|
<span className="text-text-muted"> total</span>
|
|
</div>
|
|
<div className="flex gap-2 text-xs">
|
|
<span className="text-green-400">
|
|
<CheckIcon className="h-3 w-3 inline mr-1" />
|
|
{activeMappings} active
|
|
</span>
|
|
<span className="text-blue-400">
|
|
✓ {verifiedMappings} verified
|
|
</span>
|
|
</div>
|
|
{providers && (
|
|
<div className="text-xs text-text-muted truncate" title={providers}>
|
|
{providers}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: 'Actions',
|
|
size: 100,
|
|
cell: ({ row }) => (
|
|
<button
|
|
onClick={() => handleAddProviderMapping(row.original.id, row.original.name)}
|
|
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
|
|
title="Add Provider Mapping"
|
|
>
|
|
<PlusIcon className="h-3 w-3" />
|
|
Add Mapping
|
|
</button>
|
|
),
|
|
},
|
|
{
|
|
id: 'updated_at',
|
|
header: 'Last Updated',
|
|
accessorKey: 'updated_at',
|
|
size: 120,
|
|
maxSize: 120,
|
|
cell: ({ getValue }) => (
|
|
<span className="text-xs text-text-muted">
|
|
{new Date(getValue() as string).toLocaleDateString()}
|
|
</span>
|
|
),
|
|
},
|
|
];
|
|
}, [
|
|
editingCell,
|
|
editValue,
|
|
expandedRows,
|
|
handleCellEdit,
|
|
handleToggleActive,
|
|
handleAddProviderMapping,
|
|
handleToggleExpandRow,
|
|
]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="bg-danger/10 border border-danger/20 rounded-lg p-4">
|
|
<h3 className="text-danger font-medium mb-2">Error Loading Exchanges</h3>
|
|
<p className="text-text-secondary text-sm">{error}</p>
|
|
<p className="text-text-muted text-xs mt-2">
|
|
Make sure the web-api service is running on localhost:4000
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const renderExpandedRow = (exchange: Exchange) => {
|
|
const mappings = expandedRowData[exchange.id] || [];
|
|
|
|
if (mappings.length === 0) {
|
|
return (
|
|
<div className="p-4 text-center text-text-muted">
|
|
<div className="text-sm">No provider mappings found for this exchange.</div>
|
|
<button
|
|
onClick={() => handleAddProviderMapping(exchange.id, exchange.name)}
|
|
className="mt-2 text-primary-400 hover:text-primary-300 text-sm underline"
|
|
>
|
|
Add the first provider mapping
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 bg-surface-secondary/50">
|
|
<h4 className="text-sm font-medium text-text-primary mb-3">Provider Mappings</h4>
|
|
<div className="space-y-2">
|
|
{mappings.map((mapping) => (
|
|
<div key={mapping.id} className="flex items-center justify-between p-3 bg-surface rounded border border-border">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-mono text-xs bg-primary-500/20 text-primary-400 px-2 py-1 rounded">
|
|
{mapping.provider.toUpperCase()}
|
|
</span>
|
|
<span className="font-medium text-text-primary">
|
|
{mapping.provider_exchange_code}
|
|
</span>
|
|
<span className="text-text-secondary">
|
|
{mapping.provider_exchange_name}
|
|
</span>
|
|
{mapping.country_code && (
|
|
<span className="text-xs text-text-muted">
|
|
{mapping.country_code}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4 mt-1 text-xs text-text-muted">
|
|
<span>Confidence: {mapping.confidence}</span>
|
|
<span>Created: {new Date(mapping.created_at).toLocaleDateString()}</span>
|
|
{mapping.auto_mapped && <span className="text-yellow-400">Auto-mapped</span>}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1">
|
|
{mapping.verified && (
|
|
<span className="text-blue-400" title="Verified">
|
|
✓
|
|
</span>
|
|
)}
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={mapping.active}
|
|
onChange={() => handleToggleProviderMapping(mapping.id, mapping.active)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-6 h-3 bg-surface-secondary peer-focus:outline-none peer-focus:ring-1 peer-focus:ring-primary-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[1px] after:left-[1px] after:bg-white after:rounded-full after:h-2.5 after:w-2.5 after:transition-all peer-checked:bg-primary-500"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-0">
|
|
<DataTable
|
|
data={exchanges || []}
|
|
columns={columns}
|
|
loading={loading}
|
|
className="rounded-lg border border-border"
|
|
/>
|
|
|
|
{/* Expanded rows */}
|
|
{Array.from(expandedRows).map(exchangeId => {
|
|
const exchange = exchanges?.find(e => e.id === exchangeId);
|
|
if (!exchange) return null;
|
|
|
|
return (
|
|
<div key={`expanded-${exchangeId}`} className="border-l border-r border-b border-border rounded-b-lg -mt-1">
|
|
{renderExpandedRow(exchange)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{addProviderDialog && (
|
|
<AddProviderMappingDialog
|
|
isOpen={true}
|
|
exchangeId={addProviderDialog.exchangeId}
|
|
exchangeName={addProviderDialog.exchangeName}
|
|
onClose={() => setAddProviderDialog(null)}
|
|
onCreateMapping={async (mappingRequest) => {
|
|
const result = await createProviderMapping(mappingRequest);
|
|
if (result) {
|
|
setAddProviderDialog(null);
|
|
refetch();
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|