payment-kit 1.24.2 → 1.24.3

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,588 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { getDurableData } from '@arcblock/ux/lib/Datatable';
3
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
+ import { api, formatTime, Table, formatBNStr, getCustomerAvatar } from '@blocklet/payment-react';
5
+ import type { TCustomer, TMeter, TMeterEventExpanded } from '@blocklet/payment-types';
6
+ import {
7
+ CircularProgress,
8
+ Chip,
9
+ Box,
10
+ Button,
11
+ Menu,
12
+ MenuItem,
13
+ TextField,
14
+ styled,
15
+ Checkbox,
16
+ ListItemText,
17
+ } from '@mui/material';
18
+ import { useEffect, useMemo, useState } from 'react';
19
+ import { Link, useSearchParams } from 'react-router-dom';
20
+ import { Add, Close } from '@mui/icons-material';
21
+
22
+ import { useRequest } from 'ahooks';
23
+ import CustomerLink from '../../../../components/customer/link';
24
+ import InfoCard from '../../../../components/info-card';
25
+
26
+ const fetchData = (params: Record<string, any> = {}): Promise<{ list: TMeterEventExpanded[]; count: number }> => {
27
+ const search = new URLSearchParams();
28
+ Object.keys(params).forEach((key) => {
29
+ const v = params[key];
30
+ if (v !== undefined && v !== null && v !== '') {
31
+ if (key === 'q') {
32
+ const queryStr = Object.entries(v)
33
+ .map((x) => x.join(':'))
34
+ .join(' ');
35
+ search.set(key, queryStr);
36
+ } else {
37
+ search.set(key, String(v));
38
+ }
39
+ }
40
+ });
41
+ return api.get(`/api/meter-events?${search.toString()}`).then((res) => res.data);
42
+ };
43
+
44
+ const fetchCustomers = (params: Record<string, any> = {}): Promise<{ list: TCustomer[]; count: number }> => {
45
+ const search = new URLSearchParams();
46
+ Object.keys(params).forEach((key) => {
47
+ let v = params[key];
48
+ if (key === 'q') {
49
+ v = Object.entries(v)
50
+ .map((x) => x.join(':'))
51
+ .join(' ');
52
+ }
53
+ search.set(key, String(v));
54
+ });
55
+ return api.get(`/api/customers?${search.toString()}`).then((res) => res.data);
56
+ };
57
+
58
+ const fetchMeters = (): Promise<{ list: TMeter[]; count: number }> => {
59
+ return api.get('/api/meters?pageSize=100').then((res) => res.data);
60
+ };
61
+
62
+ type SearchProps = {
63
+ status: string;
64
+ customer_id: string;
65
+ meter_id: string;
66
+ pageSize: number;
67
+ page: number;
68
+ q?: any;
69
+ o?: string;
70
+ };
71
+
72
+ const parseSearchParams = (searchParamsString: string) => {
73
+ if (!searchParamsString) {
74
+ return {
75
+ customer_id: '',
76
+ status: '',
77
+ meter_id: '',
78
+ };
79
+ }
80
+
81
+ const params = new URLSearchParams(searchParamsString);
82
+ return {
83
+ customer_id: params.get('customer_id') || '',
84
+ status: params.get('status') || '',
85
+ meter_id: params.get('meter_id') || '',
86
+ };
87
+ };
88
+
89
+ export default function MeterEventsList() {
90
+ const listKey = 'meter-events';
91
+ const persisted = getDurableData(listKey);
92
+
93
+ const { t } = useLocaleContext();
94
+ const [searchParams] = useSearchParams();
95
+ const searchParamsString = searchParams.toString();
96
+ const [search, setSearch] = useState<SearchProps>(() => {
97
+ const initialFilters = parseSearchParams(searchParamsString);
98
+ return {
99
+ status: initialFilters.status,
100
+ customer_id: initialFilters.customer_id,
101
+ meter_id: initialFilters.meter_id,
102
+ pageSize: persisted.rowsPerPage || 20,
103
+ page: persisted.page ? persisted.page + 1 : 1,
104
+ };
105
+ });
106
+
107
+ const [data, setData] = useState<{ list: TMeterEventExpanded[]; count: number } | null>(null);
108
+ const [loading, setLoading] = useState(true);
109
+ const [meters, setMeters] = useState<TMeter[]>([]);
110
+
111
+ useEffect(() => {
112
+ const { customer_id: customerId, status, meter_id: meterId } = parseSearchParams(searchParamsString);
113
+
114
+ if (!customerId && !status && !meterId) return;
115
+
116
+ setSearch((prev: any) => ({
117
+ ...prev,
118
+ customer_id: customerId,
119
+ status,
120
+ meter_id: meterId,
121
+ page: 1,
122
+ }));
123
+ }, [searchParamsString]);
124
+
125
+ useEffect(() => {
126
+ setLoading(true);
127
+ fetchData(search)
128
+ .then((res) => setData(res))
129
+ .finally(() => setLoading(false));
130
+ }, [search]);
131
+
132
+ useEffect(() => {
133
+ fetchMeters()
134
+ .then((res) => setMeters(res.list))
135
+ .catch(() => setMeters([]));
136
+ }, []);
137
+
138
+ const meterByEventName = useMemo(() => {
139
+ return new Map(meters.map((meter) => [meter.event_name, meter]));
140
+ }, [meters]);
141
+
142
+ if (loading && !data) {
143
+ return <CircularProgress />;
144
+ }
145
+
146
+ const getStatusColor = (status: string) => {
147
+ switch (status) {
148
+ case 'pending':
149
+ return 'info';
150
+ case 'requires_action':
151
+ return 'warning';
152
+ case 'requires_capture':
153
+ return 'warning';
154
+ case 'completed':
155
+ return 'success';
156
+ case 'processing':
157
+ return 'info';
158
+ case 'canceled':
159
+ return 'default';
160
+ default:
161
+ return 'default';
162
+ }
163
+ };
164
+
165
+ const handleSearchChange = (updates: Partial<SearchProps>) => {
166
+ setSearch((prev: any) => ({
167
+ ...prev,
168
+ ...updates,
169
+ page: 1,
170
+ }));
171
+ };
172
+
173
+ const columns = [
174
+ {
175
+ label: t('common.customer'),
176
+ name: 'customer',
177
+ options: {
178
+ filter: false,
179
+ customBodyRenderLite: (_: string, index: number) => {
180
+ const item = data?.list[index] as TMeterEventExpanded;
181
+ return item.customer ? (
182
+ <Link to={`/admin/customers/${item.customer.id}`}>
183
+ <CustomerLink customer={item.customer} size="small" />
184
+ </Link>
185
+ ) : (
186
+ '-'
187
+ );
188
+ },
189
+ },
190
+ },
191
+ {
192
+ label: t('common.meter'),
193
+ name: 'event_name',
194
+ options: {
195
+ filter: false,
196
+ customBodyRenderLite: (_: string, index: number) => {
197
+ const item = data?.list[index] as TMeterEventExpanded | undefined;
198
+ if (!item) {
199
+ return '-';
200
+ }
201
+
202
+ const meter = item.meter || meterByEventName.get(item.event_name);
203
+ if (!meter) {
204
+ return item.event_name || '-';
205
+ }
206
+
207
+ return <Link to={`/admin/billing/${meter.id}`}>{item.event_name}</Link>;
208
+ },
209
+ },
210
+ },
211
+ {
212
+ label: t('admin.meterEvent.settlementAmount'),
213
+ name: 'settlement_amount',
214
+ options: {
215
+ filter: false,
216
+ customBodyRenderLite: (_: string, index: number) => {
217
+ const item = data?.list[index] as TMeterEventExpanded;
218
+ // payload.decimal 是存储在数据中的 decimal 值,更准确
219
+ const reportedDecimal = (item.payload as any)?.decimal ?? item.paymentCurrency?.decimal ?? 18;
220
+ const reportedValue = item.payload?.value;
221
+ const consumedValue = item.credit_consumed;
222
+ const hasReported = reportedValue !== undefined && reportedValue !== null && reportedValue !== '';
223
+ const hasConsumed = consumedValue !== undefined && consumedValue !== null && consumedValue !== '';
224
+
225
+ if (!hasReported && !hasConsumed) {
226
+ return <Link to={`/admin/billing/${item.id}`}>-</Link>;
227
+ }
228
+
229
+ const formattedReported = hasReported ? formatBNStr(reportedValue, reportedDecimal) : '-';
230
+ const consumedDecimal = item.paymentCurrency?.decimal ?? reportedDecimal;
231
+ const formattedConsumed = hasConsumed ? formatBNStr(consumedValue, consumedDecimal) : '-';
232
+ const symbol = item.paymentCurrency?.symbol || '';
233
+ const isUnsettled = Boolean(item.credit_pending && item.credit_pending !== '0');
234
+
235
+ return (
236
+ <Link to={`/admin/billing/${item.id}`}>
237
+ <Box
238
+ component="span"
239
+ sx={{
240
+ color: isUnsettled ? 'warning.main' : 'text.primary',
241
+ fontWeight: isUnsettled ? 600 : 400,
242
+ }}>
243
+ {formattedConsumed} / {formattedReported}
244
+ {symbol ? ` ${symbol}` : ''}
245
+ </Box>
246
+ </Link>
247
+ );
248
+ },
249
+ },
250
+ },
251
+ {
252
+ label: t('common.status'),
253
+ name: 'status',
254
+ options: {
255
+ filter: false,
256
+ customBodyRenderLite: (_: string, index: number) => {
257
+ const item = data?.list[index] as TMeterEventExpanded;
258
+ return (
259
+ <Link to={`/admin/billing/${item.id}`}>
260
+ <Chip
261
+ label={item.status}
262
+ color={getStatusColor(item.status)}
263
+ size="small"
264
+ sx={{ textTransform: 'capitalize' }}
265
+ />
266
+ </Link>
267
+ );
268
+ },
269
+ },
270
+ },
271
+ {
272
+ label: t('admin.meterEvent.reportedAt'),
273
+ name: 'timestamp',
274
+ options: {
275
+ sort: true,
276
+ customBodyRenderLite: (_: string, index: number) => {
277
+ const item = data?.list[index] as TMeterEventExpanded;
278
+ return <Link to={`/admin/billing/${item.id}`}>{formatTime(item.timestamp * 1000)}</Link>;
279
+ },
280
+ },
281
+ },
282
+ ];
283
+
284
+ const onTableChange = ({ page, rowsPerPage }: any) => {
285
+ if (search!.pageSize !== rowsPerPage) {
286
+ setSearch((x: any) => ({ ...x, pageSize: rowsPerPage, page: 1 }));
287
+ } else if (search!.page !== page + 1) {
288
+ setSearch((x: any) => ({ ...x, page: page + 1 }));
289
+ }
290
+ };
291
+
292
+ return (
293
+ <Table
294
+ hasRowLink
295
+ durable={`__${listKey}__`}
296
+ durableKeys={['page', 'rowsPerPage', 'searchText']}
297
+ data={data?.list || []}
298
+ columns={columns}
299
+ loading={loading}
300
+ onChange={onTableChange}
301
+ options={{
302
+ count: data?.count || 0,
303
+ page: search!.page - 1,
304
+ rowsPerPage: search!.pageSize,
305
+ onColumnSortChange(_: any, order: any) {
306
+ setSearch({
307
+ ...search!,
308
+ q: search!.q || {},
309
+ o: order,
310
+ });
311
+ },
312
+ onSearchChange: (text: string) => {
313
+ if (text) {
314
+ setSearch({
315
+ ...search!,
316
+ q: {
317
+ 'like-event_name': text,
318
+ 'like-id': text,
319
+ },
320
+ pageSize: 100,
321
+ page: 1,
322
+ });
323
+ } else {
324
+ setSearch({
325
+ ...search!,
326
+ pageSize: 100,
327
+ page: 1,
328
+ q: {},
329
+ });
330
+ }
331
+ },
332
+ }}
333
+ title={<FilterToolbar search={search} onSearchChange={handleSearchChange} meters={meters} />}
334
+ />
335
+ );
336
+ }
337
+
338
+ function FilterToolbar({
339
+ search,
340
+ onSearchChange,
341
+ meters,
342
+ }: {
343
+ search: SearchProps;
344
+ onSearchChange: (updates: Partial<SearchProps>) => void;
345
+ meters: TMeter[];
346
+ }) {
347
+ return (
348
+ <Root>
349
+ <Box className="table-toolbar-left">
350
+ <StatusFilter search={search} onSearchChange={onSearchChange} />
351
+ <CustomerFilter search={search} onSearchChange={onSearchChange} />
352
+ <MeterFilter search={search} onSearchChange={onSearchChange} meters={meters} />
353
+ </Box>
354
+ </Root>
355
+ );
356
+ }
357
+
358
+ function StatusFilter({
359
+ search,
360
+ onSearchChange,
361
+ }: {
362
+ search: SearchProps;
363
+ onSearchChange: (updates: Partial<SearchProps>) => void;
364
+ }) {
365
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
366
+ const { t } = useLocaleContext();
367
+ const statuses = ['pending', 'processing', 'requires_action', 'requires_capture', 'completed', 'canceled'];
368
+ const selectedStatuses = search.status ? search.status.split(',').filter(Boolean) : [];
369
+
370
+ const handleToggleStatus = (status: string) => {
371
+ const newStatuses = selectedStatuses.includes(status)
372
+ ? selectedStatuses.filter((s) => s !== status)
373
+ : [...selectedStatuses, status];
374
+
375
+ onSearchChange({ status: newStatuses.join(',') });
376
+ };
377
+
378
+ return (
379
+ <section>
380
+ <Button className="option-btn" variant="text" onClick={(e) => setAnchorEl(e.currentTarget)}>
381
+ {search.status ? (
382
+ <Close
383
+ sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }}
384
+ onClick={(e) => {
385
+ e.stopPropagation();
386
+ onSearchChange({ status: '' });
387
+ setAnchorEl(null);
388
+ }}
389
+ />
390
+ ) : (
391
+ <Add sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }} />
392
+ )}
393
+ {t('common.status')}
394
+ <span>{selectedStatuses.length > 0 ? `${selectedStatuses.length} selected` : ''}</span>
395
+ </Button>
396
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)} className="status-options">
397
+ {statuses.map((status) => (
398
+ <MenuItem key={status} onClick={() => handleToggleStatus(status)} sx={{ py: 0.5 }}>
399
+ <Checkbox checked={selectedStatuses.includes(status)} size="small" />
400
+ <ListItemText primary={status} />
401
+ </MenuItem>
402
+ ))}
403
+ </Menu>
404
+ </section>
405
+ );
406
+ }
407
+
408
+ function CustomerFilter({
409
+ search,
410
+ onSearchChange,
411
+ }: {
412
+ search: SearchProps;
413
+ onSearchChange: (updates: Partial<SearchProps>) => void;
414
+ }) {
415
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
416
+ const [customers, setCustomers] = useState<TCustomer[]>([]);
417
+ const [searchText, setSearchText] = useState('');
418
+ const { t } = useLocaleContext();
419
+
420
+ const customerRequest = useRequest(
421
+ (text: string) => {
422
+ return fetchCustomers({
423
+ q: {
424
+ 'like-name': text,
425
+ 'like-email': text,
426
+ 'like-did': text,
427
+ },
428
+ page: 1,
429
+ pageSize: 10,
430
+ });
431
+ },
432
+ {
433
+ onSuccess: (data) => {
434
+ setCustomers(data.list);
435
+ },
436
+ debounceWait: 500,
437
+ }
438
+ );
439
+
440
+ useEffect(() => {
441
+ customerRequest.run(searchText);
442
+ }, [searchText]);
443
+
444
+ const selectedCustomer = customers.find((c) => c.id === search.customer_id);
445
+
446
+ return (
447
+ <section>
448
+ <Button className="option-btn" variant="text" onClick={(e) => setAnchorEl(e.currentTarget)}>
449
+ {search.customer_id ? (
450
+ <Close
451
+ sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }}
452
+ onClick={(e) => {
453
+ e.stopPropagation();
454
+ onSearchChange({ customer_id: '' });
455
+ setAnchorEl(null);
456
+ }}
457
+ />
458
+ ) : (
459
+ <Add sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }} />
460
+ )}
461
+ {t('common.customer')}
462
+ <span>{selectedCustomer?.name || ''}</span>
463
+ </Button>
464
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
465
+ <Box sx={{ p: 1 }}>
466
+ <TextField
467
+ placeholder="Search customer"
468
+ size="small"
469
+ sx={{ width: '100%' }}
470
+ value={searchText}
471
+ onChange={(e) => {
472
+ setSearchText(e.target.value);
473
+ e.stopPropagation();
474
+ }}
475
+ onClick={(e) => e.stopPropagation()}
476
+ />
477
+ </Box>
478
+ {customers.map((customer) => (
479
+ <MenuItem
480
+ key={customer.id}
481
+ onClick={(e) => {
482
+ e.stopPropagation();
483
+ onSearchChange({ customer_id: customer.id });
484
+ setAnchorEl(null);
485
+ }}>
486
+ <InfoCard
487
+ logo={getCustomerAvatar(
488
+ customer?.did,
489
+ customer?.updated_at ? new Date(customer.updated_at).toISOString() : undefined,
490
+ 48
491
+ )}
492
+ name={customer.name || customer.email}
493
+ key={customer.id}
494
+ description={`${customer.did.slice(0, 6)}...${customer.did.slice(-6)}`}
495
+ />
496
+ </MenuItem>
497
+ ))}
498
+ </Menu>
499
+ </section>
500
+ );
501
+ }
502
+
503
+ function MeterFilter({
504
+ search,
505
+ onSearchChange,
506
+ meters,
507
+ }: {
508
+ search: SearchProps;
509
+ onSearchChange: (updates: Partial<SearchProps>) => void;
510
+ meters: TMeter[];
511
+ }) {
512
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
513
+ const { t } = useLocaleContext();
514
+
515
+ const selectedMeter = meters.find((m) => m.id === search.meter_id);
516
+
517
+ return (
518
+ <section>
519
+ <Button className="option-btn" variant="text" onClick={(e) => setAnchorEl(e.currentTarget)}>
520
+ {search.meter_id ? (
521
+ <Close
522
+ sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }}
523
+ onClick={(e) => {
524
+ e.stopPropagation();
525
+ onSearchChange({ meter_id: '' });
526
+ setAnchorEl(null);
527
+ }}
528
+ />
529
+ ) : (
530
+ <Add sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }} />
531
+ )}
532
+ {t('admin.meters')}
533
+ <span>{selectedMeter?.name || ''}</span>
534
+ </Button>
535
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)} className="status-options">
536
+ {meters.map((meter) => (
537
+ <MenuItem
538
+ key={meter.id}
539
+ onClick={() => {
540
+ onSearchChange({ meter_id: meter.id });
541
+ setAnchorEl(null);
542
+ }}>
543
+ {meter.name} ({meter.event_name})
544
+ </MenuItem>
545
+ ))}
546
+ </Menu>
547
+ </section>
548
+ );
549
+ }
550
+
551
+ const Root = styled(Box)`
552
+ .table-toolbar-left {
553
+ display: flex;
554
+ align-items: center;
555
+ }
556
+
557
+ .table-toolbar-left section {
558
+ position: relative;
559
+ list-style: none;
560
+ font-size: 14px;
561
+ font-weight: normal;
562
+ padding: 0 5px;
563
+ cursor: pointer;
564
+ }
565
+
566
+ .option-btn {
567
+ display: flex;
568
+ align-items: center;
569
+ border-radius: 25px;
570
+ background: ${({ theme }) => theme.palette.grey[100]};
571
+ padding: 5px 10px;
572
+ color: ${({ theme }) => theme.palette.text.secondary};
573
+ font-size: 14px;
574
+ line-height: 14px;
575
+ overflow: visible;
576
+ }
577
+
578
+ .option-btn span {
579
+ color: #3773f2;
580
+ padding: 0 3px;
581
+ overflow: visible;
582
+ }
583
+
584
+ .status-options {
585
+ max-height: 300px;
586
+ overflow-y: auto;
587
+ }
588
+ `;