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",
"@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,6 +35,7 @@ const App = () => {
isDirtyForm,
setDirtyForm,
}}>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Router hook={useLocationWithConfirmation}>
<ApolloProvider>
<StyledEngineProvider enableCssLayer>
@ -43,6 +46,7 @@ const App = () => {
</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,
const { data: customersResponse, loading: customerLoading } = useQuery(
GET_CUSTOMERS,
{
onCompleted: data => setFilteredCustomers(R.path(['customers'])(data)),
})
const { data: filtersResponse, loading: loadingFilters } =
useQuery(GET_CUSTOMER_FILTERS)
},
)
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,57 +1,81 @@
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 = [
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',
width: 199,
view: it => `${getFormattedPhone(it.phone, locale.country) || ''}
${it.email || ''}`,
},
{
id: 'name',
header: 'Name',
width: 241,
view: getName,
accessorFn: getName,
},
{
header: 'Total Txs',
width: 126,
textAlign: 'right',
view: it => `${Number.parseInt(it.totalTxs)}`,
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',
width: 152,
textAlign: 'right',
view: it =>
`${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}`,
...alignRight,
},
{
header: 'Last active',
width: 133,
view: it =>
(it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? '',
// 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',
width: 161,
textAlign: 'right',
view: it => {
const hasLastTx = !R.isNil(it.lastTxFiatCode)
const LastTxIcon = it.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon
...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(it.lastTxFiat)} ${it.lastTxFiatCode ?? ''}`}
`${parseFloat(cell.getValue())} ${row.original.lastTxFiatCode ?? ''}`}
{hasLastTx && lastIcon}
</>
)
@ -59,20 +83,37 @@ const CustomersList = ({ data, locale, onClick, loading }) => {
},
{
header: 'Status',
width: 191,
view: it => <MainStatus statuses={[it.authorizedStatus]} />,
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,
},
},
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 }

View file

@ -27,18 +27,5 @@ function transaction() {
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, customer }
module.exports = { transaction }

View file

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