added ability to add exchanges and a custom delete exchange dialog

This commit is contained in:
Boki 2025-06-18 09:41:25 -04:00
parent 0bec1eca83
commit 1d299e52d4
8 changed files with 550 additions and 16 deletions

View file

@ -3,16 +3,21 @@ import {
CheckCircleIcon,
ExclamationTriangleIcon,
XMarkIcon,
PlusIcon,
} from '@heroicons/react/24/outline';
import { useEffect, useState } from 'react';
import { ExchangesTable } from './components/ExchangesTable';
import { AddExchangeDialog } from './components/AddExchangeDialog';
import { useExchanges } from './hooks/useExchanges';
export function ExchangesPage() {
const { createExchange } = useExchanges();
const [syncing, setSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<{
type: 'success' | 'error' | null;
message: string;
}>({ type: null, message: '' });
const [showAddDialog, setShowAddDialog] = useState(false);
// Auto-dismiss success messages after 5 seconds
useEffect(() => {
@ -54,14 +59,23 @@ export function ExchangesPage() {
Configure and manage master exchanges with their data sources and providers.
</p>
</div>
<button
onClick={handleSync}
disabled={syncing}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ArrowPathIcon className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync Exchanges'}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setShowAddDialog(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<PlusIcon className="h-4 w-4" />
Add Exchange
</button>
<button
onClick={handleSync}
disabled={syncing}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ArrowPathIcon className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync Exchanges'}
</button>
</div>
</div>
{syncing && (
@ -108,6 +122,26 @@ export function ExchangesPage() {
<div className="flex-1 min-h-0">
<ExchangesTable />
</div>
<AddExchangeDialog
isOpen={showAddDialog}
onClose={() => setShowAddDialog(false)}
onCreateExchange={async (exchangeRequest) => {
try {
await createExchange(exchangeRequest);
setShowAddDialog(false);
setSyncStatus({
type: 'success',
message: `Exchange "${exchangeRequest.code}" created successfully!`,
});
} catch (error) {
setSyncStatus({
type: 'error',
message: error instanceof Error ? error.message : 'Failed to create exchange',
});
}
}}
/>
</div>
);
}

View file

@ -0,0 +1,233 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
import { useCallback, useState } from 'react';
import { CreateExchangeRequest } from '../types';
interface AddExchangeDialogProps {
isOpen: boolean;
onClose: () => void;
onCreateExchange: (request: CreateExchangeRequest) => Promise<any>;
}
export function AddExchangeDialog({
isOpen,
onClose,
onCreateExchange,
}: AddExchangeDialogProps) {
const [formData, setFormData] = useState<CreateExchangeRequest>({
code: '',
name: '',
country: '',
currency: '',
active: true,
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = useCallback((): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.code.trim()) {
newErrors.code = 'Exchange code is required';
} else if (formData.code.length > 10) {
newErrors.code = 'Exchange code must be 10 characters or less';
}
if (!formData.name.trim()) {
newErrors.name = 'Exchange name is required';
}
if (!formData.country.trim()) {
newErrors.country = 'Country is required';
} else if (formData.country.length !== 2) {
newErrors.country = 'Country must be exactly 2 characters (e.g., US, CA, GB)';
}
if (!formData.currency.trim()) {
newErrors.currency = 'Currency is required';
} else if (formData.currency.length !== 3) {
newErrors.currency = 'Currency must be exactly 3 characters (e.g., USD, EUR, CAD)';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [formData]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
try {
await onCreateExchange({
...formData,
code: formData.code.toUpperCase(),
country: formData.country.toUpperCase(),
currency: formData.currency.toUpperCase(),
});
// Reset form on success
setFormData({
code: '',
name: '',
country: '',
currency: '',
active: true,
});
setErrors({});
} catch (error) {
console.error('Error creating exchange:', error);
} finally {
setLoading(false);
}
},
[formData, validateForm, onCreateExchange]
);
const handleClose = useCallback(() => {
setFormData({
code: '',
name: '',
country: '',
currency: '',
active: true,
});
setErrors({});
onClose();
}, [onClose]);
const handleInputChange = useCallback(
(field: keyof CreateExchangeRequest, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
},
[errors]
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add New Exchange</DialogTitle>
<p className="text-sm text-text-muted">
Create a new master exchange with no provider mappings
</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Exchange Code */}
<div>
<label htmlFor="code" className="block text-sm font-medium text-text-primary mb-1">
Exchange Code *
</label>
<input
id="code"
type="text"
value={formData.code}
onChange={e => handleInputChange('code', e.target.value)}
placeholder="e.g., NASDAQ, NYSE, TSX"
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
errors.code ? 'border-danger' : 'border-border'
}`}
maxLength={10}
required
/>
{errors.code && <p className="text-xs text-danger mt-1">{errors.code}</p>}
</div>
{/* Exchange Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-text-primary mb-1">
Exchange Name *
</label>
<input
id="name"
type="text"
value={formData.name}
onChange={e => handleInputChange('name', e.target.value)}
placeholder="e.g., NASDAQ Stock Market, New York Stock Exchange"
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
errors.name ? 'border-danger' : 'border-border'
}`}
maxLength={255}
required
/>
{errors.name && <p className="text-xs text-danger mt-1">{errors.name}</p>}
</div>
{/* Country */}
<div>
<label htmlFor="country" className="block text-sm font-medium text-text-primary mb-1">
Country Code *
</label>
<input
id="country"
type="text"
value={formData.country}
onChange={e => handleInputChange('country', e.target.value)}
placeholder="e.g., US, CA, GB"
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
errors.country ? 'border-danger' : 'border-border'
}`}
maxLength={2}
required
/>
{errors.country && <p className="text-xs text-danger mt-1">{errors.country}</p>}
</div>
{/* Currency */}
<div>
<label htmlFor="currency" className="block text-sm font-medium text-text-primary mb-1">
Currency Code *
</label>
<input
id="currency"
type="text"
value={formData.currency}
onChange={e => handleInputChange('currency', e.target.value)}
placeholder="e.g., USD, EUR, CAD"
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
errors.currency ? 'border-danger' : 'border-border'
}`}
maxLength={3}
required
/>
{errors.currency && <p className="text-xs text-danger mt-1">{errors.currency}</p>}
</div>
{/* Active Toggle */}
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.active}
onChange={e => handleInputChange('active', e.target.checked)}
className="rounded"
/>
<span className="text-sm text-text-primary">Active exchange</span>
</label>
<p className="text-xs text-text-muted mt-1">
Inactive exchanges won't be used for new symbol mappings
</p>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Exchange'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,110 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
import { useCallback, useState } from 'react';
import { TrashIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
interface DeleteExchangeDialogProps {
isOpen: boolean;
exchangeId: string;
exchangeName: string;
providerMappingCount: number;
onClose: () => void;
onConfirmDelete: (exchangeId: string) => Promise<boolean>;
}
export function DeleteExchangeDialog({
isOpen,
exchangeId,
exchangeName,
providerMappingCount,
onClose,
onConfirmDelete,
}: DeleteExchangeDialogProps) {
const [loading, setLoading] = useState(false);
const handleConfirm = useCallback(async () => {
setLoading(true);
try {
const success = await onConfirmDelete(exchangeId);
if (success) {
onClose();
}
} catch (error) {
console.error('Error deleting exchange:', error);
} finally {
setLoading(false);
}
}, [exchangeId, onConfirmDelete, onClose]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-danger">
<ExclamationTriangleIcon className="h-5 w-5" />
Delete Exchange
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="p-4 bg-danger/10 border border-danger/20 rounded-lg">
<p className="text-sm text-text-primary">
Are you sure you want to delete <strong>"{exchangeName}"</strong>?
</p>
</div>
<div className="space-y-2 text-sm text-text-secondary">
<p className="font-medium text-text-primary">This action will:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Hide the exchange from all lists</li>
{providerMappingCount > 0 && (
<li className="text-yellow-400">
Delete {providerMappingCount} provider mapping{providerMappingCount !== 1 ? 's' : ''}
</li>
)}
<li>Make provider exchanges available for remapping</li>
</ul>
</div>
{providerMappingCount > 0 && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-md">
<div className="flex items-start gap-2">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-yellow-400">Warning</p>
<p className="text-text-secondary">
This exchange has {providerMappingCount} provider mapping{providerMappingCount !== 1 ? 's' : ''}.
Deleting will permanently remove these mappings and make the provider exchanges
available for mapping to other exchanges.
</p>
</div>
</div>
</div>
)}
<div className="bg-surface-secondary p-3 rounded-md">
<p className="text-xs text-text-muted">
<strong>Note:</strong> This action cannot be undone. The exchange will be hidden
but can be restored by directly updating the database if needed.
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={loading}
className="bg-danger hover:bg-danger/90 text-white"
>
<TrashIcon className="h-4 w-4 mr-1" />
{loading ? 'Deleting...' : 'Delete Exchange'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -5,6 +5,7 @@ import { useCallback, useMemo, useState, useEffect } from 'react';
import { useExchanges } from '../hooks/useExchanges';
import { Exchange, ProviderMapping } from '../types';
import { AddProviderMappingDialog } from './AddProviderMappingDialog';
import { DeleteExchangeDialog } from './DeleteExchangeDialog';
export function ExchangesTable() {
const {
@ -24,6 +25,11 @@ export function ExchangesTable() {
exchangeId: string;
exchangeName: string;
} | null>(null);
const [deleteDialog, setDeleteDialog] = useState<{
exchangeId: string;
exchangeName: string;
providerMappingCount: number;
} | null>(null);
const handleCellEdit = useCallback(
async (id: string, field: string, value: string) => {
@ -47,14 +53,16 @@ export function ExchangesTable() {
setAddProviderDialog({ exchangeId, exchangeName });
}, []);
const handleDeleteExchange = useCallback(async (exchangeId: string, exchangeName: string) => {
if (confirm(`Are you sure you want to delete "${exchangeName}"? This will hide the exchange and make all its provider mappings available for remapping.`)) {
const success = await updateExchange(exchangeId, { visible: false });
if (success) {
// Optionally refresh the list or show a success message
refetch();
}
const handleDeleteExchange = useCallback((exchangeId: string, exchangeName: string, providerMappingCount: number) => {
setDeleteDialog({ exchangeId, exchangeName, providerMappingCount });
}, []);
const handleConfirmDelete = useCallback(async (exchangeId: string) => {
const success = await updateExchange(exchangeId, { visible: false });
if (success) {
refetch();
}
return success;
}, [updateExchange, refetch]);
const handleToggleProviderMapping = useCallback(
@ -292,7 +300,11 @@ export function ExchangesTable() {
Add
</button>
<button
onClick={() => handleDeleteExchange(row.original.id, row.original.name)}
onClick={() => handleDeleteExchange(
row.original.id,
row.original.name,
parseInt(row.original.provider_mapping_count) || 0
)}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-danger/20 text-danger rounded text-xs hover:bg-danger/30 transition-colors whitespace-nowrap"
title="Delete Exchange"
>
@ -321,6 +333,7 @@ export function ExchangesTable() {
handleToggleActive,
handleAddProviderMapping,
handleDeleteExchange,
handleConfirmDelete,
handleRowExpand,
]);
@ -433,6 +446,17 @@ export function ExchangesTable() {
}}
/>
)}
{deleteDialog && (
<DeleteExchangeDialog
isOpen={true}
exchangeId={deleteDialog.exchangeId}
exchangeName={deleteDialog.exchangeName}
providerMappingCount={deleteDialog.providerMappingCount}
onClose={() => setDeleteDialog(null)}
onConfirmDelete={handleConfirmDelete}
/>
)}
</>
);
}

View file

@ -1,3 +1,5 @@
export { AddSourceDialog } from './AddSourceDialog';
export { AddProviderMappingDialog } from './AddProviderMappingDialog';
export { AddExchangeDialog } from './AddExchangeDialog';
export { DeleteExchangeDialog } from './DeleteExchangeDialog';
export { ExchangesTable } from './ExchangesTable';

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import {
CreateProviderMappingRequest,
CreateExchangeRequest,
Exchange,
ExchangeDetails,
ExchangeStats,
@ -252,6 +253,35 @@ export function useExchanges() {
[]
);
const createExchange = useCallback(async (request: CreateExchangeRequest) => {
try {
const response = await fetch(`${API_BASE_URL}/exchanges`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Failed to create exchange: ${response.statusText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to create exchange');
}
// Refresh the exchanges list
await fetchExchanges();
return result.data;
} catch (err) {
console.error('Error creating exchange:', err);
setError(err instanceof Error ? err.message : 'Failed to create exchange');
throw err;
}
}, [fetchExchanges]);
useEffect(() => {
console.log('useExchanges useEffect triggered');
fetchExchanges();
@ -263,6 +293,7 @@ export function useExchanges() {
error,
refetch: fetchExchanges,
updateExchange,
createExchange,
fetchExchangeDetails,
fetchStats,
fetchProviderMappings,

View file

@ -72,6 +72,14 @@ export interface CreateProviderMappingRequest {
verified?: boolean;
}
export interface CreateExchangeRequest {
code: string;
name: string;
country: string;
currency: string;
active?: boolean;
}
export interface ExchangeStats {
total_exchanges: string;
active_exchanges: string;