diff --git a/lib/customers.js b/lib/customers.js
index 51478d11..8912b0e7 100644
--- a/lib/customers.js
+++ b/lib/customers.js
@@ -377,6 +377,37 @@ function batch () {
}, 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 {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 }
diff --git a/lib/new-admin/graphql/schema.js b/lib/new-admin/graphql/schema.js
index 75f673eb..7595f61c 100644
--- a/lib/new-admin/graphql/schema.js
+++ b/lib/new-admin/graphql/schema.js
@@ -4,6 +4,7 @@ const { GraphQLJSON, GraphQLJSONObject } = require('graphql-type-json')
const got = require('got')
const machineLoader = require('../../machine-loader')
+const customers = require('../../customers')
const { machineAction } = require('../machines')
const logs = require('../../logs')
const supportLogs = require('../../support_logs')
@@ -58,6 +59,17 @@ const typeDefs = gql`
statuses: [MachineStatus]
}
+ type Customer {
+ name: String!
+ phone: String
+ totalTxs: Int
+ totalSpent: String
+ lastActive: Date
+ lastTxFiat: String
+ lastTxFiatCode: String
+ lastTxClass: String
+ }
+
type Account {
code: String!
display: String!
@@ -146,6 +158,7 @@ const typeDefs = gql`
accounts: [Account]
cryptoCurrencies: [CryptoCurrency]
machines: [Machine]
+ customers: [Customer]
machineLogs(deviceId: ID!): [MachineLog]
funding: [CoinFunds]
serverVersion: String!
@@ -190,6 +203,7 @@ const resolvers = {
accounts: () => accounts,
cryptoCurrencies: () => coins,
machines: () => machineLoader.getMachineNames(),
+ customers: () => customers.getCustomersList(),
funding: () => funding.getFunding(),
machineLogs: (...[, { deviceId }]) => logs.simpleGetMachineLogs(deviceId),
serverVersion: () => serverVersion,
diff --git a/new-lamassu-admin/src/components/dataTable/DataTable.js b/new-lamassu-admin/src/components/dataTable/DataTable.js
new file mode 100644
index 00000000..2e744d8b
--- /dev/null
+++ b/new-lamassu-admin/src/components/dataTable/DataTable.js
@@ -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 (
+ <>
+
+
+ {elements.map(({ size, className, textAlign, header }, idx) => (
+ |
+ {header}
+ |
+ ))}
+
+
+
+
+ {({ height }) => (
+ (
+
+
+
+ {elements.map(
+ (
+ {
+ header,
+ size,
+ className,
+ textAlign,
+ view = it => it?.toString()
+ },
+ idx
+ ) => (
+ |
+ {view(data[index])}
+ |
+ )
+ )}
+
+
+
+ )}
+ overscanRowCount={50}
+ deferredMeasurementCache={cache}
+ />
+ )}
+
+
+ >
+ )
+})
+
+export default DataTable
diff --git a/new-lamassu-admin/src/components/dataTable/index.js b/new-lamassu-admin/src/components/dataTable/index.js
new file mode 100644
index 00000000..08a94ecf
--- /dev/null
+++ b/new-lamassu-admin/src/components/dataTable/index.js
@@ -0,0 +1,3 @@
+import DataTable from './DataTable'
+
+export { DataTable }
diff --git a/new-lamassu-admin/src/pages/Customers/Customers.js b/new-lamassu-admin/src/pages/Customers/Customers.js
new file mode 100644
index 00000000..e5a69089
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Customers/Customers.js
@@ -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' ? : }
+ >
+ )
+ }
+ ]
+
+ return (
+ <>
+
+
+
Customers
+
+
+
+
+ Cash-out
+
+
+
+ Cash-in
+
+
+
+
+ >
+ )
+}
+
+export default Customers
diff --git a/new-lamassu-admin/src/pages/Customers/Customers.styles.js b/new-lamassu-admin/src/pages/Customers/Customers.styles.js
new file mode 100644
index 00000000..48db31ee
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Customers/Customers.styles.js
@@ -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 }
diff --git a/new-lamassu-admin/src/pages/Customers/index.js b/new-lamassu-admin/src/pages/Customers/index.js
new file mode 100644
index 00000000..950b526a
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Customers/index.js
@@ -0,0 +1,3 @@
+import Customers from './Customers'
+
+export default Customers
diff --git a/new-lamassu-admin/src/routing/routes.js b/new-lamassu-admin/src/routing/routes.js
index bc040f45..af3d8ded 100644
--- a/new-lamassu-admin/src/routing/routes.js
+++ b/new-lamassu-admin/src/routing/routes.js
@@ -13,6 +13,7 @@ import AuthRegister from 'src/pages/AuthRegister'
import OperatorInfo from 'src/pages/OperatorInfo/OperatorInfo'
import MachineStatus from '../pages/maintenance/MachineStatus'
+import Customers from '../pages/Customers'
const tree = [
{ key: 'transactions', label: 'Transactions', route: '/transactions' },
@@ -55,6 +56,28 @@ const tree = [
},
{ 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' }] }
]
@@ -88,6 +111,7 @@ const Routes = () => (
+
)
diff --git a/package-lock.json b/package-lock.json
index c8931419..6cb005b4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2640,7 +2640,8 @@
"bn.js": {
"version": "4.11.8",
"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": {
"version": "4.0.1",
@@ -8132,6 +8133,15 @@
"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": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/libpq/-/libpq-1.8.7.tgz",
@@ -8545,7 +8555,8 @@
"safe-buffer": {
"version": "5.2.0",
"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",
"integrity": "sha1-PpcwauAk+yThCj11yIQwJWIhUSA=",
"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",
"utf8": "^2.1.1",
"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": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz",
diff --git a/package.json b/package.json
index dcd9d7e9..67d8906b 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"helmet": "^3.8.1",
"inquirer": "^5.2.0",
"kraken-api": "github:DeX3/npm-kraken-api",
+ "libphonenumber-js": "^1.7.38",
"lnd-async": "^1.2.0",
"lodash": "^4.17.10",
"longjohn": "^0.2.12",