initial work on exchanges page

This commit is contained in:
Boki 2025-06-16 08:23:55 -04:00
parent 3f5bbc6345
commit d7780e9684
18 changed files with 822 additions and 41 deletions

View file

@ -22,10 +22,10 @@ const app = new Hono();
app.use(
'*',
cors({
origin: ['http://localhost:4200', 'http://localhost:5173'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
credentials: false,
})
);

View file

@ -13,6 +13,7 @@
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18",
"@tanstack/react-table": "^8.21.3",
"@types/react-router-dom": "^5.3.3",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-window": "^1.8.8",
"clsx": "^2.1.1",

View file

@ -1,10 +1,31 @@
import { Layout } from '@/components/layout';
import { DashboardPage } from '@/features/dashboard';
import { ExchangesPage } from '@/features/exchanges';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
export function App() {
return (
<Layout title="Dashboard">
<DashboardPage />
</Layout>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="exchanges" element={<ExchangesPage />} />
<Route
path="portfolio"
element={<div className="p-4">Portfolio Page - Coming Soon</div>}
/>
<Route
path="strategies"
element={<div className="p-4">Strategies Page - Coming Soon</div>}
/>
<Route
path="analytics"
element={<div className="p-4">Analytics Page - Coming Soon</div>}
/>
<Route path="settings" element={<div className="p-4">Settings Page - Coming Soon</div>} />
</Route>
</Routes>
</BrowserRouter>
);
}

View file

@ -1,22 +1,28 @@
import { ReactNode, useState } from 'react';
import { useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
interface LayoutProps {
children: ReactNode;
title?: string;
}
export function Layout({ children, title }: LayoutProps) {
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
// Determine title from current route
const getTitle = () => {
const path = location.pathname.replace('/', '');
if (!path || path === 'dashboard') return 'Dashboard';
return path.charAt(0).toUpperCase() + path.slice(1);
};
return (
<div className="h-full">
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
<Header setSidebarOpen={setSidebarOpen} title={title} />
<Header setSidebarOpen={setSidebarOpen} title={getTitle()} />
<main className="py-4 lg:pl-60 w-full h-full overflow-y-auto scrollbar-sleek">
<div className="px-4 flex-col h-full">{children}</div>
<main className="py-4 lg:pl-60 w-full h-full overflow-y-auto">
<div className="px-4 flex-col h-full">
<Outlet />
</div>
</main>
</div>
);

View file

@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { Fragment } from 'react';
import { NavLink } from 'react-router-dom';
interface SidebarProps {
sidebarOpen: boolean;
@ -86,26 +87,32 @@ function SidebarContent() {
<ul role="list" className="-mx-1 space-y-0.5">
{navigation.map(item => (
<li key={item.name}>
<a
href={item.href}
className={cn(
item.current
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
'group flex gap-x-2 rounded-r-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors'
)}
<NavLink
to={item.href}
className={({ isActive }) =>
cn(
isActive
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
'group flex gap-x-2 rounded-r-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors'
)
}
>
<item.icon
className={cn(
item.current
? 'text-primary-400'
: 'text-text-muted group-hover:text-primary-400',
'h-4 w-4 shrink-0 transition-colors'
)}
aria-hidden="true"
/>
{item.name}
</a>
{({ isActive }) => (
<>
<item.icon
className={cn(
isActive
? 'text-primary-400'
: 'text-text-muted group-hover:text-primary-400',
'h-4 w-4 shrink-0 transition-colors'
)}
aria-hidden="true"
/>
{item.name}
</>
)}
</NavLink>
</li>
))}
</ul>

View file

@ -80,12 +80,12 @@ export function DataTable<T>({
Table: ({ style, ...props }) => (
<table
{...props}
style={{
...style,
minWidth: '100%',
tableLayout: 'auto',
borderCollapse: 'collapse',
borderSpacing: 0,
{...{
style: {
...style,
width: table.getCenterTotalSize(),
minWidth: '100%',
},
}}
className="bg-background"
/>

View file

@ -0,0 +1,16 @@
import { ExchangesTable } from './components/ExchangesTable';
export function ExchangesPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-lg font-bold text-text-primary mb-2">Exchange Management</h1>
<p className="text-text-secondary text-sm">
Configure and manage master exchanges with their data sources and providers.
</p>
</div>
<ExchangesTable />
</div>
);
}

View file

@ -0,0 +1,198 @@
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import React, { useState } from 'react';
import { AddSourceRequest } from '../types';
interface AddSourceDialogProps {
isOpen: boolean;
onClose: () => void;
onAddSource: (request: AddSourceRequest) => Promise<void>;
exchangeId: string;
exchangeName: string;
}
export function AddSourceDialog({
isOpen,
onClose,
onAddSource,
exchangeId,
exchangeName,
}: AddSourceDialogProps) {
const [source, setSource] = useState('');
const [id, setId] = useState('');
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [aliases, setAliases] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!source || !id || !name || !code) return;
setLoading(true);
try {
await onAddSource({
source,
mapping: {
id,
name,
code,
aliases: aliases
.split(',')
.map(a => a.trim())
.filter(Boolean),
},
});
// Reset form
setSource('');
setId('');
setName('');
setCode('');
setAliases('');
} catch (error) {
console.error('Error adding source:', error);
} finally {
setLoading(false);
}
};
return (
<Transition appear show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-lg bg-background border border-border p-6 text-left align-middle shadow-xl transition-all">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-medium text-text-primary">
Add Source to {exchangeName}
</Dialog.Title>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary transition-colors"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Source Provider
</label>
<select
value={source}
onChange={e => setSource(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
required
>
<option value="">Select a source</option>
<option value="ib">Interactive Brokers</option>
<option value="alpaca">Alpaca</option>
<option value="polygon">Polygon</option>
<option value="yahoo">Yahoo Finance</option>
<option value="alpha_vantage">Alpha Vantage</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Source ID
</label>
<input
type="text"
value={id}
onChange={e => setId(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., NYSE, NASDAQ"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Source Name
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., New York Stock Exchange"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Source Code
</label>
<input
type="text"
value={code}
onChange={e => setCode(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., NYSE"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">
Aliases (comma-separated)
</label>
<input
type="text"
value={aliases}
onChange={e => setAliases(e.target.value)}
className="w-full bg-surface border border-border rounded px-3 py-2 text-text-primary focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., NYSE, New York, Big Board"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border text-text-secondary hover:text-text-primary hover:bg-surface-secondary rounded transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !source || !id || !name || !code}
className="px-4 py-2 bg-primary-500 text-white rounded hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Adding...' : 'Add Source'}
</button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View file

@ -0,0 +1,292 @@
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 => (
<span
key={source}
className="inline-flex items-center gap-1 px-2 py-1 bg-surface-secondary rounded text-xs"
>
{source.toUpperCase()}
<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 gap-1 px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
>
<PlusIcon className="h-3 w-3" />
Add Source
</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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-text-primary">Exchanges Management</h2>
<p className="text-sm text-text-secondary">
Manage exchange configurations and source mappings
</p>
</div>
<div className="text-sm text-text-muted">{exchanges?.length || 0} exchanges loaded</div>
</div>
<DataTable
data={exchanges || []}
columns={columns}
loading={loading}
className="rounded-lg border border-border"
/>
<div className="mt-2 text-xs text-text-muted">
Debug: Data length: {exchanges?.length || 0}, Loading: {loading.toString()}, Error:{' '}
{error || 'none'}
</div>
{addSourceDialog && (
<AddSourceDialog
isOpen={true}
exchangeId={addSourceDialog.id}
exchangeName={addSourceDialog.exchangeName}
onClose={() => setAddSourceDialog(null)}
onAddSource={async (sourceRequest: {
source: string;
mapping: { id: string; name: string; code: string; aliases: string[] };
}) => {
const success = await addSource(addSourceDialog.id, sourceRequest);
if (success) {
setAddSourceDialog(null);
}
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,2 @@
export { AddSourceDialog } from './AddSourceDialog';
export { ExchangesTable } from './ExchangesTable';

View file

@ -0,0 +1 @@
export { useExchanges } from './useExchanges';

View file

@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react';
import { AddSourceRequest, Exchange, UpdateExchangeRequest } from '../types';
const API_BASE_URL = 'http://localhost:2001/api';
export function useExchanges() {
const [exchanges, setExchanges] = useState<Exchange[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchExchanges = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_BASE_URL}/exchanges`);
if (!response.ok) {
throw new Error(`Failed to fetch exchanges: ${response.statusText}`);
}
const data = await response.json();
if (data.status === 'success') {
// The API returns exchanges directly in data.data array
setExchanges(data.data || []);
} else {
throw new Error('API returned error status');
}
} catch (err) {
console.error('Error fetching exchanges:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch exchanges');
setExchanges([]); // Reset to empty array on error
} finally {
setLoading(false);
}
}, []);
const updateExchange = useCallback(
async (id: string, updates: UpdateExchangeRequest) => {
try {
const response = await fetch(`${API_BASE_URL}/exchanges/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
});
if (!response.ok) {
throw new Error(`Failed to update exchange: ${response.statusText}`);
}
// Refresh the exchanges list
await fetchExchanges();
return true;
} catch (err) {
console.error('Error updating exchange:', err);
setError(err instanceof Error ? err.message : 'Failed to update exchange');
return false;
}
},
[fetchExchanges]
);
const addSource = useCallback(
async (exchangeId: string, sourceRequest: AddSourceRequest) => {
try {
const response = await fetch(`${API_BASE_URL}/exchanges/${exchangeId}/sources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sourceRequest),
});
if (!response.ok) {
throw new Error(`Failed to add source: ${response.statusText}`);
}
// Refresh the exchanges list
await fetchExchanges();
return true;
} catch (err) {
console.error('Error adding source:', err);
setError(err instanceof Error ? err.message : 'Failed to add source');
return false;
}
},
[fetchExchanges]
);
const removeSource = useCallback(
async (exchangeId: string, sourceName: string) => {
try {
const response = await fetch(
`${API_BASE_URL}/exchanges/${exchangeId}/sources/${sourceName}`,
{
method: 'DELETE',
}
);
if (!response.ok) {
throw new Error(`Failed to remove source: ${response.statusText}`);
}
// Refresh the exchanges list
await fetchExchanges();
return true;
} catch (err) {
console.error('Error removing source:', err);
setError(err instanceof Error ? err.message : 'Failed to remove source');
return false;
}
},
[fetchExchanges]
);
useEffect(() => {
fetchExchanges();
}, [fetchExchanges]);
return {
exchanges,
loading,
error,
refetch: fetchExchanges,
updateExchange,
addSource,
removeSource,
};
}

View file

@ -0,0 +1,4 @@
export * from './components';
export { ExchangesPage } from './ExchangesPage';
export * from './hooks';
export * from './types';

View file

@ -0,0 +1,45 @@
export interface SourceMapping {
id: string;
name: string;
code: string;
aliases: string[];
lastUpdated: string;
}
export interface Exchange {
_id: string;
masterExchangeId: string;
shortName: string;
officialName: string;
country: string;
currency: string;
timezone: string;
active: boolean;
sourceMappings: Record<string, SourceMapping>;
confidence: number;
verified: boolean;
source: string;
created_at: string;
updated_at: string;
}
export interface ExchangesApiResponse {
status: string;
data: Exchange[]; // Exchanges are directly in data array
count: number;
}
export interface UpdateExchangeRequest {
shortName?: string;
active?: boolean;
}
export interface AddSourceRequest {
source: string;
mapping: {
id: string;
name: string;
code: string;
aliases: string[];
};
}

View file

@ -0,0 +1,17 @@
import {
BuildingLibraryIcon,
ChartBarIcon,
CogIcon,
DocumentTextIcon,
HomeIcon,
PresentationChartLineIcon,
} from '@heroicons/react/24/outline';
export const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ name: 'Exchanges', href: '/exchanges', icon: BuildingLibraryIcon },
{ name: 'Portfolio', href: '/portfolio', icon: ChartBarIcon },
{ name: 'Strategies', href: '/strategies', icon: DocumentTextIcon },
{ name: 'Analytics', href: '/analytics', icon: PresentationChartLineIcon },
{ name: 'Settings', href: '/settings', icon: CogIcon },
];

View file

@ -1,4 +1,5 @@
import {
BuildingOfficeIcon,
ChartBarIcon,
CogIcon,
CurrencyDollarIcon,
@ -25,6 +26,12 @@ export const navigation = [
icon: ChartBarIcon,
current: false,
},
{
name: 'Exchanges',
href: '/exchanges',
icon: BuildingOfficeIcon,
current: false,
},
{
name: 'Reports',
href: '/reports',

25
apps/web/src/lib/utils.ts Normal file
View file

@ -0,0 +1,25 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Utility functions for financial data formatting
export function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
}).format(value);
}
export function formatPercentage(value: number): string {
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`;
}
export function getValueColor(value: number): string {
if (value > 0) return 'text-success';
if (value < 0) return 'text-danger';
return 'text-text-secondary';
}

View file

@ -143,6 +143,7 @@
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18",
"@tanstack/react-table": "^8.21.3",
"@types/react-router-dom": "^5.3.3",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-window": "^1.8.8",
"clsx": "^2.1.1",
@ -750,6 +751,8 @@
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/history": ["@types/history@4.7.11", "", {}, "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="],
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.0.4", "", {}, "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@ -770,6 +773,10 @@
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/react-router": ["@types/react-router@5.1.20", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*" } }, "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q=="],
"@types/react-router-dom": ["@types/react-router-dom@5.3.3", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router": "*" } }, "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw=="],
"@types/react-virtualized-auto-sizer": ["@types/react-virtualized-auto-sizer@1.0.8", "", { "dependencies": { "react-virtualized-auto-sizer": "*" } }, "sha512-keJpNyhiwfl2+N12G1ocCVA5ZDBArbPLe/S90X3kt7fam9naeHdaYYWbpe2sHczp70JWJ+2QLhBE8kLvLuVNjA=="],
"@types/react-window": ["@types/react-window@1.8.8", "", { "dependencies": { "@types/react": "*" } }, "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q=="],