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 feat: created customer profile page and started a transition feature from the customer list refactor: make components out of customers list table and profile page feat: selecting a customer now transitions to its profile page feat: added the customer transactions list table fix: fix tx class button margins fix: fix tx class icon margins on the customer list fix: fixed crypto value style: fixed the table column widths feat: added the requirements column (no data yet, though) feat: added the header with the customer details (no image yet, though) feat: created the skeleton for the properties cards feat: create the breadcrumb on the customer profile page (no link yet) feat: added the children container in the property card feat: added block customer action button feat: added action buttons to the property cards feat: added a children prop to the property card component feat: added extra properties to the customer gql query feat: added override fields to the customers gql query style: added conditional styles to the property card component feat: added children to the customer property cards feat: create the edit button function on the property card feat: add error properties to the txs (from gql) style: fix action left editing action button and right property card margins feat: created a mutation to update a customer feat: added the customer auth override state to the gql query feat: fix the routing to the individual customer profile pages feat: made the 'Customers' label on the breadcrumb work as a link style: fixed the breadcrumb separator style: fixed the customer name style feat: made the action to block and authorize a customer as a toggle feat: removed the 'Super user' switch (left for v2) style: added the crossed camera icon on the photo style: fixed the rejected icons refactor: refactored some styles that were repetitive refactor: created constants for the override possible states feat: created functions for the authorization and blocking of overrides refactor: renamed setOverride to updateCustomer fix: remove current unused features feat: make the property cards fields read-only feat: setup id card photo and front camera photo image servers feat: add id card photo on the corresponding property card feat: add front camera photo on the customer profile header feat: added gql cache to update the front-end after any mutation style: added the crossed camera icon when there's no id card photo refactor: extracted the PropertyCard component to another file fix: deactivated the cache for the transactions (no need for it) refactor: removed unused styles fix: fixed front-camera-photo img path fix: changed gql local data updates from cache to query refetch refactor: move override status constants to the property card class refactor: make the image servers URI a const dependent on the build fix: remove requirements column from customer tx table (left for future version) fix: add aliases to gql query to correctly show errors on tx table style: fix the transaction errors styles feat: add terms and conditions page feat: add modal preview feat: remove preview fix: increase space between switch and fields 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 fix: removed unused code refactor: move transactions to a custom resolver in the customer's query refactor: break the CustomerProfile component into several smaller ones style: changed the table row error color from red to no change and the error text from tomato to comet fix: removed repeated function (wrong merge) fix: make the updateCustomer function updates only what's explicitly told so style: return with the table row error style refactor: create a function to test if a value is null prior to passing it through another function fix: make t&c changes backwards compatible chore: bump eslint import library to activate rule fix: stop showing object on empty column fix: get machine logs page up-to-date fix: small admin fixes feat: add terms and conditions page feat: add modal preview feat: remove preview fix: increase space between switch and fields 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 fix: make t&c changes backwards compatible fix: stop showing object on empty column fix: get machine logs page up-to-date feat: add terms and conditions page feat: add modal preview feat: remove preview fix: increase space between switch and fields 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 fix: make t&c changes backwards compatible fix: stop showing object on empty column fix: get machine logs page up-to-date fix: small admin fixes feat: create add machine page feat: add terms and conditions page feat: add modal preview feat: remove preview fix: increase space between switch and fields 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 fix: make t&c changes backwards compatible fix: stop showing object on empty column fix: get machine logs page up-to-date feat: create add machine page fix: fixed wrong merging fix: more fixes from last merge fix: export needed functions that wasn't exported from the customers module fix: removed the customer profile route from the header fix: replaced old dataTable with new component feat: added onClick event to new DataTable
This commit is contained in:
parent
840788e044
commit
c808ca3be9
29 changed files with 1215 additions and 106 deletions
|
|
@ -85,6 +85,30 @@ function update (id, data, userToken, txId) {
|
|||
.then(camelize)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update customer record
|
||||
*
|
||||
* @name updateCustomer
|
||||
* @function
|
||||
*
|
||||
* @param {string} id Customer's id
|
||||
* @param {object} data Fields to update
|
||||
*
|
||||
* @returns {Promise} Newly updated Customer
|
||||
*/
|
||||
async function updateCustomer (id, data) {
|
||||
const formattedData = _.pick(
|
||||
['authorized_override', 'id_card_photo_override', 'id_card_data_override', 'sms_override'],
|
||||
_.mapKeys(_.snakeCase, data))
|
||||
|
||||
const sql = Pgp.helpers.update(formattedData, _.keys(formattedData), 'customers') +
|
||||
' where id=$1'
|
||||
|
||||
await db.none(sql, [id])
|
||||
|
||||
return getCustomerById(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer by id
|
||||
*
|
||||
|
|
@ -377,21 +401,24 @@ function batch () {
|
|||
}, customers)))
|
||||
}
|
||||
|
||||
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored
|
||||
|
||||
/**
|
||||
* Query all customers, ordered by last activity
|
||||
* and with aggregate columns based on their
|
||||
* transactions
|
||||
*
|
||||
* @returns {array} Array of customers with it's
|
||||
* @returns {array} Array of customers with it's transactions aggregations
|
||||
*/
|
||||
function getCustomersList () {
|
||||
const sql = `select name, phone, total_txs, total_spent,
|
||||
coalesce(tx_created, customer_created) as last_active,
|
||||
fiat as last_tx_fiat, fiat_code as last_tx_fiat_code,
|
||||
tx_class as last_tx_class
|
||||
const sql = `select id, name, authorized_override, front_camera_path, phone, sms_override,
|
||||
id_card_data, id_card_data_override, id_card_data_expiration, id_card_photo_path,
|
||||
id_card_photo_override, 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, c.created as customer_created,
|
||||
t.tx_class, t.fiat, t.fiat_code, t.created as tx_created,
|
||||
select c.id, c.name, c.authorized_override, c.front_camera_path, c.phone, c.sms_override,
|
||||
c.id_card_data, c.id_card_data_override, c.id_card_data_expiration, c.id_card_photo_path,
|
||||
c.id_card_photo_override, t.tx_class, t.fiat, t.fiat_code, t.created,
|
||||
row_number() over (partition by c.id order by t.created desc) as rn,
|
||||
sum(case when t.id is not null then 1 else 0 end) over (partition by c.id) as total_txs,
|
||||
coalesce(sum(t.fiat) over (partition by c.id), 0) as total_spent
|
||||
|
|
@ -410,6 +437,37 @@ function getCustomersList () {
|
|||
}, customers)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all customers, ordered by last activity
|
||||
* and with aggregate columns based on their
|
||||
* transactions
|
||||
*
|
||||
* @returns {array} Array of customers with it's transactions aggregations
|
||||
*/
|
||||
function getCustomerById (id) {
|
||||
const sql = `select id, name, authorized_override, front_camera_path, phone, sms_override,
|
||||
id_card_data, id_card_data_override, id_card_data_expiration, id_card_photo_path,
|
||||
id_card_photo_override, 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.id, c.name, c.authorized_override, c.front_camera_path, c.phone, c.sms_override,
|
||||
c.id_card_data, c.id_card_data_override, c.id_card_data_expiration, c.id_card_photo_path,
|
||||
c.id_card_photo_override, 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`
|
||||
return db.oneOrNone(sql, [id])
|
||||
.then(populateOverrideUsernames)
|
||||
.then(camelize)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} id customer id
|
||||
* @param {Object} patch customer update record
|
||||
|
|
@ -515,4 +573,4 @@ function updateFrontCamera (id, patch) {
|
|||
})
|
||||
}
|
||||
|
||||
module.exports = { add, get, batch, getCustomersList, getById, update, updatePhotoCard, updateFrontCamera }
|
||||
module.exports = { add, get, batch, getCustomersList, getCustomerById, getById, update, updateCustomer, updatePhotoCard, updateFrontCamera }
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ const fs = require('fs')
|
|||
const path = require('path')
|
||||
const express = require('express')
|
||||
const https = require('https')
|
||||
const serveStatic = require('serve-static')
|
||||
const cors = require('cors')
|
||||
const helmet = require('helmet')
|
||||
const cookieParser = require('cookie-parser')
|
||||
const { ApolloServer, AuthenticationError } = require('apollo-server-express')
|
||||
const _ = require('lodash/fp')
|
||||
|
||||
const T = require('../time')
|
||||
const options = require('../options')
|
||||
|
|
@ -15,6 +17,8 @@ const { typeDefs, resolvers } = require('./graphql/schema')
|
|||
|
||||
const devMode = require('minimist')(process.argv.slice(2)).dev
|
||||
const NEVER = new Date(Date.now() + 100 * T.years)
|
||||
const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
|
||||
const frontCameraBasedir = _.get('frontCameraDir', options)
|
||||
|
||||
const hostname = options.hostname
|
||||
if (!hostname) {
|
||||
|
|
@ -55,6 +59,9 @@ apolloServer.applyMiddleware({
|
|||
// cors on app for /api/register endpoint.
|
||||
app.use(cors({ credentials: true, origin: devMode && 'https://localhost:3000' }))
|
||||
|
||||
app.use('/id-card-photo', serveStatic(idPhotoCardBasedir, {index: false}))
|
||||
app.use('/front-camera-photo', serveStatic(frontCameraBasedir, {index: false}))
|
||||
|
||||
app.get('/api/register', (req, res, next) => {
|
||||
const otp = req.query.otp
|
||||
|
||||
|
|
|
|||
|
|
@ -61,8 +61,37 @@ const typeDefs = gql`
|
|||
}
|
||||
|
||||
type Customer {
|
||||
name: String
|
||||
id: ID!
|
||||
name: String!
|
||||
authorizedOverride: String
|
||||
frontCameraPath: String
|
||||
phone: String
|
||||
smsOverride: String
|
||||
idCardData: JSONObject
|
||||
idCardDataOverride: String
|
||||
idCardDataExpiration: Date
|
||||
idCardPhotoPath: String
|
||||
idCardPhotoOverride: String
|
||||
totalTxs: Int
|
||||
totalSpent: String
|
||||
lastActive: Date
|
||||
lastTxFiat: String
|
||||
lastTxFiatCode: String
|
||||
lastTxClass: String
|
||||
transactions: [Transaction]
|
||||
}
|
||||
|
||||
input CustomerInput {
|
||||
name: String
|
||||
authorizedOverride: String
|
||||
frontCameraPath: String
|
||||
phone: String
|
||||
smsOverride: String
|
||||
idCardData: JSONObject
|
||||
idCardDataOverride: String
|
||||
idCardDataExpiration: Date
|
||||
idCardPhotoPath: String
|
||||
idCardPhotoOverride: String
|
||||
totalTxs: Int
|
||||
totalSpent: String
|
||||
lastActive: Date
|
||||
|
|
@ -160,6 +189,7 @@ const typeDefs = gql`
|
|||
cryptoCurrencies: [CryptoCurrency]
|
||||
machines: [Machine]
|
||||
customers: [Customer]
|
||||
customer(customerId: ID!): Customer
|
||||
machineLogs(deviceId: ID!): [MachineLog]
|
||||
funding: [CoinFunds]
|
||||
serverVersion: String!
|
||||
|
|
@ -187,6 +217,7 @@ const typeDefs = gql`
|
|||
machineAction(deviceId:ID!, action: MachineAction!): Machine
|
||||
machineSupportLogs(deviceId: ID!): SupportLogsResponse
|
||||
serverSupportLogs: SupportLogsResponse
|
||||
setCustomer(customerId: ID!, customerInput: CustomerInput): Customer
|
||||
saveConfig(config: JSONObject): JSONObject
|
||||
createPairingTotem(name: String!): String
|
||||
saveAccount(account: JSONObject): [JSONObject]
|
||||
|
|
@ -201,6 +232,9 @@ const resolvers = {
|
|||
JSON: GraphQLJSON,
|
||||
JSONObject: GraphQLJSONObject,
|
||||
Date: GraphQLDateTime,
|
||||
Customer: {
|
||||
transactions: parent => transactions.getCustomerTransactions(parent.id)
|
||||
},
|
||||
Query: {
|
||||
countries: () => countries,
|
||||
currencies: () => currencies,
|
||||
|
|
@ -209,6 +243,7 @@ const resolvers = {
|
|||
cryptoCurrencies: () => coins,
|
||||
machines: () => machineLoader.getMachineNames(),
|
||||
customers: () => customers.getCustomersList(),
|
||||
customer: (...[, { customerId }]) => customers.getCustomerById(customerId),
|
||||
funding: () => funding.getFunding(),
|
||||
machineLogs: (...[, { deviceId }]) => logs.simpleGetMachineLogs(deviceId),
|
||||
serverVersion: () => serverVersion,
|
||||
|
|
@ -225,6 +260,7 @@ const resolvers = {
|
|||
serverSupportLogs: () => serverLogs.insert(),
|
||||
saveAccount: (...[, { account }]) => settingsLoader.saveAccounts([account]),
|
||||
saveAccounts: (...[, { accounts }]) => settingsLoader.saveAccounts(accounts),
|
||||
setCustomer: (...[, { customerId, customerInput } ]) => customers.updateCustomer(customerId, customerInput),
|
||||
saveConfig: (...[, { config }]) => settingsLoader.saveConfig(config)
|
||||
.then(it => {
|
||||
notify()
|
||||
|
|
|
|||
|
|
@ -61,6 +61,49 @@ function batch () {
|
|||
.then(packager)
|
||||
}
|
||||
|
||||
function getCustomerTransactions (customerId) {
|
||||
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']),
|
||||
_.take(NUM_RESULTS), _.map(camelize), addNames)
|
||||
|
||||
const cashInSql = `select 'cashIn' as tx_class, txs.*,
|
||||
c.phone as customer_phone,
|
||||
c.id_card_data_number as customer_id_card_data_number,
|
||||
c.id_card_data_expiration as customer_id_card_data_expiration,
|
||||
c.id_card_data as customer_id_card_data,
|
||||
c.name as customer_name,
|
||||
c.front_camera_path as customer_front_camera_path,
|
||||
c.id_card_photo_path as customer_id_card_photo_path,
|
||||
((not txs.send_confirmed) and (txs.created <= now() - interval $2)) as expired
|
||||
from cash_in_txs as txs
|
||||
left outer join customers c on txs.customer_id = c.id
|
||||
where c.id = $1
|
||||
order by created desc limit $3`
|
||||
|
||||
const cashOutSql = `select 'cashOut' as tx_class,
|
||||
txs.*,
|
||||
actions.tx_hash,
|
||||
c.phone as customer_phone,
|
||||
c.id_card_data_number as customer_id_card_data_number,
|
||||
c.id_card_data_expiration as customer_id_card_data_expiration,
|
||||
c.id_card_data as customer_id_card_data,
|
||||
c.name as customer_name,
|
||||
c.front_camera_path as customer_front_camera_path,
|
||||
c.id_card_photo_path as customer_id_card_photo_path,
|
||||
(extract(epoch from (now() - greatest(txs.created, txs.confirmed_at))) * 1000) >= $3 as expired
|
||||
from cash_out_txs txs
|
||||
inner join cash_out_actions actions on txs.id = actions.tx_id
|
||||
and actions.action = 'provisionAddress'
|
||||
left outer join customers c on txs.customer_id = c.id
|
||||
where c.id = $1
|
||||
order by created desc limit $2`
|
||||
|
||||
return Promise.all([
|
||||
db.any(cashInSql, [customerId, cashInTx.PENDING_INTERVAL, NUM_RESULTS]),
|
||||
db.any(cashOutSql, [customerId, NUM_RESULTS, REDEEMABLE_AGE])
|
||||
])
|
||||
.then(packager)
|
||||
}
|
||||
|
||||
function single (txId) {
|
||||
const packager = _.flow(_.compact, _.map(camelize), addNames)
|
||||
|
||||
|
|
@ -107,4 +150,4 @@ function cancel (txId) {
|
|||
.then(() => single(txId))
|
||||
}
|
||||
|
||||
module.exports = { batch, single, cancel }
|
||||
module.exports = { batch, getCustomerTransactions, single, cancel }
|
||||
|
|
|
|||
6
new-lamassu-admin/package-lock.json
generated
6
new-lamassu-admin/package-lock.json
generated
|
|
@ -15049,9 +15049,9 @@
|
|||
}
|
||||
},
|
||||
"libphonenumber-js": {
|
||||
"version": "1.7.49",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.7.49.tgz",
|
||||
"integrity": "sha512-AthHsii6+s+TBNMCUvKRzjscxMJAUD9rjDYZNj8rCVKBX9w1TzRbsmv+f4/pSuoHeKoNI64rcOV0Xb+7hoHudw==",
|
||||
"version": "1.7.50",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.7.50.tgz",
|
||||
"integrity": "sha512-FmdA2WvwdTgu1X05zBnAE+3UAA09o3hFxEaqR0J+x7tGPAt1AD7Dj54L58PTJodrFBve/AIThFtC/UGqfSLbBw==",
|
||||
"requires": {
|
||||
"minimist": "^1.2.5",
|
||||
"xml2js": "^0.4.17"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"fuse.js": "^3.4.6",
|
||||
"graphql": "^14.5.8",
|
||||
"jss-plugin-extend": "^10.0.0",
|
||||
"libphonenumber-js": "^1.7.49",
|
||||
"libphonenumber-js": "^1.7.50",
|
||||
"moment": "2.24.0",
|
||||
"qrcode.react": "0.9.3",
|
||||
"ramda": "^0.26.1",
|
||||
|
|
|
|||
|
|
@ -91,12 +91,14 @@ const Tr = ({ error, errorMessage, children, className }) => {
|
|||
const cardClasses = { root: classes.cardContentRoot }
|
||||
const classNames = {
|
||||
[classes.tr]: true,
|
||||
[classes.trError]: error
|
||||
[classes.trError]: error,
|
||||
[classes.card]: true,
|
||||
className
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={classnames(classNames, classes.card, className)}>
|
||||
<Card className={classnames(classNames, className)}>
|
||||
<CardContent classes={cardClasses}>
|
||||
<div className={classes.mainContent}>{children}</div>
|
||||
{error && <div className={classes.errorContent}>{errorMessage}</div>}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,15 @@ const Row = ({
|
|||
expanded,
|
||||
expandRow,
|
||||
expWidth,
|
||||
expandable
|
||||
expandable,
|
||||
onClick
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={onClick && { cursor: 'pointer' }}
|
||||
onClick={() => onClick && onClick(data)}>
|
||||
<Tr
|
||||
className={classnames(classes.row)}
|
||||
error={data.error}
|
||||
|
|
@ -66,7 +69,7 @@ const Row = ({
|
|||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +79,7 @@ const DataTable = ({
|
|||
Details,
|
||||
className,
|
||||
expandable,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
|
|
@ -114,6 +118,7 @@ const DataTable = ({
|
|||
expanded={index === expanded}
|
||||
expandRow={expandRow}
|
||||
expandable={expandable}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
</CellMeasurer>
|
||||
|
|
|
|||
180
new-lamassu-admin/src/pages/Customers/CustomerProfile.js
Normal file
180
new-lamassu-admin/src/pages/Customers/CustomerProfile.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/react-hooks'
|
||||
import { gql } from 'apollo-boost'
|
||||
import { useHistory, useParams } from 'react-router-dom'
|
||||
import Breadcrumbs from '@material-ui/core/Breadcrumbs'
|
||||
import NavigateNextIcon from '@material-ui/icons/NavigateNext'
|
||||
|
||||
import {
|
||||
OVERRIDE_AUTHORIZED,
|
||||
OVERRIDE_REJECTED
|
||||
} from 'src/pages/Customers/components/propertyCard'
|
||||
import { ActionButton } from 'src/components/buttons'
|
||||
import { Label1 } from 'src/components/typography'
|
||||
import { ReactComponent as BlockReversedIcon } from 'src/styling/icons/button/block/white.svg'
|
||||
import { ReactComponent as BlockIcon } from 'src/styling/icons/button/block/zodiac.svg'
|
||||
import { ReactComponent as AuthorizeReversedIcon } from 'src/styling/icons/button/authorize/white.svg'
|
||||
import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/zodiac.svg'
|
||||
|
||||
import {
|
||||
CustomerDetails,
|
||||
IdDataCard,
|
||||
PhoneCard,
|
||||
IdCardPhotoCard,
|
||||
TransactionsList
|
||||
} from './components'
|
||||
import { mainStyles } from './Customers.styles'
|
||||
|
||||
const useStyles = makeStyles(mainStyles)
|
||||
|
||||
const GET_CUSTOMER = gql`
|
||||
query customer($customerId: ID!) {
|
||||
customer(customerId: $customerId) {
|
||||
id
|
||||
name
|
||||
authorizedOverride
|
||||
frontCameraPath
|
||||
phone
|
||||
smsOverride
|
||||
idCardData
|
||||
idCardDataOverride
|
||||
idCardDataExpiration
|
||||
idCardPhotoPath
|
||||
idCardPhotoOverride
|
||||
totalTxs
|
||||
totalSpent
|
||||
lastActive
|
||||
lastTxFiat
|
||||
lastTxFiatCode
|
||||
lastTxClass
|
||||
transactions {
|
||||
txClass
|
||||
id
|
||||
fiat
|
||||
fiatCode
|
||||
cryptoAtoms
|
||||
cryptoCode
|
||||
created
|
||||
errorMessage: error
|
||||
error: errorCode
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SET_CUSTOMER = gql`
|
||||
mutation setCustomer($customerId: ID!, $customerInput: CustomerInput) {
|
||||
setCustomer(customerId: $customerId, customerInput: $customerInput) {
|
||||
id
|
||||
name
|
||||
authorizedOverride
|
||||
frontCameraPath
|
||||
phone
|
||||
smsOverride
|
||||
idCardData
|
||||
idCardDataOverride
|
||||
idCardDataExpiration
|
||||
idCardPhotoPath
|
||||
idCardPhotoOverride
|
||||
totalTxs
|
||||
totalSpent
|
||||
lastActive
|
||||
lastTxFiat
|
||||
lastTxFiatCode
|
||||
lastTxClass
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CustomerProfile = memo(() => {
|
||||
const classes = useStyles()
|
||||
const history = useHistory()
|
||||
const { id: customerId } = useParams()
|
||||
|
||||
const { data: customerResponse, refetch: getCustomer } = useQuery(
|
||||
GET_CUSTOMER,
|
||||
{
|
||||
variables: { customerId },
|
||||
fetchPolicy: 'no-cache'
|
||||
}
|
||||
)
|
||||
|
||||
const [setCustomer] = useMutation(SET_CUSTOMER, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const updateCustomer = it =>
|
||||
setCustomer({
|
||||
variables: {
|
||||
customerId,
|
||||
customerInput: it
|
||||
}
|
||||
})
|
||||
|
||||
const customerData = R.path(['customer'])(customerResponse) ?? []
|
||||
|
||||
const transactionsData = R.sortWith([R.descend('created')])(
|
||||
R.path(['transactions'])(customerData) ?? []
|
||||
)
|
||||
|
||||
const blocked =
|
||||
R.path(['authorizedOverride'])(customerData) === OVERRIDE_REJECTED
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs
|
||||
separator={<NavigateNextIcon fontSize="small" />}
|
||||
aria-label="breadcrumb">
|
||||
<Label1
|
||||
className={classes.labelLink}
|
||||
onClick={() => history.push('/compliance/customers')}>
|
||||
Customers
|
||||
</Label1>
|
||||
<Label1 className={classes.bold}>
|
||||
{R.path(['name'])(customerData)}
|
||||
</Label1>
|
||||
</Breadcrumbs>
|
||||
<div>
|
||||
<div className={classes.header}>
|
||||
<CustomerDetails customer={customerData} />
|
||||
<div className={classes.rightAligned}>
|
||||
<Label1 className={classes.label1}>Actions</Label1>
|
||||
<ActionButton
|
||||
className={classes.actionButton}
|
||||
color="primary"
|
||||
Icon={blocked ? AuthorizeIcon : BlockIcon}
|
||||
InverseIcon={blocked ? AuthorizeReversedIcon : BlockReversedIcon}
|
||||
onClick={() =>
|
||||
updateCustomer({
|
||||
authorizedOverride: blocked
|
||||
? OVERRIDE_AUTHORIZED
|
||||
: OVERRIDE_REJECTED
|
||||
})
|
||||
}>
|
||||
{`${blocked ? 'Authorize' : 'Block'} customer`}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.rowCenterAligned}>
|
||||
<IdDataCard
|
||||
customerData={customerData}
|
||||
updateCustomer={updateCustomer}
|
||||
/>
|
||||
<PhoneCard
|
||||
customerData={customerData}
|
||||
updateCustomer={updateCustomer}
|
||||
/>
|
||||
<IdCardPhotoCard
|
||||
customerData={customerData}
|
||||
updateCustomer={updateCustomer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TransactionsList data={transactionsData} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default CustomerProfile
|
||||
|
|
@ -1,23 +1,15 @@
|
|||
import { useQuery } from '@apollo/react-hooks'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { gql } from 'apollo-boost'
|
||||
import { parsePhoneNumberFromString } from 'libphonenumber-js'
|
||||
import moment from 'moment'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
|
||||
import Title from 'src/components/Title'
|
||||
import DataTable from 'src/components/tables/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)
|
||||
import CustomersList from './CustomersList'
|
||||
|
||||
const GET_CUSTOMERS = gql`
|
||||
{
|
||||
customers {
|
||||
id
|
||||
name
|
||||
phone
|
||||
totalTxs
|
||||
|
|
@ -31,82 +23,17 @@ const GET_CUSTOMERS = gql`
|
|||
`
|
||||
|
||||
const Customers = () => {
|
||||
const classes = useStyles()
|
||||
|
||||
const history = useHistory()
|
||||
const { data: customersResponse } = useQuery(GET_CUSTOMERS)
|
||||
|
||||
const elements = [
|
||||
{
|
||||
header: 'Name',
|
||||
width: 277,
|
||||
view: R.path(['name'])
|
||||
},
|
||||
{
|
||||
header: 'Phone',
|
||||
width: 166,
|
||||
view: it => parsePhoneNumberFromString(it.phone)?.formatInternational()
|
||||
},
|
||||
{
|
||||
header: 'Total TXs',
|
||||
width: 174,
|
||||
textAlign: 'right',
|
||||
view: it => `${Number.parseInt(it.totalTxs)}`
|
||||
},
|
||||
{
|
||||
header: 'Total spent',
|
||||
width: 188,
|
||||
textAlign: 'right',
|
||||
view: it =>
|
||||
it.lastTxFiatCode
|
||||
? `${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode}`
|
||||
: null
|
||||
},
|
||||
{
|
||||
header: 'Last active',
|
||||
width: 197,
|
||||
view: it =>
|
||||
it.lastActive ? moment.utc(it.lastActive).format('YYYY-MM-D') : null
|
||||
},
|
||||
{
|
||||
header: 'Last transaction',
|
||||
width: 198,
|
||||
textAlign: 'right',
|
||||
view: it =>
|
||||
it.lastTxFiatCode ? (
|
||||
<div>
|
||||
{`${Number.parseFloat(it.lastTxFiat)} ${it.lastTxFiatCode} `}
|
||||
{it.lastTxClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
]
|
||||
const handleCustomerClicked = customer =>
|
||||
history.push(`/compliance/customer/${customer.id}`)
|
||||
|
||||
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.ascend(R.prop('name')),
|
||||
R.descend(R.prop('lastActive'))
|
||||
])(R.path(['customers'])(customersResponse) ?? [])}
|
||||
/>
|
||||
</>
|
||||
const customersData = R.sortWith([R.descend('lastActive')])(
|
||||
R.path(['customers'])(customersResponse) ?? []
|
||||
)
|
||||
|
||||
return <CustomersList data={customersData} onClick={handleCustomerClicked} />
|
||||
}
|
||||
|
||||
export default Customers
|
||||
|
|
|
|||
|
|
@ -1,13 +1,91 @@
|
|||
import typographyStyles from 'src/components/typography/styles'
|
||||
import baseStyles from 'src/pages/Logs.styles'
|
||||
import { zircon, primaryColor, comet } from 'src/styling/variables'
|
||||
|
||||
const { label1 } = typographyStyles
|
||||
const { titleWrapper, titleAndButtonsContainer, buttonsWrapper } = baseStyles
|
||||
const { titleWrapper, titleAndButtonsContainer } = baseStyles
|
||||
|
||||
const mainStyles = {
|
||||
rightAligned: {
|
||||
display: 'flex',
|
||||
flexFlow: 'column nowrap',
|
||||
right: 0
|
||||
},
|
||||
actionButton: {
|
||||
height: 28
|
||||
},
|
||||
titleWrapper,
|
||||
titleAndButtonsContainer,
|
||||
buttonsWrapper,
|
||||
header: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
customerDetails: {
|
||||
display: 'flex',
|
||||
flex: 1
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap'
|
||||
},
|
||||
rowCenterAligned: {
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center'
|
||||
},
|
||||
rowSpaceBetween: {
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
column: {
|
||||
display: 'flex',
|
||||
flexFlow: 'column nowrap',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
textInput: {
|
||||
width: 144
|
||||
},
|
||||
label1: {
|
||||
fontFamily: 'MuseoSans',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
fontStretch: 'normal',
|
||||
fontStyle: 'normal',
|
||||
lineHeight: 1.33,
|
||||
letterSpacing: 'normal',
|
||||
color: comet,
|
||||
margin: [[4, 0]]
|
||||
},
|
||||
p: {
|
||||
fontFamily: 'MuseoSans',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
fontStretch: 'normal',
|
||||
fontStyle: 'normal',
|
||||
lineHeight: 1.14,
|
||||
letterSpacing: 'normal',
|
||||
color: primaryColor
|
||||
},
|
||||
bold: {
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
txId: {
|
||||
fontFamily: 'MuseoSans',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
},
|
||||
txClassIconLeft: {
|
||||
marginRight: 11
|
||||
},
|
||||
txClassIconRight: {
|
||||
marginLeft: 11
|
||||
},
|
||||
headerLabels: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
|
|
@ -22,6 +100,62 @@ const mainStyles = {
|
|||
extend: label1,
|
||||
marginLeft: 6
|
||||
}
|
||||
},
|
||||
photo: {
|
||||
width: 92,
|
||||
height: 92,
|
||||
borderRadius: 8,
|
||||
backgroundColor: zircon,
|
||||
margin: [[15, 28, 0, 0]],
|
||||
padding: [[30]],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
idDataCard: {
|
||||
width: 544,
|
||||
height: 240
|
||||
},
|
||||
phoneCard: {
|
||||
width: 253,
|
||||
height: 240
|
||||
},
|
||||
idCardPhotoCard: {
|
||||
width: 378,
|
||||
height: 240,
|
||||
margin: [[32, 0, 0, 0]]
|
||||
},
|
||||
labelLink: {
|
||||
cursor: 'pointer'
|
||||
},
|
||||
field: {
|
||||
position: 'relative',
|
||||
width: 144,
|
||||
height: 46,
|
||||
padding: [[0, 4, 4, 0]],
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& > p:first-child': {
|
||||
height: 16,
|
||||
lineHeight: '16px',
|
||||
paddingLeft: 3,
|
||||
margin: [[0, 0, 5, 0]]
|
||||
},
|
||||
'& > p:last-child': {
|
||||
margin: 0,
|
||||
paddingLeft: 4
|
||||
}
|
||||
},
|
||||
customerName: {
|
||||
marginBottom: 32
|
||||
},
|
||||
fieldDisplay: {
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
},
|
||||
idCardPhoto: {
|
||||
maxWidth: 171,
|
||||
maxHeight: 97
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
86
new-lamassu-admin/src/pages/Customers/CustomersList.js
Normal file
86
new-lamassu-admin/src/pages/Customers/CustomersList.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { parsePhoneNumberFromString } from 'libphonenumber-js'
|
||||
import moment from 'moment'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
|
||||
import Title from 'src/components/Title'
|
||||
import DataTable from 'src/components/tables/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 CustomersList = ({ data, onClick }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const elements = [
|
||||
{
|
||||
header: 'Name',
|
||||
width: 277,
|
||||
view: R.path(['name'])
|
||||
},
|
||||
{
|
||||
header: 'Phone',
|
||||
width: 186,
|
||||
view: it => parsePhoneNumberFromString(it.phone).formatInternational()
|
||||
},
|
||||
{
|
||||
header: 'Total TXs',
|
||||
width: 154,
|
||||
textAlign: 'right',
|
||||
view: it => `${Number.parseInt(it.totalTxs)}`
|
||||
},
|
||||
{
|
||||
header: 'Total spent',
|
||||
width: 188,
|
||||
textAlign: 'right',
|
||||
view: it => `${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode}`
|
||||
},
|
||||
{
|
||||
header: 'Last active',
|
||||
width: 197,
|
||||
view: it => moment.utc(it.lastActive).format('YYYY-MM-D')
|
||||
},
|
||||
{
|
||||
header: 'Last transaction',
|
||||
width: 198,
|
||||
textAlign: 'right',
|
||||
view: it => (
|
||||
<>
|
||||
{`${Number.parseFloat(it.lastTxFiat)} ${it.lastTxFiatCode}`}
|
||||
{it.lastTxClass === 'cashOut' ? (
|
||||
<TxOutIcon className={classes.txClassIconRight} />
|
||||
) : (
|
||||
<TxInIcon className={classes.txClassIconRight} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
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={data} onClick={onClick} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomersList
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import * as R from 'ramda'
|
||||
import moment from 'moment'
|
||||
import React, { memo } from 'react'
|
||||
|
||||
import { H2 } from 'src/components/typography'
|
||||
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'
|
||||
import { ifNotNull } from '../../../utils/nullCheck'
|
||||
|
||||
import FrontCameraPhoto from './FrontCameraPhoto'
|
||||
|
||||
const useStyles = makeStyles(mainStyles)
|
||||
|
||||
const CustomerDetails = memo(({ customer }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const elements = [
|
||||
{
|
||||
header: 'Transactions',
|
||||
size: 127,
|
||||
value: ifNotNull(
|
||||
customer.totalTxs,
|
||||
`${Number.parseInt(customer.totalTxs)}`
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Transaction volume',
|
||||
size: 167,
|
||||
value: ifNotNull(
|
||||
customer.totalSpent,
|
||||
`${Number.parseFloat(customer.totalSpent)} ${customer.lastTxFiatCode}`
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Last active',
|
||||
size: 142,
|
||||
value: ifNotNull(
|
||||
customer.lastActive,
|
||||
moment.utc(customer.lastActive).format('YYYY-MM-D')
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Last transaction',
|
||||
size: 198,
|
||||
value: ifNotNull(
|
||||
customer.lastTxFiat,
|
||||
<>
|
||||
{`${Number.parseFloat(customer.lastTxFiat)}
|
||||
${customer.lastTxFiatCode}`}
|
||||
{customer.lastTxClass === 'cashOut' ? (
|
||||
<TxOutIcon className={classes.txClassIconRight} />
|
||||
) : (
|
||||
<TxInIcon className={classes.txClassIconRight} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={classes.customerDetails}>
|
||||
<FrontCameraPhoto
|
||||
frontCameraPath={R.path(['frontCameraPath'])(customer)}
|
||||
/>
|
||||
<div>
|
||||
<div className={classes.rowCenterAligned}>
|
||||
<H2 className={classes.customerName}>{R.path(['name'])(customer)}</H2>
|
||||
</div>
|
||||
<div className={classes.rowCenterAligned}>
|
||||
{elements.map(({ size, header }, idx) => (
|
||||
<div key={idx} className={classes.label1} style={{ width: size }}>
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={classes.rowCenterAligned}>
|
||||
{elements.map(({ size, value }, idx) => (
|
||||
<div key={idx} className={classes.p} style={{ width: size }}>
|
||||
{value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default CustomerDetails
|
||||
21
new-lamassu-admin/src/pages/Customers/components/Field.js
Normal file
21
new-lamassu-admin/src/pages/Customers/components/Field.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import React, { memo } from 'react'
|
||||
|
||||
import { Info3, Label1 } from 'src/components/typography'
|
||||
|
||||
import { mainStyles } from '../Customers.styles'
|
||||
|
||||
const useStyles = makeStyles(mainStyles)
|
||||
|
||||
const Field = memo(({ label, display }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<div className={classes.field}>
|
||||
<Label1>{label}</Label1>
|
||||
<Info3 className={classes.fieldDisplay}>{display}</Info3>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default Field
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import React, { memo } from 'react'
|
||||
import { Paper } from '@material-ui/core'
|
||||
|
||||
import { ReactComponent as CrossedCameraIcon } from 'src/styling/icons/ID/photo/crossed-camera.svg'
|
||||
|
||||
import { mainStyles } from '../Customers.styles'
|
||||
|
||||
import { IMAGES_URI } from './variables'
|
||||
|
||||
const useStyles = makeStyles(mainStyles)
|
||||
|
||||
const FrontCameraPhoto = memo(({ frontCameraPath }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<Paper className={classes.photo} elevation={0}>
|
||||
{frontCameraPath ? (
|
||||
<img
|
||||
src={`${IMAGES_URI}/front-camera-photo/${frontCameraPath}`}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<CrossedCameraIcon />
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
})
|
||||
|
||||
export default FrontCameraPhoto
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import * as R from 'ramda'
|
||||
import moment from 'moment'
|
||||
import React, { memo } from 'react'
|
||||
|
||||
import { ReactComponent as CrossedCameraIcon } from 'src/styling/icons/ID/photo/crossed-camera.svg'
|
||||
import {
|
||||
PropertyCard,
|
||||
OVERRIDE_AUTHORIZED,
|
||||
OVERRIDE_REJECTED
|
||||
} from 'src/pages/Customers/components/propertyCard'
|
||||
|
||||
import { mainStyles } from '../Customers.styles'
|
||||
|
||||
import Field from './Field'
|
||||
import { IMAGES_URI } from './variables'
|
||||
|
||||
const useStyles = makeStyles(mainStyles)
|
||||
|
||||
const IdCardPhotoCard = memo(({ customerData, updateCustomer }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<PropertyCard
|
||||
className={classes.idCardPhotoCard}
|
||||
title={'ID card photo'}
|
||||
state={R.path(['idCardPhotoOverride'])(customerData)}
|
||||
authorize={() =>
|
||||
updateCustomer({ idCardPhotoOverride: OVERRIDE_AUTHORIZED })
|
||||
}
|
||||
reject={() => updateCustomer({ idCardPhotoOverride: OVERRIDE_REJECTED })}>
|
||||
<div className={classes.row}>
|
||||
{customerData.idCardPhotoPath ? (
|
||||
<img
|
||||
className={classes.idCardPhoto}
|
||||
src={`${IMAGES_URI}/id-card-photo/${R.path(['idCardPhotoPath'])(
|
||||
customerData
|
||||
)}`}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<CrossedCameraIcon />
|
||||
)}
|
||||
<Field
|
||||
label={'Expiration date'}
|
||||
display={moment
|
||||
.utc(R.path(['idCardDataExpiration'])(customerData))
|
||||
.format('YYYY-MM-D')}
|
||||
/>
|
||||
</div>
|
||||
</PropertyCard>
|
||||
)
|
||||
})
|
||||
|
||||
export default IdCardPhotoCard
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import * as R from 'ramda'
|
||||
import moment from 'moment'
|
||||
import React, { memo } from 'react'
|
||||
|
||||
import {
|
||||
PropertyCard,
|
||||
OVERRIDE_AUTHORIZED,
|
||||
OVERRIDE_REJECTED
|
||||
} from 'src/pages/Customers/components/propertyCard'
|
||||
|
||||
import { mainStyles } from '../Customers.styles'
|
||||
|
||||
import Field from './Field'
|
||||
|
||||
const useStyles = makeStyles(mainStyles)
|
||||
|
||||
const IdDataCard = memo(({ customerData, updateCustomer }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<PropertyCard
|
||||
className={classes.idDataCard}
|
||||
title={'ID data'}
|
||||
state={R.path(['idCardDataOverride'])(customerData)}
|
||||
authorize={() =>
|
||||
updateCustomer({ idCardDataOverride: OVERRIDE_AUTHORIZED })
|
||||
}
|
||||
reject={() => updateCustomer({ idCardDataOverride: OVERRIDE_REJECTED })}>
|
||||
<div className={classes.rowSpaceBetween}>
|
||||
<div className={classes.column}>
|
||||
<Field
|
||||
label={'Name'}
|
||||
display={`${R.path(['idCardData', 'firstName'])(
|
||||
customerData
|
||||
)} ${R.path(['idCardData', 'lastName'])(customerData)}`}
|
||||
/>
|
||||
<Field
|
||||
label={'Gender'}
|
||||
display={R.path(['idCardData', 'gender'])(customerData)}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.column}>
|
||||
<Field
|
||||
label={'ID number'}
|
||||
display={R.path(['idCardData', 'documentNumber'])(customerData)}
|
||||
/>
|
||||
<Field
|
||||
label={'Country'}
|
||||
display={R.path(['idCardData', 'country'])(customerData)}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.column}>
|
||||
<Field
|
||||
label={'Age'}
|
||||
display={moment
|
||||
.utc()
|
||||
.diff(
|
||||
moment
|
||||
.utc(R.path(['idCardData', 'dateOfBirth'])(customerData))
|
||||
.format('YYYY-MM-D'),
|
||||
'years'
|
||||
)}
|
||||
/>
|
||||
<Field
|
||||
label={'Expiration date'}
|
||||
display={moment
|
||||
.utc(R.path(['idCardData', 'expirationDate'])(customerData))
|
||||
.format('YYYY-MM-D')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PropertyCard>
|
||||
)
|
||||
})
|
||||
|
||||
export default IdDataCard
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo } from 'react'
|
||||
import { parsePhoneNumberFromString } from 'libphonenumber-js'
|
||||
|
||||
import {
|
||||
PropertyCard,
|
||||
OVERRIDE_AUTHORIZED,
|
||||
OVERRIDE_REJECTED
|
||||
} from 'src/pages/Customers/components/propertyCard'
|
||||
|
||||
import { mainStyles } from '../Customers.styles'
|
||||
|
||||
import Field from './Field'
|
||||
|
||||
const useStyles = makeStyles(mainStyles)
|
||||
|
||||
const PhoneCard = memo(({ customerData, updateCustomer }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<PropertyCard
|
||||
className={classes.phoneCard}
|
||||
title={'Phone'}
|
||||
state={R.path(['smsOverride'])(customerData)}
|
||||
authorize={() => updateCustomer({ smsOverride: OVERRIDE_AUTHORIZED })}
|
||||
reject={() => updateCustomer({ smsOverride: OVERRIDE_REJECTED })}>
|
||||
<Field
|
||||
label={'Phone'}
|
||||
display={
|
||||
R.path(['phone'])(customerData)
|
||||
? parsePhoneNumberFromString(
|
||||
R.path(['phone'])(customerData)
|
||||
).formatInternational()
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</PropertyCard>
|
||||
)
|
||||
})
|
||||
|
||||
export default PhoneCard
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import BigNumber from 'bignumber.js'
|
||||
import moment from 'moment'
|
||||
import React from 'react'
|
||||
|
||||
import DataTable from 'src/components/tables/DataTable'
|
||||
import { H4, Label2 } from 'src/components/typography'
|
||||
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 { toUnit } from 'src/utils/coin'
|
||||
|
||||
import CopyToClipboard from '../../Transactions/CopyToClipboard'
|
||||
import { mainStyles } from '../Customers.styles'
|
||||
|
||||
const useStyles = makeStyles(mainStyles)
|
||||
|
||||
const TransactionsList = ({ data }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const elements = [
|
||||
{
|
||||
header: 'Direction',
|
||||
width: 207,
|
||||
view: it => (
|
||||
<>
|
||||
{it.txClass === 'cashOut' ? (
|
||||
<TxOutIcon className={classes.txClassIconLeft} />
|
||||
) : (
|
||||
<TxInIcon className={classes.txClassIconLeft} />
|
||||
)}
|
||||
{it.txClass === 'cashOut' ? 'Cach-out' : 'Cash-in'}
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Transaction ID',
|
||||
width: 414,
|
||||
view: it => (
|
||||
<CopyToClipboard className={classes.txId}>{it.id}</CopyToClipboard>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Cash',
|
||||
width: 146,
|
||||
view: it => (
|
||||
<>
|
||||
{`${Number.parseFloat(it.fiat)} `}
|
||||
<Label2 inline>{it.fiatCode}</Label2>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Crypto',
|
||||
width: 142,
|
||||
view: it => (
|
||||
<>
|
||||
{`${toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode).toFormat(
|
||||
5
|
||||
)} `}
|
||||
<Label2 inline>{it.cryptoCode}</Label2>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Date',
|
||||
width: 157,
|
||||
view: it => moment.utc(it.created).format('YYYY-MM-D')
|
||||
},
|
||||
{
|
||||
header: 'Time (h:m:s)',
|
||||
width: 134,
|
||||
view: it => moment.utc(it.created).format('hh:mm:ss')
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.titleWrapper}>
|
||||
<div className={classes.titleAndButtonsContainer}>
|
||||
<H4>All transactions from this customer</H4>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable elements={elements} data={data} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransactionsList
|
||||
13
new-lamassu-admin/src/pages/Customers/components/index.js
Normal file
13
new-lamassu-admin/src/pages/Customers/components/index.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import CustomerDetails from './CustomerDetails'
|
||||
import IdDataCard from './IdDataCard'
|
||||
import IdCardPhotoCard from './IdCardPhotoCard'
|
||||
import PhoneCard from './PhoneCard'
|
||||
import TransactionsList from './TransactionsList'
|
||||
|
||||
export {
|
||||
CustomerDetails,
|
||||
IdDataCard,
|
||||
IdCardPhotoCard,
|
||||
PhoneCard,
|
||||
TransactionsList
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import classnames from 'classnames'
|
||||
import React, { memo } from 'react'
|
||||
import { Paper } from '@material-ui/core'
|
||||
|
||||
import { ActionButton } from 'src/components/buttons'
|
||||
import { H3 } from 'src/components/typography'
|
||||
import { ReactComponent as AuthorizeReversedIcon } from 'src/styling/icons/button/authorize/white.svg'
|
||||
import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/zodiac.svg'
|
||||
import { ReactComponent as RejectReversedIcon } from 'src/styling/icons/button/cancel/white.svg'
|
||||
import { ReactComponent as RejectIcon } from 'src/styling/icons/button/cancel/zodiac.svg'
|
||||
|
||||
import { propertyCardStyles } from './PropertyCard.styles'
|
||||
|
||||
const useStyles = makeStyles(propertyCardStyles)
|
||||
|
||||
const OVERRIDE_PENDING = 'automatic'
|
||||
const OVERRIDE_AUTHORIZED = 'verified'
|
||||
const OVERRIDE_REJECTED = 'blocked'
|
||||
|
||||
const PropertyCard = memo(
|
||||
({ className, title, state, authorize, reject, children }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const propertyCardClassNames = {
|
||||
[classes.propertyCard]: true,
|
||||
[classes.propertyCardPending]: state === OVERRIDE_PENDING,
|
||||
[classes.propertyCardRejected]: state === OVERRIDE_REJECTED,
|
||||
[classes.propertyCardAccepted]: state === OVERRIDE_AUTHORIZED
|
||||
}
|
||||
|
||||
const label1ClassNames = {
|
||||
[classes.label1]: true,
|
||||
[classes.label1Pending]: state === OVERRIDE_PENDING,
|
||||
[classes.label1Rejected]: state === OVERRIDE_REJECTED,
|
||||
[classes.label1Accepted]: state === OVERRIDE_AUTHORIZED
|
||||
}
|
||||
|
||||
const AuthorizeButton = () => (
|
||||
<ActionButton
|
||||
className={classes.cardActionButton}
|
||||
color="secondary"
|
||||
Icon={AuthorizeIcon}
|
||||
InverseIcon={AuthorizeReversedIcon}
|
||||
onClick={() => authorize()}>
|
||||
Authorize
|
||||
</ActionButton>
|
||||
)
|
||||
|
||||
const RejectButton = () => (
|
||||
<ActionButton
|
||||
className={classes.cardActionButton}
|
||||
color="secondary"
|
||||
Icon={RejectIcon}
|
||||
InverseIcon={RejectReversedIcon}
|
||||
onClick={() => reject()}>
|
||||
Reject
|
||||
</ActionButton>
|
||||
)
|
||||
|
||||
const authorizedAsString =
|
||||
state === OVERRIDE_PENDING
|
||||
? 'Pending'
|
||||
: state === OVERRIDE_REJECTED
|
||||
? 'Rejected'
|
||||
: 'Accepted'
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={classnames(propertyCardClassNames, className)}
|
||||
elevation={0}>
|
||||
<div className={classes.rowSpaceBetween}>
|
||||
<H3>{title}</H3>
|
||||
<div className={classnames(label1ClassNames)}>
|
||||
{authorizedAsString}
|
||||
</div>
|
||||
</div>
|
||||
<Paper className={classes.cardProperties} elevation={0}>
|
||||
{children}
|
||||
</Paper>
|
||||
<div className={classes.buttonsWrapper}>
|
||||
{state !== OVERRIDE_AUTHORIZED && AuthorizeButton()}
|
||||
{state !== OVERRIDE_REJECTED && RejectButton()}
|
||||
</div>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export {
|
||||
PropertyCard,
|
||||
OVERRIDE_PENDING,
|
||||
OVERRIDE_AUTHORIZED,
|
||||
OVERRIDE_REJECTED
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
white,
|
||||
zircon,
|
||||
mistyRose,
|
||||
tomato,
|
||||
spring3,
|
||||
spring4,
|
||||
comet
|
||||
} from 'src/styling/variables'
|
||||
|
||||
const propertyCardStyles = {
|
||||
propertyCard: {
|
||||
margin: [[32, 12, 0, 0]],
|
||||
padding: [[0, 16]],
|
||||
borderRadius: 8
|
||||
},
|
||||
propertyCardPending: {
|
||||
backgroundColor: zircon
|
||||
},
|
||||
propertyCardRejected: {
|
||||
backgroundColor: mistyRose
|
||||
},
|
||||
propertyCardAccepted: {
|
||||
backgroundColor: spring3
|
||||
},
|
||||
label1: {
|
||||
fontFamily: 'MuseoSans',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
fontStretch: 'normal',
|
||||
fontStyle: 'normal',
|
||||
lineHeight: 1.33,
|
||||
letterSpacing: 'normal',
|
||||
color: comet,
|
||||
margin: [[4, 0]]
|
||||
},
|
||||
label1Pending: {
|
||||
color: comet
|
||||
},
|
||||
label1Rejected: {
|
||||
color: tomato
|
||||
},
|
||||
label1Accepted: {
|
||||
color: spring4
|
||||
},
|
||||
cardActionButton: {
|
||||
height: 28,
|
||||
marginLeft: 12
|
||||
},
|
||||
cardProperties: {
|
||||
borderRadius: 8,
|
||||
width: '100%',
|
||||
height: 'calc(100% - 100px)',
|
||||
padding: [[20]],
|
||||
boxSizing: 'border-box',
|
||||
boxShadow: '0 0 8px 0 rgba(0, 0, 0, 0.04)',
|
||||
border: 'solid 0',
|
||||
backgroundColor: white
|
||||
},
|
||||
rowSpaceBetween: {
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
buttonsWrapper: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 16,
|
||||
marginBottom: 16
|
||||
}
|
||||
}
|
||||
|
||||
export { propertyCardStyles }
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import {
|
||||
PropertyCard,
|
||||
OVERRIDE_PENDING,
|
||||
OVERRIDE_AUTHORIZED,
|
||||
OVERRIDE_REJECTED
|
||||
} from './PropertyCard'
|
||||
|
||||
export {
|
||||
PropertyCard,
|
||||
OVERRIDE_PENDING,
|
||||
OVERRIDE_AUTHORIZED,
|
||||
OVERRIDE_REJECTED
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
const IMAGES_URI =
|
||||
process.env.NODE_ENV === 'development' ? 'https://localhost:8070' : ''
|
||||
|
||||
export { IMAGES_URI }
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import Customers from './Customers'
|
||||
import CustomerProfile from './CustomerProfile'
|
||||
|
||||
export default Customers
|
||||
export { Customers, CustomerProfile }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Route, Redirect, Switch } from 'react-router-dom'
|
|||
|
||||
import AuthRegister from 'src/pages/AuthRegister'
|
||||
import Commissions from 'src/pages/Commissions'
|
||||
import Customers from 'src/pages/Customers'
|
||||
import { Customers, CustomerProfile } from 'src/pages/Customers'
|
||||
import Funding from 'src/pages/Funding'
|
||||
import Locales from 'src/pages/Locales'
|
||||
import MachineLogs from 'src/pages/MachineLogs'
|
||||
|
|
@ -124,6 +124,11 @@ const tree = [
|
|||
label: 'Customers',
|
||||
route: '/compliance/customers',
|
||||
component: Customers
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
route: '/compliance/customer/:id',
|
||||
component: CustomerProfile
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
|
||||
<title>icon/crossed-camera</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="icon/crossed-camera" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="crossed-camera">
|
||||
<g id="Group-2">
|
||||
<g id="icon/ID/cam/zodiac" transform="translate(0.000000, 3.657143)" fill="#1B2559" fill-rule="nonzero">
|
||||
<path d="M16,19.1876029 C12.4482116,19.1876029 9.56809571,16.389776 9.56809571,12.9394673 C9.56809571,9.48915858 12.4482116,6.69133172 16,6.69133172 C19.5517884,6.69133172 22.4319043,9.48915858 22.4319043,12.9394673 C22.4319043,16.389776 19.5517884,19.1876029 16,19.1876029 Z M16,17.104891 C18.3677075,17.104891 20.2879362,15.239526 20.2879362,12.9394673 C20.2879362,10.6394086 18.3677075,8.77404358 16,8.77404358 C13.6322925,8.77404358 11.7120638,10.6394086 11.7120638,12.9394673 C11.7120638,15.239526 13.6322925,17.104891 16,17.104891 Z M22.7667469,3.30692494 L30.7397807,3.30692494 C31.3318211,3.30692494 31.8117647,3.77315587 31.8117647,4.34828087 L31.8117647,22.0513317 C31.8117647,22.6264567 31.3318211,23.0926877 30.7397807,23.0926877 L1.26021934,23.0926877 C0.6681789,23.0926877 0.188235294,22.6264567 0.188235294,22.0513317 L0.188235294,4.34828087 C0.188235294,3.77315587 0.6681789,3.30692494 1.26021934,3.30692494 L9.23325311,3.30692494 L12.0766705,0.494526627 C12.2782333,0.295162767 12.5538198,0.182857143 12.8414756,0.182857143 L19.1585244,0.182857143 C19.4461802,0.182857143 19.7217667,0.295162767 19.9233295,0.494526627 L22.7667469,3.30692494 Z M29.6677966,5.3896368 L22.3170489,5.3896368 C22.0293931,5.3896368 21.7538065,5.27733118 21.5522438,5.07796732 L18.7088264,2.26556901 L13.2911736,2.26556901 L10.4477562,5.07796732 C10.2461935,5.27733118 9.97060695,5.3896368 9.68295115,5.3896368 L2.33220339,5.3896368 L2.33220339,21.0099758 L29.6677966,21.0099758 L29.6677966,5.3896368 Z" id="Stroke-1"></path>
|
||||
</g>
|
||||
<line x1="32" y1="0" x2="0" y2="32" id="Line" stroke="#FF584A" stroke-width="2" stroke-linecap="square"></line>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -122,6 +122,7 @@ export {
|
|||
comet,
|
||||
spring2,
|
||||
spring3,
|
||||
spring4,
|
||||
tomato,
|
||||
pumpkin,
|
||||
mistyRose,
|
||||
|
|
|
|||
5
new-lamassu-admin/src/utils/nullCheck.js
Normal file
5
new-lamassu-admin/src/utils/nullCheck.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const ifNotNull = (value, valueIfNotNull) => {
|
||||
return value === null ? '' : valueIfNotNull
|
||||
}
|
||||
|
||||
export { ifNotNull }
|
||||
Loading…
Add table
Add a link
Reference in a new issue