Feat: make dashboard and machine profile page

This commit is contained in:
Cesar 2020-11-17 12:13:10 +00:00 committed by Josh Harvey
parent d17ca43abb
commit 19cd086436
54 changed files with 11680 additions and 2611 deletions

View file

@ -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 {

View file

@ -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) {

View file

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

View file

@ -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 }

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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
} }

View 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

View file

@ -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
}
}
}

View 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

View file

@ -0,0 +1,2 @@
import Alerts from './Alerts'
export default Alerts

View 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

View 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'
}
}
}

View 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

View file

@ -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
}
}

View file

@ -0,0 +1,2 @@
import Footer from './Footer'
export default Footer

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
}
}

View file

@ -0,0 +1,2 @@
import SystemPerformance from './SystemPerformance'
export default SystemPerformance

View file

@ -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

View file

@ -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
}
}

View file

@ -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

View file

@ -0,0 +1,2 @@
import SystemStatus from './SystemStatus'
export default SystemStatus

View file

@ -0,0 +1,2 @@
import Dashboard from './Dashboard'
export default Dashboard

View file

@ -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

View file

@ -0,0 +1,6 @@
export default {
cashbox: {
width: 80,
height: 36
}
}

View file

@ -0,0 +1,2 @@
import Cassettes from './Cassettes'
export default Cassettes

View file

@ -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

View file

@ -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 }

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'
})
}

View file

@ -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)

View file

@ -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
}
}

View file

@ -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

View file

@ -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

View file

@ -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 }

View file

@ -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 }

View file

@ -0,0 +1,2 @@
import Transactions from './Transactions'
export default Transactions

View 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

View 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
}
}

View file

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

View file

@ -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',

View file

@ -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

View file

@ -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