From 1d299e52d446185cb0b98d64ecdea1d508391b09 Mon Sep 17 00:00:00 2001 From: Boki Date: Wed, 18 Jun 2025 09:41:25 -0400 Subject: [PATCH] added ability to add exchanges and a custom delete exchange dialog --- apps/web-api/src/routes/exchange.routes.ts | 92 +++++++ .../src/features/exchanges/ExchangesPage.tsx | 50 +++- .../components/AddExchangeDialog.tsx | 233 ++++++++++++++++++ .../components/DeleteExchangeDialog.tsx | 110 +++++++++ .../exchanges/components/ExchangesTable.tsx | 40 ++- .../features/exchanges/components/index.ts | 2 + .../features/exchanges/hooks/useExchanges.ts | 31 +++ .../src/features/exchanges/types/index.ts | 8 + 8 files changed, 550 insertions(+), 16 deletions(-) create mode 100644 apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx create mode 100644 apps/web-app/src/features/exchanges/components/DeleteExchangeDialog.tsx diff --git a/apps/web-api/src/routes/exchange.routes.ts b/apps/web-api/src/routes/exchange.routes.ts index 76def4d..1837e09 100644 --- a/apps/web-api/src/routes/exchange.routes.ts +++ b/apps/web-api/src/routes/exchange.routes.ts @@ -714,6 +714,98 @@ exchangeRoutes.get('/provider-exchanges/unmapped/:provider', async c => { } }); +// Create new exchange +exchangeRoutes.post('/', async c => { + try { + const body = await c.req.json(); + const postgresClient = getPostgreSQLClient(); + + const { code, name, country, currency, active = true } = body; + + if (!code || !name || !country || !currency) { + return c.json( + { + success: false, + error: 'Missing required fields: code, name, country, currency', + }, + 400 + ); + } + + // Validate currency is 3 characters + if (currency.length !== 3) { + return c.json( + { + success: false, + error: 'Currency must be exactly 3 characters (e.g., USD, EUR, CAD)', + }, + 400 + ); + } + + // Validate country is 2 characters + if (country.length !== 2) { + return c.json( + { + success: false, + error: 'Country must be exactly 2 characters (e.g., US, CA, GB)', + }, + 400 + ); + } + + const query = ` + INSERT INTO exchanges (code, name, country, currency, active, visible) + VALUES ($1, $2, $3, $4, $5, true) + RETURNING * + `; + + const result = await postgresClient.query(query, [ + code.toUpperCase(), + name, + country.toUpperCase(), + currency.toUpperCase(), + active, + ]); + + logger.info('Exchange created', { + exchangeId: result.rows[0].id, + code, + name, + }); + + return c.json( + { + success: true, + data: result.rows[0], + message: 'Exchange created successfully', + }, + 201 + ); + } catch (error) { + logger.error('Failed to create exchange', { error }); + + // Handle unique constraint violations + if (error instanceof Error && error.message.includes('duplicate key')) { + return c.json( + { + success: false, + error: 'Exchange with this code already exists', + }, + 409 + ); + } + + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + 500 + ); + } +}); + // Get exchange statistics exchangeRoutes.get('/stats/summary', async c => { try { diff --git a/apps/web-app/src/features/exchanges/ExchangesPage.tsx b/apps/web-app/src/features/exchanges/ExchangesPage.tsx index ba588cd..447cec5 100644 --- a/apps/web-app/src/features/exchanges/ExchangesPage.tsx +++ b/apps/web-app/src/features/exchanges/ExchangesPage.tsx @@ -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.

- +
+ + +
{syncing && ( @@ -108,6 +122,26 @@ export function ExchangesPage() {
+ + 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', + }); + } + }} + /> ); } diff --git a/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx b/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx new file mode 100644 index 0000000..e28e5f0 --- /dev/null +++ b/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx @@ -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; +} + +export function AddExchangeDialog({ + isOpen, + onClose, + onCreateExchange, +}: AddExchangeDialogProps) { + const [formData, setFormData] = useState({ + code: '', + name: '', + country: '', + currency: '', + active: true, + }); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + + const validateForm = useCallback((): boolean => { + const newErrors: Record = {}; + + 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 ( + + + + Add New Exchange +

+ Create a new master exchange with no provider mappings +

+
+ +
+ {/* Exchange Code */} +
+ + 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 &&

{errors.code}

} +
+ + {/* Exchange Name */} +
+ + 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 &&

{errors.name}

} +
+ + {/* Country */} +
+ + 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 &&

{errors.country}

} +
+ + {/* Currency */} +
+ + 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 &&

{errors.currency}

} +
+ + {/* Active Toggle */} +
+ +

+ Inactive exchanges won't be used for new symbol mappings +

+
+ + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web-app/src/features/exchanges/components/DeleteExchangeDialog.tsx b/apps/web-app/src/features/exchanges/components/DeleteExchangeDialog.tsx new file mode 100644 index 0000000..d7fc095 --- /dev/null +++ b/apps/web-app/src/features/exchanges/components/DeleteExchangeDialog.tsx @@ -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; +} + +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 ( + + + + + + Delete Exchange + + + +
+
+

+ Are you sure you want to delete "{exchangeName}"? +

+
+ +
+

This action will:

+
    +
  • Hide the exchange from all lists
  • + {providerMappingCount > 0 && ( +
  • + Delete {providerMappingCount} provider mapping{providerMappingCount !== 1 ? 's' : ''} +
  • + )} +
  • Make provider exchanges available for remapping
  • +
+
+ + {providerMappingCount > 0 && ( +
+
+ +
+

Warning

+

+ 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. +

+
+
+
+ )} + +
+

+ Note: This action cannot be undone. The exchange will be hidden + but can be restored by directly updating the database if needed. +

+
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web-app/src/features/exchanges/components/ExchangesTable.tsx b/apps/web-app/src/features/exchanges/components/ExchangesTable.tsx index cc8558e..37d2a44 100644 --- a/apps/web-app/src/features/exchanges/components/ExchangesTable.tsx +++ b/apps/web-app/src/features/exchanges/components/ExchangesTable.tsx @@ -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