feat: transactions table

This commit is contained in:
Rafael Taranto 2025-06-05 14:02:07 +01:00
parent 1ead9fe359
commit d6166ce752
29 changed files with 1204 additions and 726 deletions

View file

@ -4,7 +4,7 @@
"license": "../LICENSE",
"type": "module",
"dependencies": {
"@apollo/client": "^3.13.7",
"@apollo/client": "^3.13.8",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@lamassu/coins": "v1.6.1",
@ -22,6 +22,7 @@
"downshift": "9.0.9",
"file-saver": "2.0.2",
"formik": "2.2.0",
"immer": "^10.1.1",
"jss-plugin-extend": "^10.0.0",
"jszip": "^3.6.0",
"libphonenumber-js": "^1.11.15",

View file

@ -0,0 +1,118 @@
import React, { useState, useEffect } from 'react'
import {
Autocomplete,
TextField,
Select,
MenuItem,
FormControl,
} from '@mui/material'
import { AsyncAutocomplete } from './inputs/base/AsyncAutocomplete.jsx'
export const SelectFilter = ({ column, options = [] }) => {
const columnFilterValue = column.getFilterValue()
return (
<FormControl variant="standard" size="small" fullWidth>
<Select
value={columnFilterValue || ''}
onChange={event => {
column.setFilterValue(event.target.value || undefined)
}}
displayEmpty
variant="standard">
<MenuItem value="">All</MenuItem>
{options.map(option => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
)
}
export const AutocompleteFilter = ({
column,
options = [],
placeholder = 'Filter...',
renderOption,
getOptionLabel = option => option.label || '',
}) => {
const columnFilterValue = column.getFilterValue()
const selectedOption =
options.find(option => option.value === columnFilterValue) || null
return (
<Autocomplete
options={options}
value={selectedOption}
onChange={(event, newValue) => {
column.setFilterValue(newValue?.value || '')
}}
getOptionLabel={getOptionLabel}
isOptionEqualToValue={(option, value) => option?.value === value?.value}
renderOption={renderOption}
renderInput={params => (
<TextField
{...params}
variant="standard"
placeholder={placeholder}
size="small"
fullWidth
/>
)}
size="small"
fullWidth
slotProps={{
listbox: {
style: { maxHeight: 200 },
},
popper: {
style: { width: 'auto' },
},
}}
/>
)
}
export const TextFilter = ({ column, placeholder = 'Filter...' }) => {
const columnFilterValue = column.getFilterValue()
return (
<TextField
value={columnFilterValue ?? ''}
onChange={event => {
column.setFilterValue(event.target.value || undefined)
}}
placeholder={placeholder}
variant="standard"
size="small"
fullWidth
/>
)
}
export const AsyncAutocompleteFilter = ({ column, ...props }) => {
const [selectedOption, setSelectedOption] = useState(null)
const columnFilterValue = column.getFilterValue()
const getOptionId = props.getOptionId || (option => option.id)
useEffect(() => {
if (!columnFilterValue) {
setSelectedOption(null)
}
}, [columnFilterValue])
const handleChange = (event, newValue) => {
column.setFilterValue(newValue ? getOptionId(newValue) : '')
setSelectedOption(newValue)
}
return (
<AsyncAutocomplete
{...props}
value={selectedOption}
onChange={handleChange}
/>
)
}

View file

@ -41,7 +41,7 @@ const HelpTooltip = memo(({ children, width }) => {
)}
<button
type="button"
className="border-0 bg-transparent outline-0 cursor-pointer mt-1"
className="flex justify-center align-center border-0 bg-transparent outline-0 cursor-pointer px-1"
onMouseEnter={handler.openHelpPopper}>
<HelpIcon />
</button>

View file

@ -0,0 +1,75 @@
import React, { useState, useRef } from 'react'
import { Autocomplete, TextField } from '@mui/material'
export const AsyncAutocomplete = ({
value,
onChange,
onSearch,
getOptionLabel,
getOptionId = option => option.id,
placeholder = 'Search...',
noOptionsText = 'Type to start searching...',
minSearchLength = 2,
debounceMs = 300,
variant = 'standard',
size = 'small',
fullWidth = true,
...textFieldProps
}) => {
const [options, setOptions] = useState([])
const timeoutRef = useRef(null)
// Simple debounce using timeout
const debouncedSearch = searchTerm => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
onSearch(searchTerm).then(results => {
setOptions(results)
})
}, debounceMs)
}
const handleInputChange = (event, newInputValue, reason) => {
// Only search when user is typing, not when selecting an option
if (
reason === 'input' &&
newInputValue &&
newInputValue.length > minSearchLength
) {
debouncedSearch(newInputValue)
}
}
const handleBlur = () => {
setOptions([])
}
return (
<Autocomplete
options={options}
value={value}
onChange={onChange}
onInputChange={handleInputChange}
getOptionLabel={getOptionLabel}
isOptionEqualToValue={(option, value) =>
getOptionId(option) === getOptionId(value)
}
noOptionsText={noOptionsText}
renderInput={params => (
<TextField
{...params}
variant={variant}
placeholder={placeholder}
size={size}
fullWidth={fullWidth}
onBlur={handleBlur}
{...textFieldProps}
/>
)}
size={size}
fullWidth={fullWidth}
/>
)
}

View file

@ -62,24 +62,14 @@ const GET_TRANSACTIONS = gql`
until: $until
excludeTestingCustomers: $excludeTestingCustomers
) {
id
txClass
txHash
toAddress
commissionPercentage
expired
machineName
operatorCompleted
sendConfirmed
dispense
hasError: error
deviceId
fiat
fixedFee
fiatCode
cryptoAtoms
cryptoCode
toAddress
created
profit
}

View file

@ -34,6 +34,20 @@ import { getFormattedPhone, getName, formatPhotosData } from './helper'
const GET_CUSTOMER = gql`
query customer($customerId: ID!) {
config
transactions(customerId: $customerId, limit: 20) {
txClass
id
fiat
fiatCode
cryptoAtoms
cryptoCode
created
machineName
errorMessage: error
error: errorCode
txCustomerPhotoAt
txCustomerPhotoPath
}
customer(customerId: $customerId) {
id
authorizedOverride
@ -81,20 +95,6 @@ const GET_CUSTOMER = gql`
created
lastEditedAt
}
transactions {
txClass
id
fiat
fiatCode
cryptoAtoms
cryptoCode
created
machineName
errorMessage: error
error: errorCode
txCustomerPhotoAt
txCustomerPhotoPath
}
customInfoRequests {
customerId
override
@ -459,7 +459,7 @@ const CustomerProfile = memo(() => {
const configData = R.path(['config'])(customerResponse) ?? []
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
const customerData = R.path(['customer'])(customerResponse) ?? []
const rawTransactions = R.path(['transactions'])(customerData) ?? []
const rawTransactions = R.path(['transactions'])(customerResponse) ?? []
const sortedTransactions = R.sort(R.descend(R.prop('cryptoAtoms')))(
rawTransactions,
)

View file

@ -32,8 +32,6 @@ const GET_DATA = gql`
) {
fiatCode
fiat
fixedFee
commissionPercentage
created
txClass
error

View file

@ -16,18 +16,8 @@ import DataTable from '../../../../components/tables/DataTable'
const NUM_LOG_RESULTS = 5
const GET_TRANSACTIONS = gql`
query transactions(
$limit: Int
$from: DateTimeISO
$until: DateTimeISO
$deviceId: String
) {
transactions(
limit: $limit
from: $from
until: $until
deviceId: $deviceId
) {
query transactions($limit: Int, $deviceId: String) {
transactions(limit: $limit, deviceId: $deviceId) {
id
txClass
txHash
@ -47,7 +37,6 @@ const GET_TRANSACTIONS = gql`
cryptoCode
toAddress
created
customerName
customerIdCardData
customerIdCardPhotoPath
customerFrontCameraPath

View file

@ -0,0 +1,91 @@
import React from 'react'
import {
AutocompleteFilter,
SelectFilter,
AsyncAutocompleteFilter,
} from '../../components/TableFilters'
export const DirectionFilter = ({ column }) => {
const options = [
{ label: 'Cash-in', value: 'cashIn' },
{ label: 'Cash-out', value: 'cashOut' },
]
return <SelectFilter column={column} options={options} />
}
export const SweptFilter = ({ column }) => {
const options = [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
]
return <SelectFilter column={column} options={options} />
}
export const StatusFilter = ({ column }) => {
const options = [
{ label: 'Cancelled', value: 'Cancelled' },
{ label: 'Error', value: 'Error' },
{ label: 'Success', value: 'Success' },
{ label: 'Expired', value: 'Expired' },
{ label: 'Pending', value: 'Pending' },
{ label: 'Sent', value: 'Sent' },
]
return <SelectFilter column={column} options={options} />
}
export const MachineFilter = ({ column, machines }) => {
const machineOptions = machines.map(machine => ({
label: machine.name,
value: machine.deviceId,
}))
const renderOption = (props, option) => (
<li {...props}>
<div>
<div>{option.label}</div>
<div style={{ fontSize: '0.8em', color: '#666' }}>{option.value}</div>
</div>
</li>
)
return (
<AutocompleteFilter
column={column}
options={machineOptions}
placeholder="Filter machines..."
renderOption={renderOption}
/>
)
}
export const CryptoFilter = ({ column, cryptoCurrencies }) => {
const cryptoOptions = cryptoCurrencies.map(crypto => ({
label: crypto.code,
value: crypto.code,
}))
return (
<AutocompleteFilter
column={column}
options={cryptoOptions}
placeholder="Filter crypto..."
/>
)
}
export const CustomerFilter = ({ column, onSearch }) => (
<AsyncAutocompleteFilter
column={column}
onSearch={onSearch}
getOptionLabel={option => {
const name = option.name || 'Unknown'
const contact = option.phone || option.email || ''
return contact ? `${name} (${contact})` : name
}}
placeholder="Search customers..."
noOptionsText="Type to start searching..."
/>
)

View file

@ -1,14 +1,13 @@
import { useQuery, gql } from '@apollo/client'
import { useQuery, useLazyQuery, gql } from '@apollo/client'
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { toUnit, formatCryptoAddress } from '@lamassu/coins/lightUtils'
import BigNumber from 'bignumber.js'
import * as R from 'ramda'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo } from 'react'
import { useLocation } from 'wouter'
import LogsDowloaderPopover from '../../components/LogsDownloaderPopper'
import SearchBox from '../../components/SearchBox'
import SearchFilter from '../../components/SearchFilter'
import { HelpTooltip } from '../../components/Tooltip'
import DataTable from '../../components/tables/DataTable'
import TxInIcon from '../../styling/icons/direction/cash-in.svg?react'
import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react'
import CustomerLinkIcon from '../../styling/icons/month arrows/right.svg?react'
@ -20,14 +19,46 @@ import * as Customer from '../../utils/customer'
import { formatDate } from '../../utils/timezones'
import DetailsRow from './DetailsCard'
import { getStatus } from './helper'
import TitleSection from '../../components/layout/TitleSection.jsx'
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'
import {
alignRight,
defaultMaterialTableOpts,
} from '../../utils/materialReactTableOpts.js'
import { getStatusDetails } from './helper.js'
import {
CustomerFilter,
MachineFilter,
DirectionFilter,
CryptoFilter,
StatusFilter,
SweptFilter,
} from './Filters.jsx'
const NUM_LOG_RESULTS = 1000
const GET_DATA = gql`
query getData {
config
machines {
name
deviceId
}
cryptoCurrencies {
code
display
}
}
`
const SEARCH_CUSTOMERS = gql`
query searchCustomers($searchTerm: String!, $limit: Int) {
searchCustomers(searchTerm: $searchTerm, limit: $limit) {
id
name
phone
email
}
}
`
@ -51,24 +82,16 @@ const GET_TRANSACTIONS_CSV = gql`
}
`
const GET_TRANSACTION_FILTERS = gql`
query filters {
transactionFilters {
type
value
label
}
}
`
const GET_TRANSACTIONS = gql`
query transactions(
$limit: Int
$offset: Int
$from: DateTimeISO
$until: DateTimeISO
$txClass: String
$deviceId: String
$customerName: String
$customerId: ID
$fiatCode: String
$cryptoCode: String
$toAddress: String
@ -77,17 +100,22 @@ const GET_TRANSACTIONS = gql`
) {
transactions(
limit: $limit
offset: $offset
from: $from
until: $until
txClass: $txClass
deviceId: $deviceId
customerName: $customerName
customerId: $customerId
fiatCode: $fiatCode
cryptoCode: $cryptoCode
toAddress: $toAddress
status: $status
swept: $swept
) {
paginationStats {
totalCount
}
id
txClass
txHash
@ -109,7 +137,6 @@ const GET_TRANSACTIONS = gql`
cryptoCode
toAddress
created
customerName
customerIdCardData
customerIdCardPhotoPath
customerFrontCameraPath
@ -126,203 +153,294 @@ const GET_TRANSACTIONS = gql`
walletScore
profit
swept
status
}
}
`
const getFiltersObj = filters =>
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters)
const useTableStore = create(
immer(set => ({
variables: { limit: NUM_LOG_RESULTS },
columnFilters: [],
pagination: {
pageIndex: 0,
pageSize: 10,
},
previousData: [],
updateField: (field, updates) =>
set(state => {
if (typeof updates === 'function') {
state[field] = updates(state[field])
} else {
state[field] = updates
}
}),
})),
)
const Transactions = () => {
const [, navigate] = useLocation()
const { variables, columnFilters, pagination, previousData, updateField } =
useTableStore()
const [filters, setFilters] = useState([])
const { data: filtersResponse, loading: filtersLoading } = useQuery(
GET_TRANSACTION_FILTERS,
)
const [variables, setVariables] = useState({ limit: NUM_LOG_RESULTS })
const {
data: txData,
loading: transactionsLoading,
refetch,
startPolling,
stopPolling,
} = useQuery(GET_TRANSACTIONS, { variables })
useEffect(() => {
startPolling(10000)
return stopPolling
const { data: configResponse } = useQuery(GET_DATA)
const { data, loading } = useQuery(GET_TRANSACTIONS, {
variables,
notifyOnNetworkStatusChange: true,
})
const txList = txData?.transactions ?? []
const [searchCustomersQuery] = useLazyQuery(SEARCH_CUSTOMERS)
const displayData = useMemo(() => {
const formattedData = (data?.transactions ?? []).map(row => ({
...row,
toAddress: formatCryptoAddress(row.cryptoCode, row.toAddress),
}))
return loading && previousData.length > 0 ? previousData : formattedData
}, [data])
useEffect(() => {
if (!loading && displayData && displayData.length > 0) {
updateField('previousData', displayData)
}
}, [displayData, loading])
useEffect(() => {
listFilterChange({
offset: pagination.pageIndex * pagination.pageSize,
limit: pagination.pageSize,
})
}, [pagination, columnFilters])
const { data: configResponse, configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const machines = configResponse?.machines || []
const cryptoCurrencies = configResponse?.cryptoCurrencies || []
const columns = useMemo(
() => [
{
header: 'Direction',
accessorKey: 'txClass',
Filter: DirectionFilter,
grow: false,
size: 50,
muiTableBodyCellProps: {
align: 'center',
},
Cell: ({ cell }) =>
cell.getValue() === 'cashOut' ? <TxOutIcon /> : <TxInIcon />,
},
{
accessorKey: 'id',
header: 'ID',
size: 315,
},
{
accessorKey: 'swept',
header: 'Swept',
Filter: SweptFilter,
size: 50,
Cell: ({ cell }) => (cell.getValue() ? 'Yes' : 'No'),
},
{
accessorKey: 'machineName',
header: 'Machine',
Filter: ({ column }) => (
<MachineFilter column={column} machines={machines} />
),
size: 160,
maxSize: 160,
},
{
accessorKey: 'customerId',
header: 'Customer',
size: 202,
Filter: ({ column }) => (
<CustomerFilter column={column} onSearch={searchCustomers} />
),
Cell: ({ row }) => (
<div className="flex items-center justify-between w-full">
<div className="overflow-hidden whitespace-nowrap text-ellipsis">
{Customer.displayName(row.original)}
</div>
{!row.original.isAnonymous && (
<div
className="cursor-pointer flex"
data-cy="customer-link"
onClick={() => redirect(row.original.customerId)}>
{getStatusDetails(row.original) ? (
<CustomerLinkWhiteIcon />
) : (
<CustomerLinkIcon />
)}
</div>
)}
</div>
),
},
{
accessorKey: 'fiat',
header: 'Cash',
enableColumnFilter: false,
size: 144,
...alignRight,
Cell: ({ cell, row }) =>
`${Number.parseFloat(cell.getValue())} ${row.original.fiatCode}`,
},
{
accessorKey: 'cryptoAtoms',
header: 'Crypto',
Filter: ({ column }) => (
<CryptoFilter column={column} cryptoCurrencies={cryptoCurrencies} />
),
size: 150,
...alignRight,
Cell: ({ cell, row }) =>
`${toUnit(new BigNumber(cell.getValue()), row.original.cryptoCode)} ${
row.original.cryptoCode
}`,
},
{
accessorKey: 'toAddress',
header: 'Address',
size: 140,
muiTableBodyCellProps: {
className: 'overflow-hidden whitespace-nowrap text-ellipsis',
},
},
{
accessorKey: 'created',
header: 'Date',
enableColumnFilter: false,
Cell: ({ cell }) =>
timezone && formatDate(cell.getValue(), timezone, 'yyyy-MM-dd HH:mm'),
...alignRight,
size: 155,
},
{
accessorKey: 'status',
header: 'Status',
size: 80,
Filter: StatusFilter,
Cell: ({ cell }) => {
if (cell.getValue() === 'Pending')
return (
<div className="flex items-center justify-between w-full">
<div className="overflow-hidden whitespace-nowrap text-ellipsis">
Pending
</div>
<HelpTooltip width={200}>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115001210452-Cancelling-cash-out-transactions"
label="Cancelling cash-out transactions"
bottomSpace="0"
/>
</HelpTooltip>
</div>
)
else return cell.getValue()
},
},
],
[machines, cryptoCurrencies, timezone],
)
const table = useMaterialReactTable({
enableColumnResizing: true,
...defaultMaterialTableOpts,
initialState: {
...defaultMaterialTableOpts.initialState,
columnVisibility: {
id: false,
swept: false,
},
},
columns: columns,
rowCount: displayData?.[0]?.paginationStats?.totalCount ?? 0,
getRowId: it => it.id,
data: displayData || [],
manualFiltering: true,
manualPagination: true,
enableSorting: false,
onPaginationChange: it => {
console.log('PAGINATION', it)
updateField('pagination', it)
},
muiFilterTextFieldProps: {
size: 'small',
},
muiFilterCheckboxProps: { size: 'small' },
onColumnFiltersChange: it => updateField('columnFilters', it),
enableExpandAll: false,
state: {
columnFilters,
pagination,
isLoading: loading,
},
muiTableBodyRowProps: ({ row }) => ({
sx: {
backgroundColor: getStatusDetails(row.original) ? '#ffeceb' : '',
},
}),
displayColumnDefOptions: {
'mrt-row-expand': {
header: '',
},
},
muiExpandButtonProps: ({ row, table }) => ({
onClick: () => table.setExpanded({ [row.id]: !row.getIsExpanded() }), //set only this row to be expanded
}),
renderDetailPanel: ({ row }) =>
row.original ? (
<DetailsRow it={row.original} timezone={timezone} />
) : null,
})
const searchCustomers = async searchTerm => {
const { data } = await searchCustomersQuery({
variables: { searchTerm, limit: 20 },
})
return data?.searchCustomers || []
}
const redirect = customerId => {
return navigate(`/compliance/customer/${customerId}`)
}
const elements = [
{
header: '',
width: 32,
size: 'sm',
view: it => (it.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />),
},
{
header: 'Machine',
name: 'machineName',
width: 160,
size: 'sm',
view: R.path(['machineName']),
},
{
header: 'Customer',
width: 202,
size: 'sm',
view: it => (
<div className="flex items-center justify-between mr-4">
<div className="overflow-hidden whitespace-nowrap text-ellipsis">
{Customer.displayName(it)}
</div>
{!it.isAnonymous && (
<div
data-cy="customer-link"
onClick={() => redirect(it.customerId)}>
{it.hasError || it.batchError ? (
<CustomerLinkWhiteIcon />
) : (
<CustomerLinkIcon />
)}
</div>
)}
</div>
),
},
{
header: 'Cash',
width: 144,
textAlign: 'right',
size: 'sm',
view: it => `${Number.parseFloat(it.fiat)} ${it.fiatCode}`,
},
{
header: 'Crypto',
width: 150,
textAlign: 'right',
size: 'sm',
view: it =>
`${toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode)} ${
it.cryptoCode
}`,
},
{
header: 'Address',
view: it => formatCryptoAddress(it.cryptoCode, it.toAddress),
className: 'overflow-hidden whitespace-nowrap text-ellipsis',
size: 'sm',
width: 140,
},
{
header: 'Date',
view: it =>
timezone && formatDate(it.created, timezone, 'yyyy-MM-dd HH:mm'),
textAlign: 'right',
size: 'sm',
width: 195,
},
{
header: 'Status',
view: it => {
if (getStatus(it) === 'Pending')
return (
<div className="flex items-center">
{'Pending'}
<HelpTooltip width={285}>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115001210452-Cancelling-cash-out-transactions"
label="Cancelling cash-out transactions"
bottomSpace="0"
/>
</HelpTooltip>
</div>
)
else return getStatus(it)
},
textAlign: 'left',
size: 'sm',
width: 80,
},
]
const mapColumnFiltersToVariables = filters => {
const filterMap = {
machineName: 'deviceId',
customerId: 'customerId',
cryptoAtoms: 'cryptoCode',
toAddress: 'toAddress',
status: 'status',
txClass: 'txClass',
swept: 'swept',
}
const onFilterChange = filters => {
const filtersObject = getFiltersObj(filters)
setFilters(filters)
setVariables({
limit: NUM_LOG_RESULTS,
txClass: filtersObject.type,
deviceId: filtersObject.machine,
customerName: filtersObject.customer,
fiatCode: filtersObject.fiat,
cryptoCode: filtersObject.crypto,
toAddress: filtersObject.address,
status: filtersObject.status,
swept: filtersObject.swept && filtersObject.swept === 'Swept',
})
refetch && refetch()
return filters.reduce((acc, filter) => {
const mappedKey = filterMap[filter.id] || filter.id
if (mappedKey && filter.value !== undefined && filter.value !== '') {
acc[mappedKey] = filter.value
}
return acc
}, {})
}
const onFilterDelete = filter => {
const newFilters = R.filter(
f => !R.whereEq(R.pick(['type', 'value'], f), filter),
)(filters)
const listFilterChange = inputs => {
const { limit, offset } = inputs ?? {}
const mappedFilters = mapColumnFiltersToVariables(columnFilters)
setFilters(newFilters)
const filtersObject = getFiltersObj(newFilters)
setVariables({
limit: NUM_LOG_RESULTS,
txClass: filtersObject.type,
deviceId: filtersObject.machine,
customerName: filtersObject.customer,
fiatCode: filtersObject.fiat,
cryptoCode: filtersObject.crypto,
toAddress: filtersObject.address,
status: filtersObject.status,
swept: filtersObject.swept && filtersObject.swept === 'Swept',
updateField('variables', {
limit,
offset,
...mappedFilters,
})
refetch && refetch()
}
const deleteAllFilters = () => {
setFilters([])
const filtersObject = getFiltersObj([])
setVariables({
limit: NUM_LOG_RESULTS,
txClass: filtersObject.type,
deviceId: filtersObject.machine,
customerName: filtersObject.customer,
fiatCode: filtersObject.fiat,
cryptoCode: filtersObject.crypto,
toAddress: filtersObject.address,
status: filtersObject.status,
swept: filtersObject.swept && filtersObject.swept === 'Swept',
})
refetch && refetch()
}
const filterOptions = R.path(['transactionFilters'])(filtersResponse)
const loading = transactionsLoading || filtersLoading || configLoading
const errorLabel = (
<svg width={12} height={12}>
<rect width={12} height={12} rx={3} fill={errorColor} />
@ -340,14 +458,7 @@ const Transactions = () => {
]}
appendix={
<div className="flex ml-4 gap-4">
<SearchBox
loading={filtersLoading}
filters={filters}
options={filterOptions}
inputPlaceholder={'Search transactions'}
onChange={onFilterChange}
/>
{txList && (
{
<LogsDowloaderPopover
title="Download logs"
name="transactions"
@ -357,28 +468,11 @@ const Transactions = () => {
timezone={timezone}
args={{ timezone }}
/>
)}
}
</div>
}
/>
{filters.length > 0 && (
<SearchFilter
entries={txList.length}
filters={filters}
onFilterDelete={onFilterDelete}
deleteAllFilters={deleteAllFilters}
/>
)}
<DataTable
loading={loading}
emptyText="No transactions so far"
elements={elements}
data={txList}
Details={DetailsRow}
expandable
rowSize="sm"
timezone={timezone}
/>
<MaterialReactTable table={table} />
</>
)
}

View file

@ -26,7 +26,8 @@ const getStatus = it => {
const getStatusDetails = it => {
if (!R.isNil(it.hasError)) return it.hasError
if (!R.isNil(it.batchError)) return `Batch error: ${it.batchError}`
if (!R.isNil(it.batchError) && it.txClass === 'cashIn')
return `Batch error: ${it.batchError}`
return null
}

View file

@ -26,15 +26,12 @@ const formatName = idCardData => {
/* Expects a transaction object */
const displayName = ({
isAnonymous,
customerName,
customerIdCardData,
customerPhone,
customerEmail,
}) =>
isAnonymous
? 'Anonymous'
: customerName ||
customerEmail ||
R.defaultTo(customerPhone, formatName(customerIdCardData))
: formatName(customerIdCardData) || customerEmail || customerPhone
export { displayName, formatFullName, formatName }

View file

@ -1,4 +1,5 @@
const defaultMaterialTableOpts = {
enableKeyboardShortcuts: false,
enableGlobalFilter: false,
paginationDisplayMode: 'pages',
enableColumnActions: false,