From 992adde04c99dd15df01206aff1a461879479bd6 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:45:51 +0100 Subject: [PATCH 1/4] feat: add financial dashboard screen (#978) * feat: add financial dashboard screen Add admin-only /dashboard/financial/log screen with 4 ApexCharts (long-term balance vs BTC, short-term breakdown, stacked area by financial type, latest bar chart) and a financial log table. Uses nested route structure for future dashboard expansion. * feat: split financial dashboard into live, history, liquidity, and expenses pages - /live: summary cards + balance by type bar chart - /history: cumulative fee income (plus/minus/total), balance charts with details button - /liquidity: stacked bar chart by provider with asset breakdown - /expenses: detailed referral, binance, blockchain expense charts - Fix auth race condition: wait for isLoggedIn before API calls - Fix white-on-white text color issue - Split all combined charts into separate plus/minus/total views - Aggregate small values (<5k CHF) under "Other" category - Move legends to bottom, fix toolbar overlap * feat: add dashboard index screens with navigation * feat: add referral reward recipients table to expenses page * fix: remove unused latestChanges state from live dashboard * feat: add timeframe selector and sync chart axes on history page Add 24h/3D/1W/1M timeframe selector (default 24h) that loads data efficiently per selected range. Sync all chart x-axes via shared timeRange. Move expenses route under /financial/history/expenses. * fix: rename useDailySample to isDailySample to avoid React hook violation --- src/App.tsx | 45 +++++ .../dashboard/balance-by-type-area-chart.tsx | 120 ++++++++++++ .../dashboard/financial-changes-chart.tsx | 102 ++++++++++ .../dashboard/financial-log-table.tsx | 49 +++++ .../dashboard/latest-balance-bar-chart.tsx | 109 +++++++++++ .../dashboard/total-balance-long-chart.tsx | 72 +++++++ .../dashboard/total-balance-short-chart.tsx | 82 ++++++++ src/dto/dashboard.dto.ts | 54 ++++++ src/hooks/dashboard.hook.ts | 64 +++++++ .../dashboard-financial-expenses.screen.tsx | 175 ++++++++++++++++++ .../dashboard-financial-history.screen.tsx | 131 +++++++++++++ .../dashboard-financial-liquidity.screen.tsx | 43 +++++ .../dashboard-financial-live.screen.tsx | 82 ++++++++ src/screens/dashboard-financial.screen.tsx | 36 ++++ src/screens/dashboard.screen.tsx | 24 +++ src/util/chart.ts | 6 + 16 files changed, 1194 insertions(+) create mode 100644 src/components/dashboard/balance-by-type-area-chart.tsx create mode 100644 src/components/dashboard/financial-changes-chart.tsx create mode 100644 src/components/dashboard/financial-log-table.tsx create mode 100644 src/components/dashboard/latest-balance-bar-chart.tsx create mode 100644 src/components/dashboard/total-balance-long-chart.tsx create mode 100644 src/components/dashboard/total-balance-short-chart.tsx create mode 100644 src/dto/dashboard.dto.ts create mode 100644 src/hooks/dashboard.hook.ts create mode 100644 src/screens/dashboard-financial-expenses.screen.tsx create mode 100644 src/screens/dashboard-financial-history.screen.tsx create mode 100644 src/screens/dashboard-financial-liquidity.screen.tsx create mode 100644 src/screens/dashboard-financial-live.screen.tsx create mode 100644 src/screens/dashboard-financial.screen.tsx create mode 100644 src/screens/dashboard.screen.tsx diff --git a/src/App.tsx b/src/App.tsx index 50ef51748..e87510385 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,6 +71,12 @@ const RealunitTransactionDetailScreen = lazy(() => import('./screens/realunit-tr const RealunitUserScreen = lazy(() => import('./screens/realunit-user.screen')); const PersonalIbanScreen = lazy(() => import('./screens/personal-iban.screen')); const BuyCryptoUpdateScreen = lazy(() => import('./screens/buy-crypto-update.screen')); +const DashboardScreen = lazy(() => import('./screens/dashboard.screen')); +const DashboardFinancialScreen = lazy(() => import('./screens/dashboard-financial.screen')); +const DashboardFinancialHistoryScreen = lazy(() => import('./screens/dashboard-financial-history.screen')); +const DashboardFinancialLiveScreen = lazy(() => import('./screens/dashboard-financial-live.screen')); +const DashboardFinancialExpensesScreen = lazy(() => import('./screens/dashboard-financial-expenses.screen')); +const DashboardFinancialLiquidityScreen = lazy(() => import('./screens/dashboard-financial-liquidity.screen')); setupLanguages(); @@ -406,6 +412,45 @@ export const Routes = [ }, ], }, + { + path: 'dashboard', + children: [ + { + index: true, + element: withSuspense(), + }, + { + path: 'financial', + children: [ + { + index: true, + element: withSuspense(), + }, + { + path: 'live', + element: withSuspense(), + }, + { + path: 'history', + children: [ + { + index: true, + element: withSuspense(), + }, + { + path: 'expenses', + element: withSuspense(), + }, + ], + }, + { + path: 'liquidity', + element: withSuspense(), + }, + ], + }, + ], + }, ], }, ]; diff --git a/src/components/dashboard/balance-by-type-area-chart.tsx b/src/components/dashboard/balance-by-type-area-chart.tsx new file mode 100644 index 000000000..0f82ba135 --- /dev/null +++ b/src/components/dashboard/balance-by-type-area-chart.tsx @@ -0,0 +1,120 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { FinancialLogEntry } from 'src/dto/dashboard.dto'; +import { TimeRange } from 'src/screens/dashboard-financial-history.screen'; + +interface Props { + entries: FinancialLogEntry[]; + timeRange?: TimeRange; +} + +const TYPE_COLORS: Record = { + DEPS: '#3b82f6', + CHF: '#22c55e', + EUR: '#f59e0b', + USD: '#8b5cf6', + BTC: '#f97316', +}; + +const DEFAULT_COLOR = '#6b7280'; + +function useFinancialTypes(entries: FinancialLogEntry[]) { + return useMemo(() => { + const types = new Set(); + for (const entry of entries) { + for (const type of Object.keys(entry.balancesByType)) { + types.add(type); + } + } + return Array.from(types).sort(); + }, [entries]); +} + +function makeOptions(financialTypes: string[], timeRange?: TimeRange): ApexOptions { + return { + chart: { + type: 'area', + stacked: true, + toolbar: { show: true, offsetY: -5 }, + zoom: { enabled: true }, + background: '0', + }, + stroke: { width: 1, curve: 'smooth' }, + colors: financialTypes.map((t) => TYPE_COLORS[t] ?? DEFAULT_COLOR), + dataLabels: { enabled: false }, + grid: { borderColor: '#e5e7eb' }, + xaxis: { + type: 'datetime', + labels: { datetimeUTC: false, format: 'dd MMM yy' }, + ...(timeRange && { min: timeRange.min, max: timeRange.max }), + }, + yaxis: { + title: { text: 'CHF' }, + labels: { + formatter: (val: number) => val >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0), + }, + }, + tooltip: { + x: { format: 'dd MMM yyyy HH:mm' }, + y: { formatter: (val: number) => `${val.toLocaleString('de-CH')} CHF` }, + }, + legend: { position: 'bottom' }, + fill: { type: 'solid', opacity: 0.6 }, + }; +} + +export function BalanceByTypePlusChart({ entries, timeRange }: Props) { + const financialTypes = useFinancialTypes(entries); + const options = useMemo(() => makeOptions(financialTypes, timeRange), [financialTypes, timeRange]); + + const series = useMemo(() => financialTypes.map((type) => ({ + name: type, + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.balancesByType[type]?.plusBalanceChf ?? 0)]), + })), [entries, financialTypes]); + + return ( +
+

Plus Balance by Type

+ +
+ ); +} + +export function BalanceByTypeMinusChart({ entries, timeRange }: Props) { + const financialTypes = useFinancialTypes(entries); + const options = useMemo(() => makeOptions(financialTypes, timeRange), [financialTypes, timeRange]); + + const series = useMemo(() => financialTypes.map((type) => ({ + name: type, + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.balancesByType[type]?.minusBalanceChf ?? 0)]), + })), [entries, financialTypes]); + + return ( +
+

Minus Balance by Type

+ +
+ ); +} + +export function BalanceByTypeTotalChart({ entries, timeRange }: Props) { + const financialTypes = useFinancialTypes(entries); + const options = useMemo(() => makeOptions(financialTypes, timeRange), [financialTypes, timeRange]); + + const series = useMemo(() => financialTypes.map((type) => ({ + name: type, + data: entries.map((e) => { + const b = e.balancesByType[type]; + const net = b ? b.plusBalanceChf - b.minusBalanceChf : 0; + return [new Date(e.timestamp).getTime(), Math.round(net)]; + }), + })), [entries, financialTypes]); + + return ( +
+

Net Balance by Type

+ +
+ ); +} diff --git a/src/components/dashboard/financial-changes-chart.tsx b/src/components/dashboard/financial-changes-chart.tsx new file mode 100644 index 000000000..fc8963d2a --- /dev/null +++ b/src/components/dashboard/financial-changes-chart.tsx @@ -0,0 +1,102 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { FinancialChangesEntry } from 'src/dto/dashboard.dto'; +import { TimeRange } from 'src/screens/dashboard-financial-history.screen'; + +interface FinancialChangesChartProps { + entries: FinancialChangesEntry[]; + timeRange?: TimeRange; +} + +function makeBaseOptions(timeRange?: TimeRange): ApexOptions { + return { + chart: { + type: 'area', + toolbar: { show: true, offsetY: -5 }, + zoom: { enabled: true }, + background: '0', + }, + stroke: { width: 2, curve: 'smooth' }, + dataLabels: { enabled: false }, + fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } }, + grid: { borderColor: '#e5e7eb' }, + xaxis: { + type: 'datetime', + labels: { datetimeUTC: false, format: 'dd MMM yy' }, + ...(timeRange && { min: timeRange.min, max: timeRange.max }), + }, + yaxis: { + title: { text: 'CHF (cumulative)' }, + labels: { + formatter: (val: number) => val >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0), + }, + }, + tooltip: { + x: { format: 'dd MMM yyyy HH:mm' }, + y: { formatter: (val: number) => `${val.toLocaleString('de-CH', { maximumFractionDigits: 0 })} CHF` }, + }, + legend: { position: 'bottom' }, + }; +} + +export function FinancialChangesTotalChart({ entries, timeRange }: FinancialChangesChartProps) { + const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#22c55e'] }), [timeRange]); + + const series = useMemo(() => [ + { name: 'Net Total', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.total)]) }, + ], [entries]); + + return ( +
+

Net Total (cumulative)

+ +
+ ); +} + +export function FinancialChangesPlusChart({ entries, timeRange }: FinancialChangesChartProps) { + const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#3b82f6', '#f97316', '#8b5cf6', '#64748b'] }), [timeRange]); + + const series = useMemo(() => [ + { name: 'BuyCrypto', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.plus.buyCrypto)]) }, + { name: 'BuyFiat', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.plus.buyFiat)]) }, + { name: 'PaymentLink', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.plus.paymentLink)]) }, + { name: 'Trading', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.plus.trading)]) }, + ], [entries]); + + return ( +
+

Income / Plus (cumulative)

+ +
+ ); +} + +interface FinancialChangesMinusChartProps extends FinancialChangesChartProps { + onDetails?: () => void; +} + +export function FinancialChangesMinusChart({ entries, timeRange, onDetails }: FinancialChangesMinusChartProps) { + const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#ef4444', '#f97316', '#64748b'] }), [timeRange]); + + const series = useMemo(() => [ + { name: 'Referral', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.ref.total)]) }, + { name: 'Binance', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.binance.total)]) }, + { name: 'Blockchain', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.blockchain.total)]) }, + ], [entries]); + + return ( +
+
+

Expenses / Minus (cumulative)

+ {onDetails && ( + + )} +
+ +
+ ); +} diff --git a/src/components/dashboard/financial-log-table.tsx b/src/components/dashboard/financial-log-table.tsx new file mode 100644 index 000000000..ceab21540 --- /dev/null +++ b/src/components/dashboard/financial-log-table.tsx @@ -0,0 +1,49 @@ +import { FinancialLogEntry } from 'src/dto/dashboard.dto'; + +interface FinancialLogTableProps { + entries: FinancialLogEntry[]; +} + +function formatChf(value: number): string { + return value.toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +} + +function formatTimestamp(timestamp: string): string { + return new Date(timestamp).toLocaleString('de-CH', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + }); +} + +export function FinancialLogTable({ entries }: FinancialLogTableProps) { + const reversed = [...entries].reverse(); + + return ( +
+ + + + + + + + + + + {reversed.map((entry, i) => ( + + + + + + + ))} + +
Timestamp (UTC)Vermögen (CHF)Plus (CHF)Minus (CHF)
{formatTimestamp(entry.timestamp)}{formatChf(entry.totalBalanceChf)}{formatChf(entry.plusBalanceChf)}{formatChf(entry.minusBalanceChf)}
+
+ ); +} diff --git a/src/components/dashboard/latest-balance-bar-chart.tsx b/src/components/dashboard/latest-balance-bar-chart.tsx new file mode 100644 index 000000000..80512fd95 --- /dev/null +++ b/src/components/dashboard/latest-balance-bar-chart.tsx @@ -0,0 +1,109 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { BalanceByGroup } from 'src/dto/dashboard.dto'; + +interface BalanceBarChartProps { + title: string; + data: BalanceByGroup[]; +} + +const ASSET_COLORS = [ + '#3b82f6', '#22c55e', '#f97316', '#8b5cf6', '#ef4444', + '#06b6d4', '#f59e0b', '#ec4899', '#14b8a6', '#6366f1', + '#84cc16', '#e11d48', '#0ea5e9', '#a855f7', '#64748b', +]; + +export function BalanceBarChart({ title, data }: BalanceBarChartProps) { + const hasAssets = data.some((d) => d.assets && Object.keys(d.assets).length > 0); + + const sorted = useMemo(() => { + const others = data.filter((i) => i.name === 'Other'); + const rest = data.filter((i) => i.name !== 'Other').sort((a, b) => b.netBalanceChf - a.netBalanceChf); + return [...rest, ...others]; + }, [data]); + + if (data.length === 0) return null; + + if (hasAssets) return ; + return ; +} + +function SimpleBarChart({ title, data }: { title: string; data: BalanceByGroup[] }) { + const categories = data.map((i) => i.name); + const values = data.map((i) => Math.round(i.netBalanceChf)); + const colors = data.map((i) => (i.netBalanceChf >= 0 ? '#22c55e' : '#ef4444')); + + const options = useMemo((): ApexOptions => ({ + chart: { type: 'bar', toolbar: { show: false }, background: '0' }, + plotOptions: { bar: { distributed: true, borderRadius: 4 } }, + colors, + dataLabels: { enabled: false }, + grid: { borderColor: '#e5e7eb' }, + xaxis: { categories }, + yaxis: { + title: { text: 'Net Balance (CHF)' }, + labels: { formatter: (val: number) => Math.abs(val) >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0) }, + }, + tooltip: { y: { formatter: (val: number) => `${val.toLocaleString('de-CH')} CHF` } }, + legend: { show: false }, + }), [categories, colors]); + + const series = useMemo(() => [{ name: 'Net Balance', data: values }], [values]); + + return ( +
+

{title}

+ +
+ ); +} + +function StackedBarChart({ title, data }: { title: string; data: BalanceByGroup[] }) { + const { categories, assetNames, series } = useMemo(() => { + const cats = data.map((d) => d.name); + + // Collect all asset names across all blockchains + const assetSet = new Set(); + for (const d of data) { + if (d.assets) Object.keys(d.assets).forEach((a) => assetSet.add(a)); + } + const names = Array.from(assetSet).sort((a, b) => { + if (a === 'Other') return 1; + if (b === 'Other') return -1; + // Sort by total across all blockchains + const totalA = data.reduce((s, d) => s + (d.assets?.[a] ?? 0), 0); + const totalB = data.reduce((s, d) => s + (d.assets?.[b] ?? 0), 0); + return totalB - totalA; + }); + + const seriesData = names.map((assetName) => ({ + name: assetName, + data: cats.map((_, i) => Math.round(data[i].assets?.[assetName] ?? 0)), + })); + + return { categories: cats, assetNames: names, series: seriesData }; + }, [data]); + + const options = useMemo((): ApexOptions => ({ + chart: { type: 'bar', stacked: true, toolbar: { show: false }, background: '0' }, + plotOptions: { bar: { borderRadius: 4, borderRadiusWhenStacked: 'last' as any } }, + colors: assetNames.map((_, i) => ASSET_COLORS[i % ASSET_COLORS.length]), + dataLabels: { enabled: false }, + grid: { borderColor: '#e5e7eb' }, + xaxis: { categories }, + yaxis: { + title: { text: 'Balance (CHF)' }, + labels: { formatter: (val: number) => Math.abs(val) >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0) }, + }, + tooltip: { y: { formatter: (val: number) => `${val.toLocaleString('de-CH')} CHF` } }, + legend: { position: 'bottom' }, + }), [categories, assetNames]); + + return ( +
+

{title}

+ +
+ ); +} diff --git a/src/components/dashboard/total-balance-long-chart.tsx b/src/components/dashboard/total-balance-long-chart.tsx new file mode 100644 index 000000000..a46a878a5 --- /dev/null +++ b/src/components/dashboard/total-balance-long-chart.tsx @@ -0,0 +1,72 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { FinancialLogEntry } from 'src/dto/dashboard.dto'; +import { TimeRange } from 'src/screens/dashboard-financial-history.screen'; + +interface TotalBalanceLongChartProps { + entries: FinancialLogEntry[]; + timeRange?: TimeRange; +} + +export function TotalBalanceLongChart({ entries, timeRange }: TotalBalanceLongChartProps) { + const chartOptions = useMemo((): ApexOptions => { + return { + chart: { + type: 'line', + toolbar: { show: true, offsetY: -5 }, + zoom: { enabled: true }, + background: '0', + }, + stroke: { width: [3, 3], curve: 'smooth' }, + colors: ['#22c55e', '#f97316'], + dataLabels: { enabled: false }, + grid: { borderColor: '#e5e7eb' }, + xaxis: { + type: 'datetime', + labels: { datetimeUTC: false, format: 'dd MMM yy' }, + ...(timeRange && { min: timeRange.min, max: timeRange.max }), + }, + yaxis: [ + { + title: { text: 'Total Balance (CHF)' }, + labels: { + formatter: (val: number) => val >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0), + }, + }, + { + opposite: true, + title: { text: 'BTC Price (CHF)' }, + labels: { + formatter: (val: number) => val >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0), + }, + }, + ], + tooltip: { + x: { format: 'dd MMM yyyy HH:mm' }, + y: { formatter: (val: number) => `${val.toLocaleString('de-CH')} CHF` }, + }, + legend: { position: 'bottom' }, + }; + }, [timeRange]); + + const chartSeries = useMemo(() => { + return [ + { + name: 'Total Balance (CHF)', + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.totalBalanceChf)]), + }, + { + name: 'BTC Price (CHF)', + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.btcPriceChf)]), + }, + ]; + }, [entries]); + + return ( +
+

Total Balance vs BTC Price

+ +
+ ); +} diff --git a/src/components/dashboard/total-balance-short-chart.tsx b/src/components/dashboard/total-balance-short-chart.tsx new file mode 100644 index 000000000..28d7ed2ab --- /dev/null +++ b/src/components/dashboard/total-balance-short-chart.tsx @@ -0,0 +1,82 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { FinancialLogEntry } from 'src/dto/dashboard.dto'; +import { TimeRange } from 'src/screens/dashboard-financial-history.screen'; + +interface Props { + entries: FinancialLogEntry[]; + timeRange?: TimeRange; +} + +function makeBaseOptions(timeRange?: TimeRange): ApexOptions { + return { + chart: { + type: 'line', + toolbar: { show: true, offsetY: -5 }, + zoom: { enabled: true }, + background: '0', + }, + stroke: { width: 2, curve: 'smooth' }, + dataLabels: { enabled: false }, + grid: { borderColor: '#e5e7eb' }, + xaxis: { + type: 'datetime', + labels: { datetimeUTC: false, format: 'dd MMM HH:mm' }, + ...(timeRange && { min: timeRange.min, max: timeRange.max }), + }, + yaxis: { + title: { text: 'CHF' }, + labels: { + formatter: (val: number) => val >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0), + }, + }, + tooltip: { + x: { format: 'dd MMM yyyy HH:mm' }, + y: { formatter: (val: number) => `${val.toLocaleString('de-CH')} CHF` }, + }, + legend: { position: 'bottom' }, + }; +} + +export function ShortTermPlusChart({ entries, timeRange }: Props) { + const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#22c55e'] }), [timeRange]); + const series = useMemo(() => [ + { name: 'Plus Balance', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.plusBalanceChf)]) }, + ], [entries]); + + return ( +
+

Plus Balance

+ +
+ ); +} + +export function ShortTermMinusChart({ entries, timeRange }: Props) { + const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#ef4444'] }), [timeRange]); + const series = useMemo(() => [ + { name: 'Minus Balance', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minusBalanceChf)]) }, + ], [entries]); + + return ( +
+

Minus Balance

+ +
+ ); +} + +export function ShortTermTotalChart({ entries, timeRange }: Props) { + const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#3b82f6'] }), [timeRange]); + const series = useMemo(() => [ + { name: 'Total Balance', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.totalBalanceChf)]) }, + ], [entries]); + + return ( +
+

Total Balance

+ +
+ ); +} diff --git a/src/dto/dashboard.dto.ts b/src/dto/dashboard.dto.ts new file mode 100644 index 000000000..196e1bd26 --- /dev/null +++ b/src/dto/dashboard.dto.ts @@ -0,0 +1,54 @@ +export interface FinancialLogEntry { + timestamp: string; + totalBalanceChf: number; + plusBalanceChf: number; + minusBalanceChf: number; + btcPriceChf: number; + balancesByType: Record; +} + +export interface FinancialLogResponse { + entries: FinancialLogEntry[]; +} + +export interface FinancialChangesEntry { + timestamp: string; + total: number; + plus: { + total: number; + buyCrypto: number; + buyFiat: number; + paymentLink: number; + trading: number; + }; + minus: { + total: number; + ref: { total: number; amount: number; fee: number }; + binance: { total: number; withdraw: number; trading: number }; + blockchain: { total: number; txIn: number; txOut: number; trading: number }; + }; +} + +export interface FinancialChangesResponse { + entries: FinancialChangesEntry[]; +} + +export interface BalanceByGroup { + name: string; + plusBalanceChf: number; + minusBalanceChf: number; + netBalanceChf: number; + assets?: Record; +} + +export interface RefRewardRecipient { + userDataId: number; + count: number; + totalChf: number; +} + +export interface LatestBalanceResponse { + timestamp: string; + byType: BalanceByGroup[]; + byBlockchain: BalanceByGroup[]; +} diff --git a/src/hooks/dashboard.hook.ts b/src/hooks/dashboard.hook.ts new file mode 100644 index 000000000..eb1857d4e --- /dev/null +++ b/src/hooks/dashboard.hook.ts @@ -0,0 +1,64 @@ +import { useApi } from '@dfx.swiss/react'; +import { useMemo } from 'react'; +import { + FinancialChangesEntry, + FinancialChangesResponse, + FinancialLogResponse, + LatestBalanceResponse, + RefRewardRecipient, +} from 'src/dto/dashboard.dto'; + +export function useDashboard() { + const { call } = useApi(); + + async function getFinancialLog(from?: string, dailySample?: boolean): Promise { + const params = new URLSearchParams(); + if (from) params.set('from', from); + if (dailySample !== undefined) params.set('dailySample', String(dailySample)); + const query = params.toString(); + + return call({ + url: `dashboard/financial/log${query ? `?${query}` : ''}`, + method: 'GET', + }); + } + + async function getFinancialChanges(from?: string, dailySample?: boolean): Promise { + const params = new URLSearchParams(); + if (from) params.set('from', from); + if (dailySample !== undefined) params.set('dailySample', String(dailySample)); + const query = params.toString(); + + return call({ + url: `dashboard/financial/changes${query ? `?${query}` : ''}`, + method: 'GET', + }); + } + + async function getLatestBalance(): Promise { + return call({ + url: 'dashboard/financial/latest', + method: 'GET', + }); + } + + async function getLatestChanges(): Promise { + return call({ + url: 'dashboard/financial/changes/latest', + method: 'GET', + }); + } + + async function getRefRecipients(from?: string): Promise { + const query = from ? `?from=${from}` : ''; + return call({ + url: `dashboard/financial/ref-recipients${query}`, + method: 'GET', + }); + } + + return useMemo( + () => ({ getFinancialLog, getFinancialChanges, getLatestBalance, getLatestChanges, getRefRecipients }), + [call], + ); +} diff --git a/src/screens/dashboard-financial-expenses.screen.tsx b/src/screens/dashboard-financial-expenses.screen.tsx new file mode 100644 index 000000000..11e91f62f --- /dev/null +++ b/src/screens/dashboard-financial-expenses.screen.tsx @@ -0,0 +1,175 @@ +import { useSessionContext } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { ApexOptions } from 'apexcharts'; +import { useEffect, useMemo, useState } from 'react'; +import Chart from 'react-apexcharts'; +import { FinancialChangesEntry, RefRewardRecipient } from 'src/dto/dashboard.dto'; + +const KNOWN_USERS: Record = { + 8938: 'Fab', + 187402: 'Cake Wallet', +}; +import { useDashboard } from 'src/hooks/dashboard.hook'; +import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; + +const baseOptions: ApexOptions = { + chart: { type: 'area', toolbar: { show: true, offsetY: -5 }, zoom: { enabled: true }, background: '0' }, + stroke: { width: 2, curve: 'smooth' }, + dataLabels: { enabled: false }, + fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } }, + grid: { borderColor: '#e5e7eb' }, + xaxis: { type: 'datetime', labels: { datetimeUTC: false, format: 'dd MMM yy' } }, + yaxis: { + title: { text: 'CHF (cumulative)' }, + labels: { formatter: (val: number) => (val >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0)) }, + }, + tooltip: { + x: { format: 'dd MMM yyyy HH:mm' }, + y: { formatter: (val: number) => `${val.toLocaleString('de-CH', { maximumFractionDigits: 0 })} CHF` }, + }, + legend: { position: 'bottom' }, +}; + +function RefDetailChart({ entries }: { entries: FinancialChangesEntry[] }) { + const options = useMemo((): ApexOptions => ({ ...baseOptions, colors: ['#ef4444', '#f97316'] }), []); + const series = useMemo( + () => [ + { + name: 'Ref Amount', + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.ref.amount)]), + }, + { name: 'Ref Fee', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.ref.fee)]) }, + ], + [entries], + ); + + return ( +
+

Referral Expenses (cumulative)

+ +
+ ); +} + +function BinanceDetailChart({ entries }: { entries: FinancialChangesEntry[] }) { + const options = useMemo((): ApexOptions => ({ ...baseOptions, colors: ['#f97316', '#8b5cf6'] }), []); + const series = useMemo( + () => [ + { + name: 'Trading', + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.binance.trading)]), + }, + { + name: 'Withdraw', + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.binance.withdraw)]), + }, + ], + [entries], + ); + + return ( +
+

Binance Expenses (cumulative)

+ +
+ ); +} + +function BlockchainDetailChart({ entries }: { entries: FinancialChangesEntry[] }) { + const options = useMemo((): ApexOptions => ({ ...baseOptions, colors: ['#3b82f6', '#ef4444', '#64748b'] }), []); + const series = useMemo( + () => [ + { + name: 'TX Out', + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.blockchain.txOut)]), + }, + { + name: 'TX In', + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.blockchain.txIn)]), + }, + { + name: 'Trading', + data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.blockchain.trading)]), + }, + ], + [entries], + ); + + return ( +
+

Blockchain Expenses (cumulative)

+ +
+ ); +} + +export default function DashboardFinancialExpensesScreen(): JSX.Element { + useAdminGuard(); + useLayoutOptions({ title: 'Expenses Detail', noMaxWidth: true }); + + const { isLoggedIn } = useSessionContext(); + const { getFinancialChanges, getRefRecipients } = useDashboard(); + + const [entries, setEntries] = useState([]); + const [recipients, setRecipients] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!isLoggedIn) return; + + Promise.all([getFinancialChanges(undefined, true), getRefRecipients()]) + .then(([changesData, recipientsData]) => { + setEntries(changesData.entries); + setRecipients(recipientsData); + }) + .finally(() => setIsLoading(false)); + }, [isLoggedIn]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+
+

Referral Recipients

+
+ + + + + + + + + + {recipients.map((r) => ( + + + + + + ))} + +
UserData IDPayoutsTotal (CHF)
{KNOWN_USERS[r.userDataId] ?? r.userDataId}{r.count} + {Number(r.totalChf).toLocaleString('de-CH')} CHF +
+
+
+
+ +
+
+ +
+
+ ); +} diff --git a/src/screens/dashboard-financial-history.screen.tsx b/src/screens/dashboard-financial-history.screen.tsx new file mode 100644 index 000000000..f4948dc78 --- /dev/null +++ b/src/screens/dashboard-financial-history.screen.tsx @@ -0,0 +1,131 @@ +import { useSessionContext } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect, useMemo, useState } from 'react'; +import { BalanceByTypeMinusChart, BalanceByTypePlusChart, BalanceByTypeTotalChart } from 'src/components/dashboard/balance-by-type-area-chart'; +import { FinancialChangesMinusChart, FinancialChangesPlusChart, FinancialChangesTotalChart } from 'src/components/dashboard/financial-changes-chart'; +import { TotalBalanceLongChart } from 'src/components/dashboard/total-balance-long-chart'; +import { ShortTermMinusChart, ShortTermPlusChart, ShortTermTotalChart } from 'src/components/dashboard/total-balance-short-chart'; +import { FinancialChangesEntry, FinancialLogEntry } from 'src/dto/dashboard.dto'; +import { useDashboard } from 'src/hooks/dashboard.hook'; +import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { useNavigation } from 'src/hooks/navigation.hook'; +import { getFromDateByTimeframe, Timeframe } from 'src/util/chart'; + +export interface TimeRange { + min: number; + max: number; +} + +const TIMEFRAME_OPTIONS = [Timeframe.DAY, Timeframe.THREE_DAYS, Timeframe.WEEK, Timeframe.MONTH] as const; + +function isDailySample(timeframe: Timeframe): boolean { + return timeframe === Timeframe.WEEK || timeframe === Timeframe.MONTH; +} + +export default function DashboardFinancialLogScreen(): JSX.Element { + useAdminGuard(); + useLayoutOptions({ title: 'Financial Log', noMaxWidth: true }); + + const { navigate } = useNavigation(); + const { isLoggedIn } = useSessionContext(); + const { getFinancialLog, getFinancialChanges } = useDashboard(); + + const [timeframe, setTimeframe] = useState(Timeframe.DAY); + const [logEntries, setLogEntries] = useState([]); + const [changesEntries, setChangesEntries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!isLoggedIn) return; + + setIsLoading(true); + + const fromTimestamp = getFromDateByTimeframe(timeframe); + const from = fromTimestamp > 0 ? new Date(fromTimestamp).toISOString() : undefined; + const dailySample = isDailySample(timeframe); + + Promise.all([getFinancialLog(from, dailySample), getFinancialChanges(from, dailySample)]) + .then(([logData, changesData]) => { + setLogEntries(logData.entries); + setChangesEntries(changesData.entries); + }) + .finally(() => setIsLoading(false)); + }, [isLoggedIn, timeframe]); + + const timeRange = useMemo((): TimeRange | undefined => { + const allTimestamps = [ + ...logEntries.map((e) => new Date(e.timestamp).getTime()), + ...changesEntries.map((e) => new Date(e.timestamp).getTime()), + ]; + if (allTimestamps.length === 0) return undefined; + return { min: Math.min(...allTimestamps), max: Math.max(...allTimestamps) }; + }, [logEntries, changesEntries]); + + return ( +
+ {/* Timeframe Selector */} +
+ {TIMEFRAME_OPTIONS.map((tf) => ( + + ))} +
+ + {isLoading ? ( +
+ +
+ ) : ( + <> + {/* Fee Income */} +
+ +
+
+ navigate('/dashboard/financial/history/expenses')} /> +
+
+ +
+ + {/* Total Balance vs BTC */} +
+ +
+ + {/* Balance Breakdown */} +
+ +
+
+ +
+
+ +
+ + {/* Balance by Financial Type */} +
+ +
+
+ +
+
+ +
+ + )} +
+ ); +} diff --git a/src/screens/dashboard-financial-liquidity.screen.tsx b/src/screens/dashboard-financial-liquidity.screen.tsx new file mode 100644 index 000000000..e47c04acd --- /dev/null +++ b/src/screens/dashboard-financial-liquidity.screen.tsx @@ -0,0 +1,43 @@ +import { useSessionContext } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect, useState } from 'react'; +import { BalanceBarChart } from 'src/components/dashboard/latest-balance-bar-chart'; +import { LatestBalanceResponse } from 'src/dto/dashboard.dto'; +import { useDashboard } from 'src/hooks/dashboard.hook'; +import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; + +export default function DashboardFinancialLiquidityScreen(): JSX.Element { + useAdminGuard(); + useLayoutOptions({ title: 'Financial Liquidity', noMaxWidth: true }); + + const { isLoggedIn } = useSessionContext(); + const { getLatestBalance } = useDashboard(); + + const [latestBalance, setLatestBalance] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!isLoggedIn) return; + + getLatestBalance() + .then(setLatestBalance) + .finally(() => setIsLoading(false)); + }, [isLoggedIn]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/src/screens/dashboard-financial-live.screen.tsx b/src/screens/dashboard-financial-live.screen.tsx new file mode 100644 index 000000000..c7efa7de1 --- /dev/null +++ b/src/screens/dashboard-financial-live.screen.tsx @@ -0,0 +1,82 @@ +import { useSessionContext } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect, useState } from 'react'; +import { BalanceBarChart } from 'src/components/dashboard/latest-balance-bar-chart'; +import { LatestBalanceResponse } from 'src/dto/dashboard.dto'; +import { useDashboard } from 'src/hooks/dashboard.hook'; +import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; + +function formatChf(value: number): string { + return value.toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +} + +interface SummaryCardProps { + label: string; + value: string; + color?: string; +} + +function SummaryCard({ label, value, color }: SummaryCardProps) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +export default function DashboardFinancialLiveScreen(): JSX.Element { + useAdminGuard(); + useLayoutOptions({ title: 'Financial Live', noMaxWidth: true }); + + const { isLoggedIn } = useSessionContext(); + const { getLatestChanges, getLatestBalance } = useDashboard(); + + const [latestBalance, setLatestBalance] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!isLoggedIn) return; + + Promise.all([getLatestBalance(), getLatestChanges()]) + .then(([balanceData]) => { + setLatestBalance(balanceData); + }) + .finally(() => setIsLoading(false)); + }, [isLoggedIn]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const totalPlus = latestBalance?.byType.reduce((s, t) => s + t.plusBalanceChf, 0) ?? 0; + const totalMinus = latestBalance?.byType.reduce((s, t) => s + t.minusBalanceChf, 0) ?? 0; + + return ( +
+ {/* Summary Cards */} +
+ + + + +
+ +
+ +
+
+ ); +} diff --git a/src/screens/dashboard-financial.screen.tsx b/src/screens/dashboard-financial.screen.tsx new file mode 100644 index 000000000..4c558a054 --- /dev/null +++ b/src/screens/dashboard-financial.screen.tsx @@ -0,0 +1,36 @@ +import { useNavigation } from 'src/hooks/navigation.hook'; +import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; + +const pages = [ + { path: '/dashboard/financial/live', title: 'Live', description: 'Current balances and latest changes' }, + { path: '/dashboard/financial/history', title: 'History', description: 'Balance history over time' }, + { path: '/dashboard/financial/liquidity', title: 'Liquidity', description: 'Balance breakdown by type' }, + { path: '/dashboard/financial/history/expenses', title: 'Expenses', description: 'Revenue and cost details' }, +]; + +export default function DashboardFinancialScreen(): JSX.Element { + useAdminGuard(); + useLayoutOptions({ title: 'Financial Dashboard' }); + + const { navigate } = useNavigation(); + + return ( +
+
+ {pages.map((page) => ( +
navigate(page.path)} + > +
{page.title}
+
+ {page.description} +
+
+ ))} +
+
+ ); +} diff --git a/src/screens/dashboard.screen.tsx b/src/screens/dashboard.screen.tsx new file mode 100644 index 000000000..c1eeb4a9c --- /dev/null +++ b/src/screens/dashboard.screen.tsx @@ -0,0 +1,24 @@ +import { useNavigation } from 'src/hooks/navigation.hook'; +import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; + +export default function DashboardScreen(): JSX.Element { + useAdminGuard(); + useLayoutOptions({ title: 'Dashboard' }); + + const { navigate } = useNavigation(); + + return ( +
+
navigate('/dashboard/financial')} + > +
Financial
+
+ Balance overview, history, liquidity & expenses +
+
+
+ ); +} diff --git a/src/util/chart.ts b/src/util/chart.ts index 21fa1a8a1..e5b0f6047 100644 --- a/src/util/chart.ts +++ b/src/util/chart.ts @@ -1,4 +1,6 @@ export enum Timeframe { + DAY = '24h', + THREE_DAYS = '3D', WEEK = '1W', MONTH = '1M', QUARTER = '1Q', @@ -12,6 +14,10 @@ export function getFromDateByTimeframe(timeframe: Timeframe): number { switch (timeframe) { case Timeframe.ALL: return 0; + case Timeframe.DAY: + return Date.now() - 1 * MILLISECONDS_PER_DAY; + case Timeframe.THREE_DAYS: + return Date.now() - 3 * MILLISECONDS_PER_DAY; case Timeframe.WEEK: return Date.now() - 7 * MILLISECONDS_PER_DAY; case Timeframe.MONTH: From 0970e5cdc7a48101dd3cb3a913c3d3d3e867f339 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:04:30 +0100 Subject: [PATCH 2/4] fix: show all expense categories and fix x-axis format (#1015) * fix: show all expense categories and fix x-axis format in financial charts Add bank and kraken series to the expenses chart so all cost categories are visible. Use time-aware x-axis format (HH:mm for short ranges, dd MMM yy for longer ranges) to avoid repeated date labels in 24h/3D views. * fix: add optional chaining for bank/kraken to prevent runtime errors Ensures backward compatibility with API responses that may not yet include the new bank and kraken fields in the minus object. --- src/components/dashboard/financial-changes-chart.tsx | 10 ++++++++-- src/dto/dashboard.dto.ts | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/financial-changes-chart.tsx b/src/components/dashboard/financial-changes-chart.tsx index fc8963d2a..e84ce59b2 100644 --- a/src/components/dashboard/financial-changes-chart.tsx +++ b/src/components/dashboard/financial-changes-chart.tsx @@ -9,7 +9,11 @@ interface FinancialChangesChartProps { timeRange?: TimeRange; } +const FOUR_DAYS_MS = 4 * 24 * 60 * 60 * 1000; + function makeBaseOptions(timeRange?: TimeRange): ApexOptions { + const isShortRange = timeRange && (timeRange.max - timeRange.min) < FOUR_DAYS_MS; + return { chart: { type: 'area', @@ -23,7 +27,7 @@ function makeBaseOptions(timeRange?: TimeRange): ApexOptions { grid: { borderColor: '#e5e7eb' }, xaxis: { type: 'datetime', - labels: { datetimeUTC: false, format: 'dd MMM yy' }, + labels: { datetimeUTC: false, format: isShortRange ? 'dd MMM HH:mm' : 'dd MMM yy' }, ...(timeRange && { min: timeRange.min, max: timeRange.max }), }, yaxis: { @@ -78,12 +82,14 @@ interface FinancialChangesMinusChartProps extends FinancialChangesChartProps { } export function FinancialChangesMinusChart({ entries, timeRange, onDetails }: FinancialChangesMinusChartProps) { - const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#ef4444', '#f97316', '#64748b'] }), [timeRange]); + const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#ef4444', '#f97316', '#64748b', '#6366f1', '#14b8a6'] }), [timeRange]); const series = useMemo(() => [ { name: 'Referral', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.ref.total)]) }, { name: 'Binance', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.binance.total)]) }, { name: 'Blockchain', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.blockchain.total)]) }, + { name: 'Bank', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.bank ?? 0)]) }, + { name: 'Kraken', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.kraken?.total ?? 0)]) }, ], [entries]); return ( diff --git a/src/dto/dashboard.dto.ts b/src/dto/dashboard.dto.ts index 196e1bd26..84bfd162e 100644 --- a/src/dto/dashboard.dto.ts +++ b/src/dto/dashboard.dto.ts @@ -23,6 +23,8 @@ export interface FinancialChangesEntry { }; minus: { total: number; + bank: number; + kraken: { total: number; withdraw: number; trading: number }; ref: { total: number; amount: number; fee: number }; binance: { total: number; withdraw: number; trading: number }; blockchain: { total: number; txIn: number; txOut: number; trading: number }; From 8cf5ab89611b9888f392f9dbaf40b38420eb1800 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:24:55 +0100 Subject: [PATCH 3/4] Improve custody orders admin page (#1017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: improve custody orders admin page - Merge input/output columns into single Transfer column - Show updated timestamp instead of created - Rename User ID to UserData ID - Use TransactionRequest values as fallback for amounts * fix: reverse transfer display order to output → input --- src/dto/order.dto.ts | 14 ++++----- .../compliance-custody-orders.screen.tsx | 29 ++++++++++--------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/dto/order.dto.ts b/src/dto/order.dto.ts index 230df8a63..2d8bbb666 100644 --- a/src/dto/order.dto.ts +++ b/src/dto/order.dto.ts @@ -71,13 +71,13 @@ export interface CustodyOrderListEntry { id: number; type: CustodyOrderType; status: CustodyOrderStatus; - inputAmount: number; - inputAsset: string; - outputAmount: number; - outputAsset: string; - userId: number; - userName: string; - created: Date; + inputAmount?: number; + inputAsset?: string; + outputAmount?: number; + outputAsset?: string; + userDataId?: number; + userName?: string; + updated: Date; } export interface ExchangeRate { diff --git a/src/screens/compliance-custody-orders.screen.tsx b/src/screens/compliance-custody-orders.screen.tsx index d86a4c483..f5c881ee4 100644 --- a/src/screens/compliance-custody-orders.screen.tsx +++ b/src/screens/compliance-custody-orders.screen.tsx @@ -8,6 +8,15 @@ import { useCompliance } from 'src/hooks/compliance.hook'; import { useAdminGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +function formatTransfer(entry: CustodyOrderListEntry): string { + const input = entry.inputAmount != null && entry.inputAsset ? `${entry.inputAmount} ${entry.inputAsset}` : undefined; + const output = + entry.outputAmount != null && entry.outputAsset ? `${entry.outputAmount} ${entry.outputAsset}` : undefined; + + if (input && output) return `${output} → ${input}`; + return input ?? output ?? '-'; +} + const statusClasses: Record = { [CustodyOrderStatus.CREATED]: 'bg-dfxGray-400 text-dfxBlue-800', [CustodyOrderStatus.CONFIRMED]: 'bg-dfxRed-100/20 text-dfxRed-100', @@ -95,13 +104,10 @@ export default function ComplianceCustodyOrdersScreen(): JSX.Element { Type Status - Input Amount - Input Asset - Output Amount - Output Asset - User ID + Transfer + UserData ID User Name - Created + Updated Actions @@ -111,13 +117,10 @@ export default function ComplianceCustodyOrdersScreen(): JSX.Element { {entry.type} {statusBadge(entry.status)} - {entry.inputAmount} - {entry.inputAsset} - {entry.outputAmount} - {entry.outputAsset} - {entry.userId} + {formatTransfer(entry)} + {entry.userDataId} {entry.userName} - {formatDate(entry.created)} + {formatDate(entry.updated)} {canApprove(entry) && (