payment-kit 1.21.12 → 1.21.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/api/src/crons/payment-stat.ts +31 -23
  2. package/api/src/libs/invoice.ts +29 -4
  3. package/api/src/libs/product.ts +28 -4
  4. package/api/src/routes/checkout-sessions.ts +46 -1
  5. package/api/src/routes/connect/re-stake.ts +2 -0
  6. package/api/src/routes/index.ts +2 -0
  7. package/api/src/routes/invoices.ts +63 -2
  8. package/api/src/routes/payment-stats.ts +244 -22
  9. package/api/src/routes/products.ts +3 -0
  10. package/api/src/routes/subscriptions.ts +2 -1
  11. package/api/src/routes/tax-rates.ts +220 -0
  12. package/api/src/store/migrations/20251001-add-tax-code-to-products.ts +20 -0
  13. package/api/src/store/migrations/20251001-create-tax-rates.ts +17 -0
  14. package/api/src/store/migrations/20251007-relate-tax-rate-to-invoice.ts +24 -0
  15. package/api/src/store/migrations/20251009-add-tax-behavior.ts +21 -0
  16. package/api/src/store/models/index.ts +3 -0
  17. package/api/src/store/models/invoice-item.ts +10 -0
  18. package/api/src/store/models/price.ts +7 -0
  19. package/api/src/store/models/product.ts +7 -0
  20. package/api/src/store/models/tax-rate.ts +352 -0
  21. package/api/tests/models/tax-rate.spec.ts +777 -0
  22. package/blocklet.yml +2 -2
  23. package/package.json +6 -6
  24. package/public/currencies/dollar.png +0 -0
  25. package/src/components/collapse.tsx +3 -2
  26. package/src/components/drawer-form.tsx +2 -1
  27. package/src/components/invoice/list.tsx +38 -1
  28. package/src/components/invoice/table.tsx +48 -2
  29. package/src/components/metadata/form.tsx +2 -2
  30. package/src/components/payment-intent/list.tsx +19 -1
  31. package/src/components/payouts/list.tsx +19 -1
  32. package/src/components/price/currency-select.tsx +105 -48
  33. package/src/components/price/form.tsx +3 -1
  34. package/src/components/product/form.tsx +79 -5
  35. package/src/components/refund/list.tsx +20 -1
  36. package/src/components/subscription/items/actions.tsx +25 -15
  37. package/src/components/subscription/list.tsx +16 -1
  38. package/src/components/tax/actions.tsx +140 -0
  39. package/src/components/tax/filter-toolbar.tsx +230 -0
  40. package/src/components/tax/tax-code-select.tsx +633 -0
  41. package/src/components/tax/tax-rate-form.tsx +177 -0
  42. package/src/components/tax/tax-utils.ts +38 -0
  43. package/src/components/tax/taxCodes.json +10882 -0
  44. package/src/components/uploader.tsx +3 -0
  45. package/src/locales/en.tsx +152 -0
  46. package/src/locales/zh.tsx +149 -0
  47. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  48. package/src/pages/admin/index.tsx +2 -0
  49. package/src/pages/admin/overview.tsx +1114 -322
  50. package/src/pages/admin/products/vendors/index.tsx +4 -2
  51. package/src/pages/admin/tax/create.tsx +104 -0
  52. package/src/pages/admin/tax/detail.tsx +476 -0
  53. package/src/pages/admin/tax/edit.tsx +126 -0
  54. package/src/pages/admin/tax/index.tsx +86 -0
  55. package/src/pages/admin/tax/list.tsx +334 -0
  56. package/src/pages/customer/subscription/change-payment.tsx +1 -1
  57. package/src/pages/home.tsx +6 -3
@@ -1,28 +1,84 @@
1
1
  /* eslint-disable react/require-default-props */
2
- /* eslint-disable no-bitwise */
3
2
  import DID from '@arcblock/ux/lib/DID';
4
3
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
5
4
  import { api, formatBNStr, formatToDate, usePaymentContext } from '@blocklet/payment-react';
5
+ import { BN } from '@ocap/util';
6
6
  import type { GroupedBN, TPaymentMethod, TPaymentStat } from '@blocklet/payment-types';
7
- import { Avatar, Box, Button, Card, Grid, Popover, Stack, Typography } from '@mui/material';
7
+ import {
8
+ Avatar,
9
+ Box,
10
+ Button,
11
+ Card,
12
+ Divider,
13
+ Grid,
14
+ IconButton,
15
+ Popover,
16
+ Skeleton,
17
+ Stack,
18
+ Tab,
19
+ Tabs,
20
+ Typography,
21
+ } from '@mui/material';
22
+ import { alpha, useTheme } from '@mui/material/styles';
8
23
  import { useRequest, useSetState } from 'ahooks';
9
24
  import omit from 'lodash/omit';
10
- import { useEffect } from 'react';
25
+ import { useMemo, useState } from 'react';
26
+ import type { SyntheticEvent } from 'react';
11
27
  import { Link } from 'react-router-dom';
12
28
 
13
- import { ArrowForward } from '@mui/icons-material';
14
- import Chart, { TCurrencyMap } from '../../components/chart';
29
+ import {
30
+ ArrowForward,
31
+ BalanceOutlined,
32
+ CalendarMonth,
33
+ KeyboardArrowDown,
34
+ KeyboardArrowUp,
35
+ LocalMallOutlined,
36
+ PaidOutlined,
37
+ PaymentsOutlined,
38
+ Refresh,
39
+ SellOutlined,
40
+ } from '@mui/icons-material';
41
+ import Chart, { TCurrencyMap, TCurrency } from '../../components/chart';
42
+ import CurrencySelect from '../../components/price/currency-select';
15
43
  import dayjs from '../../libs/dayjs';
16
44
  import { stringToColor } from '../../libs/util';
17
45
  import DateRangePicker from '../../components/date-range-picker';
18
46
 
47
+ type TRevenueStat = {
48
+ totalRevenue: string;
49
+ promotionCost: string;
50
+ vendorCost: string;
51
+ taxedRevenue: string;
52
+ netRevenue: string;
53
+ };
54
+
55
+ type ChartType = 'payments' | 'payouts' | 'refunds';
56
+
57
+ type FilterState = {
58
+ anchorEl: HTMLElement | null;
59
+ startDate: Date;
60
+ endDate: Date;
61
+ currency: string;
62
+ chartType: ChartType;
63
+ };
64
+
19
65
  type TSummary = {
20
66
  balances: GroupedBN;
21
67
  addresses: GroupedBN;
22
68
  links: GroupedBN;
23
69
  summary: { [key: string]: { status: string; count: number }[] };
70
+ revenueByCurrency?: Record<string, TRevenueStat>;
24
71
  };
25
72
 
73
+ type MetricItem = { type: string; count: number; countStatus?: string; total?: number; link: string };
74
+
75
+ const FILTER_ALL = 'all';
76
+ const DEFAULT_CURRENCY_DISPLAY_COUNT = 3;
77
+ const INDICATOR_SKELETON_KEYS = ['slot-a', 'slot-b', 'slot-c'];
78
+ const ADDRESS_SKELETON_KEYS = ['slot-a', 'slot-b'];
79
+ const BALANCE_SKELETON_KEYS = ['slot-a', 'slot-b', 'slot-c'];
80
+ const METRIC_SKELETON_KEYS = ['slot-a', 'slot-b', 'slot-c', 'slot-d'];
81
+
26
82
  export const getDefaultRanges = (date: string) => [
27
83
  {
28
84
  label: 'This Week',
@@ -78,396 +134,1132 @@ export const groupData = (data: TPaymentStat[], currencies: { [key: string]: any
78
134
  return Object.values(grouped);
79
135
  };
80
136
 
137
+ export const isZeroValue = (value: string): boolean => {
138
+ try {
139
+ return new BN(value || '0').isZero();
140
+ } catch (err) {
141
+ return Number(value) === 0;
142
+ }
143
+ };
144
+
145
+ export const sortByMethodAndSymbol = (
146
+ a: { method?: string; symbol: string },
147
+ b: { method?: string; symbol: string }
148
+ ): number => {
149
+ const methodA = a.method || '';
150
+ const methodB = b.method || '';
151
+
152
+ const methodCompare = methodA.localeCompare(methodB);
153
+ if (methodCompare !== 0) {
154
+ return methodCompare;
155
+ }
156
+
157
+ return a.symbol.localeCompare(b.symbol);
158
+ };
159
+
160
+ export const getCurrencyLabel = (currency: TCurrency): string =>
161
+ currency.symbol || ((currency as any).display_name as string | undefined) || currency.name;
162
+
81
163
  export default function Overview() {
82
164
  const { t, locale } = useLocaleContext();
83
165
  const { settings, livemode } = usePaymentContext();
166
+ const theme = useTheme();
84
167
  const maxDate = dayjs().endOf('day').toDate();
85
- const [state, setState] = useSetState({
168
+ const [state, setState] = useSetState<FilterState>({
86
169
  anchorEl: null,
87
170
  startDate: dayjs().subtract(30, 'day').startOf('day').toDate(),
88
171
  endDate: maxDate,
172
+ currency: FILTER_ALL,
173
+ chartType: 'payments',
89
174
  });
90
- const trend = useRequest<TPaymentStat[], any>(async () => {
91
- const result = await api.get('/api/payment-stats', {
92
- params: {
175
+
176
+ const currencies: TCurrencyMap = useMemo(() => {
177
+ const map: TCurrencyMap = {};
178
+ (settings.paymentMethods || []).forEach((method) => {
179
+ (method.payment_currencies || []).forEach((currency) => {
180
+ if (!map[currency.id]) {
181
+ map[currency.id] = {
182
+ ...currency,
183
+ method: omit(method, ['payment_currencies']) as TPaymentMethod,
184
+ color: stringToColor(currency.id),
185
+ };
186
+ }
187
+ });
188
+ });
189
+ return map;
190
+ }, [settings.paymentMethods]);
191
+
192
+ const currencyList = useMemo(() => {
193
+ return Object.values(currencies).sort((a, b) => {
194
+ return (a.symbol || '').localeCompare(b.symbol || '');
195
+ });
196
+ }, [currencies]);
197
+
198
+ const selectedCurrencyIds = useMemo(() => {
199
+ if (state.currency === FILTER_ALL) {
200
+ return currencyList.map((item) => item.id);
201
+ }
202
+ if (state.currency && currencies[state.currency]) {
203
+ return [state.currency];
204
+ }
205
+ return [];
206
+ }, [currencyList, currencies, state.currency]);
207
+
208
+ const filteredCurrencies = useMemo(() => {
209
+ const map: TCurrencyMap = {};
210
+ selectedCurrencyIds.forEach((id) => {
211
+ if (currencies[id]) {
212
+ map[id] = currencies[id];
213
+ }
214
+ });
215
+ return map;
216
+ }, [selectedCurrencyIds, currencies]);
217
+
218
+ const currencyAllOption = useMemo(
219
+ () => ({
220
+ value: FILTER_ALL,
221
+ label: t('admin.overviewPage.filters.allCurrencies'),
222
+ }),
223
+ [t]
224
+ );
225
+
226
+ const currencySelectedTypographySx = {
227
+ justifyContent: 'space-between',
228
+ textAlign: 'left',
229
+ minWidth: 'unset',
230
+ width: '100%',
231
+ fontWeight: 500,
232
+ color: 'text.primary',
233
+ fontSize: '0.875rem',
234
+ } as const;
235
+
236
+ const currencySelectSx = {
237
+ width: '100%',
238
+ height: 32,
239
+ border: 'none',
240
+ '& .MuiOutlinedInput-root': {
241
+ height: 32,
242
+ },
243
+ '& .MuiOutlinedInput-notchedOutline': {
244
+ border: 'none',
245
+ },
246
+ '& .MuiSelect-select': {
247
+ py: 0,
248
+ px: 2,
249
+ height: 32,
250
+ display: 'flex',
251
+ alignItems: 'center',
252
+ fontSize: '12px',
253
+ fontWeight: 400,
254
+ },
255
+ } as const;
256
+
257
+ const indicatorGridSx = {
258
+ display: 'grid',
259
+ gap: 2,
260
+ gridTemplateColumns: {
261
+ xs: 'repeat(1, minmax(0, 1fr))',
262
+ sm: 'repeat(2, minmax(0, 1fr))',
263
+ lg: 'repeat(3, minmax(0, 1fr))',
264
+ },
265
+ } as const;
266
+
267
+ const trend = useRequest<TPaymentStat[], any>(
268
+ async () => {
269
+ const params: { start: number; end: number; currency_id?: string } = {
93
270
  start: dayjs(state.startDate).unix(),
94
271
  end: dayjs(state.endDate).unix(),
272
+ };
273
+ if (state.currency !== FILTER_ALL) {
274
+ params.currency_id = state.currency;
275
+ }
276
+ const result = await api.get('/api/payment-stats', {
277
+ params,
278
+ });
279
+ return result.data.list;
280
+ },
281
+ {
282
+ refreshDeps: [state.startDate, state.endDate, state.currency],
283
+ }
284
+ );
285
+
286
+ const summary = useRequest<TSummary, any>(
287
+ async () => {
288
+ const params: { start: number; end: number; currency_id?: string } = {
289
+ start: dayjs(state.startDate).unix(),
290
+ end: dayjs(state.endDate).unix(),
291
+ };
292
+ if (state.currency !== FILTER_ALL) {
293
+ params.currency_id = state.currency;
294
+ }
295
+ const result = await api.get('/api/payment-stats/summary', {
296
+ params,
297
+ });
298
+ return result.data;
299
+ },
300
+ {
301
+ refreshDeps: [state.startDate, state.endDate, state.currency],
302
+ }
303
+ );
304
+
305
+ const summaryLoading = summary.loading;
306
+ const trendLoading = trend.loading;
307
+ const summaryData = summary.data;
308
+ const summarySummary = summaryData?.summary;
309
+ const addresses = summaryData?.addresses;
310
+ const balances = summaryData?.balances;
311
+ const links = summaryData?.links || {};
312
+ const hasAddresses = Boolean(addresses && Object.keys(addresses).length > 0);
313
+ const hasBalances = Boolean(balances && Object.keys(balances).length > 0);
314
+
315
+ const { metrics, attentions } = useMemo(() => {
316
+ const metricItems: MetricItem[] = [];
317
+ const attentionItems: MetricItem[] = [];
318
+ if (!summarySummary) {
319
+ return { metrics: metricItems, attentions: attentionItems };
320
+ }
321
+ const { subscription, invoice, refund, payment, payout } = summarySummary;
322
+
323
+ metricItems.push(
324
+ {
325
+ type: 'subscription',
326
+ count: subscription?.find((x) => x.status === 'active')?.count || 0,
327
+ countStatus: 'active',
328
+ total: subscription?.reduce((sum, item) => sum + Number(item.count), 0) || 0,
329
+ link: '/admin/billing/subscriptions',
95
330
  },
96
- });
97
- return result.data.list;
98
- });
331
+ {
332
+ type: 'invoice',
333
+ count: invoice?.find((x) => x.status === 'paid')?.count || 0,
334
+ countStatus: 'paid',
335
+ total: invoice?.reduce((sum, item) => sum + Number(item.count), 0) || 0,
336
+ link: '/admin/billing/invoices',
337
+ },
338
+ {
339
+ type: 'refund',
340
+ count: refund?.find((x) => x.status === 'succeeded')?.count || 0,
341
+ countStatus: 'succeeded',
342
+ total: refund?.reduce((sum, item) => sum + Number(item.count), 0) || 0,
343
+ link: '/admin/payments/refunds',
344
+ },
345
+ {
346
+ type: 'paymentIntent',
347
+ count: payment?.find((x) => x.status === 'succeeded')?.count || 0,
348
+ countStatus: 'succeeded',
349
+ total: payment?.reduce((sum, item) => sum + Number(item.count), 0) || 0,
350
+ link: '/admin/payments/intents',
351
+ },
352
+ {
353
+ type: 'payout',
354
+ count: payout?.find((x) => x.status === 'paid')?.count || 0,
355
+ countStatus: 'paid',
356
+ total: payout?.reduce((sum, item) => sum + Number(item.count), 0) || 0,
357
+ link: '/admin/payments/payouts',
358
+ }
359
+ );
99
360
 
100
- const summary = useRequest<TSummary, any>(async () => {
101
- const result = await api.get('/api/payment-stats/summary');
102
- return result.data;
103
- });
361
+ attentionItems.push(
362
+ {
363
+ type: 'subscription',
364
+ count: subscription?.find((x) => x.status === 'past_due')?.count || 0,
365
+ link: '/admin/billing/subscriptions?status=past_due',
366
+ },
367
+ {
368
+ type: 'invoice',
369
+ count: invoice?.find((x) => x.status === 'uncollectible')?.count || 0,
370
+ link: '/admin/billing/invoices?status=uncollectible',
371
+ },
372
+ {
373
+ type: 'refund',
374
+ count: refund?.find((x) => x.status === 'failed')?.count || 0,
375
+ link: '/admin/payments/refunds?status=failed',
376
+ },
377
+ {
378
+ type: 'paymentIntent',
379
+ count: payment?.find((x) => x.status === 'requires_action')?.count || 0,
380
+ link: '/admin/payments/intents?status=requires_action',
381
+ },
382
+ {
383
+ type: 'payout',
384
+ count: payout?.find((x) => x.status === 'failed')?.count || 0,
385
+ link: '/admin/payments/payouts?status=failed',
386
+ }
387
+ );
388
+
389
+ return { metrics: metricItems, attentions: attentionItems };
390
+ }, [summarySummary]);
391
+
392
+ const warnItems = useMemo(() => attentions.filter((x) => x.count > 0), [attentions]);
393
+ const showAddressesCard = summaryLoading || hasAddresses;
394
+ const showBalancesCard = summaryLoading || hasBalances;
395
+ const showMetricsCard = summaryLoading || metrics.length > 0;
396
+
397
+ const payments = useMemo(
398
+ () => groupData(trend.data || [], filteredCurrencies, 'amount_paid', locale),
399
+ [trend.data, filteredCurrencies, locale]
400
+ );
401
+ const payouts = useMemo(
402
+ () => groupData(trend.data || [], filteredCurrencies, 'amount_payout', locale),
403
+ [trend.data, filteredCurrencies, locale]
404
+ );
405
+ const refunds = useMemo(
406
+ () => groupData(trend.data || [], filteredCurrencies, 'amount_refund', locale),
407
+ [trend.data, filteredCurrencies, locale]
408
+ );
104
409
 
105
- useEffect(() => {
106
- if (trend.loading) {
107
- return;
410
+ const chartData = useMemo(() => {
411
+ if (state.chartType === 'payments') {
412
+ return payments;
108
413
  }
109
- if (state.startDate && state.endDate) {
110
- trend.runAsync();
414
+ if (state.chartType === 'payouts') {
415
+ return payouts;
111
416
  }
112
- }, [state.startDate, state.endDate]);
417
+ return refunds;
418
+ }, [state.chartType, payments, payouts, refunds]);
113
419
 
114
- const onTogglePicker = (e: any) => {
115
- if (state.anchorEl) {
116
- setState({ anchorEl: null });
117
- } else {
118
- setState({ anchorEl: e.currentTarget });
119
- }
120
- };
420
+ const [expandedIndicators, setExpandedIndicators] = useState<Record<string, boolean>>({});
121
421
 
122
- const onRangeChange = (range: any) => {
123
- setState({
124
- startDate: range.startDate,
125
- endDate: dayjs(range.endDate).endOf('day').toDate(),
126
- anchorEl: null,
422
+ const revenueByCurrency = useMemo(() => summaryData?.revenueByCurrency || {}, [summaryData?.revenueByCurrency]);
423
+ const displayedCurrencies = useMemo(
424
+ () =>
425
+ selectedCurrencyIds.map((id) => currencies[id]).filter((currency): currency is TCurrency => Boolean(currency)),
426
+ [selectedCurrencyIds, currencies]
427
+ );
428
+
429
+ const sortedBalanceCurrencyIds = useMemo(() => {
430
+ return Object.keys(balances || {}).sort((a, b) => {
431
+ const currencyA = currencies[a];
432
+ const currencyB = currencies[b];
433
+ if (!currencyA || !currencyB) return 0;
434
+ return sortByMethodAndSymbol(
435
+ { method: currencyA.method?.name, symbol: currencyA.symbol || '' },
436
+ { method: currencyB.method?.name, symbol: currencyB.symbol || '' }
437
+ );
127
438
  });
439
+ }, [balances, currencies]);
440
+
441
+ const indicatorConfigs = useMemo(
442
+ () => [
443
+ {
444
+ key: 'totalRevenue' as const,
445
+ title: t('admin.overviewPage.financialIndicators.totalIncome.title'),
446
+ subtitle: t('admin.overviewPage.financialIndicators.totalIncome.subtitle'),
447
+ palette: theme.palette.success,
448
+ icon: <PaymentsOutlined />,
449
+ },
450
+ {
451
+ key: 'vendorCost' as const,
452
+ title: t('admin.overviewPage.financialIndicators.costOfGoods.title'),
453
+ subtitle: t('admin.overviewPage.financialIndicators.costOfGoods.subtitle'),
454
+ palette: theme.palette.error,
455
+ icon: <LocalMallOutlined />,
456
+ },
457
+ {
458
+ key: 'netRevenue' as const,
459
+ title: t('admin.overviewPage.financialIndicators.netRevenue.title'),
460
+ subtitle: t('admin.overviewPage.financialIndicators.netRevenue.subtitle'),
461
+ palette: theme.palette.info,
462
+ icon: <PaidOutlined />,
463
+ },
464
+ {
465
+ key: 'promotionCost' as const,
466
+ title: t('admin.overviewPage.financialIndicators.promotionCost.title'),
467
+ subtitle: t('admin.overviewPage.financialIndicators.promotionCost.subtitle'),
468
+ palette: theme.palette.secondary,
469
+ icon: <SellOutlined />,
470
+ },
471
+ {
472
+ key: 'taxedRevenue' as const,
473
+ title: t('admin.overviewPage.financialIndicators.taxedRevenue.title'),
474
+ subtitle: t('admin.overviewPage.financialIndicators.taxedRevenue.subtitle'),
475
+ palette: theme.palette.primary,
476
+ icon: <BalanceOutlined />,
477
+ },
478
+ ],
479
+ [t, theme]
480
+ );
481
+
482
+ // 辅助函数:构建指标行数据
483
+ const buildIndicatorRow = (currency: TCurrency, config: (typeof indicatorConfigs)[0]) => {
484
+ const value = revenueByCurrency[currency.id]?.[config.key] || '0';
485
+ const info = currencies[currency.id];
486
+ const formattedNumber = info ? formatBNStr(value || '0', info.decimal || 0) : value || '0';
487
+ const symbolLabel = getCurrencyLabel(currency);
488
+ const suffix = info?.symbol || '';
489
+ return {
490
+ id: currency.id,
491
+ symbol: symbolLabel,
492
+ logo: currency.logo,
493
+ value: suffix ? `${formattedNumber} ${suffix}` : formattedNumber,
494
+ number: formattedNumber,
495
+ suffix,
496
+ rawValue: value,
497
+ method: currency.method?.name,
498
+ };
128
499
  };
129
500
 
130
- const open = Boolean(state.anchorEl);
131
- const id = open ? 'date-range-picker-popover' : undefined;
132
- const currencies: TCurrencyMap = {};
133
- for (const method of settings.paymentMethods) {
134
- for (const currency of method.payment_currencies) {
135
- currencies[currency.id] = {
136
- ...currency,
137
- method: omit(method, ['payment_currencies']) as TPaymentMethod,
138
- color: stringToColor(currency.id),
139
- };
140
- }
141
- }
501
+ // 辅助函数:处理始终显示的指标(totalRevenue netRevenue)
502
+ const processAlwaysVisibleIndicator = (rows: ReturnType<typeof buildIndicatorRow>[]) => {
503
+ const baseCurrencyId =
504
+ typeof settings.baseCurrency === 'string' ? settings.baseCurrency : (settings.baseCurrency as any)?.id;
142
505
 
143
- const payments = groupData(trend.data || [], currencies, 'amount_paid', locale);
144
- const payouts = groupData(trend.data || [], currencies, 'amount_payout', locale);
145
- const refunds = groupData(trend.data || [], currencies, 'amount_refund', locale);
146
-
147
- const metrics = [];
148
- const attentions = [];
149
- if (summary.data?.summary) {
150
- const { subscription, invoice, refund, payment, payout } = summary.data.summary;
151
- metrics.push({
152
- type: 'subscription',
153
- count: subscription?.find((x) => x.status === 'active')?.count || 0,
154
- link: '/admin/billing/subscriptions',
155
- });
156
- metrics.push({
157
- type: 'invoice',
158
- count: invoice?.find((x) => x.status === 'paid')?.count || 0,
159
- link: '/admin/billing/invoices',
160
- });
161
- metrics.push({
162
- type: 'refund',
163
- count: refund?.find((x) => x.status === 'succeeded')?.count || 0,
164
- link: '/admin/payments/refunds',
165
- });
166
- metrics.push({
167
- type: 'paymentIntent',
168
- count: payment?.find((x) => x.status === 'succeeded')?.count || 0,
169
- link: '/admin/payments/intents',
170
- });
171
- metrics.push({
172
- type: 'payout',
173
- count: payout?.find((x) => x.status === 'paid')?.count || 0,
174
- link: '/admin/payments/payouts',
175
- });
506
+ const baseCurrencyRow = rows.find((row) => row.id === baseCurrencyId);
507
+ const otherNonZeroRows = rows.filter((row) => row.id !== baseCurrencyId && !isZeroValue(row.rawValue));
176
508
 
177
- attentions.push({
178
- type: 'subscription',
179
- count: subscription?.find((x) => x.status === 'past_due')?.count || 0,
180
- link: '/admin/billing/subscriptions?status=past_due',
181
- });
182
- attentions.push({
183
- type: 'invoice',
184
- count: invoice?.find((x) => x.status === 'uncollectible')?.count || 0,
185
- link: '/admin/billing/invoices?status=uncollectible',
186
- });
187
- attentions.push({
188
- type: 'refund',
189
- count: refund?.find((x) => x.status === 'failed')?.count || 0,
190
- link: '/admin/payments/refunds?status=failed',
191
- });
192
- attentions.push({
193
- type: 'paymentIntent',
194
- count: payment?.find((x) => x.status === 'requires_action')?.count || 0,
195
- link: '/admin/payments/intents?status=requires_action',
196
- });
197
- attentions.push({
198
- type: 'payout',
199
- count: payout?.find((x) => x.status === 'failed')?.count || 0,
200
- link: '/admin/payments/payouts?status=failed',
201
- });
202
- }
509
+ return baseCurrencyRow
510
+ ? [baseCurrencyRow, ...otherNonZeroRows].sort(sortByMethodAndSymbol)
511
+ : otherNonZeroRows.sort(sortByMethodAndSymbol);
512
+ };
203
513
 
204
- const getChainId = (type: string) => {
205
- if (type === 'arcblock') {
206
- return livemode ? 'main' : 'beta';
207
- }
208
- return '';
514
+ const financialIndicators = useMemo(
515
+ () =>
516
+ indicatorConfigs
517
+ .map((config) => {
518
+ const rows = displayedCurrencies.map((currency) => buildIndicatorRow(currency, config));
519
+ const isAlwaysVisible = config.key === 'totalRevenue' || config.key === 'netRevenue';
520
+
521
+ let finalRows;
522
+ if (isAlwaysVisible) {
523
+ finalRows = processAlwaysVisibleIndicator(rows);
524
+ } else {
525
+ const meaningfulRows = rows.filter((row) => !isZeroValue(row.rawValue));
526
+ if (meaningfulRows.length === 0) return null;
527
+ finalRows = meaningfulRows.sort(sortByMethodAndSymbol);
528
+ }
529
+
530
+ return {
531
+ ...config,
532
+ rows: finalRows,
533
+ gradient: `linear-gradient(135deg, ${alpha(config.palette.main, 0.08)} 0%, ${alpha(
534
+ config.palette.main,
535
+ 0.02
536
+ )} 100%)`,
537
+ borderColor: alpha(config.palette.main, 0.24),
538
+ };
539
+ })
540
+ .filter((item): item is NonNullable<typeof item> => Boolean(item)),
541
+ [indicatorConfigs, displayedCurrencies, revenueByCurrency, currencies, settings.baseCurrency]
542
+ );
543
+
544
+ const handleToggleIndicator = (key: string) => {
545
+ setExpandedIndicators((prev) => ({
546
+ ...prev,
547
+ [key]: !prev[key],
548
+ }));
209
549
  };
210
550
 
211
- return (
212
- <Grid
213
- container
551
+ const renderFilterBar = () => (
552
+ <Stack
553
+ direction={{ xs: 'column', md: 'row' }}
554
+ spacing={2}
214
555
  sx={{
215
- gap: { xs: 2, sm: 5, md: 8 },
216
- mb: 4,
556
+ alignItems: { md: 'center' },
557
+ justifyContent: 'space-between',
558
+ flexWrap: 'wrap',
559
+ mb: 2,
217
560
  }}>
218
- <Grid
219
- size={{
220
- xs: 12,
221
- sm: 12,
222
- md: 8,
561
+ <Stack
562
+ direction={{ xs: 'column', sm: 'row' }}
563
+ spacing={2}
564
+ sx={{
565
+ flex: 1,
566
+ alignItems: { sm: 'center' },
567
+ }}>
568
+ <Button
569
+ onClick={onTogglePicker}
570
+ variant="outlined"
571
+ size="small"
572
+ startIcon={<CalendarMonth fontSize="small" />}
573
+ sx={{
574
+ borderRadius: 1.5,
575
+ textTransform: 'none',
576
+ fontWeight: 400,
577
+ px: 2,
578
+ color: 'text.primary',
579
+ borderColor: 'divider',
580
+ '&:hover': {
581
+ borderColor: 'primary.main',
582
+ backgroundColor: 'transparent',
583
+ },
584
+ }}>
585
+ {dateRangeLabel}
586
+ </Button>
587
+ <Box
588
+ sx={{
589
+ width: { xs: '100%', sm: 'auto' },
590
+ minWidth: 140,
591
+ border: '1px solid',
592
+ borderColor: 'divider',
593
+ borderRadius: 1.5,
594
+ height: 32,
595
+ display: 'flex',
596
+ alignItems: 'center',
597
+ px: 2,
598
+ transition: 'border-color 0.2s',
599
+ '&:hover': {
600
+ borderColor: 'primary.main',
601
+ },
602
+ '&:has(.MuiSelect-root)': {
603
+ px: 0,
604
+ },
605
+ }}>
606
+ <CurrencySelect
607
+ mode="selected"
608
+ value={state.currency}
609
+ hasSelected={() => false}
610
+ onSelect={handleCurrencySelect}
611
+ includeAllOption
612
+ allOption={currencyAllOption}
613
+ selectedSx={currencySelectedTypographySx}
614
+ selectSX={currencySelectSx}
615
+ />
616
+ </Box>
617
+ <IconButton
618
+ onClick={handleRefresh}
619
+ size="small"
620
+ sx={{
621
+ border: '1px solid',
622
+ borderColor: 'divider',
623
+ borderRadius: 1.5,
624
+ width: {
625
+ xs: 'fit-content',
626
+ md: 'auto',
627
+ },
628
+ alignSelf: {
629
+ xs: 'flex-end',
630
+ md: 'flex-start',
631
+ },
632
+ '&:hover': {
633
+ borderColor: 'primary.main',
634
+ },
635
+ }}>
636
+ <Refresh fontSize="small" />
637
+ </IconButton>
638
+ </Stack>
639
+ </Stack>
640
+ );
641
+
642
+ const renderAttentionBanner = () => {
643
+ if (warnItems.length === 0) {
644
+ return null;
645
+ }
646
+
647
+ return (
648
+ <Box
649
+ sx={{
650
+ mb: 2,
651
+ p: 1,
652
+ borderRadius: 1,
653
+ backgroundColor: alpha(theme.palette.warning.main, 0.05),
223
654
  }}>
224
655
  <Stack
225
- direction="column"
656
+ direction="row"
657
+ spacing={1}
658
+ useFlexGap
226
659
  sx={{
227
- gap: { xs: 1, sm: 3 },
660
+ alignItems: 'center',
661
+ flexWrap: 'wrap',
228
662
  }}>
229
- <Stack
230
- direction="row"
663
+ <Typography
664
+ variant="body2"
231
665
  sx={{
232
- alignItems: 'flex-end',
233
- justifyContent: 'space-between',
666
+ color: 'text.secondary',
667
+ fontWeight: 500,
668
+ width: {
669
+ xs: '100%',
670
+ sm: 'auto',
671
+ },
234
672
  }}>
235
- <Typography component="h3" variant="h4">
236
- {t('admin.trends')}
237
- </Typography>
238
- <Button onClick={onTogglePicker} variant="outlined" color="secondary">
239
- {formatToDate(state.startDate, locale, 'YYYY-MM-DD')} -{' '}
240
- {formatToDate(state.endDate, locale, 'YYYY-MM-DD')}
673
+ {t('admin.overviewPage.metrics.essential')}:
674
+ </Typography>
675
+ {warnItems.map((metric) => (
676
+ <Button
677
+ key={metric.type}
678
+ component={Link}
679
+ to={metric.link}
680
+ variant="text"
681
+ size="small"
682
+ sx={{
683
+ minWidth: 'auto',
684
+ px: 1.5,
685
+ py: 0.5,
686
+ borderRadius: 0.75,
687
+ textTransform: 'none',
688
+ color: 'warning.main',
689
+ backgroundColor: alpha(theme.palette.warning.main, 0.12),
690
+ fontWeight: 500,
691
+ fontSize: '0.875rem',
692
+ '&:hover': {
693
+ backgroundColor: alpha(theme.palette.warning.main, 0.2),
694
+ },
695
+ }}>
696
+ {t(`admin.${metric.type}.attention`)}: {metric.count}
241
697
  </Button>
242
- </Stack>
243
- <Stack
244
- direction="column"
245
- sx={{
246
- gap: 1,
247
- }}>
248
- <Typography component="h4" variant="subtitle1">
249
- {t('admin.paymentIntent.list')}
698
+ ))}
699
+ </Stack>
700
+ </Box>
701
+ );
702
+ };
703
+
704
+ const renderFinancialIndicatorCards = () => {
705
+ if (summaryLoading) {
706
+ return (
707
+ <Box sx={indicatorGridSx}>
708
+ {indicatorConfigs.map((indicator) => (
709
+ <Card
710
+ key={`${indicator.key}-loading`}
711
+ sx={{
712
+ p: 2,
713
+ borderRadius: 1,
714
+ border: '1px solid',
715
+ borderColor: alpha(indicator.palette.main, 0.12),
716
+ backgroundColor: alpha(indicator.palette.main, 0.02),
717
+ }}>
718
+ <Stack spacing={2}>
719
+ <Stack
720
+ direction="row"
721
+ spacing={1.5}
722
+ sx={{
723
+ alignItems: 'center',
724
+ }}>
725
+ <Skeleton variant="rectangular" width={48} height={48} sx={{ borderRadius: 1 }} />
726
+ <Box sx={{ flex: 1 }}>
727
+ <Skeleton variant="text" width="70%" height={20} />
728
+ <Skeleton variant="text" width="45%" height={16} />
729
+ </Box>
730
+ </Stack>
731
+ <Skeleton variant="rectangular" height={1} />
732
+ <Stack spacing={1.5}>
733
+ {INDICATOR_SKELETON_KEYS.map((placeholder) => (
734
+ <Stack
735
+ key={`indicator-skeleton-${indicator.key}-${placeholder}`}
736
+ direction="row"
737
+ spacing={1}
738
+ sx={{
739
+ alignItems: 'center',
740
+ }}>
741
+ <Skeleton variant="circular" width={24} height={24} />
742
+ <Skeleton variant="text" width="45%" height={18} />
743
+ <Skeleton variant="text" width="30%" height={18} sx={{ marginLeft: 'auto' }} />
744
+ </Stack>
745
+ ))}
746
+ </Stack>
747
+ </Stack>
748
+ </Card>
749
+ ))}
750
+ </Box>
751
+ );
752
+ }
753
+
754
+ if (financialIndicators.length > 0) {
755
+ return (
756
+ <Box sx={indicatorGridSx}>
757
+ {financialIndicators.map((indicator) => {
758
+ const isExpanded = !!expandedIndicators[indicator.key];
759
+ const hasMore = indicator.rows.length > DEFAULT_CURRENCY_DISPLAY_COUNT;
760
+ const rowsToShow = isExpanded ? indicator.rows : indicator.rows.slice(0, DEFAULT_CURRENCY_DISPLAY_COUNT);
761
+
762
+ return (
763
+ <Card
764
+ key={indicator.key}
765
+ sx={{
766
+ p: 2,
767
+ borderRadius: 1,
768
+ backgroundImage: indicator.gradient,
769
+ border: '1px solid',
770
+ borderColor: indicator.borderColor,
771
+ boxShadow: 'none',
772
+ position: 'relative',
773
+ }}>
774
+ <Stack spacing={1.5}>
775
+ <Stack
776
+ direction="row"
777
+ spacing={1}
778
+ sx={{
779
+ alignItems: 'center',
780
+ }}>
781
+ <Box
782
+ sx={{
783
+ width: 48,
784
+ height: 48,
785
+ borderRadius: 1,
786
+ display: 'flex',
787
+ alignItems: 'center',
788
+ justifyContent: 'center',
789
+ backgroundColor: alpha(indicator.palette.main, 0.15),
790
+ }}>
791
+ <Box
792
+ sx={{
793
+ color: indicator.palette.main,
794
+ display: 'flex',
795
+ alignItems: 'center',
796
+ justifyContent: 'center',
797
+ }}>
798
+ {indicator.icon}
799
+ </Box>
800
+ </Box>
801
+ <Box>
802
+ <Typography variant="body1" sx={{ fontWeight: 600 }}>
803
+ {indicator.title}
804
+ </Typography>
805
+ <Typography
806
+ variant="caption"
807
+ sx={{
808
+ color: 'text.secondary',
809
+ }}>
810
+ {indicator.subtitle}
811
+ </Typography>
812
+ </Box>
813
+ </Stack>
814
+
815
+ <Divider />
816
+
817
+ <Stack spacing={2} sx={{ mb: hasMore ? '30px !important' : 0 }}>
818
+ {rowsToShow.map((row) => (
819
+ <Stack
820
+ key={row.id}
821
+ direction="row"
822
+ spacing={1}
823
+ sx={{
824
+ alignItems: 'center',
825
+ }}>
826
+ <Avatar src={row.logo} alt={row.symbol} sx={{ width: 24, height: 24 }} />
827
+ <Box sx={{ flex: 1, minWidth: 0 }}>
828
+ <Typography variant="body2" sx={{ fontWeight: 600 }}>
829
+ {row.method}
830
+ </Typography>
831
+ </Box>
832
+ <Typography
833
+ variant="body2"
834
+ sx={{
835
+ fontWeight: 600,
836
+ textAlign: 'right',
837
+ color: indicator.palette.main,
838
+ }}>
839
+ {row.value}
840
+ </Typography>
841
+ </Stack>
842
+ ))}
843
+ </Stack>
844
+
845
+ {hasMore && (
846
+ <Box
847
+ sx={{
848
+ display: 'flex',
849
+ justifyContent: 'center',
850
+ pt: 0.5,
851
+ position: 'absolute',
852
+ bottom: '10px',
853
+ left: '50%',
854
+ transform: 'translateX(-50%)',
855
+ }}>
856
+ <IconButton
857
+ size="small"
858
+ onClick={() => handleToggleIndicator(indicator.key)}
859
+ sx={{
860
+ color: indicator.palette.main,
861
+ '&:hover': {
862
+ backgroundColor: alpha(indicator.palette.main, 0.1),
863
+ },
864
+ }}>
865
+ {isExpanded ? <KeyboardArrowUp fontSize="small" /> : <KeyboardArrowDown fontSize="small" />}
866
+ </IconButton>
867
+ </Box>
868
+ )}
869
+ </Stack>
870
+ </Card>
871
+ );
872
+ })}
873
+ </Box>
874
+ );
875
+ }
876
+
877
+ return (
878
+ <Typography
879
+ variant="body2"
880
+ sx={{
881
+ color: 'text.secondary',
882
+ }}>
883
+ {t('common.noData')}
884
+ </Typography>
885
+ );
886
+ };
887
+
888
+ const renderTrendCard = () => (
889
+ <Card
890
+ variant="outlined"
891
+ sx={{
892
+ p: { xs: 2, md: 3 },
893
+ borderRadius: 1,
894
+ }}>
895
+ <Stack spacing={2}>
896
+ <Stack
897
+ direction={{ xs: 'column', sm: 'row' }}
898
+ spacing={2}
899
+ sx={{
900
+ alignItems: { sm: 'center' },
901
+ justifyContent: 'space-between',
902
+ }}>
903
+ <Box>
904
+ <Typography variant="h5" sx={{ fontWeight: 600 }}>
905
+ {t('admin.overviewPage.charts.title')}
250
906
  </Typography>
251
- <Chart loading={!trend.data} height={320} data={payments} currencies={currencies} />
252
- </Stack>
253
- <Stack
254
- direction="column"
907
+ </Box>
908
+ <Tabs
909
+ value={state.chartType}
910
+ onChange={handleChartTypeChange}
255
911
  sx={{
256
- gap: 1,
912
+ minHeight: 36,
913
+ border: '1px solid',
914
+ borderColor: 'divider',
915
+ borderRadius: 1,
916
+ p: 0.5,
917
+ '& .MuiTabs-flexContainer': {
918
+ gap: 0.5,
919
+ },
920
+ '& .MuiTab-root': {
921
+ fontSize: '0.875rem',
922
+ px: '8px !important',
923
+ mr: '8px !important',
924
+ textTransform: 'none',
925
+ color: 'text.secondary',
926
+ borderRadius: 1,
927
+ '&.Mui-selected': {
928
+ color: 'primary.main',
929
+ fontSize: '0.875rem !important',
930
+ backgroundColor: alpha(theme.palette.primary.main, 0.08),
931
+ },
932
+ },
933
+ '& .MuiTabs-indicator': {
934
+ display: 'none',
935
+ },
257
936
  }}>
258
- <Typography component="h4" variant="subtitle1">
259
- {t('admin.payouts')}
260
- </Typography>
261
- <Chart loading={!trend.data} height={320} data={payouts} currencies={currencies} />
262
- </Stack>
937
+ <Tab value="payments" label={t('admin.overviewPage.charts.payments')} />
938
+ <Tab value="payouts" label={t('admin.overviewPage.charts.payouts')} />
939
+ <Tab value="refunds" label={t('admin.overviewPage.charts.refunds')} />
940
+ </Tabs>
941
+ </Stack>
942
+ <Chart loading={trendLoading} height={360} data={chartData} currencies={filteredCurrencies} />
943
+ </Stack>
944
+ </Card>
945
+ );
946
+
947
+ const renderFinancialSection = () => (
948
+ <Stack spacing={3}>
949
+ <Typography variant="h5" sx={{ fontWeight: 600 }}>
950
+ {t('admin.overviewPage.financialIndicatorsTitle')}
951
+ </Typography>
952
+ {renderFinancialIndicatorCards()}
953
+ {renderTrendCard()}
954
+ </Stack>
955
+ );
956
+
957
+ const renderBusinessMonitoringSection = () => (
958
+ <Stack direction="column" spacing={3}>
959
+ <Typography variant="h5" sx={{ fontWeight: 600 }}>
960
+ {t('admin.overviewPage.businessMonitoringTitle')}
961
+ </Typography>
962
+ {showAddressesCard && (
963
+ <Card
964
+ variant="outlined"
965
+ sx={{
966
+ p: 2,
967
+ borderRadius: 1,
968
+ }}>
263
969
  <Stack
264
- direction="column"
970
+ direction="row"
265
971
  sx={{
266
- gap: 1,
972
+ alignItems: 'center',
973
+ justifyContent: 'space-between',
267
974
  }}>
268
- <Typography component="h4" variant="subtitle1">
269
- {t('admin.refunds')}
270
- </Typography>
271
- <Chart loading={!trend.data} height={320} data={refunds} currencies={currencies} />
975
+ <Typography variant="h5">{t('admin.addresses')}</Typography>
272
976
  </Stack>
273
- </Stack>
274
- </Grid>
275
- <Grid
276
- size={{
277
- xs: 12,
278
- sm: 12,
279
- md: 3,
280
- }}>
281
- <Stack direction="column" spacing={2}>
282
- {summary.data && summary.data.addresses && (
283
- <Box>
284
- <Stack
285
- direction="row"
286
- sx={{
287
- alignItems: 'flex-end',
288
- justifyContent: 'space-between',
289
- }}>
290
- <Typography component="h3" variant="h4">
291
- {t('admin.addresses')}
292
- </Typography>
293
- </Stack>
294
- <Stack direction="column" spacing={1} sx={{ mt: 2 }}>
295
- {Object.keys(summary.data.addresses).map((chain) => (
296
- <DID
977
+ <Stack direction="column" spacing={1.5} sx={{ mt: 2 }}>
978
+ {summaryLoading
979
+ ? ADDRESS_SKELETON_KEYS.map((placeholder) => (
980
+ <Skeleton key={`address-skeleton-${placeholder}`} variant="rounded" height={40} />
981
+ ))
982
+ : Object.keys(addresses || {}).map((chain) => (
983
+ <Box
297
984
  key={chain}
298
- did={summary.data?.addresses?.[chain] as string}
299
- chainId={getChainId(chain)}
300
- copyable
301
- showQrcode
302
- />
985
+ sx={{
986
+ backgroundColor: theme.mode === 'dark' ? 'grey.100' : 'grey.50',
987
+ p: 1,
988
+ borderRadius: 1.5,
989
+ }}>
990
+ <DID did={addresses?.[chain] as string} chainId={getChainId(chain)} copyable showQrcode />
991
+ </Box>
303
992
  ))}
304
- </Stack>
305
- </Box>
306
- )}
307
- {summary.data && summary.data.balances && (
308
- <Box>
309
- <Stack
310
- direction="row"
311
- sx={{
312
- alignItems: 'flex-end',
313
- justifyContent: 'space-between',
314
- }}>
315
- <Typography component="h3" variant="h4">
316
- {t('admin.balances')}
317
- </Typography>
318
- </Stack>
319
- <Stack direction="column" spacing={1} sx={{ mt: 2 }}>
320
- {Object.keys(summary.data.balances).map((currencyId) => (
321
- <Card
993
+ </Stack>
994
+ </Card>
995
+ )}
996
+ {showBalancesCard && (
997
+ <Card
998
+ variant="outlined"
999
+ sx={{
1000
+ p: 2,
1001
+ borderRadius: 1,
1002
+ }}>
1003
+ <Typography component="h3" variant="h5" sx={{ mb: 2 }}>
1004
+ {t('admin.balances')}
1005
+ </Typography>
1006
+ <Stack spacing={1}>
1007
+ {summaryLoading
1008
+ ? BALANCE_SKELETON_KEYS.map((placeholder) => (
1009
+ <Skeleton key={`balance-skeleton-${placeholder}`} variant="rounded" height={56} />
1010
+ ))
1011
+ : sortedBalanceCurrencyIds.map((currencyId) => (
1012
+ <Stack
322
1013
  key={currencyId}
323
1014
  component="a"
324
- href={summary.data?.links[currencyId] as string}
1015
+ href={links[currencyId] as string}
325
1016
  target="_blank"
326
- variant="outlined"
1017
+ direction="row"
1018
+ spacing={1}
327
1019
  sx={{
328
- padding: 1,
329
- transition: 'all 0.2s ease-in-out',
330
- position: 'relative',
1020
+ alignItems: 'center',
1021
+ textDecoration: 'none',
1022
+ backgroundColor: theme.mode === 'dark' ? 'grey.100' : 'grey.50',
1023
+ color: 'inherit',
1024
+ p: 1,
1025
+ borderRadius: 1.5,
1026
+ transition: 'background-color 0.2s ease-in-out',
331
1027
  '&:hover': {
332
1028
  backgroundColor: 'action.hover',
333
- boxShadow: 2,
334
- '& .MuiSvgIcon-root': {
1029
+ '& .arrow-icon': {
335
1030
  opacity: 1,
336
1031
  transform: 'translateX(0)',
337
1032
  },
338
1033
  },
339
1034
  }}>
340
- <Stack
341
- direction="row"
342
- spacing={1}
343
- sx={{
344
- alignItems: 'center',
345
- }}>
346
- <Avatar
347
- src={currencies[currencyId]?.logo}
348
- alt={currencies[currencyId]?.symbol}
349
- sx={{ width: 36, height: 36 }}
350
- />
351
- <Box
352
- sx={{
353
- flex: 1,
354
- }}>
355
- <Typography variant="body1" component="div" sx={{ fontSize: '1.75rem' }}>
356
- {formatBNStr(
357
- summary.data?.balances?.[currencyId] as string,
358
- currencies[currencyId]?.decimal as number
359
- )}
360
- </Typography>
361
- <Typography
362
- sx={{
363
- color: 'text.secondary',
364
- fontSize: 14,
365
- }}>
366
- {currencies[currencyId]?.symbol} on {currencies[currencyId]?.method.name}
367
- </Typography>
368
- </Box>
369
- <ArrowForward
370
- sx={{
371
- opacity: 0,
372
- transform: 'translateX(-10px)',
373
- transition: 'all 0.2s ease-in-out',
374
- color: 'text.secondary',
375
- }}
376
- />
377
- </Stack>
378
- </Card>
379
- ))}
380
- </Stack>
381
- </Box>
382
- )}
383
- {metrics.length > 0 && (
384
- <Box>
385
- <Stack
386
- direction="row"
387
- sx={{
388
- alignItems: 'flex-end',
389
- justifyContent: 'space-between',
390
- }}>
391
- <Typography component="h3" variant="h4">
392
- {t('admin.metrics')}
393
- </Typography>
394
- </Stack>
395
- <Stack
396
- direction="row"
397
- sx={{
398
- flexWrap: 'wrap',
399
- gap: 1,
400
- mt: 2,
401
- }}>
402
- {metrics.map((metric) => (
403
- <Card
404
- component={Link}
405
- to={metric.link}
406
- key={metric.type}
407
- variant="outlined"
408
- sx={{ padding: 1, width: 0.48 }}>
409
- <Box>
410
- <Typography component="div" sx={{ fontSize: '1.75rem' }}>
411
- {metric.count}
1035
+ <Avatar
1036
+ src={currencies[currencyId]?.logo}
1037
+ alt={currencies[currencyId]?.symbol}
1038
+ sx={{ width: 24, height: 24 }}
1039
+ />
1040
+ <Box sx={{ flex: 1, minWidth: 0 }}>
1041
+ <Typography variant="body2" sx={{ fontWeight: 500 }}>
1042
+ {currencies[currencyId]?.symbol}
412
1043
  </Typography>
413
1044
  <Typography
1045
+ variant="caption"
414
1046
  sx={{
415
1047
  color: 'text.secondary',
416
- fontSize: 14,
417
1048
  }}>
418
- {t(`admin.${metric.type}.name`)}
1049
+ {currencies[currencyId]?.method.name}
419
1050
  </Typography>
420
1051
  </Box>
421
- </Card>
1052
+ <Typography
1053
+ variant="body2"
1054
+ sx={{
1055
+ fontWeight: 600,
1056
+ textAlign: 'right',
1057
+ }}>
1058
+ {formatBNStr((balances?.[currencyId] as string) || '0', currencies[currencyId]?.decimal ?? 0)}
1059
+ </Typography>
1060
+ <ArrowForward
1061
+ className="arrow-icon"
1062
+ fontSize="small"
1063
+ sx={{
1064
+ opacity: 0,
1065
+ transform: 'translateX(-10px)',
1066
+ transition: 'all 0.2s ease-in-out',
1067
+ color: 'text.secondary',
1068
+ }}
1069
+ />
1070
+ </Stack>
422
1071
  ))}
423
- </Stack>
424
- </Box>
425
- )}
426
- {attentions.filter((x) => x.count > 0).length > 0 && (
427
- <Box>
428
- <Stack
429
- direction="row"
430
- sx={{
431
- alignItems: 'flex-end',
432
- justifyContent: 'space-between',
433
- }}>
434
- <Typography component="h3" variant="h4" color="error">
435
- {t('admin.attention')}
436
- </Typography>
437
- </Stack>
438
- <Stack
439
- direction="row"
440
- sx={{
441
- flexWrap: 'wrap',
442
- gap: 1,
443
- mt: 2,
444
- }}>
445
- {attentions.map((metric) => (
1072
+ </Stack>
1073
+ </Card>
1074
+ )}
1075
+ {showMetricsCard && (
1076
+ <Card
1077
+ variant="outlined"
1078
+ sx={{
1079
+ p: 2,
1080
+ borderRadius: 1,
1081
+ }}>
1082
+ <Typography component="h3" variant="h5">
1083
+ {t('admin.overviewPage.metrics.transaction')}
1084
+ </Typography>
1085
+ <Stack
1086
+ direction="row"
1087
+ sx={{
1088
+ flexWrap: 'wrap',
1089
+ gap: 1,
1090
+ mt: 2,
1091
+ }}>
1092
+ {summaryLoading
1093
+ ? METRIC_SKELETON_KEYS.map((placeholder) => (
1094
+ <Skeleton
1095
+ key={`metric-skeleton-${placeholder}`}
1096
+ variant="rounded"
1097
+ height={80}
1098
+ sx={{ flex: '1 1 calc(50% - 8px)' }}
1099
+ />
1100
+ ))
1101
+ : metrics.map((metric) => (
446
1102
  <Card
447
- component={Link}
448
- to={metric.link}
449
1103
  key={metric.type}
450
1104
  variant="outlined"
451
- sx={{ padding: 1, width: 0.48 }}>
1105
+ sx={{
1106
+ p: 1.5,
1107
+ flex: '1 1 calc(50% - 8px)',
1108
+ borderRadius: 1,
1109
+ backgroundColor: theme.mode === 'dark' ? 'grey.100' : 'grey.50',
1110
+ border: 'none',
1111
+ boxShadow: 'none',
1112
+ }}>
452
1113
  <Box>
453
- <Typography component="div" sx={{ fontSize: '1.75rem' }}>
454
- {metric.count}
455
- </Typography>
456
1114
  <Typography
457
1115
  sx={{
458
- color: 'text.secondary',
459
- fontSize: 14,
1116
+ color: 'text.primary',
1117
+ fontSize: '0.875rem',
1118
+ mb: 1,
1119
+ fontWeight: 600,
460
1120
  }}>
461
- {t(`admin.${metric.type}.attention`)}
1121
+ {t(`admin.${metric.type}.name`)}
462
1122
  </Typography>
1123
+ <Stack
1124
+ sx={{
1125
+ gap: 1.5,
1126
+ flexWrap: 'wrap',
1127
+ flexDirection: {
1128
+ xs: 'column',
1129
+ md: 'row',
1130
+ },
1131
+ alignItems: {
1132
+ xs: 'flex-start',
1133
+ md: 'baseline',
1134
+ },
1135
+ }}>
1136
+ <Typography
1137
+ component={Link}
1138
+ to={metric.link}
1139
+ sx={{
1140
+ fontSize: '1.5rem',
1141
+ fontWeight: 600,
1142
+ color: 'text.primary',
1143
+ textDecoration: 'none',
1144
+ cursor: 'pointer',
1145
+ transition: 'color 0.15s ease-in-out',
1146
+ '&:hover': {
1147
+ color: 'primary.main',
1148
+ },
1149
+ }}>
1150
+ {metric.total}
1151
+ </Typography>
1152
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
1153
+ <Typography
1154
+ component="span"
1155
+ sx={{
1156
+ fontSize: '1rem',
1157
+ color: 'text.secondary',
1158
+ }}>
1159
+ {t(`admin.${metric.type}.status.${metric.countStatus}`)}
1160
+ </Typography>
1161
+ <Typography
1162
+ component={Link}
1163
+ to={`${metric.link}?status=${metric.countStatus}`}
1164
+ sx={{
1165
+ fontSize: '1rem',
1166
+ fontWeight: 600,
1167
+ color: 'text.secondary',
1168
+ textDecoration: 'none',
1169
+ cursor: 'pointer',
1170
+ transition: 'color 0.15s ease-in-out',
1171
+ '&:hover': {
1172
+ color: 'success.main',
1173
+ },
1174
+ }}>
1175
+ {metric.count}
1176
+ </Typography>
1177
+ </Box>
1178
+ </Stack>
463
1179
  </Box>
464
1180
  </Card>
465
1181
  ))}
466
- </Stack>
467
- </Box>
468
- )}
469
- </Stack>
1182
+ </Stack>
1183
+ </Card>
1184
+ )}
1185
+ </Stack>
1186
+ );
1187
+
1188
+ const onTogglePicker = (e: any) => {
1189
+ if (state.anchorEl) {
1190
+ setState({ anchorEl: null });
1191
+ } else {
1192
+ setState({ anchorEl: e.currentTarget });
1193
+ }
1194
+ };
1195
+
1196
+ const onRangeChange = (range: any) => {
1197
+ setState({
1198
+ startDate: range.startDate,
1199
+ endDate: dayjs(range.endDate).endOf('day').toDate(),
1200
+ anchorEl: null,
1201
+ });
1202
+ };
1203
+
1204
+ const handleChartTypeChange = (_: SyntheticEvent, value: ChartType) => {
1205
+ if (value) {
1206
+ setState({ chartType: value });
1207
+ }
1208
+ };
1209
+
1210
+ const handleCurrencySelect = (currencyId: string) => {
1211
+ setState({ currency: currencyId });
1212
+ };
1213
+
1214
+ const handleRefresh = () => {
1215
+ trend.run();
1216
+ summary.run();
1217
+ };
1218
+
1219
+ const open = Boolean(state.anchorEl);
1220
+ const id = open ? 'date-range-picker-popover' : undefined;
1221
+
1222
+ const dateRangeLabel = `${formatToDate(state.startDate, locale, 'YYYY-MM-DD')} - ${formatToDate(
1223
+ state.endDate,
1224
+ locale,
1225
+ 'YYYY-MM-DD'
1226
+ )}`;
1227
+
1228
+ const getChainId = (type: string) => {
1229
+ if (type === 'arcblock') {
1230
+ return livemode ? 'main' : 'beta';
1231
+ }
1232
+ return '';
1233
+ };
1234
+
1235
+ return (
1236
+ <>
1237
+ {renderFilterBar()}
1238
+ {renderAttentionBanner()}
1239
+
1240
+ <Grid
1241
+ container
1242
+ sx={{
1243
+ gap: { xs: 3, md: 6 },
1244
+ mb: 4,
1245
+ }}>
1246
+ <Grid
1247
+ size={{
1248
+ xs: 12,
1249
+ md: 8,
1250
+ }}>
1251
+ {renderFinancialSection()}
1252
+ </Grid>
1253
+
1254
+ <Grid
1255
+ size={{
1256
+ xs: 12,
1257
+ md: 3,
1258
+ }}>
1259
+ {renderBusinessMonitoringSection()}
1260
+ </Grid>
470
1261
  </Grid>
1262
+
471
1263
  <Popover
472
1264
  id={id}
473
1265
  open={open}
@@ -482,6 +1274,6 @@ export default function Overview() {
482
1274
  onChange={onRangeChange}
483
1275
  />
484
1276
  </Popover>
485
- </Grid>
1277
+ </>
486
1278
  );
487
1279
  }