feat: advanced table for customers
This commit is contained in:
parent
16c1709e99
commit
e3335d69b4
10 changed files with 2854 additions and 5568 deletions
8060
package-lock.json
generated
8060
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
33
packages/admin-ui/src/utils/materialReactTableOpts.js
Normal file
33
packages/admin-ui/src/utils/materialReactTableOpts.js
Normal 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 }
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue