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",