feat: added the compliance/customers route

feat: added customers list page

feat: created the Customer type on the gql server and consume it

Currently only with the 'name' property

feat: added query on gql to get the customers list with the needed props

feat: added the currently available props to the front end table

fix: consider only sent txs for the aggregations on the customers list

fix: replace ExpTable with a non-expandable one

fix: remove unused properties from gql and front-end

fix: fixed the customers list columns width

fix: the last active table column was reading the wrong property

chore: remove debug logging

fix: use the correct table columns to check for txs that should be
considered on the customers list page

fix: use the international format for phone numbers

feat: added the search box

fix: remove ordering from the gql customers list query and moved it to
the front-end)

fix: removed the search box

chore: refactor the customers list table into a new component

chore: cleanup code

fix: fixed styles from customer list page header
This commit is contained in:
Liordino Neto 2020-02-06 20:36:56 -03:00 committed by Josh Harvey
parent 4320df2d61
commit 507027cdee
10 changed files with 327 additions and 4 deletions

View file

@ -377,6 +377,37 @@ function batch () {
}, customers))) }, customers)))
} }
/**
* Query all customers, ordered by last activity
* and with aggregate columns based on their
* transactions
*
* @returns {array} Array of customers with it's
*/
function getCustomersList () {
const sql = `select name, phone, total_txs, total_spent,
created as last_active, fiat as last_tx_fiat, fiat_code as last_tx_fiat_code,
tx_class as last_tx_class
from (
select c.name, c.phone, t.tx_class, t.fiat, t.fiat_code, t.created,
row_number() over (partition by c.id order by t.created desc) as rn,
count(0) over (partition by c.id) as total_txs,
sum(t.fiat) over (partition by c.id) as total_spent
from customers c inner join (
select 'cashIn' as tx_class, id, fiat, fiat_code, created, customer_id
from cash_in_txs where send_confirmed = true union
select 'cashOut' as tx_class, id, fiat, fiat_code, created, customer_id
from cash_out_txs where confirmed_at is not null) t on c.id = t.customer_id
where c.id != $1
) as cl where rn = 1
limit $2`
return db.any(sql, [ anonymous.uuid, NUM_RESULTS ])
.then(customers => Promise.all(_.map(customer => {
return populateOverrideUsernames(customer)
.then(camelize)
}, customers)))
}
/** /**
* @param {String} id customer id * @param {String} id customer id
* @param {Object} patch customer update record * @param {Object} patch customer update record
@ -482,4 +513,4 @@ function updateFrontCamera (id, patch) {
}) })
} }
module.exports = { add, get, batch, getById, update, updatePhotoCard, updateFrontCamera } module.exports = { add, get, batch, getCustomersList, getById, update, updatePhotoCard, updateFrontCamera }

View file

@ -4,6 +4,7 @@ const { GraphQLJSON, GraphQLJSONObject } = require('graphql-type-json')
const got = require('got') const got = require('got')
const machineLoader = require('../../machine-loader') const machineLoader = require('../../machine-loader')
const customers = require('../../customers')
const { machineAction } = require('../machines') const { machineAction } = require('../machines')
const logs = require('../../logs') const logs = require('../../logs')
const supportLogs = require('../../support_logs') const supportLogs = require('../../support_logs')
@ -58,6 +59,17 @@ const typeDefs = gql`
statuses: [MachineStatus] statuses: [MachineStatus]
} }
type Customer {
name: String!
phone: String
totalTxs: Int
totalSpent: String
lastActive: Date
lastTxFiat: String
lastTxFiatCode: String
lastTxClass: String
}
type Account { type Account {
code: String! code: String!
display: String! display: String!
@ -146,6 +158,7 @@ const typeDefs = gql`
accounts: [Account] accounts: [Account]
cryptoCurrencies: [CryptoCurrency] cryptoCurrencies: [CryptoCurrency]
machines: [Machine] machines: [Machine]
customers: [Customer]
machineLogs(deviceId: ID!): [MachineLog] machineLogs(deviceId: ID!): [MachineLog]
funding: [CoinFunds] funding: [CoinFunds]
serverVersion: String! serverVersion: String!
@ -190,6 +203,7 @@ const resolvers = {
accounts: () => accounts, accounts: () => accounts,
cryptoCurrencies: () => coins, cryptoCurrencies: () => coins,
machines: () => machineLoader.getMachineNames(), machines: () => machineLoader.getMachineNames(),
customers: () => customers.getCustomersList(),
funding: () => funding.getFunding(), funding: () => funding.getFunding(),
machineLogs: (...[, { deviceId }]) => logs.simpleGetMachineLogs(deviceId), machineLogs: (...[, { deviceId }]) => logs.simpleGetMachineLogs(deviceId),
serverVersion: () => serverVersion, serverVersion: () => serverVersion,

View file

@ -0,0 +1,86 @@
import React, { memo } from 'react'
import {
AutoSizer,
List,
CellMeasurer,
CellMeasurerCache
} from 'react-virtualized'
import { THead, Th, Tr, Td } from 'src/components/fake-table/Table'
import { mainWidth } from 'src/styling/variables'
const DataTable = memo(({ elements, data }) => {
const cache = new CellMeasurerCache({
defaultHeight: 62,
fixedWidth: true
})
return (
<>
<div>
<THead>
{elements.map(({ size, className, textAlign, header }, idx) => (
<Th
key={idx}
size={size}
className={className}
textAlign={textAlign}>
{header}
</Th>
))}
</THead>
</div>
<div style={{ flex: '1 1 auto' }}>
<AutoSizer disableWidth>
{({ height }) => (
<List
height={height}
width={mainWidth}
rowCount={data.length}
rowHeight={cache.rowHeight}
rowRenderer={({ index, isScrolling, key, parent, style }) => (
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}>
<div style={style}>
<Tr
error={data[index].error}
errorMessage={data[index].errorMessage}>
{elements.map(
(
{
header,
size,
className,
textAlign,
view = it => it?.toString()
},
idx
) => (
<Td
key={idx}
size={size}
className={className}
textAlign={textAlign}>
{view(data[index])}
</Td>
)
)}
</Tr>
</div>
</CellMeasurer>
)}
overscanRowCount={50}
deferredMeasurementCache={cache}
/>
)}
</AutoSizer>
</div>
</>
)
})
export default DataTable

View file

@ -0,0 +1,3 @@
import DataTable from './DataTable'
export { DataTable }

View file

@ -0,0 +1,106 @@
import { makeStyles } from '@material-ui/core/styles'
import moment from 'moment'
import * as R from 'ramda'
import React from 'react'
import { useQuery } from '@apollo/react-hooks'
import { gql } from 'apollo-boost'
import { parsePhoneNumberFromString } from 'libphonenumber-js'
import Title from 'src/components/Title'
import { DataTable } from 'src/components/dataTable'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { mainStyles } from './Customers.styles'
const useStyles = makeStyles(mainStyles)
const GET_CUSTOMERS = gql`
{
customers {
name
phone
totalTxs
totalSpent
lastActive
lastTxFiat
lastTxFiatCode
lastTxClass
}
}
`
const Customers = () => {
const classes = useStyles()
const { data: customersResponse } = useQuery(GET_CUSTOMERS)
const elements = [
{
header: 'Name',
size: 277,
view: R.path(['name'])
},
{
header: 'Phone',
size: 166,
view: it => parsePhoneNumberFromString(it.phone).formatInternational()
},
{
header: 'Total TXs',
size: 174,
textAlign: 'right',
view: it => `${Number.parseInt(it.totalTxs)}`
},
{
header: 'Total spent',
size: 188,
textAlign: 'right',
view: it => `${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode}`
},
{
header: 'Last active',
size: 197,
view: it => moment.utc(it.lastActive).format('YYYY-MM-D')
},
{
header: 'Last transaction',
size: 198,
textAlign: 'right',
view: it => (
<>
{`${Number.parseFloat(it.lastTxFiat)} ${it.lastTxFiatCode} `}
{it.lastTxClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />}
</>
)
}
]
return (
<>
<div className={classes.titleWrapper}>
<div className={classes.titleAndButtonsContainer}>
<Title>Customers</Title>
</div>
<div className={classes.headerLabels}>
<div>
<TxOutIcon />
<span>Cash-out</span>
</div>
<div>
<TxInIcon />
<span>Cash-in</span>
</div>
</div>
</div>
<DataTable
elements={elements}
data={R.sortWith([R.descend('lastActive')])(
R.path(['customers'])(customersResponse) ?? []
)}
/>
</>
)
}
export default Customers

View file

@ -0,0 +1,28 @@
import typographyStyles from 'src/components/typography/styles'
import baseStyles from 'src/pages/Logs.styles'
const { label1 } = typographyStyles
const { titleWrapper, titleAndButtonsContainer, buttonsWrapper } = baseStyles
const mainStyles = {
titleWrapper,
titleAndButtonsContainer,
buttonsWrapper,
headerLabels: {
display: 'flex',
flexDirection: 'row',
'& div': {
display: 'flex',
alignItems: 'center'
},
'& > div:first-child': {
marginRight: 24
},
'& span': {
extend: label1,
marginLeft: 6
}
}
}
export { mainStyles }

View file

@ -0,0 +1,3 @@
import Customers from './Customers'
export default Customers

View file

@ -13,6 +13,7 @@ import AuthRegister from 'src/pages/AuthRegister'
import OperatorInfo from 'src/pages/OperatorInfo/OperatorInfo' import OperatorInfo from 'src/pages/OperatorInfo/OperatorInfo'
import MachineStatus from '../pages/maintenance/MachineStatus' import MachineStatus from '../pages/maintenance/MachineStatus'
import Customers from '../pages/Customers'
const tree = [ const tree = [
{ key: 'transactions', label: 'Transactions', route: '/transactions' }, { key: 'transactions', label: 'Transactions', route: '/transactions' },
@ -55,6 +56,28 @@ const tree = [
}, },
{ key: 'info', label: 'Operator Info', route: '/settings/operator-info' } { key: 'info', label: 'Operator Info', route: '/settings/operator-info' }
] ]
},
{
key: 'compliance',
label: 'Compliance',
route: '/compliance',
children: [
// {
// key: 'triggers',
// label: 'Triggers',
// route: '/compliance/triggers'
// },
{
key: 'customers',
label: 'Customers',
route: '/compliance/customers'
}
// {
// key: 'blacklist',
// label: 'Blacklist',
// route: '/compliance/blacklist'
// }
]
} }
// compliance: { label: 'Compliance', children: [{ label: 'Locale', route: '/locale' }] } // compliance: { label: 'Compliance', children: [{ label: 'Locale', route: '/locale' }] }
] ]
@ -88,6 +111,7 @@ const Routes = () => (
<Route path="/transactions" component={Transactions} /> <Route path="/transactions" component={Transactions} />
<Route path="/register" component={AuthRegister} /> <Route path="/register" component={AuthRegister} />
<Route path="/maintenance/machine-status" component={MachineStatus} /> <Route path="/maintenance/machine-status" component={MachineStatus} />
<Route path="/compliance/customers" component={Customers} />
</Switch> </Switch>
) )

33
package-lock.json generated
View file

@ -2640,7 +2640,8 @@
"bn.js": { "bn.js": {
"version": "4.11.8", "version": "4.11.8",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
"integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==",
"optional": true
}, },
"bs58": { "bs58": {
"version": "4.0.1", "version": "4.0.1",
@ -8132,6 +8133,15 @@
"type-check": "~0.3.2" "type-check": "~0.3.2"
} }
}, },
"libphonenumber-js": {
"version": "1.7.38",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.7.38.tgz",
"integrity": "sha512-3NMPjWl15E51vWtXYdhVXo+DtZcR35OmXfJQWcwQ28XEbpSWOnQXbkaIZq8RmMv5jiY4fMQxBY7MUXvioApOHg==",
"requires": {
"minimist": "^1.2.0",
"xml2js": "^0.4.17"
}
},
"libpq": { "libpq": {
"version": "1.8.7", "version": "1.8.7",
"resolved": "https://registry.npmjs.org/libpq/-/libpq-1.8.7.tgz", "resolved": "https://registry.npmjs.org/libpq/-/libpq-1.8.7.tgz",
@ -8545,7 +8555,8 @@
"safe-buffer": { "safe-buffer": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==",
"optional": true
} }
} }
}, },
@ -12728,7 +12739,7 @@
"resolved": "https://registry.npmjs.org/web3/-/web3-0.20.6.tgz", "resolved": "https://registry.npmjs.org/web3/-/web3-0.20.6.tgz",
"integrity": "sha1-PpcwauAk+yThCj11yIQwJWIhUSA=", "integrity": "sha1-PpcwauAk+yThCj11yIQwJWIhUSA=",
"requires": { "requires": {
"bignumber.js": "git+https://github.com/frozeman/bignumber.js-nolookahead.git#57692b3ecfc98bbdd6b3a516cb2353652ea49934", "bignumber.js": "git+https://github.com/frozeman/bignumber.js-nolookahead.git",
"crypto-js": "^3.1.4", "crypto-js": "^3.1.4",
"utf8": "^2.1.1", "utf8": "^2.1.1",
"xhr2": "*", "xhr2": "*",
@ -12999,6 +13010,22 @@
} }
} }
}, },
"xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"dependencies": {
"xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
}
}
},
"xmlbuilder": { "xmlbuilder": {
"version": "8.2.2", "version": "8.2.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz",

View file

@ -37,6 +37,7 @@
"helmet": "^3.8.1", "helmet": "^3.8.1",
"inquirer": "^5.2.0", "inquirer": "^5.2.0",
"kraken-api": "github:DeX3/npm-kraken-api", "kraken-api": "github:DeX3/npm-kraken-api",
"libphonenumber-js": "^1.7.38",
"lnd-async": "^1.2.0", "lnd-async": "^1.2.0",
"lodash": "^4.17.10", "lodash": "^4.17.10",
"longjohn": "^0.2.12", "longjohn": "^0.2.12",