Feat: make dashboard and machine profile page
This commit is contained in:
parent
d17ca43abb
commit
19cd086436
54 changed files with 11680 additions and 2611 deletions
|
|
@ -16,12 +16,15 @@ const machineEventsByIdBatch = require("../../postgresql_interface").machineEven
|
||||||
const couponManager = require('../../coupons')
|
const couponManager = require('../../coupons')
|
||||||
|
|
||||||
const serverVersion = require('../../../package.json').version
|
const serverVersion = require('../../../package.json').version
|
||||||
|
|
||||||
const transactions = require('../transactions')
|
const transactions = require('../transactions')
|
||||||
const funding = require('../funding')
|
const funding = require('../funding')
|
||||||
const supervisor = require('../supervisor')
|
const supervisor = require('../supervisor')
|
||||||
const serverLogs = require('../server-logs')
|
const serverLogs = require('../server-logs')
|
||||||
const pairing = require('../pairing')
|
const pairing = require('../pairing')
|
||||||
|
|
||||||
|
const plugins = require('../../plugins')
|
||||||
|
const ticker = require('../../ticker')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accounts: accountsConfig,
|
accounts: accountsConfig,
|
||||||
coins,
|
coins,
|
||||||
|
|
@ -237,6 +240,10 @@ const typeDefs = gql`
|
||||||
created: Date
|
created: Date
|
||||||
age: Float
|
age: Float
|
||||||
deviceTime: Date
|
deviceTime: Date
|
||||||
|
type Rate {
|
||||||
|
code: String
|
||||||
|
name: String
|
||||||
|
rate: Float
|
||||||
}
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
|
|
@ -256,12 +263,19 @@ const typeDefs = gql`
|
||||||
uptime: [ProcessStatus]
|
uptime: [ProcessStatus]
|
||||||
serverLogs(from: Date, until: Date, limit: Int, offset: Int): [ServerLog]
|
serverLogs(from: Date, until: Date, limit: Int, offset: Int): [ServerLog]
|
||||||
serverLogsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
serverLogsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
||||||
transactions(from: Date, until: Date, limit: Int, offset: Int): [Transaction]
|
transactions(
|
||||||
|
from: Date
|
||||||
|
until: Date
|
||||||
|
limit: Int
|
||||||
|
offset: Int
|
||||||
|
id: ID
|
||||||
|
): [Transaction]
|
||||||
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
||||||
accounts: JSONObject
|
accounts: JSONObject
|
||||||
config: JSONObject
|
config: JSONObject
|
||||||
blacklist: [Blacklist]
|
blacklist: [Blacklist]
|
||||||
# userTokens: [UserToken]
|
# userTokens: [UserToken]
|
||||||
|
<<<<<<< HEAD
|
||||||
coupons: [Coupon]
|
coupons: [Coupon]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,6 +283,10 @@ const typeDefs = gql`
|
||||||
id: ID!
|
id: ID!
|
||||||
timestamp: Date!
|
timestamp: Date!
|
||||||
deviceId: ID
|
deviceId: ID
|
||||||
|
=======
|
||||||
|
rates: JSONObject
|
||||||
|
btcRates(to: String, from: String): [Rate]
|
||||||
|
>>>>>>> 9d88b4f... Feat: make dashboard and machine profile page
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MachineAction {
|
enum MachineAction {
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,19 @@ function addNames (txs) {
|
||||||
|
|
||||||
const camelize = _.mapKeys(_.camelCase)
|
const camelize = _.mapKeys(_.camelCase)
|
||||||
|
|
||||||
function batch (from = new Date(0).toISOString(), until = new Date().toISOString(), limit = null, offset = 0) {
|
function batch (
|
||||||
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames)
|
from = new Date(0).toISOString(),
|
||||||
|
until = new Date().toISOString(),
|
||||||
|
limit = null,
|
||||||
|
offset = 0,
|
||||||
|
id = null
|
||||||
|
) {
|
||||||
|
const packager = _.flow(
|
||||||
|
_.flatten,
|
||||||
|
_.orderBy(_.property('created'), ['desc']),
|
||||||
|
_.map(camelize),
|
||||||
|
addNames
|
||||||
|
)
|
||||||
|
|
||||||
const cashInSql = `select 'cashIn' as tx_class, txs.*,
|
const cashInSql = `select 'cashIn' as tx_class, txs.*,
|
||||||
c.phone as customer_phone,
|
c.phone as customer_phone,
|
||||||
|
|
@ -38,7 +49,9 @@ function batch (from = new Date(0).toISOString(), until = new Date().toISOString
|
||||||
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
|
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
|
||||||
from cash_in_txs as txs
|
from cash_in_txs as txs
|
||||||
left outer join customers c on txs.customer_id = c.id
|
left outer join customers c on txs.customer_id = c.id
|
||||||
where txs.created >= $2 and txs.created <= $3
|
where txs.created >= $2 and txs.created <= $3 ${
|
||||||
|
id !== null ? `and txs.device_id = $6` : ``
|
||||||
|
}
|
||||||
order by created desc limit $4 offset $5`
|
order by created desc limit $4 offset $5`
|
||||||
|
|
||||||
const cashOutSql = `select 'cashOut' as tx_class,
|
const cashOutSql = `select 'cashOut' as tx_class,
|
||||||
|
|
@ -56,14 +69,22 @@ function batch (from = new Date(0).toISOString(), until = new Date().toISOString
|
||||||
inner join cash_out_actions actions on txs.id = actions.tx_id
|
inner join cash_out_actions actions on txs.id = actions.tx_id
|
||||||
and actions.action = 'provisionAddress'
|
and actions.action = 'provisionAddress'
|
||||||
left outer join customers c on txs.customer_id = c.id
|
left outer join customers c on txs.customer_id = c.id
|
||||||
where txs.created >= $2 and txs.created <= $3
|
where txs.created >= $2 and txs.created <= $3 ${
|
||||||
|
id !== null ? `and txs.device_id = $6` : ``
|
||||||
|
}
|
||||||
order by created desc limit $4 offset $5`
|
order by created desc limit $4 offset $5`
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
db.any(cashInSql, [cashInTx.PENDING_INTERVAL, from, until, limit, offset]),
|
db.any(cashInSql, [
|
||||||
db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset])
|
cashInTx.PENDING_INTERVAL,
|
||||||
])
|
from,
|
||||||
.then(packager)
|
until,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
id
|
||||||
|
]),
|
||||||
|
db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset, id])
|
||||||
|
]).then(packager)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCustomerTransactionsBatch (ids) {
|
function getCustomerTransactionsBatch (ids) {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,29 @@ const PONG_TTL = '1 week'
|
||||||
const tradesQueues = {}
|
const tradesQueues = {}
|
||||||
|
|
||||||
function plugins (settings, deviceId) {
|
function plugins (settings, deviceId) {
|
||||||
|
function buildRatesNoCommission (tickers) {
|
||||||
|
const localeConfig = configManager.getLocale(deviceId, settings.config)
|
||||||
|
const cryptoCodes = localeConfig.cryptoCurrencies
|
||||||
|
|
||||||
|
const rates = {}
|
||||||
|
|
||||||
|
cryptoCodes.forEach((cryptoCode, i) => {
|
||||||
|
const rateRec = tickers[i]
|
||||||
|
|
||||||
|
if (!rateRec) return
|
||||||
|
|
||||||
|
if (Date.now() - rateRec.timestamp > STALE_TICKER)
|
||||||
|
return logger.warn('Stale rate for ' + cryptoCode)
|
||||||
|
const rate = rateRec.rates
|
||||||
|
rates[cryptoCode] = {
|
||||||
|
cashIn: rate.ask.round(5),
|
||||||
|
cashOut: rate.bid.round(5)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return rates
|
||||||
|
}
|
||||||
|
|
||||||
function buildRates (tickers) {
|
function buildRates (tickers) {
|
||||||
const localeConfig = configManager.getLocale(deviceId, settings.config)
|
const localeConfig = configManager.getLocale(deviceId, settings.config)
|
||||||
const cryptoCodes = localeConfig.cryptoCurrencies
|
const cryptoCodes = localeConfig.cryptoCurrencies
|
||||||
|
|
@ -791,6 +814,7 @@ function plugins (settings, deviceId) {
|
||||||
getRates,
|
getRates,
|
||||||
buildRates,
|
buildRates,
|
||||||
getRawRates,
|
getRawRates,
|
||||||
|
buildRatesNoCommission,
|
||||||
pollQueries,
|
pollQueries,
|
||||||
sendCoins,
|
sendCoins,
|
||||||
newAddress,
|
newAddress,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const mem = require('mem')
|
||||||
const configManager = require('./new-config-manager')
|
const configManager = require('./new-config-manager')
|
||||||
const ph = require('./plugin-helper')
|
const ph = require('./plugin-helper')
|
||||||
const logger = require('./logger')
|
const logger = require('./logger')
|
||||||
|
const axios = require('axios')
|
||||||
|
|
||||||
const lastRate = {}
|
const lastRate = {}
|
||||||
|
|
||||||
|
|
@ -39,4 +40,26 @@ const getRates = mem(_getRates, {
|
||||||
cacheKey: (settings, fiatCode, cryptoCode) => JSON.stringify([fiatCode, cryptoCode])
|
cacheKey: (settings, fiatCode, cryptoCode) => JSON.stringify([fiatCode, cryptoCode])
|
||||||
})
|
})
|
||||||
|
|
||||||
module.exports = { getRates }
|
const getBtcRates = (to = null, from = 'USD') => {
|
||||||
|
// if to !== null, then we return only the rates with from (default USD) and to (so an array with 2 items)
|
||||||
|
return axios.get('https://bitpay.com/api/rates').then(response => {
|
||||||
|
const fxRates = response.data
|
||||||
|
if (to === null) {
|
||||||
|
return fxRates
|
||||||
|
}
|
||||||
|
const toRate = fxRates.find(o => o.code === to)
|
||||||
|
const fromRate = fxRates.find(o => o.code === from)
|
||||||
|
|
||||||
|
let res = []
|
||||||
|
if (toRate && to !== from) {
|
||||||
|
res = [...res, toRate]
|
||||||
|
}
|
||||||
|
if (fromRate) {
|
||||||
|
res = [...res, fromRate]
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getBtcRates, getRates }
|
||||||
|
|
|
||||||
10450
new-lamassu-admin/package-lock.json
generated
10450
new-lamassu-admin/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,7 @@
|
||||||
"axios": "0.19.0",
|
"axios": "0.19.0",
|
||||||
"bignumber.js": "9.0.0",
|
"bignumber.js": "9.0.0",
|
||||||
"classnames": "2.2.6",
|
"classnames": "2.2.6",
|
||||||
|
"d3": "^6.2.0",
|
||||||
"downshift": "3.3.4",
|
"downshift": "3.3.4",
|
||||||
"file-saver": "2.0.2",
|
"file-saver": "2.0.2",
|
||||||
"formik": "2.2.0",
|
"formik": "2.2.0",
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ const TL2 = pBuilder('tl2')
|
||||||
const Label1 = pBuilder('label1')
|
const Label1 = pBuilder('label1')
|
||||||
const Label2 = pBuilder('label2')
|
const Label2 = pBuilder('label2')
|
||||||
const Label3 = pBuilder('label3')
|
const Label3 = pBuilder('label3')
|
||||||
|
const Label4 = pBuilder('regularLabel')
|
||||||
|
|
||||||
function pBuilder(elementClass) {
|
function pBuilder(elementClass) {
|
||||||
return ({ inline, noMargin, className, children, ...props }) => {
|
return ({ inline, noMargin, className, children, ...props }) => {
|
||||||
|
|
@ -124,5 +125,6 @@ export {
|
||||||
Mono,
|
Mono,
|
||||||
Label1,
|
Label1,
|
||||||
Label2,
|
Label2,
|
||||||
Label3
|
Label3,
|
||||||
|
Label4
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
new-lamassu-admin/src/pages/Dashboard/Alerts/Alerts.js
Normal file
122
new-lamassu-admin/src/pages/Dashboard/Alerts/Alerts.js
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
import { Label1, H4 } from 'src/components/typography'
|
||||||
|
|
||||||
|
import styles from '../Dashboard.styles'
|
||||||
|
|
||||||
|
import AlertsTable from './AlertsTable'
|
||||||
|
|
||||||
|
const NUM_TO_RENDER = 3
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
alerts: [
|
||||||
|
{ text: 'alert 1' },
|
||||||
|
{ text: 'alert 2' },
|
||||||
|
{ text: 'alert 3' },
|
||||||
|
{ text: 'alert 4' },
|
||||||
|
{ text: 'alert 5' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const Alerts = ({ cardState, setRightSideState }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const [showAllItems, setShowAllItems] = useState(false)
|
||||||
|
const [showExpandButton, setShowExpandButton] = useState(false)
|
||||||
|
const [numToRender, setNumToRender] = useState(NUM_TO_RENDER)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAllItems) {
|
||||||
|
setShowExpandButton(false)
|
||||||
|
setNumToRender(data?.alerts.length)
|
||||||
|
} else if (data && data?.alerts.length > numToRender) {
|
||||||
|
setShowExpandButton(true)
|
||||||
|
}
|
||||||
|
if (cardState.cardSize === 'small' || cardState.cardSize === 'default') {
|
||||||
|
setShowAllItems(false)
|
||||||
|
setNumToRender(NUM_TO_RENDER)
|
||||||
|
}
|
||||||
|
}, [cardState.cardSize, numToRender, showAllItems])
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setRightSideState({
|
||||||
|
systemStatus: { cardSize: 'default', buttonName: 'Show less' },
|
||||||
|
alerts: { cardSize: 'default', buttonName: 'Show less' }
|
||||||
|
})
|
||||||
|
setShowAllItems(false)
|
||||||
|
setNumToRender(NUM_TO_RENDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showAllClick = () => {
|
||||||
|
setShowExpandButton(false)
|
||||||
|
setShowAllItems(true)
|
||||||
|
setRightSideState({
|
||||||
|
systemStatus: { cardSize: 'small', buttonName: 'Show machines' },
|
||||||
|
alerts: { cardSize: 'big', buttonName: 'Show less' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<H4 className={classes.h4}>{`Alerts ${
|
||||||
|
data ? `(${data.alerts.length})` : 0
|
||||||
|
}`}</H4>
|
||||||
|
{(showAllItems || cardState.cardSize === 'small') && (
|
||||||
|
<>
|
||||||
|
<Label1
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 0,
|
||||||
|
marginTop: 0
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
onClick={reset}
|
||||||
|
size="small"
|
||||||
|
disableRipple
|
||||||
|
disableFocusRipple
|
||||||
|
className={classes.button}>
|
||||||
|
{cardState.buttonName}
|
||||||
|
</Button>
|
||||||
|
</Label1>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{cardState.cardSize !== 'small' && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<AlertsTable
|
||||||
|
numToRender={numToRender}
|
||||||
|
alerts={data?.alerts ?? []}
|
||||||
|
/>
|
||||||
|
{showExpandButton && (
|
||||||
|
<>
|
||||||
|
<Label1 style={{ textAlign: 'center', marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
onClick={showAllClick}
|
||||||
|
size="small"
|
||||||
|
disableRipple
|
||||||
|
disableFocusRipple
|
||||||
|
className={classes.button}>
|
||||||
|
{`Show all (${data.alerts.length})`}
|
||||||
|
</Button>
|
||||||
|
</Label1>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default Alerts
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
backgroundColor,
|
||||||
|
offColor,
|
||||||
|
errorColor,
|
||||||
|
primaryColor
|
||||||
|
} from 'src/styling/variables'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
label: {
|
||||||
|
margin: 0,
|
||||||
|
color: offColor
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
borderBottom: 'none'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
whiteSpace: 'pre'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: errorColor
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
color: primaryColor,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: 0,
|
||||||
|
textTransform: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
statusHeader: {
|
||||||
|
marginLeft: 2
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
maxHeight: 440,
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: 7
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: offColor,
|
||||||
|
borderRadius: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tableBody: {
|
||||||
|
overflow: 'auto'
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
marginTop: 0
|
||||||
|
},
|
||||||
|
buttonLabel: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 0,
|
||||||
|
marginTop: 0
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
'&:nth-of-type(odd)': {
|
||||||
|
backgroundColor: backgroundColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
new-lamassu-admin/src/pages/Dashboard/Alerts/AlertsTable.js
Normal file
36
new-lamassu-admin/src/pages/Dashboard/Alerts/AlertsTable.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { withStyles } from '@material-ui/core'
|
||||||
|
import List from '@material-ui/core/List'
|
||||||
|
import ListItem from '@material-ui/core/ListItem'
|
||||||
|
import ListItemText from '@material-ui/core/ListItemText'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import styles from './Alerts.styles'
|
||||||
|
// const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const StyledListItem = withStyles(() => ({
|
||||||
|
root: {
|
||||||
|
...styles.root
|
||||||
|
}
|
||||||
|
}))(ListItem)
|
||||||
|
|
||||||
|
const AlertsTable = ({ numToRender, alerts }) => {
|
||||||
|
// const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List dense>
|
||||||
|
{alerts.map((alert, idx) => {
|
||||||
|
if (idx < numToRender) {
|
||||||
|
return (
|
||||||
|
<StyledListItem key={idx}>
|
||||||
|
<ListItemText primary={alert.text} />
|
||||||
|
</StyledListItem>
|
||||||
|
)
|
||||||
|
} else return null
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertsTable
|
||||||
2
new-lamassu-admin/src/pages/Dashboard/Alerts/index.js
Normal file
2
new-lamassu-admin/src/pages/Dashboard/Alerts/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import Alerts from './Alerts'
|
||||||
|
export default Alerts
|
||||||
43
new-lamassu-admin/src/pages/Dashboard/Dashboard.js
Normal file
43
new-lamassu-admin/src/pages/Dashboard/Dashboard.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import TitleSection from 'src/components/layout/TitleSection'
|
||||||
|
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 styles from './Dashboard.styles'
|
||||||
|
import Footer from './Footer'
|
||||||
|
import LeftSide from './LeftSide'
|
||||||
|
import RightSide from './RightSide'
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TitleSection title="Dashboard">
|
||||||
|
<div className={classes.headerLabels}>
|
||||||
|
<div>
|
||||||
|
<TxOutIcon />
|
||||||
|
<span>Cash-out</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TxInIcon />
|
||||||
|
<span>Cash-in</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TitleSection>
|
||||||
|
<div className={classes.root}>
|
||||||
|
<Grid container>
|
||||||
|
<LeftSide />
|
||||||
|
<RightSide />
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard
|
||||||
62
new-lamassu-admin/src/pages/Dashboard/Dashboard.styles.js
Normal file
62
new-lamassu-admin/src/pages/Dashboard/Dashboard.styles.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import typographyStyles from 'src/components/typography/styles'
|
||||||
|
import { spacer, white, primaryColor } from 'src/styling/variables'
|
||||||
|
const { label1 } = typographyStyles
|
||||||
|
|
||||||
|
export default {
|
||||||
|
root: {
|
||||||
|
flexGrow: 1,
|
||||||
|
marginBottom: 108
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
margin: [['auto', 0, spacer * 3, 'auto']]
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
boxShadow: '0 0 4px 0 rgba(0, 0, 0, 0.08)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: white
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
margin: 0,
|
||||||
|
marginRight: spacer * 8
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: primaryColor,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: 0,
|
||||||
|
textTransform: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
marginTop: -4
|
||||||
|
},
|
||||||
|
headerLabels: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
'& div': {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
},
|
||||||
|
'& > div:first-child': {
|
||||||
|
marginRight: 24
|
||||||
|
},
|
||||||
|
'& span': {
|
||||||
|
extend: label1,
|
||||||
|
marginLeft: 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
color: primaryColor,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: 0,
|
||||||
|
textTransform: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js
Normal file
121
new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { useQuery } from '@apollo/react-hooks'
|
||||||
|
import { makeStyles } from '@material-ui/core'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { 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 { fromNamespace } from 'src/utils/config'
|
||||||
|
|
||||||
|
import styles from './Footer.styles'
|
||||||
|
const GET_DATA = gql`
|
||||||
|
query getData {
|
||||||
|
rates
|
||||||
|
cryptoCurrencies {
|
||||||
|
code
|
||||||
|
display
|
||||||
|
}
|
||||||
|
config
|
||||||
|
accountsConfig {
|
||||||
|
code
|
||||||
|
display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
const Footer = () => {
|
||||||
|
const { data, loading } = useQuery(GET_DATA)
|
||||||
|
|
||||||
|
const classes = useStyles()
|
||||||
|
const wallets = fromNamespace('wallets')(data?.config)
|
||||||
|
|
||||||
|
const renderFooterItem = key => {
|
||||||
|
const idx = R.findIndex(R.propEq('code', key))(data.cryptoCurrencies)
|
||||||
|
const tickerCode = wallets[`${key}_ticker`]
|
||||||
|
const tickerIdx = R.findIndex(R.propEq('code', tickerCode))(
|
||||||
|
data.accountsConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
const tickerName = data.accountsConfig[tickerIdx].display
|
||||||
|
|
||||||
|
const cashInNoCommission = parseFloat(
|
||||||
|
R.path(['rates', 'withoutCommissions', key, 'cashIn'])(data)
|
||||||
|
)
|
||||||
|
const cashOutNoCommission = parseFloat(
|
||||||
|
R.path(['rates', 'withoutCommissions', key, 'cashOut'])(data)
|
||||||
|
)
|
||||||
|
|
||||||
|
// check https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
|
||||||
|
// to see reason for this implementation. It makes 1.005 round to 1.01 and not 1
|
||||||
|
// const monetaryValue = +(Math.round(askBidAvg + 'e+2') + 'e-2')
|
||||||
|
const avgOfAskBid = +(
|
||||||
|
Math.round((cashInNoCommission + cashOutNoCommission) / 2 + 'e+2') + 'e-2'
|
||||||
|
)
|
||||||
|
const cashIn = +(
|
||||||
|
Math.round(
|
||||||
|
parseFloat(R.path(['rates', 'withCommissions', key, 'cashIn'])(data)) +
|
||||||
|
'e+2'
|
||||||
|
) + 'e-2'
|
||||||
|
)
|
||||||
|
const cashOut = +(
|
||||||
|
Math.round(
|
||||||
|
parseFloat(R.path(['rates', 'withCommissions', key, 'cashOut'])(data)) +
|
||||||
|
'e+2'
|
||||||
|
) + 'e-2'
|
||||||
|
)
|
||||||
|
|
||||||
|
const localeFiatCurrency = data.config.locale_fiatCurrency
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid key={key} item xs={3} style={{ marginBottom: 18 }}>
|
||||||
|
<Label2 className={classes.label}>
|
||||||
|
{data.cryptoCurrencies[idx].display}
|
||||||
|
</Label2>
|
||||||
|
<div className={classes.headerLabels}>
|
||||||
|
<div>
|
||||||
|
<TxInIcon />
|
||||||
|
<Label2>{` ${cashIn.toLocaleString(
|
||||||
|
'en-US'
|
||||||
|
)} ${localeFiatCurrency}`}</Label2>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TxOutIcon />
|
||||||
|
<Label2>{` ${cashOut.toLocaleString(
|
||||||
|
'en-US'
|
||||||
|
)} ${localeFiatCurrency}`}</Label2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Label2
|
||||||
|
className={
|
||||||
|
classes.tickerLabel
|
||||||
|
}>{`${tickerName}: ${avgOfAskBid.toLocaleString(
|
||||||
|
'en-US'
|
||||||
|
)} ${localeFiatCurrency}`}</Label2>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classes.footer}>
|
||||||
|
<div className={classes.content}>
|
||||||
|
{!loading && data && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
{R.keys(data.rates.withCommissions).map(key =>
|
||||||
|
renderFooterItem(key)
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import typographyStyles from 'src/components/typography/styles'
|
||||||
|
import {
|
||||||
|
backgroundColor,
|
||||||
|
offColor,
|
||||||
|
errorColor,
|
||||||
|
primaryColor,
|
||||||
|
white
|
||||||
|
} from 'src/styling/variables'
|
||||||
|
const { label1 } = typographyStyles
|
||||||
|
|
||||||
|
export default {
|
||||||
|
label: {
|
||||||
|
color: offColor
|
||||||
|
},
|
||||||
|
tickerLabel: {
|
||||||
|
color: offColor,
|
||||||
|
marginTop: -5
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
borderBottom: 'none'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
whiteSpace: 'pre'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: errorColor
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
color: primaryColor,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: 0,
|
||||||
|
textTransform: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
statusHeader: {
|
||||||
|
marginLeft: 2
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
maxHeight: 440,
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: 7
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: offColor,
|
||||||
|
borderRadius: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tableBody: {
|
||||||
|
overflow: 'auto'
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
marginTop: 0
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: 'fixed',
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '100vw',
|
||||||
|
backgroundColor: white,
|
||||||
|
textAlign: 'left',
|
||||||
|
height: 88
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
width: 1200,
|
||||||
|
margin: '0 auto'
|
||||||
|
},
|
||||||
|
headerLabels: {
|
||||||
|
whiteSpace: 'pre',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
'& div': {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
},
|
||||||
|
'& > div:first-child': {
|
||||||
|
marginRight: 24
|
||||||
|
},
|
||||||
|
'& span': {
|
||||||
|
extend: label1,
|
||||||
|
marginLeft: 6
|
||||||
|
},
|
||||||
|
marginTop: -20
|
||||||
|
}
|
||||||
|
}
|
||||||
2
new-lamassu-admin/src/pages/Dashboard/Footer/index.js
Normal file
2
new-lamassu-admin/src/pages/Dashboard/Footer/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import Footer from './Footer'
|
||||||
|
export default Footer
|
||||||
26
new-lamassu-admin/src/pages/Dashboard/LeftSide.js
Normal file
26
new-lamassu-admin/src/pages/Dashboard/LeftSide.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import styles from './Dashboard.styles'
|
||||||
|
import SystemPerformance from './SystemPerformance'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const RightSide = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Grid item style={{ marginRight: 24 }}>
|
||||||
|
<div className={classes.card}>
|
||||||
|
<SystemPerformance />
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RightSide
|
||||||
52
new-lamassu-admin/src/pages/Dashboard/RightSide.js
Normal file
52
new-lamassu-admin/src/pages/Dashboard/RightSide.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import Alerts from './Alerts'
|
||||||
|
import styles from './Dashboard.styles'
|
||||||
|
import SystemStatus from './SystemStatus'
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const RightSide = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [rightSideState, setRightSide] = useState({
|
||||||
|
alerts: {
|
||||||
|
cardSize: 'default',
|
||||||
|
buttonName: 'Show less'
|
||||||
|
},
|
||||||
|
systemStatus: {
|
||||||
|
cardSize: 'default',
|
||||||
|
buttonName: 'Show less'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const setRightSideState = newState => {
|
||||||
|
setRightSide(newState)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Grid item style={{ marginBottom: 16 }}>
|
||||||
|
<div className={classes.card}>
|
||||||
|
<Alerts
|
||||||
|
cardState={rightSideState.alerts}
|
||||||
|
setRightSideState={setRightSideState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
<Grid item>
|
||||||
|
<div className={classes.card}>
|
||||||
|
<SystemStatus
|
||||||
|
cardState={rightSideState.systemStatus}
|
||||||
|
setRightSideState={setRightSideState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RightSide
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
import { backgroundColor, zircon, primaryColor } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const RefLineChart = ({ data: realData, timeFrame }) => {
|
||||||
|
const svgRef = useRef()
|
||||||
|
|
||||||
|
// this variable will flip to true if there's no data points or the profit is zero
|
||||||
|
// this will force the line graph to touch the x axis instead of centering,
|
||||||
|
// centering is bad because it gives the impression that there could be negative values
|
||||||
|
// so, if this is true the y domain should be [0, 0.1]
|
||||||
|
const [zeroProfit, setZeroProfit] = useState(false)
|
||||||
|
|
||||||
|
const drawGraph = useCallback(() => {
|
||||||
|
const svg = d3.select(svgRef.current)
|
||||||
|
const margin = { top: 0, right: 0, bottom: 0, left: 0 }
|
||||||
|
const width = 336 - margin.left - margin.right
|
||||||
|
const height = 128 - margin.top - margin.bottom
|
||||||
|
|
||||||
|
const transactionProfit = tx => {
|
||||||
|
let cashInFee = 0
|
||||||
|
if (tx.cashInFee) {
|
||||||
|
cashInFee = Number.parseFloat(tx.cashInFee)
|
||||||
|
}
|
||||||
|
const commission =
|
||||||
|
Number.parseFloat(tx.commissionPercentage) * Number.parseFloat(tx.fiat)
|
||||||
|
return commission + cashInFee
|
||||||
|
}
|
||||||
|
|
||||||
|
const massageData = () => {
|
||||||
|
const methods = {
|
||||||
|
day: function(obj) {
|
||||||
|
return new Date(obj.created).toISOString().substring(0, 10)
|
||||||
|
},
|
||||||
|
hour: function(obj) {
|
||||||
|
return new Date(obj.created).toISOString().substring(0, 13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = timeFrame === 'Day' ? 'hour' : 'day'
|
||||||
|
const f = methods[method]
|
||||||
|
const groupedTx = R.values(R.groupBy(f)(realData))
|
||||||
|
let aggregatedTX = groupedTx.map(list => {
|
||||||
|
const temp = { ...list[0], profit: transactionProfit(list[0]) }
|
||||||
|
if (list.length > 1) {
|
||||||
|
for (let i = 1; i < list.length; i++) {
|
||||||
|
temp.profit += transactionProfit(list[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return temp
|
||||||
|
})
|
||||||
|
|
||||||
|
// if no point exists, then create a (0,0) point
|
||||||
|
if (aggregatedTX.length === 0) {
|
||||||
|
setZeroProfit(true)
|
||||||
|
aggregatedTX = [{ created: new Date().toISOString(), profit: 0 }]
|
||||||
|
} else {
|
||||||
|
setZeroProfit(false)
|
||||||
|
}
|
||||||
|
// create point on the left if only one point exists, otherwise line won't be drawn
|
||||||
|
if (aggregatedTX.length === 1) {
|
||||||
|
const temp = { ...aggregatedTX[0] }
|
||||||
|
const date = new Date(temp.created)
|
||||||
|
date.setHours(date.getHours() - 1)
|
||||||
|
temp.created = date.toISOString()
|
||||||
|
aggregatedTX = [...aggregatedTX, temp]
|
||||||
|
}
|
||||||
|
return aggregatedTX
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Important step to make the graph look good!
|
||||||
|
This function groups transactions by either day or hour depending on the time grame
|
||||||
|
This makes the line look smooth and not all wonky when there are many transactions in a given time
|
||||||
|
*/
|
||||||
|
const data = massageData()
|
||||||
|
|
||||||
|
// sets width of the graph
|
||||||
|
svg.attr('width', width)
|
||||||
|
|
||||||
|
// background color for the graph
|
||||||
|
svg
|
||||||
|
.append('rect')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', -margin.top)
|
||||||
|
.attr('width', width + margin.left + margin.right)
|
||||||
|
.attr('height', height + margin.top)
|
||||||
|
.attr('fill', backgroundColor)
|
||||||
|
.attr('transform', `translate(${0},${margin.top})`)
|
||||||
|
|
||||||
|
// gradient color for the graph (creates the "url", the color is applied by calling the url, in the area color fill )
|
||||||
|
svg
|
||||||
|
.append('linearGradient')
|
||||||
|
.attr('id', 'area-gradient')
|
||||||
|
.attr('gradientUnits', 'userSpaceOnUse')
|
||||||
|
.attr('x1', 0)
|
||||||
|
.attr('y1', 0)
|
||||||
|
.attr('x2', 0)
|
||||||
|
.attr('y2', '100%')
|
||||||
|
.selectAll('stop')
|
||||||
|
.data([
|
||||||
|
{ offset: '0%', color: zircon },
|
||||||
|
{ offset: '25%', color: zircon },
|
||||||
|
{ offset: '100%', color: backgroundColor }
|
||||||
|
])
|
||||||
|
.enter()
|
||||||
|
.append('stop')
|
||||||
|
.attr('offset', function(d) {
|
||||||
|
return d.offset
|
||||||
|
})
|
||||||
|
.attr('stop-color', function(d) {
|
||||||
|
return d.color
|
||||||
|
})
|
||||||
|
|
||||||
|
const g = svg
|
||||||
|
.append('g')
|
||||||
|
.attr('transform', `translate(${margin.left},${margin.top})`)
|
||||||
|
|
||||||
|
const xDomain = d3.extent(data, t => t.created)
|
||||||
|
const yDomain = zeroProfit ? [0, 0.1] : [0, d3.max(data, t => t.profit)]
|
||||||
|
|
||||||
|
const y = d3
|
||||||
|
.scaleLinear()
|
||||||
|
// 30 is a margin so that the labels and the percentage change label can fit and not overlay the line path
|
||||||
|
.range([height, 30])
|
||||||
|
.domain([0, yDomain[1]])
|
||||||
|
const x = d3
|
||||||
|
.scaleTime()
|
||||||
|
.domain([new Date(xDomain[0]), new Date(xDomain[1])])
|
||||||
|
.range([0, width])
|
||||||
|
|
||||||
|
const line = d3
|
||||||
|
.line()
|
||||||
|
.x(function(d) {
|
||||||
|
return x(new Date(d.created))
|
||||||
|
})
|
||||||
|
.y(function(d) {
|
||||||
|
return y(d.profit)
|
||||||
|
})
|
||||||
|
|
||||||
|
const area = d3
|
||||||
|
.area()
|
||||||
|
.x(function(d) {
|
||||||
|
return x(new Date(d.created))
|
||||||
|
})
|
||||||
|
.y0(height)
|
||||||
|
.y1(function(d) {
|
||||||
|
return y(d.profit)
|
||||||
|
})
|
||||||
|
|
||||||
|
// area color fill
|
||||||
|
g.append('path')
|
||||||
|
.datum(data)
|
||||||
|
.attr('d', area)
|
||||||
|
.attr('fill', 'url(#area-gradient)')
|
||||||
|
// draw the line
|
||||||
|
g.append('path')
|
||||||
|
.datum(data)
|
||||||
|
.attr('d', line)
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke-width', '2')
|
||||||
|
.attr('stroke-linejoin', 'round')
|
||||||
|
.attr('stroke', primaryColor)
|
||||||
|
}, [realData, timeFrame, zeroProfit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// first we clear old chart DOM elements on component update
|
||||||
|
d3.select(svgRef.current)
|
||||||
|
.selectAll('*')
|
||||||
|
.remove()
|
||||||
|
drawGraph()
|
||||||
|
}, [drawGraph, realData])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<svg ref={svgRef} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default RefLineChart
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import moment from 'moment'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useEffect, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
|
import { backgroundColor, java, neon } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const RefScatterplot = ({ data: realData, timeFrame }) => {
|
||||||
|
const svgRef = useRef()
|
||||||
|
|
||||||
|
const cashIns = R.filter(R.propEq('txClass', 'cashIn'))(realData)
|
||||||
|
const cashOuts = R.filter(R.propEq('txClass', 'cashOut'))(realData)
|
||||||
|
|
||||||
|
const drawGraph = useCallback(() => {
|
||||||
|
const svg = d3.select(svgRef.current)
|
||||||
|
const margin = { top: 25, right: 0, bottom: 25, left: 15 }
|
||||||
|
const width = 555 - margin.left - margin.right
|
||||||
|
const height = 150 - margin.top - margin.bottom
|
||||||
|
|
||||||
|
// finds maximum value for the Y axis. Minimum value is 100. If value is multiple of 1000, add 100
|
||||||
|
// (this is because the Y axis looks best with multiples of 100)
|
||||||
|
const findMaxY = () => {
|
||||||
|
let maxY = d3.max(realData, t => parseFloat(t.fiat))
|
||||||
|
maxY = 100 * Math.ceil(maxY / 100)
|
||||||
|
if (maxY < 100) {
|
||||||
|
return 100
|
||||||
|
} else if (maxY % 1000 === 0) {
|
||||||
|
return maxY + 100
|
||||||
|
}
|
||||||
|
return maxY
|
||||||
|
}
|
||||||
|
|
||||||
|
// changes values of arguments in some d3 function calls to make the graph labels look good according to the selected time frame
|
||||||
|
const findXAxisSettings = () => {
|
||||||
|
const res = {
|
||||||
|
nice: null,
|
||||||
|
ticks: 4,
|
||||||
|
subtractDays: 1,
|
||||||
|
timeFormat: '%H:%M',
|
||||||
|
timeRange: [0, 500]
|
||||||
|
}
|
||||||
|
switch (timeFrame) {
|
||||||
|
case 'Day':
|
||||||
|
return res
|
||||||
|
case 'Week':
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
nice: 7,
|
||||||
|
ticks: 7,
|
||||||
|
subtractDays: 7,
|
||||||
|
timeFormat: '%d',
|
||||||
|
timeRange: [50, 500]
|
||||||
|
}
|
||||||
|
case 'Month':
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
nice: 6,
|
||||||
|
ticks: 6,
|
||||||
|
subtractDays: 30,
|
||||||
|
timeFormat: '%b %d',
|
||||||
|
timeRange: [50, 500]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sets width of the graph
|
||||||
|
svg.attr('width', width)
|
||||||
|
|
||||||
|
// background color for the graph
|
||||||
|
svg
|
||||||
|
.append('rect')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', -margin.top)
|
||||||
|
.attr('width', width + margin.left + margin.right)
|
||||||
|
.attr('height', height + margin.top)
|
||||||
|
.attr('fill', backgroundColor)
|
||||||
|
.attr('transform', `translate(${0},${margin.top})`)
|
||||||
|
|
||||||
|
// declare g variable where more svg components will be attached
|
||||||
|
const g = svg
|
||||||
|
.append('g')
|
||||||
|
.attr('transform', `translate(${margin.left},${margin.top})`)
|
||||||
|
|
||||||
|
// y axis range: round up to 100 highest data value, if rounds up to 1000, add 100.
|
||||||
|
// this keeps the vertical axis nice looking
|
||||||
|
const maxY = findMaxY()
|
||||||
|
const xAxisSettings = findXAxisSettings()
|
||||||
|
|
||||||
|
// y and x scales
|
||||||
|
const y = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.range([height, 0])
|
||||||
|
.domain([0, maxY])
|
||||||
|
.nice(3)
|
||||||
|
const x = d3
|
||||||
|
.scaleTime()
|
||||||
|
.domain([
|
||||||
|
moment()
|
||||||
|
.endOf('day')
|
||||||
|
.add(-xAxisSettings.subtractDays, 'day')
|
||||||
|
.valueOf(),
|
||||||
|
moment()
|
||||||
|
.endOf('day')
|
||||||
|
.valueOf()
|
||||||
|
])
|
||||||
|
.range(xAxisSettings.timeRange)
|
||||||
|
.nice(xAxisSettings.nice)
|
||||||
|
|
||||||
|
// horizontal gridlines
|
||||||
|
const makeYGridlines = () => {
|
||||||
|
return d3.axisLeft(y).ticks(4)
|
||||||
|
}
|
||||||
|
g.append('g')
|
||||||
|
.style('color', '#eef1ff')
|
||||||
|
.call(
|
||||||
|
makeYGridlines()
|
||||||
|
.tickSize(-width)
|
||||||
|
.tickFormat('')
|
||||||
|
)
|
||||||
|
.call(g => g.select('.domain').remove())
|
||||||
|
|
||||||
|
/* X AXIS */
|
||||||
|
// this one is for the labels at the bottom
|
||||||
|
g.append('g')
|
||||||
|
.attr('transform', 'translate(0,' + height + ')')
|
||||||
|
.style('font-size', '13px')
|
||||||
|
.style('color', '#5f668a')
|
||||||
|
.style('font-family', 'MuseoSans')
|
||||||
|
.style('margin-top', '11px')
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisBottom(x)
|
||||||
|
.ticks(xAxisSettings.ticks)
|
||||||
|
.tickSize(0)
|
||||||
|
.tickFormat(d3.timeFormat(xAxisSettings.timeFormat))
|
||||||
|
// .tickFormat(d3.timeFormat('%H:%M'))
|
||||||
|
)
|
||||||
|
.selectAll('text')
|
||||||
|
// .attr('dx', '4em')
|
||||||
|
.attr('dy', '1.5em')
|
||||||
|
// this is for the x axis line. It is the same color as the horizontal grid lines
|
||||||
|
g.append('g')
|
||||||
|
.attr('transform', 'translate(0,' + height + ')')
|
||||||
|
.style('color', '#eef1ff')
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisBottom(x)
|
||||||
|
.ticks(6)
|
||||||
|
.tickSize(0)
|
||||||
|
.tickFormat('')
|
||||||
|
)
|
||||||
|
.selectAll('text')
|
||||||
|
.attr('dy', '1.5em')
|
||||||
|
/* ******************** */
|
||||||
|
|
||||||
|
// Y axis
|
||||||
|
g.append('g')
|
||||||
|
.style('font-size', '13px')
|
||||||
|
.style('color', '#5f668a')
|
||||||
|
.style('font-family', 'MuseoSans')
|
||||||
|
.style('margin-top', '11px')
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.axisLeft(y)
|
||||||
|
.ticks(4)
|
||||||
|
.tickSize(0)
|
||||||
|
)
|
||||||
|
.call(g => g.select('.domain').remove())
|
||||||
|
.selectAll('text')
|
||||||
|
.attr('dy', '-0.40em')
|
||||||
|
.attr('dx', '3em')
|
||||||
|
|
||||||
|
/* APPEND DOTS */
|
||||||
|
svg
|
||||||
|
.append('g')
|
||||||
|
.selectAll('dot')
|
||||||
|
.data(cashIns)
|
||||||
|
.enter()
|
||||||
|
.append('circle')
|
||||||
|
.attr('cx', function(d) {
|
||||||
|
return x(new Date(d.created))
|
||||||
|
})
|
||||||
|
.attr('cy', function(d) {
|
||||||
|
return y(d.fiat)
|
||||||
|
})
|
||||||
|
.attr('r', 4)
|
||||||
|
.attr('transform', 'translate(' + margin.left + ',' + 15 + ')')
|
||||||
|
.style('fill', java)
|
||||||
|
svg
|
||||||
|
.append('g')
|
||||||
|
.selectAll('dot')
|
||||||
|
.data(cashOuts)
|
||||||
|
.enter()
|
||||||
|
.append('circle')
|
||||||
|
.attr('cx', function(d) {
|
||||||
|
return x(new Date(d.created))
|
||||||
|
})
|
||||||
|
.attr('cy', function(d) {
|
||||||
|
return y(d.fiat)
|
||||||
|
})
|
||||||
|
.attr('r', 4)
|
||||||
|
.attr('transform', 'translate(' + margin.left + ',' + 15 + ')')
|
||||||
|
.style('fill', neon)
|
||||||
|
|
||||||
|
/* ************************** */
|
||||||
|
}, [cashIns, cashOuts, realData, timeFrame])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// first we clear old chart DOM elements on component update
|
||||||
|
d3.select(svgRef.current)
|
||||||
|
.selectAll('*')
|
||||||
|
.remove()
|
||||||
|
drawGraph()
|
||||||
|
}, [drawGraph])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<svg ref={svgRef} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default RefScatterplot
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
/*eslint-disable*/
|
||||||
|
import { scaleLinear, scaleTime, max, axisLeft, axisBottom, select } from 'd3'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import moment from 'moment'
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
[0, '2020-11-08T18:00:05.664Z'],
|
||||||
|
[40.01301, '2020-11-09T11:17:05.664Z']
|
||||||
|
]
|
||||||
|
|
||||||
|
const marginTop = 10
|
||||||
|
const marginRight = 30
|
||||||
|
const marginBottom = 30
|
||||||
|
const marginLeft = 60
|
||||||
|
const width = 510 - marginLeft - marginRight
|
||||||
|
const height = 141 - marginTop - marginBottom
|
||||||
|
|
||||||
|
const Scatterplot = ({ data: realData }) => {
|
||||||
|
const x = scaleTime()
|
||||||
|
.domain([
|
||||||
|
moment()
|
||||||
|
.add(-1, 'day')
|
||||||
|
.valueOf(),
|
||||||
|
moment().valueOf()
|
||||||
|
])
|
||||||
|
.range([0, width])
|
||||||
|
.nice()
|
||||||
|
|
||||||
|
const y = scaleLinear()
|
||||||
|
.domain([0, 1000])
|
||||||
|
.range([height, 0])
|
||||||
|
.nice()
|
||||||
|
|
||||||
|
// viewBox="0 0 540 141"
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
width={width + marginLeft + marginRight}
|
||||||
|
height={height + marginTop + marginBottom}>
|
||||||
|
<g transform={`translate(${marginLeft},${marginTop})`}>
|
||||||
|
<XAxis
|
||||||
|
transform={`translate(0, ${height + marginTop})`}
|
||||||
|
scale={x}
|
||||||
|
numTicks={6}
|
||||||
|
/>
|
||||||
|
<g>{axisLeft(y)}</g>
|
||||||
|
{/* <YAxis transform={`translate(0, 0)`} scale={y} numTicks={6} /> */}
|
||||||
|
<RenderCircles data={data} scale={{ x, y }} />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const XAxis = ({
|
||||||
|
range = [10, 500],
|
||||||
|
transform,
|
||||||
|
scale: xScale,
|
||||||
|
numTicks = 7
|
||||||
|
}) => {
|
||||||
|
const ticks = useMemo(() => {
|
||||||
|
return xScale.ticks(numTicks).map(value => ({
|
||||||
|
value,
|
||||||
|
xOffset: xScale(value)
|
||||||
|
}))
|
||||||
|
}, [range.join('-')])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g transform={transform}>
|
||||||
|
{ticks.map(({ value, xOffset }) => (
|
||||||
|
<g key={value} transform={`translate(${xOffset}, 0)`}>
|
||||||
|
<text
|
||||||
|
key={value}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
textAnchor: 'middle',
|
||||||
|
transform: 'translateY(10px)'
|
||||||
|
}}>
|
||||||
|
{value.getHours()}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const YAxis = ({
|
||||||
|
range = [10, 500],
|
||||||
|
transform,
|
||||||
|
scale: xScale,
|
||||||
|
numTicks = 7
|
||||||
|
}) => {
|
||||||
|
const ticks = useMemo(() => {
|
||||||
|
return xScale.ticks(numTicks).map(value => ({
|
||||||
|
value,
|
||||||
|
xOffset: xScale(value)
|
||||||
|
}))
|
||||||
|
}, [range.join('-')])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g transform={transform}>
|
||||||
|
{ticks.map(({ value, xOffset }) => (
|
||||||
|
<g key={value} transform={`translate(0, ${xOffset})`}>
|
||||||
|
<text
|
||||||
|
key={value}
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
textAnchor: 'middle',
|
||||||
|
transform: 'translateX(-10px)'
|
||||||
|
}}>
|
||||||
|
{value}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenderCircles = ({ data, scale }) => {
|
||||||
|
let renderCircles = data.map((item, idx) => {
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={scale.x(new Date(item[1]))}
|
||||||
|
cy={scale.y(item[0])}
|
||||||
|
r="4"
|
||||||
|
style={{ fill: 'rgba(25, 158, 199, .9)' }}
|
||||||
|
key={idx}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return <g>{renderCircles}</g>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Scatterplot
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Info1, Label1 } from 'src/components/typography/index'
|
||||||
|
const InfoWithLabel = ({ info, label }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Info1 style={{ marginBottom: 0 }}>{info}</Info1>
|
||||||
|
<Label1 style={{ margin: 0 }}>{label}</Label1>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfoWithLabel
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
// import Button from '@material-ui/core/Button'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { H4 } from 'src/components/typography'
|
||||||
|
|
||||||
|
import styles from './SystemPerformance.styles'
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const Nav = ({ handleSetRange }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const [clickedItem, setClickedItem] = useState('Day')
|
||||||
|
|
||||||
|
const isSelected = innerText => {
|
||||||
|
return innerText === clickedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = range => {
|
||||||
|
setClickedItem(range)
|
||||||
|
handleSetRange(range)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classnames(classes.titleWrapper)}>
|
||||||
|
<div className={classes.titleAndButtonsContainer}>
|
||||||
|
<H4 className={classes.h4}>{'System performance'}</H4>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<div
|
||||||
|
onClick={e => handleClick(e.target.innerText)}
|
||||||
|
className={
|
||||||
|
isSelected('Month')
|
||||||
|
? classnames(classes.newHighlightedLabel, classes.navButton)
|
||||||
|
: classnames(classes.label, classes.navButton)
|
||||||
|
}>
|
||||||
|
Month
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={e => handleClick(e.target.innerText)}
|
||||||
|
className={
|
||||||
|
isSelected('Week')
|
||||||
|
? classnames(classes.newHighlightedLabel, classes.navButton)
|
||||||
|
: classnames(classes.label, classes.navButton)
|
||||||
|
}>
|
||||||
|
Week
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isSelected('Day')
|
||||||
|
? classnames(classes.newHighlightedLabel, classes.navButton)
|
||||||
|
: classnames(classes.label, classes.navButton)
|
||||||
|
}
|
||||||
|
onClick={e => handleClick(e.target.innerText)}>
|
||||||
|
Day
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Nav
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
import { useQuery } from '@apollo/react-hooks'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import moment from 'moment'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
import { Label1, Label2 } from 'src/components/typography/index'
|
||||||
|
import { ReactComponent as TriangleDown } from 'src/styling/icons/arrow/triangle_down.svg'
|
||||||
|
import { ReactComponent as TriangleUp } from 'src/styling/icons/arrow/triangle_up.svg'
|
||||||
|
import { fromNamespace } from 'src/utils/config'
|
||||||
|
|
||||||
|
import LineChart from './Graphs/RefLineChart'
|
||||||
|
import Scatterplot from './Graphs/RefScatterplot'
|
||||||
|
import InfoWithLabel from './InfoWithLabel'
|
||||||
|
import Nav from './Nav'
|
||||||
|
import styles from './SystemPerformance.styles'
|
||||||
|
|
||||||
|
const isNotProp = R.curry(R.compose(R.isNil, R.prop))
|
||||||
|
const getFiats = R.map(R.prop('fiat'))
|
||||||
|
const getProps = propName => R.map(R.prop(propName))
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
const getDateDaysAgo = (days = 0) => {
|
||||||
|
return moment().subtract(days, 'day')
|
||||||
|
}
|
||||||
|
|
||||||
|
// const now = moment()
|
||||||
|
|
||||||
|
const GET_DATA = gql`
|
||||||
|
query getData {
|
||||||
|
transactions {
|
||||||
|
fiatCode
|
||||||
|
fiat
|
||||||
|
cashInFee
|
||||||
|
commissionPercentage
|
||||||
|
created
|
||||||
|
txClass
|
||||||
|
error
|
||||||
|
}
|
||||||
|
btcRates {
|
||||||
|
code
|
||||||
|
name
|
||||||
|
rate
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const currentTime = new Date()
|
||||||
|
|
||||||
|
const SystemPerformance = () => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [selectedRange, setSelectedRange] = useState('Day')
|
||||||
|
const [transactionsToShow, setTransactionsToShow] = useState([])
|
||||||
|
const [transactionsLastTimePeriod, setTransactionsLastTimePeriod] = useState(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data, loading } = useQuery(GET_DATA)
|
||||||
|
|
||||||
|
const fiatLocale = fromNamespace('locale')(data?.config).fiatCurrency
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isInRange = (getLastTimePeriod = false) => t => {
|
||||||
|
const now = moment(currentTime)
|
||||||
|
switch (selectedRange) {
|
||||||
|
case 'Day':
|
||||||
|
if (getLastTimePeriod) {
|
||||||
|
return (
|
||||||
|
t.error === null &&
|
||||||
|
moment(t.created).isBetween(
|
||||||
|
getDateDaysAgo(2),
|
||||||
|
now.subtract(25, 'hours')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
t.error === null &&
|
||||||
|
moment(t.created).isBetween(getDateDaysAgo(1), now)
|
||||||
|
)
|
||||||
|
case 'Week':
|
||||||
|
if (getLastTimePeriod) {
|
||||||
|
return (
|
||||||
|
t.error === null &&
|
||||||
|
moment(t.created).isBetween(
|
||||||
|
getDateDaysAgo(14),
|
||||||
|
now.subtract(24 * 7 + 1, 'hours')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
t.error === null &&
|
||||||
|
moment(t.created).isBetween(getDateDaysAgo(7), now)
|
||||||
|
)
|
||||||
|
case 'Month':
|
||||||
|
if (getLastTimePeriod) {
|
||||||
|
return (
|
||||||
|
t.error === null &&
|
||||||
|
moment(t.created).isBetween(
|
||||||
|
getDateDaysAgo(60),
|
||||||
|
now.subtract(24 * 30 + 1, 'hours')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
t.error === null &&
|
||||||
|
moment(t.created).isBetween(getDateDaysAgo(30), now)
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return t.error === null && true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertFiatToLocale = item => {
|
||||||
|
if (item.fiatCode === fiatLocale) return item
|
||||||
|
const itemRate = R.find(R.propEq('code', item.fiatCode))(data.btcRates)
|
||||||
|
const localeRate = R.find(R.propEq('code', fiatLocale))(data.btcRates)
|
||||||
|
const multiplier = localeRate.rate / itemRate.rate
|
||||||
|
return { ...item, fiat: parseFloat(item.fiat) * multiplier }
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransactionsToShow(
|
||||||
|
R.map(convertFiatToLocale)(
|
||||||
|
R.filter(isInRange(false), data?.transactions ?? [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setTransactionsLastTimePeriod(
|
||||||
|
R.map(convertFiatToLocale)(
|
||||||
|
R.filter(isInRange(true), data?.transactions ?? [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [data, fiatLocale, selectedRange])
|
||||||
|
|
||||||
|
const handleSetRange = range => {
|
||||||
|
setSelectedRange(range)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNumTransactions = () => {
|
||||||
|
return R.length(R.filter(isNotProp('error'), transactionsToShow))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFiatVolume = () => {
|
||||||
|
// for explanation check https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
|
||||||
|
return +(
|
||||||
|
Math.round(
|
||||||
|
R.sum(getFiats(R.filter(isNotProp('error'), transactionsToShow))) +
|
||||||
|
'e+2'
|
||||||
|
) + 'e-2'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProfit = (transactions = transactionsToShow) => {
|
||||||
|
const cashInFees = R.sum(
|
||||||
|
getProps('cashInFee')(R.filter(isNotProp('error'), transactions))
|
||||||
|
)
|
||||||
|
let commissionFees = 0
|
||||||
|
transactions.forEach(t => {
|
||||||
|
if (t.error === null) {
|
||||||
|
commissionFees +=
|
||||||
|
Number.parseFloat(t.commissionPercentage) * Number.parseFloat(t.fiat)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return +(Math.round(commissionFees + cashInFees + 'e+2') + 'e-2')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPercentChange = () => {
|
||||||
|
const thisTimePeriodProfit = getProfit(transactionsToShow)
|
||||||
|
const previousTimePeriodProfit = getProfit(transactionsLastTimePeriod)
|
||||||
|
if (previousTimePeriodProfit === 0) {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return Math.round(
|
||||||
|
(100 * (thisTimePeriodProfit - previousTimePeriodProfit)) /
|
||||||
|
Math.abs(previousTimePeriodProfit)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDirectionPercent = () => {
|
||||||
|
const directions = {
|
||||||
|
cashIn: 0,
|
||||||
|
cashOut: 0,
|
||||||
|
length: 0
|
||||||
|
}
|
||||||
|
transactionsToShow.forEach(t => {
|
||||||
|
if (t.error === null) {
|
||||||
|
switch (t.txClass) {
|
||||||
|
case 'cashIn':
|
||||||
|
directions.cashIn += 1
|
||||||
|
directions.length += 1
|
||||||
|
break
|
||||||
|
case 'cashOut':
|
||||||
|
directions.cashOut += 1
|
||||||
|
directions.length += 1
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
cashIn:
|
||||||
|
directions.length > 0
|
||||||
|
? Math.round((directions.cashIn / directions.length) * 100)
|
||||||
|
: 0,
|
||||||
|
cashOut:
|
||||||
|
directions.length > 0
|
||||||
|
? Math.round((directions.cashOut / directions.length) * 100)
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentChange = getPercentChange()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav handleSetRange={handleSetRange} />
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={3}>
|
||||||
|
<InfoWithLabel
|
||||||
|
info={getNumTransactions()}
|
||||||
|
label={'transactions'}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={3}>
|
||||||
|
<InfoWithLabel
|
||||||
|
info={getFiatVolume()}
|
||||||
|
label={`${data?.config.locale_fiatCurrency} volume`}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
{/* todo new customers */}
|
||||||
|
</Grid>
|
||||||
|
<Grid container style={{ marginTop: 30 }}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Label2>Transactions</Label2>
|
||||||
|
<Scatterplot
|
||||||
|
timeFrame={selectedRange}
|
||||||
|
data={transactionsToShow}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container style={{ marginTop: 30 }}>
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<Label2>Profit from commissions</Label2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
margin: '0 26px -30px 16px',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<div className={classes.profitLabel}>
|
||||||
|
{`${getProfit()} ${data?.config.locale_fiatCurrency}`}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
percentChange <= 0 ? classes.percentDown : classes.percentUp
|
||||||
|
}>
|
||||||
|
{percentChange <= 0 ? (
|
||||||
|
<TriangleDown style={{ height: 13 }} />
|
||||||
|
) : (
|
||||||
|
<TriangleUp style={{ height: 10 }} />
|
||||||
|
)}{' '}
|
||||||
|
{`${percentChange}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LineChart timeFrame={selectedRange} data={transactionsToShow} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Label2>Direction</Label2>
|
||||||
|
<Grid container>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Label1>CashIn: </Label1>
|
||||||
|
{` ${getDirectionPercent().cashIn}%`}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Label1>CashOut: </Label1>
|
||||||
|
{` ${getDirectionPercent().cashOut}%`}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SystemPerformance
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import {
|
||||||
|
offColor,
|
||||||
|
spacer,
|
||||||
|
primaryColor,
|
||||||
|
fontSize3,
|
||||||
|
fontSecondary,
|
||||||
|
fontColor,
|
||||||
|
secondaryColorDarker,
|
||||||
|
linkSecondaryColor
|
||||||
|
} from 'src/styling/variables'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
titleWrapper: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row'
|
||||||
|
},
|
||||||
|
titleAndButtonsContainer: {
|
||||||
|
display: 'flex'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
marginLeft: 12
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
marginRight: 6
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
margin: 0,
|
||||||
|
marginRight: spacer * 8
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: 0,
|
||||||
|
color: offColor,
|
||||||
|
textTransform: 'none',
|
||||||
|
borderBottom: `2px solid transparent`,
|
||||||
|
display: 'inline-block',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
newHighlightedLabel: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
textTransform: 'none',
|
||||||
|
borderBottom: `2px solid ${primaryColor}`,
|
||||||
|
display: 'inline-block',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navButton: {
|
||||||
|
marginLeft: 24
|
||||||
|
},
|
||||||
|
profitLabel: {
|
||||||
|
fontSize: fontSize3,
|
||||||
|
fontFamily: fontSecondary,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: fontColor
|
||||||
|
},
|
||||||
|
percentUp: {
|
||||||
|
fontSize: fontSize3,
|
||||||
|
fontFamily: fontSecondary,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: secondaryColorDarker
|
||||||
|
},
|
||||||
|
percentDown: {
|
||||||
|
fontSize: fontSize3,
|
||||||
|
fontFamily: fontSecondary,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: linkSecondaryColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import SystemPerformance from './SystemPerformance'
|
||||||
|
export default SystemPerformance
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { makeStyles, withStyles } from '@material-ui/core'
|
||||||
|
import Table from '@material-ui/core/Table'
|
||||||
|
import TableBody from '@material-ui/core/TableBody'
|
||||||
|
import TableCell from '@material-ui/core/TableCell'
|
||||||
|
import TableContainer from '@material-ui/core/TableContainer'
|
||||||
|
import TableHead from '@material-ui/core/TableHead'
|
||||||
|
import TableRow from '@material-ui/core/TableRow'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Status } from 'src/components/Status'
|
||||||
|
import { Label2, TL2 } 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 styles from './MachinesTable.styles'
|
||||||
|
|
||||||
|
// percentage threshold where below this number the text in the cash cassettes percentage turns red
|
||||||
|
const PERCENTAGE_THRESHOLD = 20
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const StyledCell = withStyles({
|
||||||
|
root: {
|
||||||
|
borderBottom: '4px solid white',
|
||||||
|
padding: 0,
|
||||||
|
paddingLeft: 15
|
||||||
|
}
|
||||||
|
})(TableCell)
|
||||||
|
|
||||||
|
const HeaderCell = withStyles({
|
||||||
|
root: {
|
||||||
|
borderBottom: '4px solid white',
|
||||||
|
padding: 0,
|
||||||
|
paddingLeft: 15,
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}
|
||||||
|
})(TableCell)
|
||||||
|
|
||||||
|
const MachinesTable = ({ machines, numToRender }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const getPercent = (notes, capacity = 500) => {
|
||||||
|
return Math.round((notes / capacity) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const makePercentageText = (notes, capacity = 500) => {
|
||||||
|
const percent = getPercent(notes, capacity)
|
||||||
|
if (percent < PERCENTAGE_THRESHOLD) {
|
||||||
|
return <TL2 className={classes.error}>{`${percent}%`}</TL2>
|
||||||
|
}
|
||||||
|
return <TL2>{`${percent}%`}</TL2>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableContainer className={classes.table}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<HeaderCell>
|
||||||
|
<div className={classes.header}>
|
||||||
|
<Label2 className={classes.label}>Machines</Label2>
|
||||||
|
</div>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell>
|
||||||
|
<div className={`${classes.header} ${classes.statusHeader}`}>
|
||||||
|
<Label2 className={classes.label}>Status</Label2>
|
||||||
|
</div>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell>
|
||||||
|
<div className={classes.header}>
|
||||||
|
<TxInIcon />
|
||||||
|
</div>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell>
|
||||||
|
<div className={classes.header}>
|
||||||
|
<TxOutIcon />
|
||||||
|
<Label2 className={classes.label}> 1</Label2>
|
||||||
|
</div>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell>
|
||||||
|
<div className={classes.header}>
|
||||||
|
<TxOutIcon />
|
||||||
|
<Label2 className={classes.label}> 2</Label2>
|
||||||
|
</div>
|
||||||
|
</HeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{machines.map((machine, idx) => {
|
||||||
|
if (idx < numToRender) {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={machine.deviceId + idx}
|
||||||
|
className={classes.row}>
|
||||||
|
<StyledCell align="left">
|
||||||
|
<TL2>{machine.name}</TL2>
|
||||||
|
</StyledCell>
|
||||||
|
<StyledCell>
|
||||||
|
<Status status={machine.statuses[0]} />
|
||||||
|
</StyledCell>
|
||||||
|
<StyledCell align="left">
|
||||||
|
{makePercentageText(machine.cashbox)}
|
||||||
|
</StyledCell>
|
||||||
|
<StyledCell align="left">
|
||||||
|
{makePercentageText(machine.cassette1)}
|
||||||
|
</StyledCell>
|
||||||
|
<StyledCell align="left">
|
||||||
|
{makePercentageText(machine.cassette2)}
|
||||||
|
</StyledCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MachinesTable
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import {
|
||||||
|
backgroundColor,
|
||||||
|
offColor,
|
||||||
|
errorColor,
|
||||||
|
primaryColor
|
||||||
|
} from 'src/styling/variables'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
label: {
|
||||||
|
margin: 0,
|
||||||
|
color: offColor
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
borderBottom: 'none'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
whiteSpace: 'pre'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: errorColor
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
color: primaryColor,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: 0,
|
||||||
|
textTransform: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
statusHeader: {
|
||||||
|
marginLeft: 2
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
maxHeight: 440,
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: 7
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: offColor,
|
||||||
|
borderRadius: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tableBody: {
|
||||||
|
overflow: 'auto'
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
marginTop: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { useQuery } from '@apollo/react-hooks'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
import ActionButton from 'src/components/buttons/ActionButton'
|
||||||
|
import { H4, TL2, Label1 } from 'src/components/typography'
|
||||||
|
|
||||||
|
import MachinesTable from './MachinesTable'
|
||||||
|
import styles from './MachinesTable.styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
// number of machines in the table to render on page load
|
||||||
|
const NUM_TO_RENDER = 3
|
||||||
|
|
||||||
|
const GET_DATA = gql`
|
||||||
|
query getData {
|
||||||
|
machines {
|
||||||
|
name
|
||||||
|
deviceId
|
||||||
|
cashbox
|
||||||
|
cassette1
|
||||||
|
cassette2
|
||||||
|
statuses {
|
||||||
|
label
|
||||||
|
type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serverVersion
|
||||||
|
uptime {
|
||||||
|
name
|
||||||
|
state
|
||||||
|
uptime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const parseUptime = time => {
|
||||||
|
if (time < 60) return `${time}s`
|
||||||
|
if (time < 3600) return `${Math.floor(time / 60)}m`
|
||||||
|
if (time < 86400) return `${Math.floor(time / 60 / 60)}h`
|
||||||
|
return `${Math.floor(time / 60 / 60 / 24)}d`
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemStatus = ({ cardState, setRightSideState }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { data, loading } = useQuery(GET_DATA)
|
||||||
|
const [showAllItems, setShowAllItems] = useState(false)
|
||||||
|
const [showExpandButton, setShowExpandButton] = useState(false)
|
||||||
|
const [numToRender, setNumToRender] = useState(NUM_TO_RENDER)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAllItems) {
|
||||||
|
setShowExpandButton(false)
|
||||||
|
setNumToRender(data?.machines.length)
|
||||||
|
} else if (data && data?.machines.length > numToRender) {
|
||||||
|
setShowExpandButton(true)
|
||||||
|
}
|
||||||
|
if (cardState.cardSize === 'small' || cardState.cardSize === 'default') {
|
||||||
|
setShowAllItems(false)
|
||||||
|
setNumToRender(NUM_TO_RENDER)
|
||||||
|
}
|
||||||
|
}, [cardState.cardSize, data, numToRender, showAllItems])
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setShowAllItems(false)
|
||||||
|
setNumToRender(NUM_TO_RENDER)
|
||||||
|
setRightSideState({
|
||||||
|
systemStatus: { cardSize: 'default', buttonName: 'Show less' },
|
||||||
|
alerts: { cardSize: 'default', buttonName: 'Show less' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showAllClick = () => {
|
||||||
|
setShowExpandButton(false)
|
||||||
|
setShowAllItems(true)
|
||||||
|
setRightSideState({
|
||||||
|
systemStatus: { cardSize: 'big', buttonName: 'Show less' },
|
||||||
|
alerts: { cardSize: 'small', buttonName: 'Show alerts' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholder data
|
||||||
|
if (data) {
|
||||||
|
data.uptime = [{ time: 1854125, state: 'RUNNING' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const uptime = data?.uptime ?? [{}]
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<H4 className={classes.h4}>System status</H4>
|
||||||
|
{(showAllItems || cardState.cardSize === 'small') && (
|
||||||
|
<>
|
||||||
|
<Label1
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 0,
|
||||||
|
marginTop: 0
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
onClick={reset}
|
||||||
|
size="small"
|
||||||
|
disableRipple
|
||||||
|
disableFocusRipple
|
||||||
|
className={classes.button}>
|
||||||
|
{cardState.buttonName}
|
||||||
|
</Button>
|
||||||
|
</Label1>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!loading && cardState.cardSize !== 'small' && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<TL2 style={{ display: 'inline' }}>
|
||||||
|
{parseUptime(uptime[0].time)}
|
||||||
|
</TL2>
|
||||||
|
<Label1 style={{ display: 'inline' }}> System up time</Label1>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<TL2 style={{ display: 'inline' }}>{data?.serverVersion}</TL2>
|
||||||
|
<Label1 style={{ display: 'inline' }}> server version</Label1>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<ActionButton
|
||||||
|
color="primary"
|
||||||
|
className={classes.actionButton}
|
||||||
|
onClick={() => console.log('Upgrade button clicked')}>
|
||||||
|
Update to v10.6.0
|
||||||
|
</ActionButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} style={{ marginTop: 23 }}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<MachinesTable
|
||||||
|
numToRender={numToRender}
|
||||||
|
machines={data?.machines ?? []}
|
||||||
|
/>
|
||||||
|
{showExpandButton && (
|
||||||
|
<>
|
||||||
|
<Label1 style={{ textAlign: 'center', marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
onClick={showAllClick}
|
||||||
|
size="small"
|
||||||
|
disableRipple
|
||||||
|
disableFocusRipple
|
||||||
|
className={classes.button}>
|
||||||
|
{`Show all (${data.machines.length})`}
|
||||||
|
</Button>
|
||||||
|
</Label1>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SystemStatus
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import SystemStatus from './SystemStatus'
|
||||||
|
export default SystemStatus
|
||||||
2
new-lamassu-admin/src/pages/Dashboard/index.js
Normal file
2
new-lamassu-admin/src/pages/Dashboard/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import Dashboard from './Dashboard'
|
||||||
|
export default Dashboard
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { makeStyles } from '@material-ui/core'
|
||||||
|
import React from 'react'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
|
||||||
|
import { Table as EditableTable } from 'src/components/editableTable'
|
||||||
|
import { CashOut } from 'src/components/inputs/cashbox/Cashbox'
|
||||||
|
import { fromNamespace } from 'src/utils/config'
|
||||||
|
|
||||||
|
import styles from './Cassettes.styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const ValidationSchema = Yup.object().shape({
|
||||||
|
name: Yup.string().required('Required'),
|
||||||
|
cassette1: Yup.number()
|
||||||
|
.required('Required')
|
||||||
|
.integer()
|
||||||
|
.min(0)
|
||||||
|
.max(500),
|
||||||
|
cassette2: Yup.number()
|
||||||
|
.required('Required')
|
||||||
|
.integer()
|
||||||
|
.min(0)
|
||||||
|
.max(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
const CashCassettes = ({ machine, config }) => {
|
||||||
|
const data = { machine, config }
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const cashout = data?.config && fromNamespace('cashOut')(data.config)
|
||||||
|
const locale = data?.config && fromNamespace('locale')(data.config)
|
||||||
|
const fiatCurrency = locale?.fiatCurrency
|
||||||
|
|
||||||
|
const getCashoutSettings = id => fromNamespace(id)(cashout)
|
||||||
|
// const isCashOutDisabled = ({ id }) => !getCashoutSettings(id).active
|
||||||
|
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
name: 'cassette1',
|
||||||
|
header: 'Cash-out 1',
|
||||||
|
width: 265,
|
||||||
|
stripe: true,
|
||||||
|
view: (value, { deviceId }) => (
|
||||||
|
<CashOut
|
||||||
|
className={classes.cashbox}
|
||||||
|
denomination={getCashoutSettings(deviceId)?.bottom}
|
||||||
|
currency={{ code: fiatCurrency }}
|
||||||
|
notes={value}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cassette2',
|
||||||
|
header: 'Cash-out 2',
|
||||||
|
width: 265,
|
||||||
|
stripe: true,
|
||||||
|
view: (value, { deviceId }) => {
|
||||||
|
return (
|
||||||
|
<CashOut
|
||||||
|
className={classes.cashbox}
|
||||||
|
denomination={getCashoutSettings(deviceId)?.top}
|
||||||
|
currency={{ code: fiatCurrency }}
|
||||||
|
notes={value}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{machine.name && (
|
||||||
|
<EditableTable
|
||||||
|
name="cashboxes"
|
||||||
|
elements={elements}
|
||||||
|
data={[machine] || []}
|
||||||
|
validationSchema={ValidationSchema}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CashCassettes
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
cashbox: {
|
||||||
|
width: 80,
|
||||||
|
height: 36
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import Cassettes from './Cassettes'
|
||||||
|
export default Cassettes
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useQuery, useMutation } from '@apollo/react-hooks'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Table as EditableTable } from 'src/components/editableTable'
|
||||||
|
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
|
||||||
|
|
||||||
|
import { overrides } from './helper'
|
||||||
|
|
||||||
|
const GET_DATA = gql`
|
||||||
|
query getData {
|
||||||
|
config
|
||||||
|
cryptoCurrencies {
|
||||||
|
code
|
||||||
|
display
|
||||||
|
}
|
||||||
|
machines {
|
||||||
|
name
|
||||||
|
deviceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const SAVE_CONFIG = gql`
|
||||||
|
mutation Save($config: JSONObject) {
|
||||||
|
saveConfig(config: $config)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Commissions = ({ name: SCREEN_KEY, id: deviceId }) => {
|
||||||
|
const { data, loading } = useQuery(GET_DATA)
|
||||||
|
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||||
|
refetchQueries: () => ['getData']
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
|
||||||
|
const currency = R.path(['fiatCurrency'])(
|
||||||
|
fromNamespace(namespaces.LOCALE)(data?.config)
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveOverrides = it => {
|
||||||
|
const config = toNamespace(SCREEN_KEY)(it)
|
||||||
|
return saveConfig({ variables: { config } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMachineCommissions = () => {
|
||||||
|
if (loading || !deviceId || !config) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const commissions = {}
|
||||||
|
|
||||||
|
// first, get general non overridden commissions
|
||||||
|
const makeInfo = x =>
|
||||||
|
(commissions[R.prop('code')(x)] = {
|
||||||
|
code: x.code,
|
||||||
|
name: x.display,
|
||||||
|
cashIn: config.cashIn,
|
||||||
|
cashOut: config.cashOut,
|
||||||
|
fixedFee: config.fixedFee,
|
||||||
|
minimumTx: config.minimumTx
|
||||||
|
})
|
||||||
|
R.forEach(makeInfo)(data.cryptoCurrencies)
|
||||||
|
|
||||||
|
// second, get overrides for all machines
|
||||||
|
const isId = id => R.propEq('machine', id)
|
||||||
|
const generalOverrides = config.overrides
|
||||||
|
? R.filter(isId('ALL_MACHINES'))(config.overrides)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const overrideInfo = o => {
|
||||||
|
commissions[o.cryptoCurrencies[0]].cashIn = o.cashIn
|
||||||
|
commissions[o.cryptoCurrencies[0]].cashOut = o.cashOut
|
||||||
|
commissions[o.cryptoCurrencies[0]].fixedFee = o.fixedFee
|
||||||
|
commissions[o.cryptoCurrencies[0]].minimumTx = o.minimumTx
|
||||||
|
}
|
||||||
|
R.forEach(overrideInfo)(generalOverrides)
|
||||||
|
|
||||||
|
// third, get overrides for this machine
|
||||||
|
const machineOverrides = config.overrides
|
||||||
|
? R.filter(isId(deviceId))(config.overrides)
|
||||||
|
: []
|
||||||
|
R.forEach(overrideInfo)(machineOverrides)
|
||||||
|
|
||||||
|
// in the end, the machine specific overrides overwrite the less general ALL_MACHINE overrides or the general overrides
|
||||||
|
return R.values(commissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMachineCommissions()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EditableTable
|
||||||
|
name="overrides"
|
||||||
|
save={saveOverrides}
|
||||||
|
data={getMachineCommissions()}
|
||||||
|
elements={overrides(currency)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Commissions
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
|
||||||
|
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
|
||||||
|
|
||||||
|
const cashInAndOutHeaderStyle = { marginLeft: 6 }
|
||||||
|
|
||||||
|
const cashInHeader = (
|
||||||
|
<div>
|
||||||
|
<TxInIcon />
|
||||||
|
<span style={cashInAndOutHeaderStyle}>Cash-in</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const cashOutHeader = (
|
||||||
|
<div>
|
||||||
|
<TxOutIcon />
|
||||||
|
<span style={cashInAndOutHeaderStyle}>Cash-out</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const getOverridesFields = currency => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
width: 280,
|
||||||
|
size: 'sm',
|
||||||
|
view: it => `${it}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: cashInHeader,
|
||||||
|
name: 'cashIn',
|
||||||
|
display: 'Cash-in',
|
||||||
|
width: 130,
|
||||||
|
textAlign: 'right',
|
||||||
|
suffix: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: cashOutHeader,
|
||||||
|
name: 'cashOut',
|
||||||
|
display: 'Cash-out',
|
||||||
|
width: 130,
|
||||||
|
textAlign: 'right',
|
||||||
|
suffix: '%',
|
||||||
|
inputProps: {
|
||||||
|
decimalPlaces: 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fixedFee',
|
||||||
|
display: 'Fixed fee',
|
||||||
|
width: 144,
|
||||||
|
doubleHeader: 'Cash-in only',
|
||||||
|
textAlign: 'right',
|
||||||
|
suffix: currency
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'minimumTx',
|
||||||
|
display: 'Minimun Tx',
|
||||||
|
width: 144,
|
||||||
|
doubleHeader: 'Cash-in only',
|
||||||
|
textAlign: 'right',
|
||||||
|
suffix: currency
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides = currency => {
|
||||||
|
return getOverridesFields(currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { overrides }
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import Commissions from './Commissions'
|
||||||
|
|
||||||
|
export default Commissions
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { useMutation } from '@apollo/react-hooks'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import moment from 'moment'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { ConfirmDialog } from 'src/components/ConfirmDialog'
|
||||||
|
import { Status } from 'src/components/Status'
|
||||||
|
import ActionButton from 'src/components/buttons/ActionButton'
|
||||||
|
import { Label4, P } from 'src/components/typography'
|
||||||
|
import { ReactComponent as RebootReversedIcon } from 'src/styling/icons/button/reboot/white.svg'
|
||||||
|
import { ReactComponent as RebootIcon } from 'src/styling/icons/button/reboot/zodiac.svg'
|
||||||
|
import { ReactComponent as ShutdownReversedIcon } from 'src/styling/icons/button/shut down/white.svg'
|
||||||
|
import { ReactComponent as ShutdownIcon } from 'src/styling/icons/button/shut down/zodiac.svg'
|
||||||
|
import { ReactComponent as UnpairReversedIcon } from 'src/styling/icons/button/unpair/white.svg'
|
||||||
|
import { ReactComponent as UnpairIcon } from 'src/styling/icons/button/unpair/zodiac.svg'
|
||||||
|
|
||||||
|
import styles from '../Machines.styles'
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const MACHINE_ACTION = gql`
|
||||||
|
mutation MachineAction(
|
||||||
|
$deviceId: ID!
|
||||||
|
$action: MachineAction!
|
||||||
|
$newName: String
|
||||||
|
) {
|
||||||
|
machineAction(deviceId: $deviceId, action: $action, newName: $newName) {
|
||||||
|
deviceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Details = ({ data, onActionSuccess }) => {
|
||||||
|
const [action, setAction] = useState('')
|
||||||
|
const [confirmActionDialogOpen, setConfirmActionDialogOpen] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState(null)
|
||||||
|
const [machineAction] = useMutation(MACHINE_ACTION, {
|
||||||
|
onError: ({ message }) => {
|
||||||
|
const errorMessage = message ?? 'An error ocurred'
|
||||||
|
setErrorMessage(errorMessage)
|
||||||
|
},
|
||||||
|
onCompleted: () => {
|
||||||
|
onActionSuccess && onActionSuccess()
|
||||||
|
setConfirmActionDialogOpen(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const confirmActionDialog = action =>
|
||||||
|
setAction(action) || setConfirmActionDialogOpen(true)
|
||||||
|
const classes = useStyles()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classes.row}>
|
||||||
|
<div className={classes.rowItem}>
|
||||||
|
{' '}
|
||||||
|
<Label4 className={classes.tl2}>Status</Label4>
|
||||||
|
{data && data.statuses ? <Status status={data.statuses[0]} /> : null}
|
||||||
|
{/* <Label4 className={classes.tl2}>Machine model</Label4>
|
||||||
|
<P>{data.model}</P> */}
|
||||||
|
</div>
|
||||||
|
<div className={classes.rowItem}>
|
||||||
|
<Label4 className={classes.tl2}>Machine model</Label4>
|
||||||
|
<P>{data.model}</P>
|
||||||
|
</div>
|
||||||
|
<div className={classes.rowItem}>
|
||||||
|
{' '}
|
||||||
|
<Label4 className={classes.tl2}>Software version</Label4>
|
||||||
|
<P>{data.version}</P>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.rowItem}>
|
||||||
|
{' '}
|
||||||
|
<Label4 className={classes.tl2}>Paired at</Label4>
|
||||||
|
<P>
|
||||||
|
{data.pairedAt
|
||||||
|
? moment(data.pairedAt).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
: ''}
|
||||||
|
</P>
|
||||||
|
</div>
|
||||||
|
<div className={classes.rowItem}>
|
||||||
|
{' '}
|
||||||
|
<Label4 className={classes.tl2}>Last ping</Label4>
|
||||||
|
<P>
|
||||||
|
{data.lastPing
|
||||||
|
? moment(data.lastPing).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
: ''}
|
||||||
|
</P>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.row}>
|
||||||
|
<div className={classes.rowItem}>
|
||||||
|
{' '}
|
||||||
|
<Label4 className={classes.tl2}>Actions</Label4>
|
||||||
|
{data.name && (
|
||||||
|
<div className={classes.actionButtonsContainer}>
|
||||||
|
<ActionButton
|
||||||
|
color="primary"
|
||||||
|
className={classes.actionButton}
|
||||||
|
Icon={UnpairIcon}
|
||||||
|
InverseIcon={UnpairReversedIcon}
|
||||||
|
onClick={() => confirmActionDialog('Unpair')}>
|
||||||
|
Unpair
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
color="primary"
|
||||||
|
className={classes.actionButton}
|
||||||
|
Icon={RebootIcon}
|
||||||
|
InverseIcon={RebootReversedIcon}
|
||||||
|
onClick={() => confirmActionDialog('Reboot')}>
|
||||||
|
Reboot
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
className={classes.actionButton}
|
||||||
|
color="primary"
|
||||||
|
Icon={ShutdownIcon}
|
||||||
|
InverseIcon={ShutdownReversedIcon}
|
||||||
|
onClick={() => confirmActionDialog('Shutdown')}>
|
||||||
|
Shutdown
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmActionDialogOpen}
|
||||||
|
title={`${action} this machine?`}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
toBeConfirmed={data.name}
|
||||||
|
onConfirmed={() => {
|
||||||
|
setErrorMessage(null)
|
||||||
|
machineAction({
|
||||||
|
variables: {
|
||||||
|
deviceId: data.deviceId,
|
||||||
|
action: `${action}`.toLowerCase()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onDissmised={() => {
|
||||||
|
setConfirmActionDialogOpen(false)
|
||||||
|
setErrorMessage(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Details
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { CopyToClipboard as ReactCopyToClipboard } from 'react-copy-to-clipboard'
|
||||||
|
|
||||||
|
import Popover from 'src/components/Popper'
|
||||||
|
import { ReactComponent as CopyIcon } from 'src/styling/icons/action/copy/copy.svg'
|
||||||
|
import { comet } from 'src/styling/variables'
|
||||||
|
|
||||||
|
import { cpcStyles } from './Transactions.styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(cpcStyles)
|
||||||
|
|
||||||
|
const CopyToClipboard = ({
|
||||||
|
className,
|
||||||
|
buttonClassname,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (anchorEl) setTimeout(() => setAnchorEl(null), 3000)
|
||||||
|
}, [anchorEl])
|
||||||
|
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const handleClick = event => {
|
||||||
|
setAnchorEl(anchorEl ? null : event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl)
|
||||||
|
const id = open ? 'simple-popper' : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.wrapper}>
|
||||||
|
{children && (
|
||||||
|
<>
|
||||||
|
<div className={classnames(classes.address, className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className={classnames(classes.buttonWrapper, buttonClassname)}>
|
||||||
|
<ReactCopyToClipboard text={R.replace(/\s/g, '')(children)}>
|
||||||
|
<button
|
||||||
|
aria-describedby={id}
|
||||||
|
onClick={event => handleClick(event)}>
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
</ReactCopyToClipboard>
|
||||||
|
</div>
|
||||||
|
<Popover
|
||||||
|
id={id}
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
arrowSize={3}
|
||||||
|
bgColor={comet}
|
||||||
|
placement="top">
|
||||||
|
<div className={classes.popoverContent}>
|
||||||
|
<div>Copied to clipboard!</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CopyToClipboard
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { makeStyles, Box } from '@material-ui/core'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
AutoSizer,
|
||||||
|
List,
|
||||||
|
CellMeasurer,
|
||||||
|
CellMeasurerCache
|
||||||
|
} from 'react-virtualized'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TBody,
|
||||||
|
THead,
|
||||||
|
Tr,
|
||||||
|
Td,
|
||||||
|
Th
|
||||||
|
} from 'src/components/fake-table/Table'
|
||||||
|
import { H4 } from 'src/components/typography'
|
||||||
|
import { ReactComponent as ExpandClosedIcon } from 'src/styling/icons/action/expand/closed.svg'
|
||||||
|
import { ReactComponent as ExpandOpenIcon } from 'src/styling/icons/action/expand/open.svg'
|
||||||
|
|
||||||
|
import styles from './DataTable.styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const Row = ({
|
||||||
|
id,
|
||||||
|
elements,
|
||||||
|
data,
|
||||||
|
width,
|
||||||
|
Details,
|
||||||
|
expanded,
|
||||||
|
expandRow,
|
||||||
|
expWidth,
|
||||||
|
expandable,
|
||||||
|
onClick
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const hasPointer = onClick || expandable
|
||||||
|
const trClasses = {
|
||||||
|
[classes.pointer]: hasPointer,
|
||||||
|
[classes.row]: true,
|
||||||
|
[classes.expanded]: expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.rowWrapper}>
|
||||||
|
<div className={classnames({ [classes.before]: expanded && id !== 0 })}>
|
||||||
|
<Tr
|
||||||
|
className={classnames(trClasses)}
|
||||||
|
onClick={() => {
|
||||||
|
expandable && expandRow(id)
|
||||||
|
onClick && onClick(data)
|
||||||
|
}}
|
||||||
|
error={data.error}
|
||||||
|
errorMessage={data.errorMessage}>
|
||||||
|
{elements.map(({ view = it => it?.toString(), ...props }, idx) => (
|
||||||
|
<Td key={idx} {...props}>
|
||||||
|
{view(data)}
|
||||||
|
</Td>
|
||||||
|
))}
|
||||||
|
{expandable && (
|
||||||
|
<Td width={expWidth} textAlign="center">
|
||||||
|
<button
|
||||||
|
onClick={() => expandRow(id)}
|
||||||
|
className={classes.expandButton}>
|
||||||
|
{expanded && <ExpandOpenIcon />}
|
||||||
|
{!expanded && <ExpandClosedIcon />}
|
||||||
|
</button>
|
||||||
|
</Td>
|
||||||
|
)}
|
||||||
|
</Tr>
|
||||||
|
</div>
|
||||||
|
{expandable && expanded && (
|
||||||
|
<div className={classes.after}>
|
||||||
|
<Tr className={classnames({ [classes.expanded]: expanded })}>
|
||||||
|
<Td width={width}>
|
||||||
|
<Details it={data} />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataTable = ({
|
||||||
|
elements = [],
|
||||||
|
data = [],
|
||||||
|
Details,
|
||||||
|
className,
|
||||||
|
expandable,
|
||||||
|
initialExpanded,
|
||||||
|
onClick,
|
||||||
|
loading,
|
||||||
|
emptyText,
|
||||||
|
extraHeight,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [expanded, setExpanded] = useState(initialExpanded)
|
||||||
|
|
||||||
|
useEffect(() => setExpanded(initialExpanded), [initialExpanded])
|
||||||
|
|
||||||
|
const coreWidth = R.compose(R.sum, R.map(R.prop('width')))(elements)
|
||||||
|
const expWidth = 1200 - coreWidth
|
||||||
|
const width = coreWidth + (expandable ? expWidth : 0)
|
||||||
|
|
||||||
|
const classes = useStyles({ width })
|
||||||
|
|
||||||
|
const expandRow = id => {
|
||||||
|
setExpanded(id === expanded ? null : id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new CellMeasurerCache({
|
||||||
|
defaultHeight: 62,
|
||||||
|
fixedWidth: true
|
||||||
|
})
|
||||||
|
|
||||||
|
function rowRenderer({ index, key, parent, style }) {
|
||||||
|
return (
|
||||||
|
<CellMeasurer
|
||||||
|
cache={cache}
|
||||||
|
columnIndex={0}
|
||||||
|
key={key}
|
||||||
|
parent={parent}
|
||||||
|
rowIndex={index}>
|
||||||
|
<div style={style}>
|
||||||
|
<Row
|
||||||
|
width={width}
|
||||||
|
id={index}
|
||||||
|
expWidth={expWidth}
|
||||||
|
elements={elements}
|
||||||
|
data={data[index]}
|
||||||
|
Details={Details}
|
||||||
|
expanded={index === expanded}
|
||||||
|
expandRow={expandRow}
|
||||||
|
expandable={expandable}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CellMeasurer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box display="flex" flex="1" flexDirection="column">
|
||||||
|
<Table className={classes.table}>
|
||||||
|
<THead>
|
||||||
|
{elements.map(({ width, className, textAlign, header }, idx) => (
|
||||||
|
<Th
|
||||||
|
key={idx}
|
||||||
|
width={width}
|
||||||
|
className={className}
|
||||||
|
textAlign={textAlign}>
|
||||||
|
{header}
|
||||||
|
</Th>
|
||||||
|
))}
|
||||||
|
{expandable && <Th width={expWidth}></Th>}
|
||||||
|
</THead>
|
||||||
|
<TBody className={classes.body}>
|
||||||
|
{loading && <H4>Loading...</H4>}
|
||||||
|
{!loading && R.isEmpty(data) && <H4>{emptyText}</H4>}
|
||||||
|
<AutoSizer disableWidth disableHeight>
|
||||||
|
{() => (
|
||||||
|
<List
|
||||||
|
// this has to be in a style because of how the component works
|
||||||
|
style={{ overflow: 'inherit', outline: 'none' }}
|
||||||
|
{...props}
|
||||||
|
height={data.length * 62 + extraHeight}
|
||||||
|
width={width}
|
||||||
|
rowCount={data.length}
|
||||||
|
rowHeight={cache.rowHeight}
|
||||||
|
rowRenderer={rowRenderer}
|
||||||
|
overscanRowCount={50}
|
||||||
|
deferredMeasurementCache={cache}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataTable
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { zircon } from 'src/styling/variables'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
expandButton: {
|
||||||
|
outline: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4
|
||||||
|
},
|
||||||
|
rowWrapper: {
|
||||||
|
// workaround to shadows cut by r-virtualized when scroll is visible
|
||||||
|
padding: 1
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
border: [[2, 'solid', 'transparent']],
|
||||||
|
borderRadius: 0
|
||||||
|
},
|
||||||
|
expanded: {
|
||||||
|
border: [[2, 'solid', zircon]],
|
||||||
|
boxShadow: '0 0 8px 0 rgba(0,0,0,0.08)'
|
||||||
|
},
|
||||||
|
before: {
|
||||||
|
paddingTop: 12
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
paddingBottom: 12
|
||||||
|
},
|
||||||
|
pointer: {
|
||||||
|
cursor: 'pointer'
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
flex: [[1, 1, 'auto']]
|
||||||
|
},
|
||||||
|
table: ({ width }) => ({
|
||||||
|
marginBottom: 30,
|
||||||
|
minHeight: 200,
|
||||||
|
width,
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { makeStyles, Box } from '@material-ui/core'
|
||||||
|
import BigNumber from 'bignumber.js'
|
||||||
|
import moment from 'moment'
|
||||||
|
import React, { memo } from 'react'
|
||||||
|
|
||||||
|
import { IDButton } from 'src/components/buttons'
|
||||||
|
import { Label1 } from 'src/components/typography'
|
||||||
|
import { ReactComponent as CardIdInverseIcon } from 'src/styling/icons/ID/card/white.svg'
|
||||||
|
import { ReactComponent as CardIdIcon } from 'src/styling/icons/ID/card/zodiac.svg'
|
||||||
|
import { ReactComponent as PhoneIdInverseIcon } from 'src/styling/icons/ID/phone/white.svg'
|
||||||
|
import { ReactComponent as PhoneIdIcon } from 'src/styling/icons/ID/phone/zodiac.svg'
|
||||||
|
import { ReactComponent as CamIdInverseIcon } from 'src/styling/icons/ID/photo/white.svg'
|
||||||
|
import { ReactComponent as CamIdIcon } from 'src/styling/icons/ID/photo/zodiac.svg'
|
||||||
|
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 { URI } from 'src/utils/apollo'
|
||||||
|
import { toUnit, formatCryptoAddress } from 'src/utils/coin'
|
||||||
|
import { onlyFirstToUpper } from 'src/utils/string'
|
||||||
|
|
||||||
|
import CopyToClipboard from './CopyToClipboard'
|
||||||
|
import styles from './DetailsCard.styles'
|
||||||
|
import { getStatus } from './helper'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const formatAddress = (cryptoCode = '', address = '') =>
|
||||||
|
formatCryptoAddress(cryptoCode, address).replace(/(.{5})/g, '$1 ')
|
||||||
|
|
||||||
|
const Label = ({ children }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
return <Label1 className={classes.label}>{children}</Label1>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DetailsRow = ({ it: tx }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const fiat = Number.parseFloat(tx.fiat)
|
||||||
|
const crypto = toUnit(new BigNumber(tx.cryptoAtoms), tx.cryptoCode)
|
||||||
|
const commissionPercentage = Number.parseFloat(tx.commissionPercentage, 2)
|
||||||
|
const commission = Number(fiat * commissionPercentage).toFixed(2)
|
||||||
|
const exchangeRate = Number(fiat / crypto).toFixed(3)
|
||||||
|
const displayExRate = `1 ${tx.cryptoCode} = ${exchangeRate} ${tx.fiatCode}`
|
||||||
|
|
||||||
|
const customer = tx.customerIdCardData && {
|
||||||
|
name: `${onlyFirstToUpper(
|
||||||
|
tx.customerIdCardData.firstName
|
||||||
|
)} ${onlyFirstToUpper(tx.customerIdCardData.lastName)}`,
|
||||||
|
age: moment().diff(moment(tx.customerIdCardData.dateOfBirth), 'years'),
|
||||||
|
country: tx.customerIdCardData.country,
|
||||||
|
idCardNumber: tx.customerIdCardData.documentNumber,
|
||||||
|
idCardExpirationDate: moment(tx.customerIdCardData.expirationDate).format(
|
||||||
|
'DD-MM-YYYY'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.wrapper}>
|
||||||
|
<div className={classes.row}>
|
||||||
|
<div className={classes.direction}>
|
||||||
|
<Label>Direction</Label>
|
||||||
|
<div>
|
||||||
|
<span className={classes.txIcon}>
|
||||||
|
{tx.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />}
|
||||||
|
</span>
|
||||||
|
<span>{tx.txClass === 'cashOut' ? 'Cash-out' : 'Cash-in'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.availableIds}>
|
||||||
|
<Label>Available IDs</Label>
|
||||||
|
<Box display="flex" flexDirection="row">
|
||||||
|
{tx.customerPhone && (
|
||||||
|
<IDButton
|
||||||
|
className={classes.idButton}
|
||||||
|
name="phone"
|
||||||
|
Icon={PhoneIdIcon}
|
||||||
|
InverseIcon={PhoneIdInverseIcon}>
|
||||||
|
{tx.customerPhone}
|
||||||
|
</IDButton>
|
||||||
|
)}
|
||||||
|
{tx.customerIdCardPhotoPath && !tx.customerIdCardData && (
|
||||||
|
<IDButton
|
||||||
|
popoverClassname={classes.popover}
|
||||||
|
className={classes.idButton}
|
||||||
|
name="card"
|
||||||
|
Icon={CardIdIcon}
|
||||||
|
InverseIcon={CardIdInverseIcon}>
|
||||||
|
<img
|
||||||
|
className={classes.idCardPhoto}
|
||||||
|
src={`${URI}/id-card-photo/${tx.customerIdCardPhotoPath}`}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</IDButton>
|
||||||
|
)}
|
||||||
|
{tx.customerIdCardData && (
|
||||||
|
<IDButton
|
||||||
|
className={classes.idButton}
|
||||||
|
name="card"
|
||||||
|
Icon={CardIdIcon}
|
||||||
|
InverseIcon={CardIdInverseIcon}>
|
||||||
|
<div className={classes.idCardDataCard}>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<div>{customer.name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Age</Label>
|
||||||
|
<div>{customer.age}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Country</Label>
|
||||||
|
<div>{customer.country}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Label>ID number</Label>
|
||||||
|
<div>{customer.idCardNumber}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Expiration date</Label>
|
||||||
|
<div>{customer.idCardExpirationDate}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IDButton>
|
||||||
|
)}
|
||||||
|
{tx.customerFrontCameraPath && (
|
||||||
|
<IDButton
|
||||||
|
name="cam"
|
||||||
|
Icon={CamIdIcon}
|
||||||
|
InverseIcon={CamIdInverseIcon}>
|
||||||
|
<img
|
||||||
|
src={`${URI}/front-camera-photo/${tx.customerFrontCameraPath}`}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</IDButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<div className={classes.exchangeRate}>
|
||||||
|
<Label>Exchange rate</Label>
|
||||||
|
<div>{crypto > 0 ? displayExRate : '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.commission}>
|
||||||
|
<Label>Commission</Label>
|
||||||
|
<div>
|
||||||
|
{`${commission} ${tx.fiatCode} (${commissionPercentage * 100} %)`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Fixed fee</Label>
|
||||||
|
<div>
|
||||||
|
{tx.txClass === 'cashIn'
|
||||||
|
? `${Number.parseFloat(tx.cashInFee)} ${tx.fiatCode}`
|
||||||
|
: 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.secondRow}>
|
||||||
|
<div className={classes.address}>
|
||||||
|
<Label>Address</Label>
|
||||||
|
<div>
|
||||||
|
<CopyToClipboard>
|
||||||
|
{formatAddress(tx.cryptoCode, tx.toAddress)}
|
||||||
|
</CopyToClipboard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.transactionId}>
|
||||||
|
<Label>Transaction ID</Label>
|
||||||
|
<div>
|
||||||
|
{tx.txClass === 'cashOut' ? (
|
||||||
|
'N/A'
|
||||||
|
) : (
|
||||||
|
<CopyToClipboard>{tx.txHash}</CopyToClipboard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.sessionId}>
|
||||||
|
<Label>Session ID</Label>
|
||||||
|
<CopyToClipboard>{tx.id}</CopyToClipboard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.lastRow}>
|
||||||
|
<div>
|
||||||
|
<Label>Transaction status</Label>
|
||||||
|
<span className={classes.bold}>{getStatus(tx)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(DetailsRow, (prev, next) => prev.id === next.id)
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import typographyStyles from 'src/components/typography/styles'
|
||||||
|
import { offColor } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const { p } = typographyStyles
|
||||||
|
|
||||||
|
export default {
|
||||||
|
wrapper: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
marginTop: 24
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 36
|
||||||
|
},
|
||||||
|
secondRow: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 36
|
||||||
|
},
|
||||||
|
lastRow: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 32
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: offColor,
|
||||||
|
margin: [[0, 0, 6, 0]]
|
||||||
|
},
|
||||||
|
txIcon: {
|
||||||
|
marginRight: 10
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
height: 164,
|
||||||
|
width: 215
|
||||||
|
},
|
||||||
|
idButton: {
|
||||||
|
marginRight: 4
|
||||||
|
},
|
||||||
|
idCardDataCard: {
|
||||||
|
extend: p,
|
||||||
|
display: 'flex',
|
||||||
|
padding: [[11, 8]],
|
||||||
|
// rework this into a proper component
|
||||||
|
'& > div': {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'& > div': {
|
||||||
|
width: 144,
|
||||||
|
height: 37,
|
||||||
|
marginBottom: 15,
|
||||||
|
'&:last-child': {
|
||||||
|
marginBottom: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bold: {
|
||||||
|
fontWeight: 700
|
||||||
|
},
|
||||||
|
direction: {
|
||||||
|
width: 233
|
||||||
|
},
|
||||||
|
availableIds: {
|
||||||
|
width: 232
|
||||||
|
},
|
||||||
|
exchangeRate: {
|
||||||
|
width: 250
|
||||||
|
},
|
||||||
|
commission: {
|
||||||
|
width: 217
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
width: 280
|
||||||
|
},
|
||||||
|
transactionId: {
|
||||||
|
width: 280
|
||||||
|
},
|
||||||
|
sessionId: {
|
||||||
|
width: 215
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Td } from 'src/components/fake-table/Table'
|
||||||
|
import { ReactComponent as StripesSvg } from 'src/styling/icons/stripes.svg'
|
||||||
|
|
||||||
|
const Stripes = ({ width }) => (
|
||||||
|
<Td width={width}>
|
||||||
|
<StripesSvg />
|
||||||
|
</Td>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Stripes
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { useLazyQuery } from '@apollo/react-hooks'
|
||||||
|
import { makeStyles } from '@material-ui/core'
|
||||||
|
import BigNumber from 'bignumber.js'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import moment from 'moment'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
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, formatCryptoAddress } from 'src/utils/coin'
|
||||||
|
|
||||||
|
import DataTable from './DataTable'
|
||||||
|
import DetailsRow from './DetailsCard'
|
||||||
|
import { mainStyles } from './Transactions.styles'
|
||||||
|
import { getStatus } from './helper'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(mainStyles)
|
||||||
|
|
||||||
|
const NUM_LOG_RESULTS = 5
|
||||||
|
|
||||||
|
const GET_TRANSACTIONS = gql`
|
||||||
|
query transactions($limit: Int, $from: Date, $until: Date, $id: String) {
|
||||||
|
transactions(limit: $limit, from: $from, until: $until, id: $id) {
|
||||||
|
id
|
||||||
|
txClass
|
||||||
|
txHash
|
||||||
|
toAddress
|
||||||
|
commissionPercentage
|
||||||
|
expired
|
||||||
|
machineName
|
||||||
|
operatorCompleted
|
||||||
|
sendConfirmed
|
||||||
|
dispense
|
||||||
|
hasError: error
|
||||||
|
deviceId
|
||||||
|
fiat
|
||||||
|
cashInFee
|
||||||
|
fiatCode
|
||||||
|
cryptoAtoms
|
||||||
|
cryptoCode
|
||||||
|
toAddress
|
||||||
|
created
|
||||||
|
customerName
|
||||||
|
customerIdCardData
|
||||||
|
customerIdCardPhotoPath
|
||||||
|
customerFrontCameraPath
|
||||||
|
customerPhone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Transactions = ({ id }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [extraHeight, setExtraHeight] = useState(0)
|
||||||
|
const [clickedId, setClickedId] = useState('')
|
||||||
|
|
||||||
|
const [getTx, { data: txResponse, loading }] = useLazyQuery(
|
||||||
|
GET_TRANSACTIONS,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
limit: NUM_LOG_RESULTS,
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id !== null) {
|
||||||
|
getTx()
|
||||||
|
}
|
||||||
|
}, [getTx, id])
|
||||||
|
|
||||||
|
const formatCustomerName = customer => {
|
||||||
|
const { firstName, lastName } = customer
|
||||||
|
|
||||||
|
return `${R.o(R.toUpper, R.head)(firstName)}. ${lastName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomerDisplayName = tx => {
|
||||||
|
if (tx.customerName) return tx.customerName
|
||||||
|
if (tx.customerIdCardData) return formatCustomerName(tx.customerIdCardData)
|
||||||
|
return tx.customerPhone
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
header: '',
|
||||||
|
width: 62,
|
||||||
|
size: 'sm',
|
||||||
|
view: it => (it.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Machine',
|
||||||
|
name: 'machineName',
|
||||||
|
width: 180,
|
||||||
|
size: 'sm',
|
||||||
|
view: R.path(['machineName'])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Customer',
|
||||||
|
width: 162,
|
||||||
|
size: 'sm',
|
||||||
|
view: getCustomerDisplayName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Cash',
|
||||||
|
width: 144,
|
||||||
|
textAlign: 'right',
|
||||||
|
size: 'sm',
|
||||||
|
view: it => `${Number.parseFloat(it.fiat)} ${it.fiatCode}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Crypto',
|
||||||
|
width: 144,
|
||||||
|
textAlign: 'right',
|
||||||
|
size: 'sm',
|
||||||
|
view: it =>
|
||||||
|
`${toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode).toFormat(5)} ${
|
||||||
|
it.cryptoCode
|
||||||
|
}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Address',
|
||||||
|
view: it => formatCryptoAddress(it.cryptoCode, it.toAddress),
|
||||||
|
className: classes.overflowTd,
|
||||||
|
size: 'sm',
|
||||||
|
width: 140
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Date (UTC)',
|
||||||
|
view: it => moment.utc(it.created).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
textAlign: 'right',
|
||||||
|
size: 'sm',
|
||||||
|
width: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
view: it => getStatus(it),
|
||||||
|
size: 'sm',
|
||||||
|
width: 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleClick = e => {
|
||||||
|
if (clickedId === e.id) {
|
||||||
|
setClickedId('')
|
||||||
|
setExtraHeight(0)
|
||||||
|
} else {
|
||||||
|
setClickedId(e.id)
|
||||||
|
setExtraHeight(310)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
extraHeight={extraHeight}
|
||||||
|
onClick={handleClick}
|
||||||
|
loading={loading || id === null}
|
||||||
|
emptyText="No transactions so far"
|
||||||
|
elements={elements}
|
||||||
|
data={R.path(['transactions'])(txResponse)}
|
||||||
|
Details={DetailsRow}
|
||||||
|
expandable
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Transactions
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import typographyStyles from 'src/components/typography/styles'
|
||||||
|
import baseStyles from 'src/pages/Logs.styles'
|
||||||
|
import { offColor, white } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const { label1, mono, p } = typographyStyles
|
||||||
|
const { titleWrapper, titleAndButtonsContainer, buttonsWrapper } = baseStyles
|
||||||
|
|
||||||
|
const cpcStyles = {
|
||||||
|
wrapper: {
|
||||||
|
extend: mono,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: 32
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
lineBreak: 'anywhere'
|
||||||
|
},
|
||||||
|
buttonWrapper: {
|
||||||
|
'& button': {
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
popoverContent: {
|
||||||
|
extend: label1,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: white,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: [[5, 9]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsRowStyles = {
|
||||||
|
idCardDataCard: {
|
||||||
|
extend: p,
|
||||||
|
display: 'flex',
|
||||||
|
padding: [[11, 8]],
|
||||||
|
'& > div': {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'& > div': {
|
||||||
|
width: 144,
|
||||||
|
height: 37,
|
||||||
|
marginBottom: 15,
|
||||||
|
'&:last-child': {
|
||||||
|
marginBottom: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyles = {
|
||||||
|
label: {
|
||||||
|
extend: label1,
|
||||||
|
color: offColor,
|
||||||
|
marginBottom: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overflowTd: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cpcStyles, detailsRowStyles, labelStyles, mainStyles }
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
const getCashOutStatus = it => {
|
||||||
|
if (it.hasError) return 'Error'
|
||||||
|
if (it.dispense) return 'Success'
|
||||||
|
if (it.expired) return 'Expired'
|
||||||
|
return 'Pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCashInStatus = it => {
|
||||||
|
if (it.operatorCompleted) return 'Cancelled'
|
||||||
|
if (it.hasError) return 'Error'
|
||||||
|
if (it.sendConfirmed) return 'Sent'
|
||||||
|
if (it.expired) return 'Expired'
|
||||||
|
return 'Pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatus = it => {
|
||||||
|
if (it.class === 'cashOut') {
|
||||||
|
return getCashOutStatus(it)
|
||||||
|
}
|
||||||
|
return getCashInStatus(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getStatus }
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import Transactions from './Transactions'
|
||||||
|
export default Transactions
|
||||||
105
new-lamassu-admin/src/pages/Machines/Machines.js
Normal file
105
new-lamassu-admin/src/pages/Machines/Machines.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { useQuery } from '@apollo/react-hooks'
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
import Sidebar from 'src/components/layout/Sidebar'
|
||||||
|
import TitleSection from 'src/components/layout/TitleSection'
|
||||||
|
import { TL1 } from 'src/components/typography'
|
||||||
|
|
||||||
|
import Cassettes from './MachineComponents/Cassettes'
|
||||||
|
import Commissions from './MachineComponents/Commissions'
|
||||||
|
import Details from './MachineComponents/Details'
|
||||||
|
import Transactions from './MachineComponents/Transactions'
|
||||||
|
import styles from './Machines.styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const getMachineInfo = R.compose(R.find, R.propEq('name'))
|
||||||
|
|
||||||
|
const GET_INFO = gql`
|
||||||
|
query getInfo {
|
||||||
|
machines {
|
||||||
|
name
|
||||||
|
deviceId
|
||||||
|
paired
|
||||||
|
lastPing
|
||||||
|
pairedAt
|
||||||
|
version
|
||||||
|
model
|
||||||
|
cashbox
|
||||||
|
cassette1
|
||||||
|
cassette2
|
||||||
|
statuses {
|
||||||
|
label
|
||||||
|
type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const getMachines = R.path(['machines'])
|
||||||
|
|
||||||
|
const Machines = () => {
|
||||||
|
const { data, refetch, loading } = useQuery(GET_INFO)
|
||||||
|
const [selectedMachine, setSelectedMachine] = useState('')
|
||||||
|
const [touched, setTouched] = useState(false)
|
||||||
|
const classes = useStyles()
|
||||||
|
const machines = getMachines(data) ?? []
|
||||||
|
const machineInfo = getMachineInfo(selectedMachine)(machines) ?? {}
|
||||||
|
|
||||||
|
// pre-selects first machine from the list, if there is a machine configured.
|
||||||
|
// Only runs if user hasnt touched the sidebar yet
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && data && data.machines && !touched) {
|
||||||
|
setSelectedMachine(R.path(['machines', 0, 'name'])(data) ?? '')
|
||||||
|
}
|
||||||
|
}, [data, loading, touched])
|
||||||
|
|
||||||
|
/*
|
||||||
|
const isId = R.either(R.propEq('machine', 'ALL_MACHINES'), R.propEq('machine', 'e139c9021251ecf9c5280379b885983901b3dad14963cf38b6d7c1fb33faf72e'))
|
||||||
|
R.filter(isId)(data.overrides)
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TitleSection title="Machine details page" />
|
||||||
|
<Grid container className={classes.grid}>
|
||||||
|
<Sidebar
|
||||||
|
data={machines}
|
||||||
|
isSelected={it => it.name === selectedMachine}
|
||||||
|
displayName={it => it.name}
|
||||||
|
onClick={it => {
|
||||||
|
setTouched(true)
|
||||||
|
setSelectedMachine(it.name)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={classes.content}>
|
||||||
|
<div className={classes.detailItem}>
|
||||||
|
<TL1 className={classes.subtitle}>{'Details'}</TL1>
|
||||||
|
<Details data={machineInfo} onActionSuccess={refetch} />
|
||||||
|
</div>
|
||||||
|
<div className={classes.detailItem}>
|
||||||
|
<TL1 className={classes.subtitle}>{'Cash cassettes'}</TL1>
|
||||||
|
<Cassettes machine={machineInfo} config={data?.config ?? false} />
|
||||||
|
</div>
|
||||||
|
<div className={classes.transactionsItem}>
|
||||||
|
<TL1 className={classes.subtitle}>{'Latest transactions'}</TL1>
|
||||||
|
<Transactions id={machineInfo?.deviceId ?? null} />
|
||||||
|
</div>
|
||||||
|
<div className={classes.detailItem}>
|
||||||
|
<TL1 className={classes.subtitle}>{'Commissions'}</TL1>
|
||||||
|
<Commissions
|
||||||
|
name={'commissions'}
|
||||||
|
id={machineInfo?.deviceId ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Machines
|
||||||
73
new-lamassu-admin/src/pages/Machines/Machines.styles.js
Normal file
73
new-lamassu-admin/src/pages/Machines/Machines.styles.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import {
|
||||||
|
spacer,
|
||||||
|
fontPrimary,
|
||||||
|
primaryColor,
|
||||||
|
white,
|
||||||
|
comet
|
||||||
|
} from 'src/styling/variables'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
grid: {
|
||||||
|
flex: 1,
|
||||||
|
height: '100%'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacer * 6,
|
||||||
|
maxWidth: 900
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
margin: [['auto', 0, spacer * 3, 'auto']]
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
lineHeight: '120%',
|
||||||
|
color: primaryColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: fontPrimary,
|
||||||
|
fontWeight: 900
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
color: comet
|
||||||
|
},
|
||||||
|
tl2: {
|
||||||
|
color: comet,
|
||||||
|
marginTop: 0
|
||||||
|
},
|
||||||
|
white: {
|
||||||
|
color: white
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
paddingLeft: 13
|
||||||
|
},
|
||||||
|
addressRow: {
|
||||||
|
marginLeft: 8
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around'
|
||||||
|
},
|
||||||
|
rowItem: {
|
||||||
|
flex: 1,
|
||||||
|
marginBottom: spacer * 2
|
||||||
|
},
|
||||||
|
detailItem: {
|
||||||
|
marginBottom: spacer * 4
|
||||||
|
},
|
||||||
|
transactionsItem: {
|
||||||
|
marginBottom: -spacer * 4
|
||||||
|
},
|
||||||
|
actionButtonsContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row'
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
marginRight: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
3
new-lamassu-admin/src/pages/Machines/index.js
Normal file
3
new-lamassu-admin/src/pages/Machines/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import Machines from './Machines'
|
||||||
|
|
||||||
|
export default Machines
|
||||||
|
|
@ -19,10 +19,12 @@ import Cashout from 'src/pages/Cashout'
|
||||||
import Commissions from 'src/pages/Commissions'
|
import Commissions from 'src/pages/Commissions'
|
||||||
import ConfigMigration from 'src/pages/ConfigMigration'
|
import ConfigMigration from 'src/pages/ConfigMigration'
|
||||||
import { Customers, CustomerProfile } from 'src/pages/Customers'
|
import { Customers, CustomerProfile } from 'src/pages/Customers'
|
||||||
|
import Dashboard from 'src/pages/Dashboard'
|
||||||
import Funding from 'src/pages/Funding'
|
import Funding from 'src/pages/Funding'
|
||||||
import Locales from 'src/pages/Locales'
|
import Locales from 'src/pages/Locales'
|
||||||
import Coupons from 'src/pages/LoyaltyPanel/CouponCodes'
|
import Coupons from 'src/pages/LoyaltyPanel/CouponCodes'
|
||||||
import MachineLogs from 'src/pages/MachineLogs'
|
import MachineLogs from 'src/pages/MachineLogs'
|
||||||
|
import Machines from 'src/pages/Machines'
|
||||||
import CashCassettes from 'src/pages/Maintenance/CashCassettes'
|
import CashCassettes from 'src/pages/Maintenance/CashCassettes'
|
||||||
import MachineStatus from 'src/pages/Maintenance/MachineStatus'
|
import MachineStatus from 'src/pages/Maintenance/MachineStatus'
|
||||||
import Notifications from 'src/pages/Notifications/Notifications'
|
import Notifications from 'src/pages/Notifications/Notifications'
|
||||||
|
|
@ -49,6 +51,18 @@ const useStyles = makeStyles({
|
||||||
})
|
})
|
||||||
|
|
||||||
const tree = [
|
const tree = [
|
||||||
|
{
|
||||||
|
key: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
route: '/dashboard',
|
||||||
|
component: Dashboard
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'machines',
|
||||||
|
label: 'Machines',
|
||||||
|
route: '/machines',
|
||||||
|
component: Machines
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'transactions',
|
key: 'transactions',
|
||||||
label: 'Transactions',
|
label: 'Transactions',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="7" viewBox="0 0 10 7">
|
||||||
|
<g fill="none" fill-rule="evenodd" stroke-linejoin="round">
|
||||||
|
<g fill="#FF584A" stroke="#FF584A">
|
||||||
|
<path d="M411 561L415 567 407 567z" transform="translate(-406 -560) matrix(1 0 0 -1 0 1128)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 326 B |
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="7" viewBox="0 0 10 7">
|
||||||
|
<g fill="none" fill-rule="evenodd" stroke-linejoin="round">
|
||||||
|
<g fill="#3fd07e" stroke="#3fd07e">
|
||||||
|
<path d="M418 561L422 567 414 567z" transform="translate(-413 -561)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 302 B |
Loading…
Add table
Add a link
Reference in a new issue