diff --git a/package-lock.json b/package-lock.json index 381c7a34..a070f318 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.4", "license": "MIT", "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.256", - "@dfx.swiss/react-components": "^1.3.0-beta.256", + "@dfx.swiss/react": "^1.3.0-beta.261", + "@dfx.swiss/react-components": "^1.3.0-beta.261", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", "@ledgerhq/hw-transport-webhid": "^6.27.19", @@ -2632,9 +2632,9 @@ } }, "node_modules/@dfx.swiss/react": { - "version": "1.3.0-beta.258", - "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.0-beta.258.tgz", - "integrity": "sha512-3E8bWyi7iFzxty95V9M2ZA2FL6w3rF10g77vRKdZ3oc34B00LBvhOoPq5rR7wcYtEjy4KSPQoWaB4T9c/CiJUg==", + "version": "1.3.0-beta.261", + "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.0-beta.261.tgz", + "integrity": "sha512-hBPdX4kXLukkLJVGxotTOtcyJmFjSy9eQzkvIoDlm3UcL8z0bIEw7A21Vwu3pznUPlwFhfCLe3MKb8Wkde+jCw==", "license": "MIT", "dependencies": { "ibantools": "^4.2.1", @@ -2645,9 +2645,9 @@ } }, "node_modules/@dfx.swiss/react-components": { - "version": "1.3.0-beta.258", - "resolved": "https://registry.npmjs.org/@dfx.swiss/react-components/-/react-components-1.3.0-beta.258.tgz", - "integrity": "sha512-+IIGmTXUvX86v+48fW3q54pEXkPeHE1cqI08BJSqgMOHiNfyOVpSlLr67WjkIij6kACF979cx2+EGgMbANkO2Q==", + "version": "1.3.0-beta.261", + "resolved": "https://registry.npmjs.org/@dfx.swiss/react-components/-/react-components-1.3.0-beta.261.tgz", + "integrity": "sha512-BCGfKstYP05I7EPpmm2S5qMi8DV34S+BwsNpCH/ACWTNyfD/4/5bFFTvIRocwCHbVtlIbLHG9M3FLfq71gfPYg==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.18.1", diff --git a/package.json b/package.json index 5bc2231a..2752cdf4 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "access": "public" }, "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.256", - "@dfx.swiss/react-components": "^1.3.0-beta.256", + "@dfx.swiss/react": "^1.3.0-beta.261", + "@dfx.swiss/react-components": "^1.3.0-beta.261", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", "@ledgerhq/hw-transport-webhid": "^6.27.19", diff --git a/src/App.tsx b/src/App.tsx index 50ef5174..e8751038 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 00000000..0f82ba13 --- /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 00000000..e84ce59b --- /dev/null +++ b/src/components/dashboard/financial-changes-chart.tsx @@ -0,0 +1,108 @@ +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; +} + +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', + 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: isShortRange ? 'dd MMM HH:mm' : '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', '#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 ( +
+
+

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 00000000..ceab2154 --- /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 00000000..80512fd9 --- /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 00000000..a46a878a --- /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 00000000..28d7ed2a --- /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/components/exchange-rate.tsx b/src/components/exchange-rate.tsx index c499cb80..6cc62b10 100644 --- a/src/components/exchange-rate.tsx +++ b/src/components/exchange-rate.tsx @@ -42,7 +42,7 @@ export function ExchangeRate({ const networkFee = `${fees.network}${feeSymbol}`; const bankFixedFee = type !== TransactionType.SWAP && fees.bankFixed ? `${fees.bankFixed}${feeSymbol}` : undefined; const bankPercentFee = - type !== TransactionType.SWAP && fees.bankPercent ? `${fees.bankPercent}${feeSymbol}` : undefined; + type !== TransactionType.SWAP && fees.bankVariable ? `${fees.bankVariable}${feeSymbol}` : undefined; const bankFee = type !== TransactionType.SWAP && !bankFixedFee && !bankPercentFee && fees.bank ? `${fees.bank}${feeSymbol}` : false; const networkStartFee = fees?.networkStart ? `${fees?.networkStart}${feeSymbol}` : undefined; diff --git a/src/dto/dashboard.dto.ts b/src/dto/dashboard.dto.ts new file mode 100644 index 00000000..84bfd162 --- /dev/null +++ b/src/dto/dashboard.dto.ts @@ -0,0 +1,56 @@ +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; + 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 }; + }; +} + +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/dto/order.dto.ts b/src/dto/order.dto.ts index 230df8a6..2d8bbb66 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/hooks/dashboard.hook.ts b/src/hooks/dashboard.hook.ts new file mode 100644 index 00000000..eb1857d4 --- /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/compliance-custody-orders.screen.tsx b/src/screens/compliance-custody-orders.screen.tsx index d86a4c48..f5c881ee 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) && ( + ))} + + + {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 00000000..e47c04ac --- /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 00000000..c7efa7de --- /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 00000000..4c558a05 --- /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 00000000..c1eeb4a9 --- /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/screens/settings.screen.tsx b/src/screens/settings.screen.tsx index 452f37fd..c3f82183 100644 --- a/src/screens/settings.screen.tsx +++ b/src/screens/settings.screen.tsx @@ -346,11 +346,16 @@ export default function SettingsScreen(): JSX.Element { )} - setOverlayType(OverlayType.DELETE_ACCOUNT)} - /> + +

+ {translate('screens/settings', 'Danger Zone')} +

+ setOverlayType(OverlayType.DELETE_ACCOUNT)} + /> +
)} diff --git a/src/screens/transaction.screen.tsx b/src/screens/transaction.screen.tsx index 9c80735d..f222666e 100644 --- a/src/screens/transaction.screen.tsx +++ b/src/screens/transaction.screen.tsx @@ -394,7 +394,7 @@ function TransactionRefund({ setError }: TransactionRefundProps): JSX.Element { const refundName = showIbanOverride ? data.creditorName : (refundDetails?.bankDetails?.name ?? data.creditorName); await setTransactionRefundTarget(transaction.id, { - refundTarget: showIbanOverride ? formTarget : undefined, + refundTarget: showIbanOverride || !isBuy ? formTarget : undefined, creditorData: isBankRefund ? { name: refundName, @@ -1026,14 +1026,14 @@ export function TxInfo({ tx, showUserDetails }: TxInfoProps): JSX.Element { label: translate('screens/payment', 'Bank fee (fixed)'), text: `${tx.fees.bankFixed} ${tx.inputAsset}`, }); - tx.fees?.bankPercent != null && + tx.fees?.bankVariable != null && rateItems.push({ label: translate('screens/payment', 'Bank fee (percent)'), - text: `${tx.fees.bankPercent} ${tx.inputAsset}`, + text: `${tx.fees.bankVariable} ${tx.inputAsset}`, }); tx.fees?.bank != null && tx.fees?.bankFixed == null && - tx.fees?.bankPercent == null && + tx.fees?.bankVariable == null && rateItems.push({ label: translate('screens/payment', 'Bank fee'), text: `${tx.fees.bank} ${tx.inputAsset}`, diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index 58a64d22..d22820e6 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -989,7 +989,9 @@ "Yes, call me": "Ja, ruft mich an", "No, don't call me": "Nein, ruft mich nicht an", "Verification may require a phone call. Should we call you?": "Die Verifizierung kann einen Anruf erfordern. Sollen wir Dich anrufen?", - "Phone verification": "Telefonische Verifizierung" + "Phone verification": "Telefonische Verifizierung", + + "Danger Zone": "Gefahrenzone" }, "screens/safe": { "My DFX Safe": "Mein DFX Safe", diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json index 7f26a644..55a37e05 100644 --- a/src/translations/languages/fr.json +++ b/src/translations/languages/fr.json @@ -988,7 +988,9 @@ "Yes, call me": "Oui, appelez-moi", "No, don't call me": "Non, ne m'appelez pas", "Verification may require a phone call. Should we call you?": "La vérification peut nécessiter un appel téléphonique. Devons-nous vous appeler?", - "Phone verification": "Vérification téléphonique" + "Phone verification": "Vérification téléphonique", + + "Danger Zone": "Zone de danger" }, "screens/safe": { "My DFX Safe": "Mon DFX Safe", diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json index fddcd4b3..85315ac5 100644 --- a/src/translations/languages/it.json +++ b/src/translations/languages/it.json @@ -988,7 +988,9 @@ "Yes, call me": "Sì, chiamatemi", "No, don't call me": "No, non chiamatemi", "Verification may require a phone call. Should we call you?": "La verifica potrebbe richiedere una telefonata. Dobbiamo chiamarti?", - "Phone verification": "Verifica telefonica" + "Phone verification": "Verifica telefonica", + + "Danger Zone": "Zona pericolosa" }, "screens/safe": { "My DFX Safe": "Il mio DFX Safe", diff --git a/src/util/chart.ts b/src/util/chart.ts index 21fa1a8a..e5b0f604 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: