payment-kit 1.24.2 → 1.24.4

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.
@@ -0,0 +1,289 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import { api, formatBNStr, formatCreditAmount, Table, useDefaultPageSize } from '@blocklet/payment-react';
4
+ import type { TCustomer, TPaymentCurrency } from '@blocklet/payment-types';
5
+ import { Add, Close } from '@mui/icons-material';
6
+ import { Box, Button, CircularProgress, Menu, MenuItem, styled } from '@mui/material';
7
+ import { useEffect, useState } from 'react';
8
+ import { Link, useSearchParams } from 'react-router-dom';
9
+ import CustomerLink from '../../../../components/customer/link';
10
+
11
+ type OverdueRecord = {
12
+ customer: TCustomer;
13
+ currency: TPaymentCurrency;
14
+ total_pending: string;
15
+ event_count: number;
16
+ };
17
+
18
+ type SearchProps = {
19
+ pageSize: number;
20
+ page: number;
21
+ currency_id: string;
22
+ q?: string;
23
+ };
24
+
25
+ type ApiResponse = {
26
+ list: OverdueRecord[];
27
+ count: number;
28
+ paging: { page: number; pageSize: number };
29
+ };
30
+
31
+ const fetchData = (params: Record<string, any> = {}): Promise<ApiResponse> => {
32
+ const search = new URLSearchParams();
33
+ Object.keys(params).forEach((key) => {
34
+ const v = params[key];
35
+ if (v !== undefined && v !== null && v !== '') {
36
+ search.set(key, String(v));
37
+ }
38
+ });
39
+ return api.get(`/api/meter-events/overdue-customers?${search.toString()}`).then((res) => res.data);
40
+ };
41
+
42
+ const fetchCreditCurrencies = (): Promise<TPaymentCurrency[]> => {
43
+ return api.get('/api/payment-currencies?credit=true').then((res) => {
44
+ // Filter to only return credit type currencies
45
+ return (res.data || []).filter((c: TPaymentCurrency) => c.type === 'credit');
46
+ });
47
+ };
48
+
49
+ export default function OverdueList() {
50
+ const listKey = 'overdue-customers';
51
+
52
+ const { t } = useLocaleContext();
53
+ const defaultPageSize = useDefaultPageSize(20);
54
+ const [searchParams, setSearchParams] = useSearchParams();
55
+
56
+ const [currencies, setCurrencies] = useState<TPaymentCurrency[]>([]);
57
+ const [search, setSearch] = useState<SearchProps>({
58
+ pageSize: defaultPageSize,
59
+ page: 1,
60
+ currency_id: '',
61
+ });
62
+ const [data, setData] = useState<ApiResponse | null>(null);
63
+ const [loading, setLoading] = useState(true);
64
+
65
+ // Load credit currencies on mount
66
+ useEffect(() => {
67
+ fetchCreditCurrencies().then((list) => {
68
+ setCurrencies(list);
69
+ // Check if currency_id is provided in URL
70
+ const urlCurrencyId = searchParams.get('currency_id');
71
+ if (urlCurrencyId && list.some((c) => c.id === urlCurrencyId)) {
72
+ // Use URL parameter and clear it from URL
73
+ setSearch((prev: any) => ({ ...prev, currency_id: urlCurrencyId, page: 1 }));
74
+ setSearchParams({}, { replace: true });
75
+ }
76
+ // Do not auto-select first currency, keep empty by default
77
+ });
78
+ }, []);
79
+
80
+ useEffect(() => {
81
+ // Fetch data regardless of currency_id - API supports empty currency_id to return all
82
+ setLoading(true);
83
+ fetchData(search)
84
+ .then((res) => setData(res))
85
+ .finally(() => setLoading(false));
86
+ }, [search]);
87
+
88
+ const columns = [
89
+ {
90
+ label: t('common.customer'),
91
+ name: 'customer',
92
+ options: {
93
+ customBodyRenderLite: (_: string, index: number) => {
94
+ const item = data?.list[index] as OverdueRecord | undefined;
95
+ if (!item) return null;
96
+ return <CustomerLink customer={item.customer} />;
97
+ },
98
+ },
99
+ },
100
+ {
101
+ label: t('admin.overdue.selectCurrency'),
102
+ name: 'currency',
103
+ options: {
104
+ customBodyRenderLite: (_: string, index: number) => {
105
+ const item = data?.list[index] as OverdueRecord | undefined;
106
+ if (!item?.currency) return null;
107
+ return item.currency.symbol;
108
+ },
109
+ },
110
+ },
111
+ {
112
+ label: t('admin.overdue.pendingAmount'),
113
+ name: 'total_pending',
114
+ options: {
115
+ customBodyRenderLite: (_: string, index: number) => {
116
+ const item = data?.list[index] as OverdueRecord | undefined;
117
+ if (!item) return null;
118
+ const { currency } = item;
119
+ return (
120
+ <Box component="span" sx={{ fontWeight: 600, color: 'error.main' }}>
121
+ -{formatCreditAmount(formatBNStr(item.total_pending, currency?.decimal || 18), currency?.symbol || '')}
122
+ </Box>
123
+ );
124
+ },
125
+ },
126
+ },
127
+ {
128
+ label: t('admin.overdue.eventCount'),
129
+ name: 'event_count',
130
+ options: {
131
+ customBodyRenderLite: (_: string, index: number) => {
132
+ const item = data?.list[index] as OverdueRecord | undefined;
133
+ if (!item) return null;
134
+ if (!item.customer?.id) {
135
+ return item.event_count;
136
+ }
137
+
138
+ const params = new URLSearchParams({
139
+ customer_id: item.customer.id,
140
+ status: 'requires_capture,requires_action',
141
+ });
142
+
143
+ return <Link to={`/admin/billing/meter-events?${params.toString()}`}>{item.event_count}</Link>;
144
+ },
145
+ },
146
+ },
147
+ ];
148
+
149
+ const onTableChange = ({ page, rowsPerPage }: any) => {
150
+ if (search!.pageSize !== rowsPerPage) {
151
+ setSearch((x: any) => ({ ...x, pageSize: rowsPerPage, page: 1 }));
152
+ } else if (search!.page !== page + 1) {
153
+ setSearch((x: any) => ({ ...x, page: page + 1 }));
154
+ }
155
+ };
156
+
157
+ const handleCurrencyChange = (currencyId: string) => {
158
+ setSearch((prev: any) => ({ ...prev, currency_id: currencyId, page: 1 }));
159
+ };
160
+
161
+ if (loading && !data) {
162
+ return <CircularProgress />;
163
+ }
164
+
165
+ return (
166
+ <Table
167
+ data={data?.list || []}
168
+ durable={`__${listKey}__`}
169
+ durableKeys={['page', 'rowsPerPage', 'searchText']}
170
+ columns={columns}
171
+ loading={loading}
172
+ onChange={onTableChange}
173
+ options={{
174
+ count: data?.count || 0,
175
+ page: search!.page - 1,
176
+ rowsPerPage: search!.pageSize,
177
+ onSearchChange: (text: string) => {
178
+ setSearch((prev: any) => ({
179
+ ...prev,
180
+ q: text || undefined,
181
+ page: 1,
182
+ }));
183
+ },
184
+ }}
185
+ title={
186
+ <CurrencyFilter
187
+ currencies={currencies}
188
+ selectedCurrencyId={search?.currency_id || ''}
189
+ onChange={handleCurrencyChange}
190
+ />
191
+ }
192
+ emptyNodeText={t('admin.overdue.noOverdue')}
193
+ />
194
+ );
195
+ }
196
+
197
+ function CurrencyFilter({
198
+ currencies,
199
+ selectedCurrencyId,
200
+ onChange,
201
+ }: {
202
+ currencies: TPaymentCurrency[];
203
+ selectedCurrencyId: string;
204
+ onChange: (currencyId: string) => void;
205
+ }) {
206
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
207
+ const { t } = useLocaleContext();
208
+ const selectedCurrency = currencies.find((c) => c.id === selectedCurrencyId);
209
+
210
+ return (
211
+ <Root>
212
+ <Box className="table-toolbar-left">
213
+ <section>
214
+ <Button className="option-btn" variant="text" onClick={(e) => setAnchorEl(e.currentTarget)}>
215
+ {selectedCurrencyId ? (
216
+ <Close
217
+ sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }}
218
+ onClick={(e) => {
219
+ e.stopPropagation();
220
+ onChange('');
221
+ setAnchorEl(null);
222
+ }}
223
+ />
224
+ ) : (
225
+ <Add sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }} />
226
+ )}
227
+ {t('admin.overdue.selectCurrency')}
228
+ <span>{selectedCurrency?.symbol}</span>
229
+ </Button>
230
+ <Menu
231
+ anchorEl={anchorEl}
232
+ open={Boolean(anchorEl)}
233
+ onClose={() => setAnchorEl(null)}
234
+ className="status-options">
235
+ {currencies.map((currency) => (
236
+ <MenuItem
237
+ key={currency.id}
238
+ onClick={() => {
239
+ onChange(currency.id);
240
+ setAnchorEl(null);
241
+ }}>
242
+ {currency.name} ({currency.symbol})
243
+ </MenuItem>
244
+ ))}
245
+ </Menu>
246
+ </section>
247
+ </Box>
248
+ </Root>
249
+ );
250
+ }
251
+
252
+ const Root = styled(Box)`
253
+ .table-toolbar-left {
254
+ display: flex;
255
+ align-items: center;
256
+ }
257
+
258
+ .table-toolbar-left section {
259
+ position: relative;
260
+ list-style: none;
261
+ font-size: 14px;
262
+ font-weight: normal;
263
+ padding: 0 5px;
264
+ cursor: pointer;
265
+ }
266
+
267
+ .option-btn {
268
+ display: flex;
269
+ align-items: center;
270
+ border-radius: 25px;
271
+ background: ${({ theme }) => theme.palette.grey[100]};
272
+ padding: 5px 10px;
273
+ color: ${({ theme }) => theme.palette.text.secondary};
274
+ font-size: 14px;
275
+ line-height: 14px;
276
+ overflow: visible;
277
+ }
278
+
279
+ .option-btn span {
280
+ color: #3773f2;
281
+ padding: 0 3px;
282
+ overflow: visible;
283
+ }
284
+
285
+ .status-options {
286
+ max-height: 300px;
287
+ overflow-y: auto;
288
+ }
289
+ `;
@@ -3,6 +3,7 @@ import {
3
3
  api,
4
4
  formatBNStr,
5
5
  formatError,
6
+ formatTime,
6
7
  getCustomerAvatar,
7
8
  TxLink,
8
9
  SourceDataViewer,
@@ -295,6 +296,20 @@ export default function AdminCreditTransactionDetail({ id }: { id: string }) {
295
296
  }
296
297
  />
297
298
  )}
299
+ <InfoRow
300
+ label={t('admin.meterEvent.reportedAt')}
301
+ value={formatTime(
302
+ data.meterEvent?.timestamp
303
+ ? data.meterEvent.timestamp * 1000
304
+ : data.meterEvent?.created_at || data.created_at
305
+ )}
306
+ />
307
+ <InfoRow
308
+ label={t('admin.meterEvent.processedAt')}
309
+ value={formatTime(
310
+ data.meterEvent?.processed_at ? data.meterEvent.processed_at * 1000 : data.created_at
311
+ )}
312
+ />
298
313
  </InfoRowGroup>
299
314
  </Box>
300
315
 
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable react/require-default-props */
2
2
  import DID from '@arcblock/ux/lib/DID';
3
3
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
- import { api, formatBNStr, formatToDate, usePaymentContext } from '@blocklet/payment-react';
4
+ import { api, formatBNStr, formatCreditAmount, formatToDate, usePaymentContext } from '@blocklet/payment-react';
5
5
  import { BN } from '@ocap/util';
6
6
  import type { GroupedBN, TPaymentCurrency, TPaymentMethod, TPaymentStat } from '@blocklet/payment-types';
7
7
  import {
@@ -37,6 +37,7 @@ import {
37
37
  PaymentsOutlined,
38
38
  Refresh,
39
39
  SellOutlined,
40
+ WarningAmber,
40
41
  } from '@mui/icons-material';
41
42
  import Chart, { TCurrencyMap, TCurrency } from '../../components/chart';
42
43
  import CurrencySelect from '../../components/price/currency-select';
@@ -302,8 +303,31 @@ export default function Overview() {
302
303
  }
303
304
  );
304
305
 
306
+ const overdueSummary = useRequest<
307
+ {
308
+ list: Array<{
309
+ currency: TPaymentCurrency;
310
+ total_pending: string;
311
+ customer_count: number;
312
+ event_count: number;
313
+ }>;
314
+ },
315
+ any
316
+ >(
317
+ async () => {
318
+ const result = await api.get('/api/meter-events/overdue-summary');
319
+ return result.data;
320
+ },
321
+ {
322
+ refreshDeps: [],
323
+ }
324
+ );
325
+
305
326
  const summaryLoading = summary.loading;
306
327
  const trendLoading = trend.loading;
328
+ const overdueLoading = overdueSummary.loading;
329
+ const overdueList = overdueSummary.data?.list || [];
330
+ const hasOverdue = overdueList.length > 0;
307
331
  const summaryData = summary.data;
308
332
  const summarySummary = summaryData?.summary;
309
333
  const addresses = summaryData?.addresses;
@@ -954,6 +978,8 @@ export default function Overview() {
954
978
  </Stack>
955
979
  );
956
980
 
981
+ const showOverdueCard = overdueLoading || hasOverdue;
982
+
957
983
  const renderBusinessMonitoringSection = () => (
958
984
  <Stack direction="column" spacing={3}>
959
985
  <Typography variant="h5" sx={{ fontWeight: 600 }}>
@@ -1072,6 +1098,108 @@ export default function Overview() {
1072
1098
  </Stack>
1073
1099
  </Card>
1074
1100
  )}
1101
+ {showOverdueCard && (
1102
+ <Card
1103
+ variant="outlined"
1104
+ sx={{
1105
+ p: 2,
1106
+ borderRadius: 1,
1107
+ }}>
1108
+ <Stack
1109
+ direction="row"
1110
+ sx={{
1111
+ alignItems: 'center',
1112
+ mb: 2,
1113
+ }}>
1114
+ <Box
1115
+ sx={{
1116
+ width: 40,
1117
+ height: 40,
1118
+ borderRadius: 1,
1119
+ display: 'flex',
1120
+ alignItems: 'center',
1121
+ justifyContent: 'center',
1122
+ backgroundColor: alpha(theme.palette.warning.main, 0.12),
1123
+ mr: 1.5,
1124
+ }}>
1125
+ <WarningAmber sx={{ color: 'warning.main', fontSize: 24 }} />
1126
+ </Box>
1127
+ <Box>
1128
+ <Typography component="h3" variant="h5">
1129
+ {t('admin.overviewPage.overdue.title')}
1130
+ </Typography>
1131
+ <Typography variant="caption" sx={{ color: 'text.secondary' }}>
1132
+ {t('admin.overviewPage.overdue.subtitle')}
1133
+ </Typography>
1134
+ </Box>
1135
+ </Stack>
1136
+ <Stack spacing={1}>
1137
+ {overdueLoading
1138
+ ? BALANCE_SKELETON_KEYS.slice(0, 2).map((placeholder) => (
1139
+ <Skeleton key={`overdue-skeleton-${placeholder}`} variant="rounded" height={56} />
1140
+ ))
1141
+ : overdueList.map((item) => (
1142
+ <Stack
1143
+ key={item.currency.id}
1144
+ component={Link}
1145
+ to={`/admin/billing/overdue?currency_id=${item.currency.id}`}
1146
+ direction="row"
1147
+ spacing={1}
1148
+ sx={{
1149
+ alignItems: 'center',
1150
+ textDecoration: 'none',
1151
+ backgroundColor: theme.mode === 'dark' ? 'grey.100' : 'grey.50',
1152
+ color: 'inherit',
1153
+ p: 1.5,
1154
+ borderRadius: 1.5,
1155
+ transition: 'background-color 0.2s ease-in-out',
1156
+ '&:hover': {
1157
+ backgroundColor: 'action.hover',
1158
+ '& .arrow-icon': {
1159
+ opacity: 1,
1160
+ transform: 'translateX(0)',
1161
+ },
1162
+ },
1163
+ }}>
1164
+ <Avatar src={item.currency.logo} alt={item.currency.symbol} sx={{ width: 28, height: 28 }} />
1165
+ <Box sx={{ flex: 1, minWidth: 0 }}>
1166
+ <Typography variant="body2" sx={{ fontWeight: 600 }}>
1167
+ {item.currency.name}
1168
+ </Typography>
1169
+ <Typography variant="caption" sx={{ color: 'text.secondary' }}>
1170
+ {t('admin.overviewPage.overdue.customers', { count: item.customer_count })}
1171
+ {' · '}
1172
+ {t('admin.overviewPage.overdue.events', { count: item.event_count })}
1173
+ </Typography>
1174
+ </Box>
1175
+ <Typography
1176
+ variant="body2"
1177
+ sx={{
1178
+ fontWeight: 600,
1179
+ textAlign: 'right',
1180
+ color: 'warning.main',
1181
+ }}>
1182
+ -
1183
+ {formatCreditAmount(
1184
+ formatBNStr(item.total_pending, item.currency.decimal ?? 0),
1185
+ item.currency.symbol
1186
+ )}
1187
+ </Typography>
1188
+ <ArrowForward
1189
+ className="arrow-icon"
1190
+ fontSize="small"
1191
+ sx={{
1192
+ opacity: 0,
1193
+ transform: 'translateX(-10px)',
1194
+ transition: 'all 0.2s ease-in-out',
1195
+ color: 'text.secondary',
1196
+ }}
1197
+ />
1198
+ </Stack>
1199
+ ))}
1200
+ </Stack>
1201
+ </Card>
1202
+ )}
1075
1203
  {showMetricsCard && (
1076
1204
  <Card
1077
1205
  variant="outlined"
@@ -270,6 +270,18 @@ export default function CustomerCreditTransactionDetail() {
270
270
  }
271
271
  />
272
272
  )}
273
+ <InfoRow
274
+ label={t('admin.meterEvent.reportedAt')}
275
+ value={formatTime(
276
+ data.meterEvent?.timestamp
277
+ ? data.meterEvent.timestamp * 1000
278
+ : data.meterEvent?.created_at || data.created_at
279
+ )}
280
+ />
281
+ <InfoRow
282
+ label={t('admin.meterEvent.processedAt')}
283
+ value={formatTime(data.meterEvent?.processed_at ? data.meterEvent.processed_at * 1000 : data.created_at)}
284
+ />
273
285
  </InfoRowGroup>
274
286
  </Box>
275
287