stock-bot/apps/web/src/features/exchanges/components/ExchangesTable.tsx
2025-06-16 18:37:20 -04:00

283 lines
9.1 KiB
TypeScript

import { DataTable } from '@/components/ui';
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useMemo, useState } from 'react';
import { useExchanges } from '../hooks/useExchanges';
import { Exchange, SourceMapping } from '../types';
import { AddSourceDialog } from './AddSourceDialog';
export function ExchangesTable() {
const { exchanges, loading, error, updateExchange, addSource, removeSource } = useExchanges();
const [editingCell, setEditingCell] = useState<{ id: string; field: string } | null>(null);
const [editValue, setEditValue] = useState('');
const [addSourceDialog, setAddSourceDialog] = useState<{
id: string;
exchangeName: string;
} | null>(null);
const handleCellEdit = useCallback(
async (id: string, field: string, value: string) => {
if (field === 'shortName') {
await updateExchange(id, { shortName: value });
}
setEditingCell(null);
setEditValue('');
},
[updateExchange]
);
const handleToggleActive = useCallback(
async (id: string, currentStatus: boolean) => {
await updateExchange(id, { active: !currentStatus });
},
[updateExchange]
);
const handleAddSource = useCallback(async (id: string, exchangeName: string) => {
setAddSourceDialog({ id, exchangeName });
}, []);
const handleRemoveSource = useCallback(
async (exchangeId: string, sourceName: string) => {
if (confirm(`Are you sure you want to remove the ${sourceName} source?`)) {
await removeSource(exchangeId, sourceName);
}
},
[removeSource]
);
const columns = useMemo<ColumnDef<Exchange>[]>(() => {
return [
{
id: 'masterExchangeId',
header: 'Master ID',
accessorKey: 'masterExchangeId',
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: 'shortName',
header: 'Short Name',
accessorKey: 'shortName',
size: 50,
enableResizing: false,
cell: ({ getValue, row, cell }) => {
const isEditing =
editingCell?.id === row.original._id && editingCell?.field === 'shortName';
if (isEditing) {
return (
<input
type="text"
style={{ width: cell.column.getSize() }}
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={() => handleCellEdit(row.original._id, 'shortName', editValue)}
onKeyDown={e => {
if (e.key === 'Enter') {
handleCellEdit(row.original._id, 'shortName', 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: 'shortName' });
setEditValue(getValue() as string);
}}
>
{getValue() as string}
</div>
);
},
},
{
id: 'officialName',
header: 'Official Name',
accessorKey: 'officialName',
size: 150,
maxSize: 150,
enableResizing: true,
cell: ({ getValue, cell }) => (
<span
className="text-text-primary text-sm truncate block"
title={getValue() as string}
style={{ width: cell.column.getSize() }}
>
{getValue() as string}
</span>
),
},
{
id: 'country',
header: 'Country',
accessorKey: 'country',
size: 40,
maxSize: 40,
cell: ({ getValue }) => (
<span className="text-text-secondary text-sm">{getValue() as string}</span>
),
},
{
id: 'currency',
header: 'Currency',
accessorKey: 'currency',
size: 40,
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: 'sources',
header: 'Sources',
accessorKey: 'sourceMappings',
minSize: 400,
maxSize: 400,
size: 400,
enableResizing: true,
cell: ({ getValue, row, cell }) => {
const sourceMappings = getValue() as Record<string, SourceMapping>;
const sources = Object.keys(sourceMappings);
return (
<div className="flex flex-wrap gap-1" style={{ width: cell.column.getSize() }}>
{sources.map(source => {
// The source key is already in format "source_sourcecode" from the storage
const displayText = source.toUpperCase();
return (
<span
key={source}
className="inline-flex items-center gap-1 px-2 py-1 bg-surface-secondary rounded text-xs"
>
{displayText}
<button
onClick={() => handleRemoveSource(row.original._id, source)}
className="text-danger hover:text-danger/80 transition-colors"
>
<XMarkIcon className="h-3 w-3" />
</button>
</span>
);
})}
<button
onClick={() => handleAddSource(row.original._id, row.original.officialName)}
className="inline-flex items-center justify-center w-6 h-6 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
title="Add Source"
>
<PlusIcon className="h-3 w-3" />
</button>
</div>
);
},
},
{
id: 'updated_at',
header: 'Last Updated',
accessorKey: 'updated_at',
size: 150,
maxSize: 150,
cell: ({ getValue }) => (
<span className="text-xs text-text-muted">
{new Date(getValue() as string).toLocaleDateString()}
</span>
),
},
];
}, [
editingCell,
editValue,
handleCellEdit,
handleRemoveSource,
handleToggleActive,
handleAddSource,
]);
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 data-service is running on localhost:2001
</p>
</div>
);
}
return (
<>
<DataTable
data={exchanges || []}
columns={columns}
loading={loading}
className="rounded-lg border border-border"
/>
{addSourceDialog && (
<AddSourceDialog
isOpen={true}
exchangeId={addSourceDialog.id}
exchangeName={addSourceDialog.exchangeName}
onClose={() => setAddSourceDialog(null)}
onAddSource={async (sourceRequest: {
source: string;
source_code: string;
mapping: { id: string; name: string; code: string; aliases: string[] };
}) => {
const success = await addSource(addSourceDialog.id, sourceRequest);
if (success) {
setAddSourceDialog(null);
}
}}
/>
)}
</>
);
}