feat: advanced table for customers

This commit is contained in:
Rafael Taranto 2025-05-18 12:33:37 +01:00
parent 16c1709e99
commit e3335d69b4
10 changed files with 2854 additions and 5568 deletions

View file

@ -10,6 +10,7 @@
"@lamassu/coins": "v1.6.1",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@mui/x-date-pickers": "^8.3.1",
"@simplewebauthn/browser": "^3.0.0",
"apollo-upload-client": "^18.0.0",
"bignumber.js": "9.0.0",
@ -25,6 +26,7 @@
"jszip": "^3.6.0",
"libphonenumber-js": "^1.11.15",
"match-sorter": "^4.2.0",
"material-react-table": "^3.2.1",
"pretty-ms": "^2.1.0",
"qrcode.react": "4.2.0",
"ramda": "^0.26.1",

View file

@ -3,6 +3,8 @@ import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles'
import React, { useState } from 'react'
import { Router } from 'wouter'
import ApolloProvider from './utils/apollo'
import { LocalizationProvider } from '@mui/x-date-pickers'
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV2'
import AppContext from './AppContext'
import theme from './styling/theme'
@ -33,16 +35,18 @@ const App = () => {
isDirtyForm,
setDirtyForm,
}}>
<Router hook={useLocationWithConfirmation}>
<ApolloProvider>
<StyledEngineProvider enableCssLayer>
<ThemeProvider theme={theme}>
<CssBaseline />
<Main />
</ThemeProvider>
</StyledEngineProvider>
</ApolloProvider>
</Router>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Router hook={useLocationWithConfirmation}>
<ApolloProvider>
<StyledEngineProvider enableCssLayer>
<ThemeProvider theme={theme}>
<CssBaseline />
<Main />
</ThemeProvider>
</StyledEngineProvider>
</ApolloProvider>
</Router>
</LocalizationProvider>
</AppContext.Provider>
)
}

View file

@ -290,6 +290,7 @@ const CustomerProfile = memo(() => {
const [error, setError] = useState(null)
const [clickedItem, setClickedItem] = useState('overview')
const { id: customerId } = useParams()
console.log(customerId)
const {
data: customerResponse,

View file

@ -2,8 +2,6 @@ import { useQuery, useMutation, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import { useLocation } from 'wouter'
import SearchBox from '../../components/SearchBox'
import SearchFilter from '../../components/SearchFilter'
import TitleSection from '../../components/layout/TitleSection'
import TxInIcon from '../../styling/icons/direction/cash-in.svg?react'
import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react'
@ -15,15 +13,6 @@ import CustomersList from './CustomersList'
import CreateCustomerModal from './components/CreateCustomerModal'
import { getAuthorizedStatus } from './helper'
const GET_CUSTOMER_FILTERS = gql`
query filters {
customerFilters {
type
value
}
}
`
const GET_CUSTOMERS = gql`
query configAndCustomers(
$phone: String
@ -91,9 +80,6 @@ const CREATE_CUSTOMER = gql`
}
`
const getFiltersObj = filters =>
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters)
const Customers = () => {
const [, navigate] = useLocation()
@ -101,28 +87,20 @@ const Customers = () => {
navigate(`/compliance/customer/${customer.id}`)
const [filteredCustomers, setFilteredCustomers] = useState([])
const [variables, setVariables] = useState({})
const [filters, setFilters] = useState([])
const [showCreationModal, setShowCreationModal] = useState(false)
const {
data: customersResponse,
loading: customerLoading,
refetch,
} = useQuery(GET_CUSTOMERS, {
variables,
onCompleted: data => setFilteredCustomers(R.path(['customers'])(data)),
})
const { data: filtersResponse, loading: loadingFilters } =
useQuery(GET_CUSTOMER_FILTERS)
const { data: customersResponse, loading: customerLoading } = useQuery(
GET_CUSTOMERS,
{
onCompleted: data => setFilteredCustomers(R.path(['customers'])(data)),
},
)
const [createNewCustomer] = useMutation(CREATE_CUSTOMER, {
onCompleted: () => setShowCreationModal(false),
refetchQueries: () => [
{
query: GET_CUSTOMERS,
variables,
},
],
})
@ -147,74 +125,10 @@ const Customers = () => {
R.sortWith([R.ascend(byAuthorized), R.descend(byLastActive)]),
)(filteredCustomers ?? [])
const onFilterChange = filters => {
const filtersObject = getFiltersObj(filters)
setFilters(filters)
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
email: filtersObject.email,
address: filtersObject.address,
id: filtersObject.id,
})
refetch && refetch()
}
const onFilterDelete = filter => {
const newFilters = R.filter(
f => !R.whereEq(R.pick(['type', 'value'], f), filter),
)(filters)
setFilters(newFilters)
const filtersObject = getFiltersObj(newFilters)
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
email: filtersObject.email,
address: filtersObject.address,
id: filtersObject.id,
})
refetch && refetch()
}
const deleteAllFilters = () => {
setFilters([])
const filtersObject = getFiltersObj([])
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
email: filtersObject.email,
address: filtersObject.address,
id: filtersObject.id,
})
refetch && refetch()
}
const filterOptions = R.path(['customerFilters'])(filtersResponse)
return (
<>
<TitleSection
title="Customers"
appendix={
<div className="flex ml-4">
<SearchBox
loading={loadingFilters}
filters={filters}
options={filterOptions}
inputPlaceholder={'Search customers'}
onChange={onFilterChange}
/>
</div>
}
appendixRight={
<div className="flex">
<Link color="primary" onClick={() => setShowCreationModal(true)}>
@ -227,21 +141,11 @@ const Customers = () => {
{ label: 'Cash-out', icon: <TxOutIcon /> },
]}
/>
{filters.length > 0 && (
<SearchFilter
entries={customersData.length}
filters={filters}
onFilterDelete={onFilterDelete}
deleteAllFilters={deleteAllFilters}
/>
)}
<CustomersList
data={customersData}
locale={locale}
onClick={handleCustomerClicked}
loading={customerLoading}
triggers={triggers}
customRequests={customRequirementsData}
/>
<CreateCustomerModal
showModal={showCreationModal}

View file

@ -1,78 +1,119 @@
import { format } from 'date-fns/fp'
import * as R from 'ramda'
import React from 'react'
import React, { useMemo } from 'react'
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'
import { MainStatus } from '../../components/Status'
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 {
defaultMaterialTableOpts,
alignRight,
} from '../../utils/materialReactTableOpts'
import { getFormattedPhone, getName } from './helper'
const CustomersList = ({ data, locale, onClick, loading }) => {
const elements = [
{
header: 'Phone/email',
width: 199,
view: it => `${getFormattedPhone(it.phone, locale.country) || ''}
${it.email || ''}`,
},
{
header: 'Name',
width: 241,
view: getName,
},
{
header: 'Total Txs',
width: 126,
textAlign: 'right',
view: it => `${Number.parseInt(it.totalTxs)}`,
},
{
header: 'Total spent',
width: 152,
textAlign: 'right',
view: it =>
`${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}`,
},
{
header: 'Last active',
width: 133,
view: it =>
(it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? '',
},
{
header: 'Last transaction',
width: 161,
textAlign: 'right',
view: it => {
const hasLastTx = !R.isNil(it.lastTxFiatCode)
const LastTxIcon = it.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon
const lastIcon = <LastTxIcon className="ml-3" />
return (
<>
{hasLastTx &&
`${parseFloat(it.lastTxFiat)} ${it.lastTxFiatCode ?? ''}`}
{hasLastTx && lastIcon}
</>
)
const columns = useMemo(
() => [
{
accessorKey: 'id',
header: 'ID',
size: 315,
enableColumnFilter: true,
},
{
id: 'phone-email',
accessorFn: it =>
`${getFormattedPhone(it.phone, locale.country) || ''} ${it.email || ''}`,
size: 180,
header: 'Phone/email',
},
{
id: 'name',
header: 'Name',
accessorFn: getName,
},
{
accessorKey: 'totalTxs',
header: 'Total txs',
size: 126,
enableColumnFilter: false,
...alignRight,
},
{
id: 'totalSpent',
accessorKey: 'totalSpent',
size: 152,
enableColumnFilter: false,
Cell: ({ cell, row }) =>
`${Number.parseFloat(cell.getValue())} ${row.original.lastTxFiatCode ?? ''}`,
header: 'Total spent',
...alignRight,
},
{
header: 'Last active',
// accessorKey: 'lastActive',
accessorFn: it => new Date(it.lastActive),
size: 133,
enableColumnFilter: false,
Cell: ({ cell }) =>
(cell.getValue() &&
format('yyyy-MM-dd', new Date(cell.getValue()))) ??
'',
},
{
header: 'Last transaction',
...alignRight,
size: 170,
enableColumnFilter: false,
accessorKey: 'lastTxFiat',
Cell: ({ cell, row }) => {
const hasLastTx = !R.isNil(row.original.lastTxFiatCode)
const LastTxIcon =
row.original.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon
const lastIcon = <LastTxIcon className="ml-3" />
return (
<>
{hasLastTx &&
`${parseFloat(cell.getValue())} ${row.original.lastTxFiatCode ?? ''}`}
{hasLastTx && lastIcon}
</>
)
},
},
{
header: 'Status',
id: 'status',
size: 100,
enableColumnFilter: false,
accessorKey: 'authorizedStatus',
Cell: ({ cell }) => <MainStatus statuses={[cell.getValue()]} />,
},
],
[],
)
const table = useMaterialReactTable({
...defaultMaterialTableOpts,
columns: columns,
data,
initialState: {
...defaultMaterialTableOpts.initialState,
columnVisibility: {
id: false,
},
},
{
header: 'Status',
width: 191,
view: it => <MainStatus statuses={[it.authorizedStatus]} />,
},
]
state: { isLoading: loading },
getRowId: it => it.id,
muiTableBodyRowProps: ({ row }) => ({
onClick: () => onClick(row),
sx: { cursor: 'pointer' },
}),
})
return (
<>
<DataTable
loading={loading}
emptyText="No customers so far"
elements={elements}
data={data}
onClick={onClick}
/>
<MaterialReactTable table={table} />
</>
)
}

View file

@ -30,6 +30,8 @@ const { p } = typographyStyles
let theme = createTheme({
typography: {
fontFamily: inputFontFamily,
root: { ...p },
body1: { ...p },
},
palette: {
primary: {
@ -56,6 +58,18 @@ theme = createTheme(theme, {
body1: { ...p },
},
},
MuiCircularProgress: {
styleOverrides: {
root: {
color: primaryColor,
},
},
},
MuiTableCell: {
styleOverrides: {
root: { ...p },
},
},
MuiIconButtonBase: {
defaultProps: {
disableRipple: true,

View file

@ -0,0 +1,33 @@
const defaultMaterialTableOpts = {
enableGlobalFilter: false,
paginationDisplayMode: 'pages',
enableColumnActions: false,
initialState: { density: 'compact' },
mrtTheme: it => ({
...it,
baseBackgroundColor: '#fff',
}),
muiTopToolbarProps: () => ({
sx: {
backgroundColor: 'var(--zodiac)',
'& .MuiButtonBase-root': { color: '#fff' },
},
}),
muiTableHeadRowProps: () => ({
sx: { backgroundColor: 'var(--zircon)' },
}),
}
const alignRight = {
muiTableHeadCellProps: {
align: 'right',
},
muiTableBodyCellProps: {
align: 'right',
},
muiTableFooterCellProps: {
align: 'right',
},
}
export { defaultMaterialTableOpts, alignRight }