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

8056
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -3,6 +3,8 @@ import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles'
import React, { useState } from 'react' import React, { useState } from 'react'
import { Router } from 'wouter' import { Router } from 'wouter'
import ApolloProvider from './utils/apollo' 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 AppContext from './AppContext'
import theme from './styling/theme' import theme from './styling/theme'
@ -33,6 +35,7 @@ const App = () => {
isDirtyForm, isDirtyForm,
setDirtyForm, setDirtyForm,
}}> }}>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Router hook={useLocationWithConfirmation}> <Router hook={useLocationWithConfirmation}>
<ApolloProvider> <ApolloProvider>
<StyledEngineProvider enableCssLayer> <StyledEngineProvider enableCssLayer>
@ -43,6 +46,7 @@ const App = () => {
</StyledEngineProvider> </StyledEngineProvider>
</ApolloProvider> </ApolloProvider>
</Router> </Router>
</LocalizationProvider>
</AppContext.Provider> </AppContext.Provider>
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -27,18 +27,5 @@ function transaction() {
return db.any(sql) return db.any(sql)
} }
function customer() {
const sql = `SELECT DISTINCT * FROM (
SELECT 'phone' AS type, phone AS value FROM customers WHERE phone IS NOT NULL UNION
SELECT 'email' AS type, email AS value FROM customers WHERE email IS NOT NULL UNION
SELECT 'name' AS type, id_card_data::json->>'firstName' AS value FROM customers WHERE id_card_data::json->>'firstName' IS NOT NULL AND id_card_data::json->>'lastName' IS NULL UNION
SELECT 'name' AS type, id_card_data::json->>'lastName' AS value FROM customers WHERE id_card_data::json->>'firstName' IS NULL AND id_card_data::json->>'lastName' IS NOT NULL UNION
SELECT 'name' AS type, concat(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') AS value FROM customers WHERE id_card_data::json->>'firstName' IS NOT NULL AND id_card_data::json->>'lastName' IS NOT NULL UNION
SELECT 'address' as type, id_card_data::json->>'address' AS value FROM customers WHERE id_card_data::json->>'address' IS NOT NULL UNION
SELECT 'id' AS type, id_card_data::json->>'documentNumber' AS value FROM customers WHERE id_card_data::json->>'documentNumber' IS NOT NULL
) f`
return db.any(sql) module.exports = { transaction }
}
module.exports = { transaction, customer }

View file

@ -1,7 +1,6 @@
const authentication = require('../modules/userManagement') const authentication = require('../modules/userManagement')
const anonymous = require('../../../constants').anonymousCustomer const anonymous = require('../../../constants').anonymousCustomer
const customers = require('../../../customers') const customers = require('../../../customers')
const filters = require('../../filters')
const customerNotes = require('../../../customer-notes') const customerNotes = require('../../../customer-notes')
const machineLoader = require('../../../machine-loader') const machineLoader = require('../../../machine-loader')
@ -22,7 +21,6 @@ const resolvers = {
customers.getCustomersList(phone, name, address, id, email), customers.getCustomersList(phone, name, address, id, email),
customer: (...[, { customerId }]) => customer: (...[, { customerId }]) =>
customers.getCustomerById(customerId).then(addLastUsedMachineName), customers.getCustomerById(customerId).then(addLastUsedMachineName),
customerFilters: () => filters.customer(),
}, },
Mutation: { Mutation: {
setCustomer: (root, { customerId, customerInput }, context) => { setCustomer: (root, { customerId, customerInput }, context) => {