chore: use monorepo organization
This commit is contained in:
parent
deaf7d6ecc
commit
a687827f7e
1099 changed files with 8184 additions and 11535 deletions
276
packages/admin-ui/src/pages/AddMachine/AddMachine.jsx
Normal file
276
packages/admin-ui/src/pages/AddMachine/AddMachine.jsx
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { useMutation, useQuery, gql } from '@apollo/client'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import SvgIcon from '@mui/material/SvgIcon'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import classnames from 'classnames'
|
||||
import { Form, Formik, FastField } from 'formik'
|
||||
import { QRCodeSVG as QRCode } from 'qrcode.react'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useState, useEffect, useRef } from 'react'
|
||||
import Title from 'src/components/Title'
|
||||
import Sidebar from 'src/components/layout/Sidebar'
|
||||
import { Info2, P } from 'src/components/typography'
|
||||
import CameraIcon from 'src/styling/icons/ID/photo/zodiac.svg?react'
|
||||
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
|
||||
import CompleteStageIconSpring from 'src/styling/icons/stage/spring/complete.svg?react'
|
||||
import CompleteStageIconZodiac from 'src/styling/icons/stage/zodiac/complete.svg?react'
|
||||
import CurrentStageIconZodiac from 'src/styling/icons/stage/zodiac/current.svg?react'
|
||||
import EmptyStageIconZodiac from 'src/styling/icons/stage/zodiac/empty.svg?react'
|
||||
import WarningIcon from 'src/styling/icons/warning-icon/comet.svg?react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { TextInput } from 'src/components/inputs/formik'
|
||||
import { primaryColor } from 'src/styling/variables'
|
||||
|
||||
const SAVE_CONFIG = gql`
|
||||
mutation createPairingTotem($name: String!) {
|
||||
createPairingTotem(name: $name)
|
||||
}
|
||||
`
|
||||
const GET_MACHINES = gql`
|
||||
{
|
||||
machines {
|
||||
name
|
||||
deviceId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const getSize = R.compose(R.length, R.pathOr([], ['machines']))
|
||||
|
||||
const QrCodeComponent = ({ qrCode, name, count, onPaired }) => {
|
||||
const timeout = useRef(null)
|
||||
const CLOSE_SCREEN_TIMEOUT = 2000
|
||||
const { data } = useQuery(GET_MACHINES, { pollInterval: 10000 })
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const addedMachine = data?.machines?.find(m => m.name === name)
|
||||
const hasNewMachine = getSize(data) > count && addedMachine
|
||||
if (hasNewMachine) {
|
||||
timeout.current = setTimeout(
|
||||
() => onPaired(addedMachine),
|
||||
CLOSE_SCREEN_TIMEOUT
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Info2>Scan QR code with your new cryptomat</Info2>
|
||||
<div className="flex gap-20 pt-6">
|
||||
<div
|
||||
className="bg-white p-1 rounded-2xl border-solid border-zodiac border-5"
|
||||
data-cy={qrCode}>
|
||||
<QRCode
|
||||
size={280}
|
||||
fgColor={primaryColor}
|
||||
marginSize={3}
|
||||
value={qrCode}
|
||||
/>
|
||||
<div className="flex items-center mb-5 ml-5">
|
||||
<CameraIcon />
|
||||
<P noMargin className="ml-3">
|
||||
Snap a picture and scan
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-100">
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div>
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<P noMargin>
|
||||
To pair the machine you need scan the QR code with your machine.
|
||||
To do this either snap a picture of this QR code or download it
|
||||
through the button above and scan it with the scanning bay on your
|
||||
machine.
|
||||
</P>
|
||||
</div>
|
||||
{hasNewMachine && (
|
||||
<div className="bg-spring3 flex gap-4 p-2">
|
||||
<div className="flex items-center">
|
||||
<CompleteStageIconSpring />
|
||||
</div>
|
||||
<Info2 className="text-spring2 m-0">
|
||||
Machine has been successfully paired!
|
||||
</Info2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
name: ''
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.required('Machine name is required.')
|
||||
.max(50)
|
||||
.test(
|
||||
'unique-name',
|
||||
'Machine name is already in use.',
|
||||
(value, context) =>
|
||||
!R.includes(
|
||||
R.toLower(value),
|
||||
R.map(R.toLower, context.options.context.machineNames)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const MachineNameComponent = ({ nextStep, setQrCode, setName }) => {
|
||||
const [register] = useMutation(SAVE_CONFIG, {
|
||||
onCompleted: ({ createPairingTotem }) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`totem: "${createPairingTotem}" `)
|
||||
}
|
||||
setQrCode(createPairingTotem)
|
||||
nextStep()
|
||||
},
|
||||
onError: e => console.log(e)
|
||||
})
|
||||
|
||||
const { data } = useQuery(GET_MACHINES)
|
||||
const machineNames = R.map(R.prop('name'), data?.machines || {})
|
||||
|
||||
const uniqueNameValidator = value => {
|
||||
try {
|
||||
validationSchema.validateSync(value, {
|
||||
context: { machineNames: machineNames }
|
||||
})
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Info2 className="mb-6">Machine Name (ex: Coffee shop 01)</Info2>
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
initialValues={initialValues}
|
||||
validate={uniqueNameValidator}
|
||||
onSubmit={({ name }) => {
|
||||
setName(name)
|
||||
register({ variables: { name } })
|
||||
}}>
|
||||
{({ errors }) => (
|
||||
<Form>
|
||||
<div>
|
||||
<FastField
|
||||
name="name"
|
||||
label="Enter machine name"
|
||||
component={TextInput}
|
||||
/>
|
||||
</div>
|
||||
{errors && <P className="text-tomato">{errors.message}</P>}
|
||||
<div className="mt-16">
|
||||
<Button type="submit">Submit</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: 'Machine name',
|
||||
component: MachineNameComponent
|
||||
},
|
||||
{
|
||||
label: 'Scan QR code',
|
||||
component: QrCodeComponent
|
||||
}
|
||||
]
|
||||
|
||||
const renderStepper = (step, it, idx) => {
|
||||
const active = step === idx
|
||||
const past = idx < step
|
||||
const future = idx > step
|
||||
|
||||
return (
|
||||
<div className="flex relative my-3">
|
||||
<span
|
||||
className={classnames({
|
||||
'mr-6 text-comet': true,
|
||||
'text-zodiac font-bold': active,
|
||||
'text-zodiac': past
|
||||
})}>
|
||||
{it.label}
|
||||
</span>
|
||||
{active && <CurrentStageIconZodiac />}
|
||||
{past && <CompleteStageIconZodiac />}
|
||||
{future && <EmptyStageIconZodiac />}
|
||||
{idx < steps.length - 1 && (
|
||||
<div
|
||||
className={classnames({
|
||||
'absolute h-7 w-px border border-comet border-solid right-2 top-[18px]': true,
|
||||
'border-zodiac': past
|
||||
})}></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AddMachine = memo(({ close, onPaired }) => {
|
||||
const { data } = useQuery(GET_MACHINES)
|
||||
const [qrCode, setQrCode] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [step, setStep] = useState(0)
|
||||
const count = getSize(data)
|
||||
|
||||
const Component = steps[step].component
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog fullScreen open={true} aria-labelledby="form-dialog-title">
|
||||
<DialogContent className="p-0 pt-5 bg-ghost">
|
||||
<div
|
||||
className=" mx-auto flex flex-col flex-col flex-col flex-col
|
||||
flex-col flex-col flex-col flex-col w-[1200px] h-full ">
|
||||
<div className="flex items-center justify-between">
|
||||
<Title>Add Machine</Title>
|
||||
<IconButton onClick={close} size="large">
|
||||
<SvgIcon color="error">
|
||||
<CloseIcon />
|
||||
</SvgIcon>
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex flex-1">
|
||||
<Sidebar>
|
||||
{steps.map((it, idx) => renderStepper(step, it, idx))}
|
||||
</Sidebar>
|
||||
<div className="px-12 flex-1">
|
||||
<Component
|
||||
nextStep={() => setStep(1)}
|
||||
count={count}
|
||||
onPaired={onPaired}
|
||||
qrCode={qrCode}
|
||||
setQrCode={setQrCode}
|
||||
name={name}
|
||||
setName={setName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default AddMachine
|
||||
3
packages/admin-ui/src/pages/AddMachine/index.js
Normal file
3
packages/admin-ui/src/pages/AddMachine/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import AddMachine from './AddMachine'
|
||||
|
||||
export default AddMachine
|
||||
387
packages/admin-ui/src/pages/Analytics/Analytics.jsx
Normal file
387
packages/admin-ui/src/pages/Analytics/Analytics.jsx
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import classnames from 'classnames'
|
||||
import { endOfToday } from 'date-fns'
|
||||
import { subDays, format, add, startOfWeek } from 'date-fns/fp'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import { Info2, P } from 'src/components/typography'
|
||||
import DownIcon from 'src/styling/icons/dashboard/down.svg?react'
|
||||
import EqualIcon from 'src/styling/icons/dashboard/equal.svg?react'
|
||||
import UpIcon from 'src/styling/icons/dashboard/up.svg?react'
|
||||
|
||||
import { Select } from 'src/components/inputs'
|
||||
import { fromNamespace } from 'src/utils/config'
|
||||
import { numberToFiatAmount } from 'src/utils/number'
|
||||
import { DAY, WEEK, MONTH } from 'src/utils/time'
|
||||
|
||||
import LegendEntry from './components/LegendEntry'
|
||||
import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper'
|
||||
import OverTimeWrapper from './components/wrappers/OverTimeWrapper'
|
||||
import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper'
|
||||
import VolumeOverTimeWrapper from './components/wrappers/VolumeOverTimeWrapper'
|
||||
|
||||
const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }]
|
||||
const REPRESENTING_OPTIONS = [
|
||||
{ code: 'overTime', display: 'Over time' },
|
||||
{ code: 'volumeOverTime', display: 'Volume' },
|
||||
{ code: 'topMachines', display: 'Top machines' },
|
||||
{ code: 'hourOfTheDay', display: 'Hour of the day' }
|
||||
]
|
||||
const PERIOD_OPTIONS = [
|
||||
{ code: 'day', display: 'Last 24 hours' },
|
||||
{ code: 'threeDays', display: 'Last 3 days' },
|
||||
{ code: 'week', display: 'Last 7 days' },
|
||||
{ code: 'month', display: 'Last 30 days' }
|
||||
]
|
||||
const TIME_OPTIONS = {
|
||||
day: DAY,
|
||||
threeDays: 3 * DAY,
|
||||
week: WEEK,
|
||||
month: MONTH
|
||||
}
|
||||
|
||||
const DAY_OPTIONS = R.map(
|
||||
it => ({
|
||||
code: R.toLower(it),
|
||||
display: it
|
||||
}),
|
||||
Array.from(Array(7)).map((_, i) =>
|
||||
format('EEEE', add({ days: i }, startOfWeek(new Date())))
|
||||
)
|
||||
)
|
||||
|
||||
const GET_TRANSACTIONS = gql`
|
||||
query transactions(
|
||||
$from: DateTimeISO
|
||||
$until: DateTimeISO
|
||||
$excludeTestingCustomers: Boolean
|
||||
) {
|
||||
transactions(
|
||||
from: $from
|
||||
until: $until
|
||||
excludeTestingCustomers: $excludeTestingCustomers
|
||||
) {
|
||||
id
|
||||
txClass
|
||||
txHash
|
||||
toAddress
|
||||
commissionPercentage
|
||||
expired
|
||||
machineName
|
||||
operatorCompleted
|
||||
sendConfirmed
|
||||
dispense
|
||||
hasError: error
|
||||
deviceId
|
||||
fiat
|
||||
fixedFee
|
||||
fiatCode
|
||||
cryptoAtoms
|
||||
cryptoCode
|
||||
toAddress
|
||||
created
|
||||
profit
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
config
|
||||
machines {
|
||||
name
|
||||
deviceId
|
||||
}
|
||||
fiatRates {
|
||||
code
|
||||
name
|
||||
rate
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const VerticalLine = () => (
|
||||
<div className="h-16 border-solid border border-comet2" />
|
||||
)
|
||||
|
||||
const OverviewEntry = ({ label, value, oldValue, currency }) => {
|
||||
const _oldValue = !oldValue || R.equals(oldValue, 0) ? 1 : oldValue
|
||||
const growthRate = ((value - oldValue) * 100) / _oldValue
|
||||
|
||||
const growthClasses = {
|
||||
'font-bold': true,
|
||||
'text-malachite': R.gt(value, oldValue),
|
||||
'text-tomato': R.gt(oldValue, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<P noMargin>{label}</P>
|
||||
<Info2 noMargin className="mt-[6px] mb-[6px]">
|
||||
<span className="text-[24px]">{numberToFiatAmount(value)}</span>
|
||||
{!!currency && ` ${currency}`}
|
||||
</Info2>
|
||||
<span className="flex items-center gap-1">
|
||||
{R.gt(growthRate, 0) && <UpIcon height={10} />}
|
||||
{R.lt(growthRate, 0) && <DownIcon height={10} />}
|
||||
{R.equals(growthRate, 0) && <EqualIcon height={10} />}
|
||||
<P noMargin className={classnames(growthClasses)}>
|
||||
{numberToFiatAmount(growthRate)}%
|
||||
</P>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Analytics = () => {
|
||||
const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS, {
|
||||
variables: {
|
||||
from: subDays(65, endOfToday()),
|
||||
until: endOfToday(),
|
||||
excludeTestingCustomers: true
|
||||
}
|
||||
})
|
||||
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
|
||||
|
||||
const [representing, setRepresenting] = useState(REPRESENTING_OPTIONS[0])
|
||||
const [period, setPeriod] = useState(PERIOD_OPTIONS[0])
|
||||
const [machine, setMachine] = useState(MACHINE_OPTIONS[0])
|
||||
const [selectedDay, setSelectedDay] = useState(
|
||||
R.equals(representing.code, 'hourOfTheDay') ? DAY_OPTIONS[0] : null
|
||||
)
|
||||
|
||||
const loading = txLoading || configLoading
|
||||
|
||||
const transactions = R.path(['transactions'])(txResponse) ?? []
|
||||
const machines = R.path(['machines'])(configResponse) ?? []
|
||||
const config = R.path(['config'])(configResponse) ?? []
|
||||
const rates = R.path(['fiatRates'])(configResponse) ?? []
|
||||
const fiatLocale = fromNamespace('locale')(config).fiatCurrency
|
||||
|
||||
const timezone = config?.locale_timezone
|
||||
|
||||
const convertFiatToLocale = item => {
|
||||
if (item.fiatCode === fiatLocale) return item
|
||||
const itemRate = R.find(R.propEq('code', item.fiatCode))(rates)
|
||||
const localeRate = R.find(R.propEq('code', fiatLocale))(rates)
|
||||
const multiplier = localeRate?.rate / itemRate?.rate
|
||||
return { ...item, fiat: parseFloat(item.fiat) * multiplier }
|
||||
}
|
||||
|
||||
const data =
|
||||
R.map(convertFiatToLocale)(
|
||||
transactions?.filter(
|
||||
tx =>
|
||||
(!tx.dispensed || !tx.expired) &&
|
||||
(tx.sendConfirmed || tx.dispense) &&
|
||||
!tx.hasError
|
||||
)
|
||||
) ?? []
|
||||
|
||||
const machineOptions = R.clone(MACHINE_OPTIONS)
|
||||
|
||||
R.forEach(
|
||||
m => machineOptions.push({ code: m.deviceId, display: m.name }),
|
||||
machines
|
||||
)
|
||||
|
||||
const machineTxs = R.filter(
|
||||
tx => (machine.code === 'all' ? true : tx.deviceId === machine.code),
|
||||
data
|
||||
)
|
||||
|
||||
const filteredData = timeInterval => ({
|
||||
current:
|
||||
machineTxs.filter(d => {
|
||||
const txDay = new Date(d.created)
|
||||
const isSameWeekday = !R.isNil(selectedDay)
|
||||
? R.equals(R.toLower(format('EEEE', txDay)), selectedDay.code)
|
||||
: true
|
||||
|
||||
return isSameWeekday && txDay >= Date.now() - TIME_OPTIONS[timeInterval]
|
||||
}) ?? [],
|
||||
previous:
|
||||
machineTxs.filter(d => {
|
||||
const txDay = new Date(d.created)
|
||||
const isSameWeekday = !R.isNil(selectedDay)
|
||||
? R.equals(R.toLower(format('EEEE', txDay)), selectedDay.code)
|
||||
: true
|
||||
|
||||
return (
|
||||
isSameWeekday &&
|
||||
txDay < Date.now() - TIME_OPTIONS[timeInterval] &&
|
||||
txDay >= Date.now() - 2 * TIME_OPTIONS[timeInterval]
|
||||
)
|
||||
}) ?? []
|
||||
})
|
||||
|
||||
const txs = {
|
||||
current: filteredData(period.code).current.length,
|
||||
previous: filteredData(period.code).previous.length
|
||||
}
|
||||
|
||||
const median = values => (values.length === 0 ? 0 : R.median(values))
|
||||
|
||||
const medianAmount = {
|
||||
current: median(R.map(d => d.fiat, filteredData(period.code).current)),
|
||||
previous: median(R.map(d => d.fiat, filteredData(period.code).previous))
|
||||
}
|
||||
|
||||
const txVolume = {
|
||||
current: R.sum(R.map(d => d.fiat, filteredData(period.code).current)),
|
||||
previous: R.sum(R.map(d => d.fiat, filteredData(period.code).previous))
|
||||
}
|
||||
|
||||
const commissions = {
|
||||
current: R.sum(R.map(d => d.profit, filteredData(period.code).current)),
|
||||
previous: R.sum(R.map(d => d.profit, filteredData(period.code).previous))
|
||||
}
|
||||
|
||||
const handleRepresentationChange = newRepresentation => {
|
||||
setRepresenting(newRepresentation)
|
||||
setSelectedDay(
|
||||
R.equals(newRepresentation.code, 'hourOfTheDay') ? DAY_OPTIONS[0] : null
|
||||
)
|
||||
}
|
||||
|
||||
const getGraphInfo = representing => {
|
||||
switch (representing.code) {
|
||||
case 'overTime':
|
||||
return (
|
||||
<OverTimeWrapper
|
||||
title="Transactions over time"
|
||||
representing={representing}
|
||||
period={period}
|
||||
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
|
||||
machines={machineOptions}
|
||||
selectedMachine={machine}
|
||||
handleMachineChange={setMachine}
|
||||
timezone={timezone}
|
||||
currency={fiatLocale}
|
||||
/>
|
||||
)
|
||||
case 'volumeOverTime':
|
||||
return (
|
||||
<VolumeOverTimeWrapper
|
||||
title="Transactions volume over time"
|
||||
representing={representing}
|
||||
period={period}
|
||||
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
|
||||
machines={machineOptions}
|
||||
selectedMachine={machine}
|
||||
handleMachineChange={setMachine}
|
||||
timezone={timezone}
|
||||
currency={fiatLocale}
|
||||
/>
|
||||
)
|
||||
case 'topMachines':
|
||||
return (
|
||||
<TopMachinesWrapper
|
||||
title="Top 5 Machines"
|
||||
representing={representing}
|
||||
period={period}
|
||||
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
|
||||
machines={machineOptions}
|
||||
selectedMachine={machine}
|
||||
handleMachineChange={setMachine}
|
||||
timezone={timezone}
|
||||
currency={fiatLocale}
|
||||
/>
|
||||
)
|
||||
case 'hourOfTheDay':
|
||||
return (
|
||||
<HourOfDayWrapper
|
||||
title="Avg. transactions per hour of the day"
|
||||
representing={representing}
|
||||
period={period}
|
||||
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
|
||||
machines={machineOptions}
|
||||
selectedMachine={machine}
|
||||
handleMachineChange={setMachine}
|
||||
selectedDay={selectedDay}
|
||||
dayOptions={DAY_OPTIONS}
|
||||
handleDayChange={setSelectedDay}
|
||||
timezone={timezone}
|
||||
currency={fiatLocale}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
throw new Error(`There's no graph info to represent ${representing}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
!loading && (
|
||||
<>
|
||||
<TitleSection title="Analytics">
|
||||
<div className="flex gap-6 justify-end">
|
||||
<LegendEntry
|
||||
IconComponent={UpIcon}
|
||||
label={'Up since last period'}
|
||||
/>
|
||||
<LegendEntry
|
||||
IconComponent={DownIcon}
|
||||
label={'Down since last period'}
|
||||
/>
|
||||
<LegendEntry
|
||||
IconComponent={EqualIcon}
|
||||
label={'Same since last period'}
|
||||
/>
|
||||
</div>
|
||||
</TitleSection>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex gap-6">
|
||||
<Select
|
||||
label="Representing"
|
||||
onSelectedItemChange={handleRepresentationChange}
|
||||
items={REPRESENTING_OPTIONS}
|
||||
default={REPRESENTING_OPTIONS[0]}
|
||||
selectedItem={representing}
|
||||
defaultAsFilter
|
||||
/>
|
||||
<Select
|
||||
label="Time period"
|
||||
onSelectedItemChange={setPeriod}
|
||||
items={PERIOD_OPTIONS}
|
||||
default={PERIOD_OPTIONS[0]}
|
||||
selectedItem={period}
|
||||
defaultAsFilter
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-10">
|
||||
<OverviewEntry
|
||||
label="Transactions"
|
||||
value={txs.current}
|
||||
oldValue={txs.previous}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<OverviewEntry
|
||||
label="Median amount"
|
||||
value={medianAmount.current}
|
||||
oldValue={medianAmount.previous}
|
||||
currency={fiatLocale}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<OverviewEntry
|
||||
label="Volume"
|
||||
value={txVolume.current}
|
||||
oldValue={txVolume.previous}
|
||||
currency={fiatLocale}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<OverviewEntry
|
||||
label="Commissions"
|
||||
value={commissions.current}
|
||||
oldValue={commissions.previous}
|
||||
currency={fiatLocale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{getGraphInfo(representing)}
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default Analytics
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
import { P } from 'src/components/typography'
|
||||
|
||||
const LegendEntry = ({ IconElement, IconComponent, label }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{!!IconComponent && <IconComponent height={12} />}
|
||||
{!!IconElement && IconElement}
|
||||
<P>{label}</P>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default LegendEntry
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import Paper from '@mui/material/Paper'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo } from 'react'
|
||||
import { Info2, Label3, P } from 'src/components/typography'
|
||||
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
|
||||
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
|
||||
|
||||
import { numberToFiatAmount } from 'src/utils/number'
|
||||
import { singularOrPlural } from 'src/utils/string'
|
||||
import { formatDate, formatDateNonUtc } from 'src/utils/timezones'
|
||||
|
||||
const GraphTooltip = ({
|
||||
coords,
|
||||
data,
|
||||
dateInterval,
|
||||
period,
|
||||
currency,
|
||||
representing
|
||||
}) => {
|
||||
const formattedDateInterval = !R.includes('hourOfDay', representing.code)
|
||||
? [
|
||||
formatDate(dateInterval[1], null, 'MMM d'),
|
||||
formatDate(dateInterval[1], null, 'HH:mm'),
|
||||
formatDate(dateInterval[0], null, 'HH:mm')
|
||||
]
|
||||
: [
|
||||
formatDate(dateInterval[1], null, 'MMM d'),
|
||||
formatDateNonUtc(dateInterval[1], 'HH:mm'),
|
||||
formatDateNonUtc(dateInterval[0], 'HH:mm')
|
||||
]
|
||||
|
||||
const transactions = R.reduce(
|
||||
(acc, value) => {
|
||||
acc.volume += parseInt(value.fiat)
|
||||
if (value.txClass === 'cashIn') acc.cashIn++
|
||||
if (value.txClass === 'cashOut') acc.cashOut++
|
||||
return acc
|
||||
},
|
||||
{ volume: 0, cashIn: 0, cashOut: 0 },
|
||||
data
|
||||
)
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className="absolute top-[351px] w-[150px] p-3 rounded-lg"
|
||||
style={{ left: coords?.x ?? 0 }}>
|
||||
{!R.includes('hourOfDay', representing.code) && (
|
||||
<Info2 noMargin>{`${formattedDateInterval[0]}`}</Info2>
|
||||
)}
|
||||
<Info2 noMargin>
|
||||
{`${formattedDateInterval[1]} - ${formattedDateInterval[2]}`}
|
||||
</Info2>
|
||||
<P noMargin className="my-2">
|
||||
{R.length(data)}{' '}
|
||||
{singularOrPlural(R.length(data), 'transaction', 'transactions')}
|
||||
</P>
|
||||
<P noMargin className="text-comet">
|
||||
{numberToFiatAmount(transactions.volume)} {currency} in volume
|
||||
</P>
|
||||
<div className="mt-4">
|
||||
<Label3 noMargin>
|
||||
<TxInIcon />
|
||||
<span className="ml-1">{transactions.cashIn} cash-in</span>
|
||||
</Label3>
|
||||
<Label3 noMargin className="mt-1">
|
||||
<TxOutIcon />
|
||||
<span className="ml-1">{transactions.cashOut} cash-out</span>
|
||||
</Label3>
|
||||
</div>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(GraphTooltip, (prev, next) => prev.coords === next.coords)
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { getTimezoneOffset } from 'date-fns-tz'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { H2 } from 'src/components/typography'
|
||||
|
||||
import { Select } from 'src/components/inputs'
|
||||
import { MINUTE } from 'src/utils/time'
|
||||
|
||||
import Graph from '../../graphs/Graph'
|
||||
import LegendEntry from '../LegendEntry'
|
||||
import classes from './wrappers.module.css'
|
||||
|
||||
const options = [
|
||||
{ code: 'hourOfDayTransactions', display: 'Transactions' },
|
||||
{ code: 'hourOfDayVolume', display: 'Volume' }
|
||||
]
|
||||
|
||||
const HourOfDayBarGraphHeader = ({
|
||||
title,
|
||||
period,
|
||||
data,
|
||||
machines,
|
||||
selectedMachine,
|
||||
handleMachineChange,
|
||||
selectedDay,
|
||||
dayOptions,
|
||||
handleDayChange,
|
||||
timezone,
|
||||
currency
|
||||
}) => {
|
||||
const [graphType /*, setGraphType */] = useState(options[0].code)
|
||||
|
||||
const legend = {
|
||||
cashIn: <div className={classes.cashInIcon}></div>,
|
||||
cashOut: <div className={classes.cashOutIcon}></div>
|
||||
}
|
||||
|
||||
const offset = getTimezoneOffset(timezone)
|
||||
|
||||
const txsPerWeekday = R.reduce(
|
||||
(acc, value) => {
|
||||
const created = new Date(value.created)
|
||||
created.setTime(
|
||||
created.getTime() + created.getTimezoneOffset() * MINUTE + offset
|
||||
)
|
||||
switch (created.getDay()) {
|
||||
case 0:
|
||||
acc.sunday.push(value)
|
||||
break
|
||||
case 1:
|
||||
acc.monday.push(value)
|
||||
break
|
||||
case 2:
|
||||
acc.tuesday.push(value)
|
||||
break
|
||||
case 3:
|
||||
acc.wednesday.push(value)
|
||||
break
|
||||
case 4:
|
||||
acc.thursday.push(value)
|
||||
break
|
||||
case 5:
|
||||
acc.friday.push(value)
|
||||
break
|
||||
case 6:
|
||||
acc.saturday.push(value)
|
||||
break
|
||||
default:
|
||||
throw new Error('Day of week not recognized')
|
||||
}
|
||||
return acc
|
||||
},
|
||||
R.fromPairs(R.map(it => [it.code, []], dayOptions)),
|
||||
data
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.graphHeaderWrapper}>
|
||||
<div className={classes.graphHeaderLeft}>
|
||||
<H2 noMargin>{title}</H2>
|
||||
<div className={classes.graphLegend}>
|
||||
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
|
||||
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.graphHeaderRight}>
|
||||
{/* <RadioGroup
|
||||
options={options}
|
||||
className={classes.topMachinesRadio}
|
||||
value={graphType}
|
||||
onChange={e => setGraphType(e.target.value)}
|
||||
/> */}
|
||||
<Select
|
||||
label="Day of the week"
|
||||
items={dayOptions}
|
||||
default={dayOptions[0]}
|
||||
selectedItem={selectedDay}
|
||||
onSelectedItemChange={handleDayChange}
|
||||
/>
|
||||
<Select
|
||||
label="Machines"
|
||||
onSelectedItemChange={handleMachineChange}
|
||||
items={machines}
|
||||
default={machines[0]}
|
||||
selectedItem={selectedMachine}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Graph
|
||||
representing={R.find(it => it.code === graphType)(options)}
|
||||
period={period}
|
||||
data={txsPerWeekday[selectedDay.code]}
|
||||
timezone={timezone}
|
||||
currency={currency}
|
||||
selectedMachine={selectedMachine}
|
||||
machines={machines}
|
||||
selectedDay={selectedDay}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HourOfDayBarGraphHeader
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import Switch from '@mui/material/Switch'
|
||||
import React, { useState } from 'react'
|
||||
import { H2, Label1 } from 'src/components/typography'
|
||||
|
||||
import { Select } from 'src/components/inputs'
|
||||
import { primaryColor } from 'src/styling/variables'
|
||||
|
||||
import Graph from '../../graphs/Graph'
|
||||
import LegendEntry from '../LegendEntry'
|
||||
import classes from './wrappers.module.css'
|
||||
|
||||
const OverTimeDotGraphHeader = ({
|
||||
title,
|
||||
representing,
|
||||
period,
|
||||
data,
|
||||
machines,
|
||||
selectedMachine,
|
||||
handleMachineChange,
|
||||
timezone,
|
||||
currency
|
||||
}) => {
|
||||
const [logarithmic, setLogarithmic] = useState()
|
||||
|
||||
const legend = {
|
||||
cashIn: <div className={classes.cashInIcon}></div>,
|
||||
cashOut: <div className={classes.cashOutIcon}></div>,
|
||||
transaction: <div className={classes.txIcon}></div>,
|
||||
median: (
|
||||
<svg height="12" width="18">
|
||||
<path
|
||||
stroke={primaryColor}
|
||||
strokeWidth="3"
|
||||
strokeDasharray="5, 2"
|
||||
d="M 5 6 l 20 0"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.graphHeaderWrapper}>
|
||||
<div className={classes.graphHeaderLeft}>
|
||||
<H2 noMargin>{title}</H2>
|
||||
<div className={classes.graphLegend}>
|
||||
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
|
||||
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
|
||||
<LegendEntry
|
||||
IconElement={legend.transaction}
|
||||
label={'One transaction'}
|
||||
/>
|
||||
<LegendEntry IconElement={legend.median} label={'Median'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.graphHeaderRight}>
|
||||
<div className={classes.graphHeaderSwitchBox}>
|
||||
<Label1 noMargin className="mb-1 text-comet">
|
||||
Log. scale
|
||||
</Label1>
|
||||
<Switch
|
||||
className="m-0"
|
||||
onChange={event => setLogarithmic(event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Machines"
|
||||
onSelectedItemChange={handleMachineChange}
|
||||
items={machines}
|
||||
default={machines[0]}
|
||||
selectedItem={selectedMachine}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Graph
|
||||
representing={representing}
|
||||
period={period}
|
||||
data={data}
|
||||
timezone={timezone}
|
||||
currency={currency}
|
||||
selectedMachine={selectedMachine}
|
||||
machines={machines}
|
||||
log={logarithmic}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OverTimeDotGraphHeader
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { H2 } from 'src/components/typography'
|
||||
|
||||
import Graph from '../../graphs/Graph'
|
||||
import LegendEntry from '../LegendEntry'
|
||||
import classes from './wrappers.module.css'
|
||||
|
||||
const options = [
|
||||
{ code: 'topMachinesTransactions', display: 'Transactions' },
|
||||
{ code: 'topMachinesVolume', display: 'Volume' }
|
||||
]
|
||||
|
||||
const TopMachinesBarGraphHeader = ({
|
||||
title,
|
||||
period,
|
||||
data,
|
||||
machines,
|
||||
selectedMachine,
|
||||
timezone,
|
||||
currency
|
||||
}) => {
|
||||
const [graphType /*, setGraphType */] = useState(options[0].code)
|
||||
|
||||
const legend = {
|
||||
cashIn: <div className={classes.cashInIcon}></div>,
|
||||
cashOut: <div className={classes.cashOutIcon}></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.graphHeaderWrapper}>
|
||||
<div className={classes.graphHeaderLeft}>
|
||||
<H2 noMargin>{title}</H2>
|
||||
<div className={classes.graphLegend}>
|
||||
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
|
||||
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.graphHeaderRight}>
|
||||
{/* <RadioGroup
|
||||
options={options}
|
||||
className={classes.topMachinesRadio}
|
||||
value={graphType}
|
||||
onChange={e => setGraphType(e.target.value)}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
<Graph
|
||||
representing={R.find(R.propEq('code', graphType), options)}
|
||||
period={period}
|
||||
data={data}
|
||||
timezone={timezone}
|
||||
currency={currency}
|
||||
selectedMachine={selectedMachine}
|
||||
machines={machines}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopMachinesBarGraphHeader
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import Switch from '@mui/material/Switch'
|
||||
import React, { useState } from 'react'
|
||||
import { H2, Label1 } from 'src/components/typography'
|
||||
|
||||
import { Select } from 'src/components/inputs'
|
||||
import { neon, java } from 'src/styling/variables'
|
||||
|
||||
import Graph from '../../graphs/Graph'
|
||||
import LegendEntry from '../LegendEntry'
|
||||
import classes from './wrappers.module.css'
|
||||
|
||||
const VolumeOverTimeGraphHeader = ({
|
||||
title,
|
||||
representing,
|
||||
period,
|
||||
data,
|
||||
machines,
|
||||
selectedMachine,
|
||||
handleMachineChange,
|
||||
timezone,
|
||||
currency
|
||||
}) => {
|
||||
const [logarithmic, setLogarithmic] = useState()
|
||||
|
||||
const legend = {
|
||||
cashIn: (
|
||||
<svg height="12" width="18">
|
||||
<path
|
||||
stroke={java}
|
||||
strokeWidth="3"
|
||||
d="M 3 6 l 12 0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
cashOut: (
|
||||
<svg height="12" width="18">
|
||||
<path
|
||||
stroke={neon}
|
||||
strokeWidth="3"
|
||||
d="M 3 6 l 12 0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.graphHeaderWrapper}>
|
||||
<div className={classes.graphHeaderLeft}>
|
||||
<H2 noMargin>{title}</H2>
|
||||
<div className={classes.graphLegend}>
|
||||
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
|
||||
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.graphHeaderRight}>
|
||||
<div className={classes.graphHeaderSwitchBox}>
|
||||
<Label1 noMargin className="mb-1 text-comet">
|
||||
Log. scale
|
||||
</Label1>
|
||||
<Switch
|
||||
className="m-0"
|
||||
onChange={event => setLogarithmic(event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Machines"
|
||||
onSelectedItemChange={handleMachineChange}
|
||||
items={machines}
|
||||
default={machines[0]}
|
||||
selectedItem={selectedMachine}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Graph
|
||||
representing={representing}
|
||||
period={period}
|
||||
data={data}
|
||||
timezone={timezone}
|
||||
currency={currency}
|
||||
selectedMachine={selectedMachine}
|
||||
machines={machines}
|
||||
log={logarithmic}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VolumeOverTimeGraphHeader
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
.graphHeaderWrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.graphHeaderLeft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.graphHeaderRight {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
gap: 30px
|
||||
}
|
||||
|
||||
.cashInIcon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--java);
|
||||
}
|
||||
|
||||
.cashOutIcon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--neon);
|
||||
}
|
||||
|
||||
.graphLegend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.txIcon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 12px;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.graphHeaderSwitchBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/*'& > *': {*/
|
||||
/* margin: 0*/
|
||||
/*},*/
|
||||
/*'& > :first-child': {*/
|
||||
/* marginBottom: 2,*/
|
||||
/* extend: label1,*/
|
||||
/* color: offColor*/
|
||||
/*}*/
|
||||
}
|
||||
135
packages/admin-ui/src/pages/Analytics/graphs/Graph.jsx
Normal file
135
packages/admin-ui/src/pages/Analytics/graphs/Graph.jsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import * as R from 'ramda'
|
||||
import React, { memo, useState } from 'react'
|
||||
|
||||
import GraphTooltip from '../components/tooltips/GraphTooltip'
|
||||
|
||||
import HourOfDayBarGraph from './HourOfDayBarGraph'
|
||||
import OverTimeDotGraph from './OverTimeDotGraph'
|
||||
import OverTimeLineGraph from './OverTimeLineGraph'
|
||||
import TopMachinesBarGraph from './TopMachinesBarGraph'
|
||||
|
||||
const GraphWrapper = ({
|
||||
data,
|
||||
representing,
|
||||
period,
|
||||
timezone,
|
||||
currency,
|
||||
selectedMachine,
|
||||
machines,
|
||||
selectedDay,
|
||||
log
|
||||
}) => {
|
||||
const [selectionCoords, setSelectionCoords] = useState(null)
|
||||
const [selectionDateInterval, setSelectionDateInterval] = useState(null)
|
||||
const [selectionData, setSelectionData] = useState(null)
|
||||
|
||||
const getGraph = representing => {
|
||||
switch (representing.code) {
|
||||
case 'overTime':
|
||||
return (
|
||||
<OverTimeDotGraph
|
||||
data={data}
|
||||
period={period}
|
||||
timezone={timezone}
|
||||
setSelectionCoords={setSelectionCoords}
|
||||
setSelectionDateInterval={setSelectionDateInterval}
|
||||
setSelectionData={setSelectionData}
|
||||
selectedMachine={selectedMachine}
|
||||
log={log}
|
||||
/>
|
||||
)
|
||||
case 'volumeOverTime':
|
||||
return (
|
||||
<OverTimeLineGraph
|
||||
data={data}
|
||||
period={period}
|
||||
timezone={timezone}
|
||||
setSelectionCoords={setSelectionCoords}
|
||||
setSelectionDateInterval={setSelectionDateInterval}
|
||||
setSelectionData={setSelectionData}
|
||||
selectedMachine={selectedMachine}
|
||||
log={log}
|
||||
/>
|
||||
)
|
||||
case 'topMachinesVolume':
|
||||
return (
|
||||
<TopMachinesBarGraph
|
||||
data={data}
|
||||
period={period}
|
||||
timezone={timezone}
|
||||
setSelectionCoords={setSelectionCoords}
|
||||
setSelectionDateInterval={setSelectionDateInterval}
|
||||
setSelectionData={setSelectionData}
|
||||
selectedMachine={selectedMachine}
|
||||
machines={R.filter(it => it.code !== 'all', machines)}
|
||||
currency={currency}
|
||||
/>
|
||||
)
|
||||
case 'topMachinesTransactions':
|
||||
return (
|
||||
<TopMachinesBarGraph
|
||||
data={data}
|
||||
period={period}
|
||||
timezone={timezone}
|
||||
setSelectionCoords={setSelectionCoords}
|
||||
setSelectionDateInterval={setSelectionDateInterval}
|
||||
setSelectionData={setSelectionData}
|
||||
selectedMachine={selectedMachine}
|
||||
machines={R.filter(it => it.code !== 'all', machines)}
|
||||
currency={currency}
|
||||
/>
|
||||
)
|
||||
case 'hourOfDayVolume':
|
||||
return (
|
||||
<HourOfDayBarGraph
|
||||
data={data}
|
||||
period={period}
|
||||
timezone={timezone}
|
||||
setSelectionCoords={setSelectionCoords}
|
||||
setSelectionDateInterval={setSelectionDateInterval}
|
||||
setSelectionData={setSelectionData}
|
||||
selectedMachine={selectedMachine}
|
||||
machines={R.filter(it => it.code !== 'all', machines)}
|
||||
currency={currency}
|
||||
selectedDay={selectedDay}
|
||||
/>
|
||||
)
|
||||
case 'hourOfDayTransactions':
|
||||
return (
|
||||
<HourOfDayBarGraph
|
||||
data={data}
|
||||
period={period}
|
||||
timezone={timezone}
|
||||
setSelectionCoords={setSelectionCoords}
|
||||
setSelectionDateInterval={setSelectionDateInterval}
|
||||
setSelectionData={setSelectionData}
|
||||
selectedMachine={selectedMachine}
|
||||
machines={R.filter(it => it.code !== 'all', machines)}
|
||||
currency={currency}
|
||||
selectedDay={selectedDay}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
throw new Error(`There's no graph to represent ${representing}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!R.isNil(selectionCoords) && (
|
||||
<GraphTooltip
|
||||
coords={selectionCoords}
|
||||
dateInterval={selectionDateInterval}
|
||||
data={selectionData}
|
||||
period={period}
|
||||
currency={currency}
|
||||
timezone={timezone}
|
||||
representing={representing}
|
||||
/>
|
||||
)}
|
||||
{getGraph(representing)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(GraphWrapper)
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
import BigNumber from 'bignumber.js'
|
||||
import * as d3 from 'd3'
|
||||
import { getTimezoneOffset } from 'date-fns-tz'
|
||||
import { add, startOfDay } from 'date-fns/fp'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import {
|
||||
java,
|
||||
neon,
|
||||
subheaderDarkColor,
|
||||
fontColor,
|
||||
fontSecondary,
|
||||
subheaderColor
|
||||
} from 'src/styling/variables'
|
||||
import { MINUTE } from 'src/utils/time'
|
||||
import { toUtc } from 'src/utils/timezones'
|
||||
|
||||
const Graph = ({
|
||||
data,
|
||||
timezone,
|
||||
setSelectionCoords,
|
||||
setSelectionData,
|
||||
setSelectionDateInterval,
|
||||
selectedMachine
|
||||
}) => {
|
||||
const ref = useRef(null)
|
||||
|
||||
const GRAPH_POPOVER_WIDTH = 150
|
||||
const GRAPH_POPOVER_MARGIN = 25
|
||||
const BAR_MARGIN = 10
|
||||
const GRAPH_HEIGHT = 401
|
||||
const GRAPH_WIDTH = 1163
|
||||
const GRAPH_MARGIN = useMemo(
|
||||
() => ({
|
||||
top: 25,
|
||||
right: 0.5,
|
||||
bottom: 27,
|
||||
left: 36.5
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const offset = getTimezoneOffset(timezone)
|
||||
|
||||
const getTickIntervals = (domain, interval) => {
|
||||
const ticks = []
|
||||
const start = new Date(domain[0])
|
||||
const end = new Date(domain[1])
|
||||
|
||||
const step = R.clone(start)
|
||||
|
||||
while (step <= end) {
|
||||
ticks.push(R.clone(step))
|
||||
step.setUTCHours(step.getUTCHours() + interval)
|
||||
}
|
||||
|
||||
return ticks
|
||||
}
|
||||
|
||||
const filterByHourInterval = useCallback(
|
||||
(lowerBound, upperBound) =>
|
||||
R.filter(it => {
|
||||
const tzCreated = new Date(it.created).setTime(
|
||||
new Date(it.created).getTime() +
|
||||
new Date(it.created).getTimezoneOffset() * MINUTE +
|
||||
offset
|
||||
)
|
||||
const created = new Date(tzCreated)
|
||||
|
||||
return (
|
||||
(lowerBound.getUTCHours() < upperBound.getUTCHours() &&
|
||||
created.getUTCHours() >= new Date(lowerBound).getUTCHours() &&
|
||||
created.getUTCHours() < new Date(upperBound).getUTCHours()) ||
|
||||
(lowerBound.getUTCHours() > upperBound.getUTCHours() &&
|
||||
created.getUTCHours() <= new Date(lowerBound).getUTCHours() &&
|
||||
created.getUTCHours() < new Date(upperBound).getUTCHours())
|
||||
)
|
||||
}, data),
|
||||
[data, offset]
|
||||
)
|
||||
|
||||
const txClassByHourInterval = useCallback(
|
||||
(lowerBound, upperBound) =>
|
||||
R.reduce(
|
||||
(acc, value) => {
|
||||
if (value.txClass === 'cashIn')
|
||||
acc.cashIn += BigNumber(value.fiat).toNumber()
|
||||
if (value.txClass === 'cashOut')
|
||||
acc.cashOut += BigNumber(value.fiat).toNumber()
|
||||
return acc
|
||||
},
|
||||
{ cashIn: 0, cashOut: 0 },
|
||||
filterByHourInterval(lowerBound, upperBound)
|
||||
),
|
||||
[filterByHourInterval]
|
||||
)
|
||||
|
||||
const x = d3
|
||||
.scaleUtc()
|
||||
.domain([
|
||||
toUtc(startOfDay(new Date())),
|
||||
toUtc(add({ days: 1 }, startOfDay(new Date())))
|
||||
])
|
||||
.rangeRound([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
|
||||
|
||||
const groupedByDateInterval = R.map(
|
||||
it => {
|
||||
const lowerBound = R.clone(it)
|
||||
it.setUTCHours(it.getUTCHours() + 2)
|
||||
const upperBound = R.clone(it)
|
||||
return [lowerBound, filterByHourInterval(lowerBound, upperBound)]
|
||||
},
|
||||
R.init(getTickIntervals(x.domain(), 2))
|
||||
)
|
||||
|
||||
const groupedByTxClass = R.map(
|
||||
it => {
|
||||
const lowerBound = R.clone(it)
|
||||
it.setUTCHours(it.getUTCHours() + 2)
|
||||
const upperBound = R.clone(it)
|
||||
return [lowerBound, txClassByHourInterval(lowerBound, upperBound)]
|
||||
},
|
||||
R.init(getTickIntervals(x.domain(), 2))
|
||||
)
|
||||
|
||||
const y = d3
|
||||
.scaleLinear()
|
||||
.domain([
|
||||
0,
|
||||
d3.max(
|
||||
groupedByTxClass.map(it => it[1]),
|
||||
d => d.cashIn + d.cashOut
|
||||
) !== 0
|
||||
? d3.max(
|
||||
groupedByTxClass.map(it => it[1]),
|
||||
d => d.cashIn + d.cashOut
|
||||
)
|
||||
: 50
|
||||
])
|
||||
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||
|
||||
const buildXAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||
)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(d3.timeHour.every(2))
|
||||
.tickFormat(d3.timeFormat('%H:%M'))
|
||||
),
|
||||
[GRAPH_MARGIN, x]
|
||||
)
|
||||
|
||||
const buildYAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(GRAPH_HEIGHT / 100)
|
||||
.tickSize(0)
|
||||
.tickFormat(``)
|
||||
)
|
||||
.call(g => g.select('.domain').remove()),
|
||||
[GRAPH_MARGIN, y]
|
||||
)
|
||||
|
||||
const buildVerticalLines = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr('stroke', subheaderDarkColor)
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(getTickIntervals(x.domain(), 2))
|
||||
.join('line')
|
||||
.attr('x1', d => {
|
||||
const xValue = x(d)
|
||||
const intervals = getTickIntervals(x.domain(), 2)
|
||||
return xValue === x(intervals[R.length(intervals) - 1])
|
||||
? xValue - 1
|
||||
: 0.5 + xValue
|
||||
})
|
||||
.attr('x2', d => {
|
||||
const xValue = x(d)
|
||||
const intervals = getTickIntervals(x.domain(), 2)
|
||||
return xValue === x(intervals[R.length(intervals) - 1])
|
||||
? xValue - 1
|
||||
: 0.5 + xValue
|
||||
})
|
||||
.attr('y1', GRAPH_MARGIN.top)
|
||||
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom),
|
||||
[GRAPH_MARGIN, x]
|
||||
)
|
||||
|
||||
const buildHoverableEventRects = useCallback(
|
||||
g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(getTickIntervals(x.domain(), 2))
|
||||
.join('rect')
|
||||
.attr('x', d => x(d))
|
||||
.attr('y', GRAPH_MARGIN.top)
|
||||
.attr('width', d => {
|
||||
const xValue = Math.round(x(d) * 100) / 100
|
||||
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
|
||||
|
||||
const index = R.findIndex(it => it === xValue, ticks)
|
||||
const width =
|
||||
index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
|
||||
|
||||
return Math.round(width * 100) / 100
|
||||
})
|
||||
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('fill', 'transparent')
|
||||
.on('mouseover', d => {
|
||||
const date = R.clone(new Date(d.target.__data__))
|
||||
const startDate = R.clone(date)
|
||||
date.setUTCHours(date.getUTCHours() + 2)
|
||||
const endDate = R.clone(date)
|
||||
|
||||
const filteredData = groupedByDateInterval.find(it =>
|
||||
R.equals(startDate, it[0])
|
||||
)[1]
|
||||
|
||||
const rectXCoords = {
|
||||
left: R.clone(d.target.getBoundingClientRect().x),
|
||||
right: R.clone(
|
||||
d.target.getBoundingClientRect().x +
|
||||
d.target.getBoundingClientRect().width
|
||||
)
|
||||
}
|
||||
|
||||
const xCoord =
|
||||
d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH
|
||||
? rectXCoords.right + GRAPH_POPOVER_MARGIN
|
||||
: rectXCoords.left - GRAPH_POPOVER_WIDTH - GRAPH_POPOVER_MARGIN
|
||||
const yCoord = R.clone(d.target.getBoundingClientRect().y)
|
||||
|
||||
setSelectionDateInterval([endDate, startDate])
|
||||
setSelectionData(filteredData)
|
||||
setSelectionCoords({
|
||||
x: Math.round(xCoord),
|
||||
y: Math.round(yCoord)
|
||||
})
|
||||
|
||||
d3.select(`#event-rect-${x(d.target.__data__)}`).attr(
|
||||
'fill',
|
||||
subheaderColor
|
||||
)
|
||||
})
|
||||
.on('mouseleave', d => {
|
||||
d3.select(`#event-rect-${x(d.target.__data__)}`).attr(
|
||||
'fill',
|
||||
'transparent'
|
||||
)
|
||||
setSelectionDateInterval(null)
|
||||
setSelectionData(null)
|
||||
setSelectionCoords(null)
|
||||
}),
|
||||
[
|
||||
GRAPH_MARGIN,
|
||||
groupedByDateInterval,
|
||||
setSelectionCoords,
|
||||
setSelectionData,
|
||||
setSelectionDateInterval,
|
||||
x
|
||||
]
|
||||
)
|
||||
|
||||
const buildEventRects = useCallback(
|
||||
g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(getTickIntervals(x.domain(), 2))
|
||||
.join('rect')
|
||||
.attr('id', d => `event-rect-${x(d)}`)
|
||||
.attr('x', d => x(d))
|
||||
.attr('y', GRAPH_MARGIN.top)
|
||||
.attr('width', d => {
|
||||
const xValue = Math.round(x(d) * 100) / 100
|
||||
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
|
||||
|
||||
const index = R.findIndex(it => it === xValue, ticks)
|
||||
const width =
|
||||
index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
|
||||
|
||||
return Math.round(width * 100) / 100
|
||||
})
|
||||
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('fill', 'transparent'),
|
||||
[GRAPH_MARGIN, x]
|
||||
)
|
||||
|
||||
const formatTicksText = useCallback(
|
||||
() =>
|
||||
d3
|
||||
.selectAll('.tick text')
|
||||
.style('stroke', fontColor)
|
||||
.style('fill', fontColor)
|
||||
.style('stroke-width', 0.5)
|
||||
.style('font-family', fontSecondary),
|
||||
[]
|
||||
)
|
||||
|
||||
const drawCashIn = useCallback(
|
||||
g => {
|
||||
g.selectAll('rect')
|
||||
.data(R.init(getTickIntervals(x.domain(), 2)))
|
||||
.join('rect')
|
||||
.attr('stroke', java)
|
||||
.attr('fill', java)
|
||||
.attr('x', d => {
|
||||
return x(d) + BAR_MARGIN / 2
|
||||
})
|
||||
.attr('y', d => {
|
||||
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
|
||||
return y(interval[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom
|
||||
})
|
||||
.attr('height', d => {
|
||||
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
|
||||
return R.clamp(
|
||||
0,
|
||||
GRAPH_HEIGHT,
|
||||
GRAPH_HEIGHT -
|
||||
y(interval[1].cashIn) -
|
||||
GRAPH_MARGIN.bottom -
|
||||
BAR_MARGIN / 2
|
||||
)
|
||||
})
|
||||
.attr('width', d => {
|
||||
const xValue = Math.round(x(d) * 100) / 100
|
||||
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
|
||||
|
||||
const index = R.findIndex(it => it === xValue, ticks)
|
||||
const width =
|
||||
index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
|
||||
return Math.round((width - BAR_MARGIN) * 100) / 100
|
||||
})
|
||||
.attr('rx', 2.5)
|
||||
},
|
||||
[x, y, GRAPH_MARGIN, groupedByTxClass]
|
||||
)
|
||||
|
||||
const drawCashOut = useCallback(
|
||||
g => {
|
||||
g.selectAll('rect')
|
||||
.data(R.init(getTickIntervals(x.domain(), 2)))
|
||||
.join('rect')
|
||||
.attr('stroke', neon)
|
||||
.attr('fill', neon)
|
||||
.attr('x', d => {
|
||||
return x(d) + BAR_MARGIN / 2
|
||||
})
|
||||
.attr('y', d => {
|
||||
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
|
||||
return (
|
||||
y(interval[1].cashIn + interval[1].cashOut) -
|
||||
GRAPH_MARGIN.top +
|
||||
GRAPH_MARGIN.bottom
|
||||
)
|
||||
})
|
||||
.attr('height', d => {
|
||||
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
|
||||
return R.clamp(
|
||||
0,
|
||||
GRAPH_HEIGHT,
|
||||
GRAPH_HEIGHT -
|
||||
y(interval[1].cashOut) -
|
||||
GRAPH_MARGIN.bottom -
|
||||
BAR_MARGIN / 2
|
||||
)
|
||||
})
|
||||
.attr('width', d => {
|
||||
const xValue = Math.round(x(d) * 100) / 100
|
||||
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
|
||||
|
||||
const index = R.findIndex(it => it === xValue, ticks)
|
||||
const width =
|
||||
index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
|
||||
return Math.round((width - BAR_MARGIN) * 100) / 100
|
||||
})
|
||||
.attr('rx', 2.5)
|
||||
},
|
||||
[x, y, GRAPH_MARGIN, groupedByTxClass]
|
||||
)
|
||||
|
||||
const drawChart = useCallback(() => {
|
||||
const svg = d3
|
||||
.select(ref.current)
|
||||
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
|
||||
|
||||
svg.append('g').call(buildXAxis)
|
||||
svg.append('g').call(buildYAxis)
|
||||
svg.append('g').call(buildVerticalLines)
|
||||
svg.append('g').call(buildEventRects)
|
||||
svg.append('g').call(formatTicksText)
|
||||
svg.append('g').call(drawCashIn)
|
||||
svg.append('g').call(drawCashOut)
|
||||
svg.append('g').call(buildHoverableEventRects)
|
||||
|
||||
return svg.node()
|
||||
}, [
|
||||
buildXAxis,
|
||||
buildYAxis,
|
||||
buildEventRects,
|
||||
buildHoverableEventRects,
|
||||
buildVerticalLines,
|
||||
drawCashIn,
|
||||
formatTicksText,
|
||||
drawCashOut
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
d3.select(ref.current).selectAll('*').remove()
|
||||
drawChart()
|
||||
}, [drawChart])
|
||||
|
||||
return <svg ref={ref} />
|
||||
}
|
||||
|
||||
export default memo(
|
||||
Graph,
|
||||
(prev, next) =>
|
||||
R.equals(prev.period, next.period) &&
|
||||
R.equals(prev.selectedDay, next.selectedDay) &&
|
||||
R.equals(prev.selectedMachine, next.selectedMachine)
|
||||
)
|
||||
|
|
@ -0,0 +1,570 @@
|
|||
import BigNumber from 'bignumber.js'
|
||||
import * as d3 from 'd3'
|
||||
import { getTimezoneOffset } from 'date-fns-tz'
|
||||
import { add, format, startOfWeek, startOfYear } from 'date-fns/fp'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import {
|
||||
java,
|
||||
neon,
|
||||
subheaderDarkColor,
|
||||
offColor,
|
||||
fontColor,
|
||||
primaryColor,
|
||||
fontSecondary,
|
||||
subheaderColor
|
||||
} from 'src/styling/variables'
|
||||
import { numberToFiatAmount } from 'src/utils/number'
|
||||
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
|
||||
|
||||
const Graph = ({
|
||||
data,
|
||||
period,
|
||||
timezone,
|
||||
setSelectionCoords,
|
||||
setSelectionData,
|
||||
setSelectionDateInterval,
|
||||
log = false
|
||||
}) => {
|
||||
const ref = useRef(null)
|
||||
|
||||
const GRAPH_POPOVER_WIDTH = 150
|
||||
const GRAPH_POPOVER_MARGIN = 25
|
||||
const GRAPH_HEIGHT = 401
|
||||
const GRAPH_WIDTH = 1163
|
||||
const GRAPH_MARGIN = useMemo(
|
||||
() => ({
|
||||
top: 25,
|
||||
right: 3.5,
|
||||
bottom: 27,
|
||||
left: 38
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const offset = getTimezoneOffset(timezone)
|
||||
const NOW = Date.now() + offset
|
||||
|
||||
const periodDomains = {
|
||||
day: [NOW - DAY, NOW],
|
||||
threeDays: [NOW - 3 * DAY, NOW],
|
||||
week: [NOW - WEEK, NOW],
|
||||
month: [NOW - MONTH, NOW]
|
||||
}
|
||||
|
||||
const dataPoints = useMemo(
|
||||
() => ({
|
||||
day: {
|
||||
freq: 24,
|
||||
step: 60 * 60 * 1000,
|
||||
tick: d3.utcHour.every(1),
|
||||
labelFormat: '%H:%M'
|
||||
},
|
||||
threeDays: {
|
||||
freq: 12,
|
||||
step: 6 * 60 * 60 * 1000,
|
||||
tick: d3.utcDay.every(1),
|
||||
labelFormat: '%a %d'
|
||||
},
|
||||
week: {
|
||||
freq: 7,
|
||||
step: 24 * 60 * 60 * 1000,
|
||||
tick: d3.utcDay.every(1),
|
||||
labelFormat: '%a %d'
|
||||
},
|
||||
month: {
|
||||
freq: 30,
|
||||
step: 24 * 60 * 60 * 1000,
|
||||
tick: d3.utcDay.every(1),
|
||||
labelFormat: '%d'
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const getPastAndCurrentDayLabels = useCallback(d => {
|
||||
const currentDate = new Date(d)
|
||||
const currentDateDay = currentDate.getUTCDate()
|
||||
const currentDateWeekday = currentDate.getUTCDay()
|
||||
const currentDateMonth = currentDate.getUTCMonth()
|
||||
|
||||
const previousDate = new Date(currentDate.getTime())
|
||||
previousDate.setUTCDate(currentDateDay - 1)
|
||||
|
||||
const previousDateDay = previousDate.getUTCDate()
|
||||
const previousDateWeekday = previousDate.getUTCDay()
|
||||
const previousDateMonth = previousDate.getUTCMonth()
|
||||
|
||||
const daysOfWeek = Array.from(Array(7)).map((_, i) =>
|
||||
format('EEE', add({ days: i }, startOfWeek(new Date())))
|
||||
)
|
||||
|
||||
const months = Array.from(Array(12)).map((_, i) =>
|
||||
format('LLL', add({ months: i }, startOfYear(new Date())))
|
||||
)
|
||||
|
||||
return {
|
||||
previous:
|
||||
currentDateMonth !== previousDateMonth
|
||||
? months[previousDateMonth]
|
||||
: `${daysOfWeek[previousDateWeekday]} ${previousDateDay}`,
|
||||
current:
|
||||
currentDateMonth !== previousDateMonth
|
||||
? months[currentDateMonth]
|
||||
: `${daysOfWeek[currentDateWeekday]} ${currentDateDay}`
|
||||
}
|
||||
}, [])
|
||||
|
||||
const buildTicks = useCallback(
|
||||
domain => {
|
||||
const points = []
|
||||
|
||||
const roundDate = d => {
|
||||
const step = dataPoints[period.code].step
|
||||
return new Date(Math.ceil(d.valueOf() / step) * step)
|
||||
}
|
||||
|
||||
for (let i = 0; i <= dataPoints[period.code].freq; i++) {
|
||||
const stepDate = new Date(NOW - i * dataPoints[period.code].step)
|
||||
if (roundDate(stepDate) > domain[1]) continue
|
||||
if (stepDate < domain[0]) continue
|
||||
points.push(roundDate(stepDate))
|
||||
}
|
||||
|
||||
return points
|
||||
},
|
||||
[NOW, dataPoints, period.code]
|
||||
)
|
||||
|
||||
const buildAreas = useCallback(
|
||||
domain => {
|
||||
const points = []
|
||||
|
||||
points.push(domain[1])
|
||||
|
||||
const roundDate = d => {
|
||||
const step = dataPoints[period.code].step
|
||||
return new Date(Math.ceil(d.valueOf() / step) * step)
|
||||
}
|
||||
|
||||
for (let i = 0; i <= dataPoints[period.code].freq; i++) {
|
||||
const stepDate = new Date(NOW - i * dataPoints[period.code].step)
|
||||
if (roundDate(stepDate) > new Date(domain[1])) continue
|
||||
if (stepDate < new Date(domain[0])) continue
|
||||
points.push(roundDate(stepDate))
|
||||
}
|
||||
|
||||
points.push(domain[0])
|
||||
|
||||
return points
|
||||
},
|
||||
[NOW, dataPoints, period.code]
|
||||
)
|
||||
|
||||
const x = d3
|
||||
.scaleUtc()
|
||||
.domain(periodDomains[period.code])
|
||||
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
|
||||
|
||||
// Create a second X axis for mouseover events to be placed correctly across the entire graph width and not limited by X's domain
|
||||
const x2 = d3
|
||||
.scaleUtc()
|
||||
.domain(periodDomains[period.code])
|
||||
.range([GRAPH_MARGIN.left, GRAPH_WIDTH])
|
||||
|
||||
const yLin = d3
|
||||
.scaleLinear()
|
||||
.domain([
|
||||
0,
|
||||
(d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.03
|
||||
])
|
||||
.nice()
|
||||
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||
|
||||
const yLog = d3
|
||||
.scaleLog()
|
||||
.domain([
|
||||
(d3.min(data, d => new BigNumber(d.fiat).toNumber()) ?? 1) * 0.9,
|
||||
(d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.1
|
||||
])
|
||||
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||
|
||||
const y = log ? yLog : yLin
|
||||
|
||||
const getAreaInterval = (breakpoints, dataLimits, graphLimits) => {
|
||||
const fullBreakpoints = [
|
||||
graphLimits[1],
|
||||
...R.filter(it => it > dataLimits[0] && it < dataLimits[1], breakpoints),
|
||||
dataLimits[0]
|
||||
]
|
||||
|
||||
const intervals = []
|
||||
for (let i = 0; i < fullBreakpoints.length - 1; i++) {
|
||||
intervals.push([fullBreakpoints[i], fullBreakpoints[i + 1]])
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
const getAreaIntervalByX = (intervals, xValue) => {
|
||||
return R.find(it => xValue <= it[0] && xValue >= it[1], intervals) ?? [0, 0]
|
||||
}
|
||||
|
||||
const getDateIntervalByX = (areas, intervals, xValue) => {
|
||||
const flattenIntervals = R.uniq(R.flatten(intervals))
|
||||
|
||||
// flattenIntervals and areas should have the same number of elements
|
||||
for (let i = intervals.length - 1; i >= 0; i--) {
|
||||
if (xValue < flattenIntervals[i]) {
|
||||
return [areas[i], areas[i + 1]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildXAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||
)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(dataPoints[period.code].tick)
|
||||
.tickFormat(d => {
|
||||
return d3.timeFormat(dataPoints[period.code].labelFormat)(
|
||||
d.getTime() + d.getTimezoneOffset() * MINUTE
|
||||
)
|
||||
})
|
||||
.tickSizeOuter(0)
|
||||
)
|
||||
.call(g =>
|
||||
g
|
||||
.select('.domain')
|
||||
.attr('stroke', primaryColor)
|
||||
.attr('stroke-width', 1)
|
||||
),
|
||||
[GRAPH_MARGIN, dataPoints, period.code, x]
|
||||
)
|
||||
|
||||
const buildYAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(GRAPH_HEIGHT / 100)
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat(d => {
|
||||
if (log && !['1', '2', '5'].includes(d.toString()[0])) return ''
|
||||
|
||||
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
|
||||
|
||||
return numberToFiatAmount(d)
|
||||
})
|
||||
)
|
||||
.select('.domain')
|
||||
.attr('stroke', primaryColor)
|
||||
.attr('stroke-width', 1),
|
||||
[GRAPH_MARGIN, y, log]
|
||||
)
|
||||
|
||||
const buildGrid = useCallback(
|
||||
g => {
|
||||
g.attr('stroke', subheaderDarkColor)
|
||||
.attr('fill', subheaderDarkColor)
|
||||
// Vertical lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(buildTicks(x.domain()))
|
||||
.join('line')
|
||||
.attr('x1', d => 0.5 + x(d))
|
||||
.attr('x2', d => 0.5 + x(d))
|
||||
.attr('y1', GRAPH_MARGIN.top)
|
||||
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
|
||||
)
|
||||
// Horizontal lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.scale()
|
||||
.ticks(GRAPH_HEIGHT / 100)
|
||||
)
|
||||
.join('line')
|
||||
.attr('y1', d => 0.5 + y(d))
|
||||
.attr('y2', d => 0.5 + y(d))
|
||||
.attr('x1', GRAPH_MARGIN.left)
|
||||
.attr('x2', GRAPH_WIDTH)
|
||||
)
|
||||
// Vertical transparent rectangles for events
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(buildAreas(x.domain()))
|
||||
.join('rect')
|
||||
.attr('x', d => x(d))
|
||||
.attr('y', GRAPH_MARGIN.top)
|
||||
.attr('width', d => {
|
||||
const xValue = Math.round(x(d) * 100) / 100
|
||||
const intervals = getAreaInterval(
|
||||
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
|
||||
x.range(),
|
||||
x2.range()
|
||||
)
|
||||
const interval = getAreaIntervalByX(intervals, xValue)
|
||||
return Math.round((interval[0] - interval[1]) * 100) / 100
|
||||
})
|
||||
.attr(
|
||||
'height',
|
||||
GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top
|
||||
)
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('fill', 'transparent')
|
||||
.on('mouseover', d => {
|
||||
const xValue = Math.round(d.target.x.baseVal.value * 100) / 100
|
||||
const areas = buildAreas(x.domain())
|
||||
const intervals = getAreaInterval(
|
||||
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
|
||||
x.range(),
|
||||
x2.range()
|
||||
)
|
||||
|
||||
const dateInterval = getDateIntervalByX(areas, intervals, xValue)
|
||||
if (!dateInterval) return
|
||||
const filteredData = data.filter(it => {
|
||||
const created = new Date(it.created)
|
||||
const tzCreated = created.setTime(created.getTime() + offset)
|
||||
return (
|
||||
tzCreated > new Date(dateInterval[1]) &&
|
||||
tzCreated <= new Date(dateInterval[0])
|
||||
)
|
||||
})
|
||||
|
||||
const rectXCoords = {
|
||||
left: R.clone(d.target.getBoundingClientRect().x),
|
||||
right: R.clone(
|
||||
d.target.getBoundingClientRect().x +
|
||||
d.target.getBoundingClientRect().width
|
||||
)
|
||||
}
|
||||
|
||||
const xCoord =
|
||||
d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH
|
||||
? rectXCoords.right + GRAPH_POPOVER_MARGIN
|
||||
: rectXCoords.left -
|
||||
GRAPH_POPOVER_WIDTH -
|
||||
GRAPH_POPOVER_MARGIN
|
||||
const yCoord = R.clone(d.target.getBoundingClientRect().y)
|
||||
|
||||
setSelectionDateInterval(dateInterval)
|
||||
setSelectionData(filteredData)
|
||||
setSelectionCoords({
|
||||
x: Math.round(xCoord),
|
||||
y: Math.round(yCoord)
|
||||
})
|
||||
|
||||
d3.select(d.target).attr('fill', subheaderColor)
|
||||
})
|
||||
.on('mouseleave', d => {
|
||||
d3.select(d.target).attr('fill', 'transparent')
|
||||
setSelectionDateInterval(null)
|
||||
setSelectionData(null)
|
||||
setSelectionCoords(null)
|
||||
})
|
||||
)
|
||||
// Thick vertical lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(
|
||||
buildTicks(x.domain()).filter(x => {
|
||||
if (period.code === 'day') return x.getUTCHours() === 0
|
||||
return x.getUTCDate() === 1
|
||||
})
|
||||
)
|
||||
.join('line')
|
||||
.attr('class', 'dateSeparator')
|
||||
.attr('x1', d => 0.5 + x(d))
|
||||
.attr('x2', d => 0.5 + x(d))
|
||||
.attr('y1', GRAPH_MARGIN.top - 50)
|
||||
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
|
||||
.attr('stroke-width', 5)
|
||||
.join('text')
|
||||
)
|
||||
// Left side breakpoint label
|
||||
.call(g => {
|
||||
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
|
||||
|
||||
if (!separator) return
|
||||
|
||||
const breakpoint = buildTicks(x.domain()).filter(x => {
|
||||
if (period.code === 'day') return x.getUTCHours() === 0
|
||||
return x.getUTCDate() === 1
|
||||
})
|
||||
|
||||
const labels = getPastAndCurrentDayLabels(breakpoint)
|
||||
|
||||
return g
|
||||
.append('text')
|
||||
.attr('x', separator.x - 10)
|
||||
.attr('y', separator.y + 33)
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('dy', '.25em')
|
||||
.text(labels.previous)
|
||||
})
|
||||
// Right side breakpoint label
|
||||
.call(g => {
|
||||
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
|
||||
|
||||
if (!separator) return
|
||||
|
||||
const breakpoint = buildTicks(x.domain()).filter(x => {
|
||||
if (period.code === 'day') return x.getUTCHours() === 0
|
||||
return x.getUTCDate() === 1
|
||||
})
|
||||
|
||||
const labels = getPastAndCurrentDayLabels(breakpoint)
|
||||
|
||||
return g
|
||||
.append('text')
|
||||
.attr('x', separator.x + 10)
|
||||
.attr('y', separator.y + 33)
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('dy', '.25em')
|
||||
.text(labels.current)
|
||||
})
|
||||
},
|
||||
[
|
||||
GRAPH_MARGIN,
|
||||
buildTicks,
|
||||
getPastAndCurrentDayLabels,
|
||||
x,
|
||||
x2,
|
||||
y,
|
||||
period,
|
||||
buildAreas,
|
||||
data,
|
||||
offset,
|
||||
setSelectionCoords,
|
||||
setSelectionData,
|
||||
setSelectionDateInterval
|
||||
]
|
||||
)
|
||||
|
||||
const formatTicksText = useCallback(
|
||||
() =>
|
||||
d3
|
||||
.selectAll('.tick text')
|
||||
.style('stroke', fontColor)
|
||||
.style('fill', fontColor)
|
||||
.style('stroke-width', 0.5)
|
||||
.style('font-family', fontSecondary),
|
||||
[]
|
||||
)
|
||||
|
||||
const formatText = useCallback(
|
||||
() =>
|
||||
d3
|
||||
.selectAll('text')
|
||||
.style('stroke', offColor)
|
||||
.style('fill', offColor)
|
||||
.style('stroke-width', 0.5)
|
||||
.style('font-family', fontSecondary),
|
||||
[]
|
||||
)
|
||||
|
||||
const formatTicks = useCallback(() => {
|
||||
d3.selectAll('.tick line')
|
||||
.style('stroke', primaryColor)
|
||||
.style('fill', primaryColor)
|
||||
}, [])
|
||||
|
||||
const buildAvg = useCallback(
|
||||
g => {
|
||||
const median = d3.median(data, d => new BigNumber(d.fiat).toNumber()) ?? 0
|
||||
|
||||
if (log && median === 0) return
|
||||
|
||||
g.attr('stroke', primaryColor)
|
||||
.attr('stroke-width', 3)
|
||||
.attr('stroke-dasharray', '10, 5')
|
||||
.call(g =>
|
||||
g
|
||||
.append('line')
|
||||
.attr('y1', 0.5 + y(median))
|
||||
.attr('y2', 0.5 + y(median))
|
||||
.attr('x1', GRAPH_MARGIN.left)
|
||||
.attr('x2', GRAPH_WIDTH)
|
||||
)
|
||||
},
|
||||
[GRAPH_MARGIN, y, data, log]
|
||||
)
|
||||
|
||||
const drawData = useCallback(
|
||||
g => {
|
||||
g.selectAll('circle')
|
||||
.data(data)
|
||||
.join('circle')
|
||||
.attr('cx', d => {
|
||||
const created = new Date(d.created)
|
||||
return x(created.setTime(created.getTime() + offset))
|
||||
})
|
||||
.attr('cy', d => y(new BigNumber(d.fiat).toNumber()))
|
||||
.attr('fill', d => (d.txClass === 'cashIn' ? java : neon))
|
||||
.attr('r', 3.5)
|
||||
},
|
||||
[data, offset, x, y]
|
||||
)
|
||||
|
||||
const drawChart = useCallback(() => {
|
||||
const svg = d3
|
||||
.select(ref.current)
|
||||
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
|
||||
|
||||
svg.append('g').call(buildGrid)
|
||||
svg.append('g').call(buildAvg)
|
||||
svg.append('g').call(buildXAxis)
|
||||
svg.append('g').call(buildYAxis)
|
||||
svg.append('g').call(formatTicksText)
|
||||
svg.append('g').call(formatText)
|
||||
svg.append('g').call(formatTicks)
|
||||
svg.append('g').call(drawData)
|
||||
|
||||
return svg.node()
|
||||
}, [
|
||||
buildAvg,
|
||||
buildGrid,
|
||||
buildXAxis,
|
||||
buildYAxis,
|
||||
drawData,
|
||||
formatText,
|
||||
formatTicks,
|
||||
formatTicksText
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
d3.select(ref.current).selectAll('*').remove()
|
||||
drawChart()
|
||||
}, [drawChart])
|
||||
|
||||
return <svg ref={ref} />
|
||||
}
|
||||
|
||||
export default memo(
|
||||
Graph,
|
||||
(prev, next) =>
|
||||
R.equals(prev.period, next.period) &&
|
||||
R.equals(prev.selectedMachine, next.selectedMachine) &&
|
||||
R.equals(prev.log, next.log)
|
||||
)
|
||||
|
|
@ -0,0 +1,646 @@
|
|||
import BigNumber from 'bignumber.js'
|
||||
import * as d3 from 'd3'
|
||||
import { getTimezoneOffset } from 'date-fns-tz'
|
||||
import {
|
||||
add,
|
||||
addMilliseconds,
|
||||
compareDesc,
|
||||
differenceInMilliseconds,
|
||||
format,
|
||||
startOfWeek,
|
||||
startOfYear
|
||||
} from 'date-fns/fp'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import {
|
||||
java,
|
||||
neon,
|
||||
subheaderDarkColor,
|
||||
offColor,
|
||||
fontColor,
|
||||
primaryColor,
|
||||
fontSecondary,
|
||||
subheaderColor
|
||||
} from 'src/styling/variables'
|
||||
import { numberToFiatAmount } from 'src/utils/number'
|
||||
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
|
||||
|
||||
const Graph = ({
|
||||
data,
|
||||
period,
|
||||
timezone,
|
||||
setSelectionCoords,
|
||||
setSelectionData,
|
||||
setSelectionDateInterval,
|
||||
log = false
|
||||
}) => {
|
||||
const ref = useRef(null)
|
||||
|
||||
const GRAPH_POPOVER_WIDTH = 150
|
||||
const GRAPH_POPOVER_MARGIN = 25
|
||||
const GRAPH_HEIGHT = 401
|
||||
const GRAPH_WIDTH = 1163
|
||||
const GRAPH_MARGIN = useMemo(
|
||||
() => ({
|
||||
top: 25,
|
||||
right: 3.5,
|
||||
bottom: 27,
|
||||
left: 38
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const offset = getTimezoneOffset(timezone)
|
||||
const NOW = Date.now() + offset
|
||||
|
||||
const periodDomains = {
|
||||
day: [NOW - DAY, NOW],
|
||||
threeDays: [NOW - 3 * DAY, NOW],
|
||||
week: [NOW - WEEK, NOW],
|
||||
month: [NOW - MONTH, NOW]
|
||||
}
|
||||
|
||||
const dataPoints = useMemo(
|
||||
() => ({
|
||||
day: {
|
||||
freq: 24,
|
||||
step: 60 * 60 * 1000,
|
||||
tick: d3.utcHour.every(1),
|
||||
labelFormat: '%H:%M'
|
||||
},
|
||||
threeDays: {
|
||||
freq: 12,
|
||||
step: 6 * 60 * 60 * 1000,
|
||||
tick: d3.utcDay.every(1),
|
||||
labelFormat: '%a %d'
|
||||
},
|
||||
week: {
|
||||
freq: 7,
|
||||
step: 24 * 60 * 60 * 1000,
|
||||
tick: d3.utcDay.every(1),
|
||||
labelFormat: '%a %d'
|
||||
},
|
||||
month: {
|
||||
freq: 30,
|
||||
step: 24 * 60 * 60 * 1000,
|
||||
tick: d3.utcDay.every(1),
|
||||
labelFormat: '%d'
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const getPastAndCurrentDayLabels = useCallback(d => {
|
||||
const currentDate = new Date(d)
|
||||
const currentDateDay = currentDate.getUTCDate()
|
||||
const currentDateWeekday = currentDate.getUTCDay()
|
||||
const currentDateMonth = currentDate.getUTCMonth()
|
||||
|
||||
const previousDate = new Date(currentDate.getTime())
|
||||
previousDate.setUTCDate(currentDateDay - 1)
|
||||
|
||||
const previousDateDay = previousDate.getUTCDate()
|
||||
const previousDateWeekday = previousDate.getUTCDay()
|
||||
const previousDateMonth = previousDate.getUTCMonth()
|
||||
|
||||
const daysOfWeek = Array.from(Array(7)).map((_, i) =>
|
||||
format('EEE', add({ days: i }, startOfWeek(new Date())))
|
||||
)
|
||||
|
||||
const months = Array.from(Array(12)).map((_, i) =>
|
||||
format('LLL', add({ months: i }, startOfYear(new Date())))
|
||||
)
|
||||
|
||||
return {
|
||||
previous:
|
||||
currentDateMonth !== previousDateMonth
|
||||
? months[previousDateMonth]
|
||||
: `${daysOfWeek[previousDateWeekday]} ${previousDateDay}`,
|
||||
current:
|
||||
currentDateMonth !== previousDateMonth
|
||||
? months[currentDateMonth]
|
||||
: `${daysOfWeek[currentDateWeekday]} ${currentDateDay}`
|
||||
}
|
||||
}, [])
|
||||
|
||||
const buildTicks = useCallback(
|
||||
domain => {
|
||||
const points = []
|
||||
|
||||
const roundDate = d => {
|
||||
const step = dataPoints[period.code].step
|
||||
return new Date(Math.ceil(d.valueOf() / step) * step)
|
||||
}
|
||||
|
||||
for (let i = 0; i <= dataPoints[period.code].freq; i++) {
|
||||
const stepDate = new Date(NOW - i * dataPoints[period.code].step)
|
||||
if (roundDate(stepDate) > domain[1]) continue
|
||||
if (stepDate < domain[0]) continue
|
||||
points.push(roundDate(stepDate))
|
||||
}
|
||||
|
||||
return points
|
||||
},
|
||||
[NOW, dataPoints, period.code]
|
||||
)
|
||||
|
||||
const buildAreas = useCallback(
|
||||
domain => {
|
||||
const points = []
|
||||
|
||||
points.push(domain[1])
|
||||
|
||||
const roundDate = d => {
|
||||
const step = dataPoints[period.code].step
|
||||
return new Date(Math.ceil(d.valueOf() / step) * step)
|
||||
}
|
||||
|
||||
for (let i = 0; i <= dataPoints[period.code].freq; i++) {
|
||||
const stepDate = new Date(NOW - i * dataPoints[period.code].step)
|
||||
if (roundDate(stepDate) > new Date(domain[1])) continue
|
||||
if (stepDate < new Date(domain[0])) continue
|
||||
points.push(roundDate(stepDate))
|
||||
}
|
||||
|
||||
points.push(domain[0])
|
||||
|
||||
return points
|
||||
},
|
||||
[NOW, dataPoints, period.code]
|
||||
)
|
||||
|
||||
const x = d3
|
||||
.scaleUtc()
|
||||
.domain(periodDomains[period.code])
|
||||
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
|
||||
|
||||
// Create a second X axis for mouseover events to be placed correctly across the entire graph width and not limited by X's domain
|
||||
const x2 = d3
|
||||
.scaleUtc()
|
||||
.domain(periodDomains[period.code])
|
||||
.range([GRAPH_MARGIN.left, GRAPH_WIDTH])
|
||||
|
||||
const bins = buildAreas(x.domain())
|
||||
.sort((a, b) => compareDesc(a.date, b.date))
|
||||
.map(addMilliseconds(-dataPoints[period.code].step))
|
||||
.map((date, i, dates) => {
|
||||
// move first and last bin in such way
|
||||
// that all bin have uniform width
|
||||
if (i === 0)
|
||||
return addMilliseconds(dataPoints[period.code].step, dates[1])
|
||||
else if (i === dates.length - 1)
|
||||
return addMilliseconds(
|
||||
-dataPoints[period.code].step,
|
||||
dates[dates.length - 2]
|
||||
)
|
||||
else return date
|
||||
})
|
||||
.map(date => {
|
||||
const middleOfBin = addMilliseconds(
|
||||
dataPoints[period.code].step / 2,
|
||||
date
|
||||
)
|
||||
|
||||
const txs = data.filter(tx => {
|
||||
const txCreated = new Date(tx.created)
|
||||
const shift = new Date(txCreated.getTime() + offset)
|
||||
|
||||
return (
|
||||
Math.abs(differenceInMilliseconds(shift, middleOfBin)) <
|
||||
dataPoints[period.code].step / 2
|
||||
)
|
||||
})
|
||||
|
||||
const cashIn = txs
|
||||
.filter(tx => tx.txClass === 'cashIn')
|
||||
.reduce((sum, tx) => sum + new BigNumber(tx.fiat).toNumber(), 0)
|
||||
|
||||
const cashOut = txs
|
||||
.filter(tx => tx.txClass === 'cashOut')
|
||||
.reduce((sum, tx) => sum + new BigNumber(tx.fiat).toNumber(), 0)
|
||||
|
||||
return { date: middleOfBin, cashIn, cashOut }
|
||||
})
|
||||
|
||||
const min = d3.min(bins, d => Math.min(d.cashIn, d.cashOut)) ?? 0
|
||||
const max = d3.max(bins, d => Math.max(d.cashIn, d.cashOut)) ?? 1000
|
||||
|
||||
const yLin = d3
|
||||
.scaleLinear()
|
||||
.domain([0, (max === min ? min + 1000 : max) * 1.03])
|
||||
.nice()
|
||||
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||
|
||||
const yLog = d3
|
||||
.scaleLog()
|
||||
.domain([
|
||||
min === 0 ? 0.9 : min * 0.9,
|
||||
(max === min ? min + Math.pow(10, 2 * min + 1) : max) * 2
|
||||
])
|
||||
.clamp(true)
|
||||
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||
|
||||
const y = log ? yLog : yLin
|
||||
|
||||
const getAreaInterval = (breakpoints, dataLimits, graphLimits) => {
|
||||
const fullBreakpoints = [
|
||||
graphLimits[1],
|
||||
...R.filter(it => it > dataLimits[0] && it < dataLimits[1], breakpoints),
|
||||
dataLimits[0]
|
||||
]
|
||||
|
||||
const intervals = []
|
||||
for (let i = 0; i < fullBreakpoints.length - 1; i++) {
|
||||
intervals.push([fullBreakpoints[i], fullBreakpoints[i + 1]])
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
const getAreaIntervalByX = (intervals, xValue) => {
|
||||
return R.find(it => xValue <= it[0] && xValue >= it[1], intervals) ?? [0, 0]
|
||||
}
|
||||
|
||||
const getDateIntervalByX = (areas, intervals, xValue) => {
|
||||
const flattenIntervals = R.uniq(R.flatten(intervals))
|
||||
|
||||
// flattenIntervals and areas should have the same number of elements
|
||||
for (let i = intervals.length - 1; i >= 0; i--) {
|
||||
if (xValue < flattenIntervals[i]) {
|
||||
return [areas[i], areas[i + 1]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildXAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||
)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(dataPoints[period.code].tick)
|
||||
.tickFormat(d => {
|
||||
return d3.timeFormat(dataPoints[period.code].labelFormat)(
|
||||
d.getTime() + d.getTimezoneOffset() * MINUTE
|
||||
)
|
||||
})
|
||||
.tickSizeOuter(0)
|
||||
)
|
||||
.call(g =>
|
||||
g
|
||||
.select('.domain')
|
||||
.attr('stroke', primaryColor)
|
||||
.attr('stroke-width', 1)
|
||||
),
|
||||
[GRAPH_MARGIN, dataPoints, period.code, x]
|
||||
)
|
||||
|
||||
const buildYAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(GRAPH_HEIGHT / 100)
|
||||
.tickSizeOuter(0)
|
||||
.tickFormat(d => {
|
||||
if (log && !['1', '2', '5'].includes(d.toString()[0])) return ''
|
||||
|
||||
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
|
||||
|
||||
return numberToFiatAmount(d)
|
||||
})
|
||||
)
|
||||
.select('.domain')
|
||||
.attr('stroke', primaryColor)
|
||||
.attr('stroke-width', 1),
|
||||
[GRAPH_MARGIN, y, log]
|
||||
)
|
||||
|
||||
const buildGrid = useCallback(
|
||||
g => {
|
||||
g.attr('stroke', subheaderDarkColor)
|
||||
.attr('fill', subheaderDarkColor)
|
||||
// Vertical lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(buildTicks(x.domain()))
|
||||
.join('line')
|
||||
.attr('x1', d => 0.5 + x(d))
|
||||
.attr('x2', d => 0.5 + x(d))
|
||||
.attr('y1', GRAPH_MARGIN.top)
|
||||
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
|
||||
)
|
||||
// Horizontal lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.scale()
|
||||
.ticks(GRAPH_HEIGHT / 100)
|
||||
)
|
||||
.join('line')
|
||||
.attr('y1', d => 0.5 + y(d))
|
||||
.attr('y2', d => 0.5 + y(d))
|
||||
.attr('x1', GRAPH_MARGIN.left)
|
||||
.attr('x2', GRAPH_WIDTH)
|
||||
)
|
||||
// Vertical transparent rectangles for events
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(buildAreas(x.domain()))
|
||||
.join('rect')
|
||||
.attr('x', d => x(d))
|
||||
.attr('y', GRAPH_MARGIN.top)
|
||||
.attr('width', d => {
|
||||
const xValue = Math.round(x(d) * 100) / 100
|
||||
const intervals = getAreaInterval(
|
||||
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
|
||||
x.range(),
|
||||
x2.range()
|
||||
)
|
||||
const interval = getAreaIntervalByX(intervals, xValue)
|
||||
return Math.round((interval[0] - interval[1]) * 100) / 100
|
||||
})
|
||||
.attr(
|
||||
'height',
|
||||
GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top
|
||||
)
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('fill', 'transparent')
|
||||
.on('mouseover', d => {
|
||||
const xValue = Math.round(d.target.x.baseVal.value * 100) / 100
|
||||
const areas = buildAreas(x.domain())
|
||||
const intervals = getAreaInterval(
|
||||
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
|
||||
x.range(),
|
||||
x2.range()
|
||||
)
|
||||
|
||||
const dateInterval = getDateIntervalByX(areas, intervals, xValue)
|
||||
if (!dateInterval) return
|
||||
const filteredData = data.filter(it => {
|
||||
const created = new Date(it.created)
|
||||
const tzCreated = created.setTime(created.getTime() + offset)
|
||||
return (
|
||||
tzCreated > new Date(dateInterval[1]) &&
|
||||
tzCreated <= new Date(dateInterval[0])
|
||||
)
|
||||
})
|
||||
|
||||
const rectXCoords = {
|
||||
left: R.clone(d.target.getBoundingClientRect().x),
|
||||
right: R.clone(
|
||||
d.target.getBoundingClientRect().x +
|
||||
d.target.getBoundingClientRect().width
|
||||
)
|
||||
}
|
||||
|
||||
const xCoord =
|
||||
d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH
|
||||
? rectXCoords.right + GRAPH_POPOVER_MARGIN
|
||||
: rectXCoords.left -
|
||||
GRAPH_POPOVER_WIDTH -
|
||||
GRAPH_POPOVER_MARGIN
|
||||
const yCoord = R.clone(d.target.getBoundingClientRect().y)
|
||||
|
||||
setSelectionDateInterval(dateInterval)
|
||||
setSelectionData(filteredData)
|
||||
setSelectionCoords({
|
||||
x: Math.round(xCoord),
|
||||
y: Math.round(yCoord)
|
||||
})
|
||||
|
||||
d3.select(d.target).attr('fill', subheaderColor)
|
||||
})
|
||||
.on('mouseleave', d => {
|
||||
d3.select(d.target).attr('fill', 'transparent')
|
||||
setSelectionDateInterval(null)
|
||||
setSelectionData(null)
|
||||
setSelectionCoords(null)
|
||||
})
|
||||
)
|
||||
// Thick vertical lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(
|
||||
buildTicks(x.domain()).filter(x => {
|
||||
if (period.code === 'day') return x.getUTCHours() === 0
|
||||
return x.getUTCDate() === 1
|
||||
})
|
||||
)
|
||||
.join('line')
|
||||
.attr('class', 'dateSeparator')
|
||||
.attr('x1', d => 0.5 + x(d))
|
||||
.attr('x2', d => 0.5 + x(d))
|
||||
.attr('y1', GRAPH_MARGIN.top - 50)
|
||||
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
|
||||
.attr('stroke-width', 5)
|
||||
.join('text')
|
||||
)
|
||||
// Left side breakpoint label
|
||||
.call(g => {
|
||||
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
|
||||
|
||||
if (!separator) return
|
||||
|
||||
const breakpoint = buildTicks(x.domain()).filter(x => {
|
||||
if (period.code === 'day') return x.getUTCHours() === 0
|
||||
return x.getUTCDate() === 1
|
||||
})
|
||||
|
||||
const labels = getPastAndCurrentDayLabels(breakpoint)
|
||||
|
||||
return g
|
||||
.append('text')
|
||||
.attr('x', separator.x - 10)
|
||||
.attr('y', separator.y + 33)
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('dy', '.25em')
|
||||
.text(labels.previous)
|
||||
})
|
||||
// Right side breakpoint label
|
||||
.call(g => {
|
||||
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
|
||||
|
||||
if (!separator) return
|
||||
|
||||
const breakpoint = buildTicks(x.domain()).filter(x => {
|
||||
if (period.code === 'day') return x.getUTCHours() === 0
|
||||
return x.getUTCDate() === 1
|
||||
})
|
||||
|
||||
const labels = getPastAndCurrentDayLabels(breakpoint)
|
||||
|
||||
return g
|
||||
.append('text')
|
||||
.attr('x', separator.x + 10)
|
||||
.attr('y', separator.y + 33)
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('dy', '.25em')
|
||||
.text(labels.current)
|
||||
})
|
||||
},
|
||||
[
|
||||
GRAPH_MARGIN,
|
||||
buildTicks,
|
||||
getPastAndCurrentDayLabels,
|
||||
x,
|
||||
x2,
|
||||
y,
|
||||
period,
|
||||
buildAreas,
|
||||
data,
|
||||
offset,
|
||||
setSelectionCoords,
|
||||
setSelectionData,
|
||||
setSelectionDateInterval
|
||||
]
|
||||
)
|
||||
|
||||
const formatTicksText = useCallback(
|
||||
() =>
|
||||
d3
|
||||
.selectAll('.tick text')
|
||||
.style('stroke', fontColor)
|
||||
.style('fill', fontColor)
|
||||
.style('stroke-width', 0.5)
|
||||
.style('font-family', fontSecondary),
|
||||
[]
|
||||
)
|
||||
|
||||
const formatText = useCallback(
|
||||
() =>
|
||||
d3
|
||||
.selectAll('text')
|
||||
.style('stroke', offColor)
|
||||
.style('fill', offColor)
|
||||
.style('stroke-width', 0.5)
|
||||
.style('font-family', fontSecondary),
|
||||
[]
|
||||
)
|
||||
|
||||
const formatTicks = useCallback(() => {
|
||||
d3.selectAll('.tick line')
|
||||
.style('stroke', primaryColor)
|
||||
.style('fill', primaryColor)
|
||||
}, [])
|
||||
|
||||
const drawData = useCallback(
|
||||
g => {
|
||||
g.append('clipPath')
|
||||
.attr('id', 'clip-path')
|
||||
.append('rect')
|
||||
.attr('x', GRAPH_MARGIN.left)
|
||||
.attr('y', GRAPH_MARGIN.top)
|
||||
.attr('width', GRAPH_WIDTH)
|
||||
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
|
||||
.attr('fill', java)
|
||||
|
||||
g.append('g')
|
||||
.attr('clip-path', 'url(#clip-path)')
|
||||
.selectAll('circle .cashIn')
|
||||
.data(bins)
|
||||
.join('circle')
|
||||
.attr('cx', d => x(d.date))
|
||||
.attr('cy', d => y(d.cashIn))
|
||||
.attr('fill', java)
|
||||
.attr('r', d => (d.cashIn === 0 ? 0 : 3.5))
|
||||
|
||||
g.append('path')
|
||||
.datum(bins)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', java)
|
||||
.attr('stroke-width', 3)
|
||||
.attr('clip-path', 'url(#clip-path)')
|
||||
.attr(
|
||||
'd',
|
||||
d3
|
||||
.line()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x(d => x(d.date))
|
||||
.y(d => y(d.cashIn))
|
||||
)
|
||||
|
||||
g.append('g')
|
||||
.attr('clip-path', 'url(#clip-path)')
|
||||
.selectAll('circle .cashIn')
|
||||
.data(bins)
|
||||
.join('circle')
|
||||
.attr('cx', d => x(d.date))
|
||||
.attr('cy', d => y(d.cashOut))
|
||||
.attr('fill', neon)
|
||||
.attr('r', d => (d.cashOut === 0 ? 0 : 3.5))
|
||||
|
||||
g.append('path')
|
||||
.datum(bins)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', neon)
|
||||
.attr('stroke-width', 3)
|
||||
.attr('clip-path', 'url(#clip-path)')
|
||||
.attr(
|
||||
'd',
|
||||
d3
|
||||
.line()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x(d => x(d.date))
|
||||
.y(d => y(d.cashOut))
|
||||
)
|
||||
},
|
||||
[x, y, bins, GRAPH_MARGIN]
|
||||
)
|
||||
|
||||
const drawChart = useCallback(() => {
|
||||
const svg = d3
|
||||
.select(ref.current)
|
||||
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
|
||||
|
||||
svg.append('g').call(buildGrid)
|
||||
svg.append('g').call(drawData)
|
||||
svg.append('g').call(buildXAxis)
|
||||
svg.append('g').call(buildYAxis)
|
||||
svg.append('g').call(formatTicksText)
|
||||
svg.append('g').call(formatText)
|
||||
svg.append('g').call(formatTicks)
|
||||
|
||||
return svg.node()
|
||||
}, [
|
||||
buildGrid,
|
||||
buildXAxis,
|
||||
buildYAxis,
|
||||
drawData,
|
||||
formatText,
|
||||
formatTicks,
|
||||
formatTicksText
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
d3.select(ref.current).selectAll('*').remove()
|
||||
drawChart()
|
||||
}, [drawChart])
|
||||
|
||||
return <svg ref={ref} />
|
||||
}
|
||||
|
||||
export default memo(
|
||||
Graph,
|
||||
(prev, next) =>
|
||||
R.equals(prev.period, next.period) &&
|
||||
R.equals(prev.selectedMachine, next.selectedMachine) &&
|
||||
R.equals(prev.log, next.log)
|
||||
)
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
import BigNumber from 'bignumber.js'
|
||||
import * as d3 from 'd3'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import {
|
||||
java,
|
||||
neon,
|
||||
subheaderDarkColor,
|
||||
fontColor,
|
||||
fontSecondary
|
||||
} from 'src/styling/variables'
|
||||
|
||||
const Graph = ({ data, machines, currency, selectedMachine }) => {
|
||||
const ref = useRef(null)
|
||||
|
||||
const AMOUNT_OF_MACHINES = 5
|
||||
const BAR_PADDING = 0.15
|
||||
const BAR_MARGIN = 10
|
||||
const GRAPH_HEIGHT = 401
|
||||
const GRAPH_WIDTH = 1163
|
||||
const GRAPH_MARGIN = useMemo(
|
||||
() => ({
|
||||
top: 25,
|
||||
right: 0.5,
|
||||
bottom: 27,
|
||||
left: 36.5
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const machinesClone = R.clone(machines)
|
||||
|
||||
// This ensures that the graph renders a minimum amount of machines
|
||||
// and avoids having a single bar for cases with one machine
|
||||
const filledMachines =
|
||||
R.length(machines) >= AMOUNT_OF_MACHINES
|
||||
? machinesClone
|
||||
: R.map(
|
||||
it => {
|
||||
if (!R.isNil(machinesClone[it])) return machinesClone[it]
|
||||
return { code: `ghostMachine${it}`, display: `` }
|
||||
},
|
||||
R.times(R.identity, AMOUNT_OF_MACHINES)
|
||||
)
|
||||
|
||||
const txByDevice = R.reduce(
|
||||
(acc, value) => {
|
||||
acc[value.code] = R.filter(it => it.deviceId === value.code, data)
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
filledMachines
|
||||
)
|
||||
|
||||
const getDeviceVolume = deviceId =>
|
||||
R.reduce(
|
||||
(acc, value) => acc + BigNumber(value.fiat).toNumber(),
|
||||
0,
|
||||
txByDevice[deviceId]
|
||||
)
|
||||
|
||||
const getDeviceVolumeByTxClass = deviceId =>
|
||||
R.reduce(
|
||||
(acc, value) => {
|
||||
if (value.txClass === 'cashIn')
|
||||
acc.cashIn += BigNumber(value.fiat).toNumber()
|
||||
if (value.txClass === 'cashOut')
|
||||
acc.cashOut += BigNumber(value.fiat).toNumber()
|
||||
return acc
|
||||
},
|
||||
{ cashIn: 0, cashOut: 0 },
|
||||
txByDevice[deviceId]
|
||||
)
|
||||
|
||||
const devicesByVolume = R.sort(
|
||||
(a, b) => b[1] - a[1],
|
||||
R.map(m => [m.code, getDeviceVolume(m.code)], filledMachines)
|
||||
)
|
||||
|
||||
const topMachines = R.take(AMOUNT_OF_MACHINES, devicesByVolume)
|
||||
|
||||
const txClassVolumeByDevice = R.fromPairs(
|
||||
R.map(v => [v[0], getDeviceVolumeByTxClass(v[0])], topMachines)
|
||||
)
|
||||
|
||||
const x = d3
|
||||
.scaleBand()
|
||||
.domain(topMachines)
|
||||
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
|
||||
.paddingInner(BAR_PADDING)
|
||||
|
||||
const y = d3
|
||||
.scaleLinear()
|
||||
.domain([
|
||||
0,
|
||||
d3.max(topMachines, d => d[1]) !== 0 ? d3.max(topMachines, d => d[1]) : 50
|
||||
])
|
||||
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||
|
||||
const buildXAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr('class', 'x-axis-1')
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||
)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.tickFormat(
|
||||
d =>
|
||||
`${
|
||||
R.find(it => it.code === d[0], filledMachines).display ?? ''
|
||||
}`
|
||||
)
|
||||
.tickSize(0)
|
||||
.tickPadding(10)
|
||||
),
|
||||
[GRAPH_MARGIN, x, filledMachines]
|
||||
)
|
||||
|
||||
const buildXAxis2 = useCallback(
|
||||
g => {
|
||||
g.attr('class', 'x-axis-2')
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||
)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.tickFormat(d =>
|
||||
R.includes(`ghostMachine`, d[0])
|
||||
? ``
|
||||
: `${d[1].toFixed(2)} ${currency}`
|
||||
)
|
||||
.tickSize(0)
|
||||
.tickPadding(10)
|
||||
)
|
||||
},
|
||||
[GRAPH_MARGIN, x, currency]
|
||||
)
|
||||
|
||||
const positionXAxisLabels = useCallback(() => {
|
||||
d3.selectAll('.x-axis-1 .tick text').attr('transform', function (d) {
|
||||
const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES
|
||||
return `translate(${-widthPerEntry / 2.25 + this.getBBox().width / 2}, 0)`
|
||||
})
|
||||
}, [x])
|
||||
|
||||
const positionXAxis2Labels = useCallback(() => {
|
||||
d3.selectAll('.x-axis-2 .tick text').attr('transform', function (d) {
|
||||
const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES
|
||||
return `translate(${widthPerEntry / 2.25 - this.getBBox().width / 2}, 0)`
|
||||
})
|
||||
}, [x])
|
||||
|
||||
const buildYAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(GRAPH_HEIGHT / 100)
|
||||
.tickSize(0)
|
||||
.tickFormat(``)
|
||||
)
|
||||
.call(g => g.select('.domain').remove()),
|
||||
[GRAPH_MARGIN, y]
|
||||
)
|
||||
|
||||
const formatTicksText = useCallback(
|
||||
() =>
|
||||
d3
|
||||
.selectAll('.tick text')
|
||||
.style('stroke', fontColor)
|
||||
.style('fill', fontColor)
|
||||
.style('stroke-width', 0.5)
|
||||
.style('font-family', fontSecondary),
|
||||
[]
|
||||
)
|
||||
|
||||
const buildGrid = useCallback(
|
||||
g => {
|
||||
g.attr('stroke', subheaderDarkColor)
|
||||
.attr('fill', subheaderDarkColor)
|
||||
// Vertical lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(R.tail(x.domain()))
|
||||
.join('line')
|
||||
.attr('x1', d => {
|
||||
const domainIndex = R.findIndex(it => R.equals(it, d), x.domain())
|
||||
|
||||
const xValue =
|
||||
x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1])
|
||||
|
||||
const paddedXValue = xValue * (BAR_PADDING / 2)
|
||||
return 0.5 + x(d) - paddedXValue
|
||||
})
|
||||
.attr('x2', d => {
|
||||
const domainIndex = R.findIndex(it => R.equals(it, d), x.domain())
|
||||
|
||||
const xValue =
|
||||
x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1])
|
||||
|
||||
const paddedXValue = xValue * (BAR_PADDING / 2)
|
||||
return 0.5 + x(d) - paddedXValue
|
||||
})
|
||||
.attr('y1', GRAPH_MARGIN.top)
|
||||
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
|
||||
)
|
||||
},
|
||||
[GRAPH_MARGIN, x]
|
||||
)
|
||||
|
||||
const drawCashIn = useCallback(
|
||||
g => {
|
||||
g.selectAll('rect')
|
||||
.data(R.toPairs(txClassVolumeByDevice))
|
||||
.join('rect')
|
||||
.attr('fill', java)
|
||||
.attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut]))
|
||||
.attr('y', d => y(d[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom)
|
||||
.attr('height', d =>
|
||||
R.clamp(
|
||||
0,
|
||||
GRAPH_HEIGHT,
|
||||
GRAPH_HEIGHT - y(d[1].cashIn) - GRAPH_MARGIN.bottom - BAR_MARGIN
|
||||
)
|
||||
)
|
||||
.attr('width', x.bandwidth())
|
||||
.attr('rx', 2.5)
|
||||
},
|
||||
[txClassVolumeByDevice, x, y, GRAPH_MARGIN]
|
||||
)
|
||||
|
||||
const drawCashOut = useCallback(
|
||||
g => {
|
||||
g.selectAll('rect')
|
||||
.data(R.toPairs(txClassVolumeByDevice))
|
||||
.join('rect')
|
||||
.attr('fill', neon)
|
||||
.attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut]))
|
||||
.attr(
|
||||
'y',
|
||||
d =>
|
||||
y(d[1].cashIn + d[1].cashOut) -
|
||||
GRAPH_MARGIN.top +
|
||||
GRAPH_MARGIN.bottom
|
||||
)
|
||||
.attr('height', d => {
|
||||
return R.clamp(
|
||||
0,
|
||||
GRAPH_HEIGHT,
|
||||
GRAPH_HEIGHT -
|
||||
y(d[1].cashOut) -
|
||||
GRAPH_MARGIN.bottom -
|
||||
BAR_MARGIN / 2
|
||||
)
|
||||
})
|
||||
.attr('width', x.bandwidth())
|
||||
.attr('rx', 2.5)
|
||||
},
|
||||
[txClassVolumeByDevice, x, y, GRAPH_MARGIN]
|
||||
)
|
||||
|
||||
const drawChart = useCallback(() => {
|
||||
const svg = d3
|
||||
.select(ref.current)
|
||||
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
|
||||
|
||||
svg.append('g').call(buildXAxis)
|
||||
svg.append('g').call(buildXAxis2)
|
||||
svg.append('g').call(buildYAxis)
|
||||
svg.append('g').call(formatTicksText)
|
||||
svg.append('g').call(buildGrid)
|
||||
svg.append('g').call(drawCashIn)
|
||||
svg.append('g').call(drawCashOut)
|
||||
svg.append('g').call(positionXAxisLabels)
|
||||
svg.append('g').call(positionXAxis2Labels)
|
||||
|
||||
return svg.node()
|
||||
}, [
|
||||
buildXAxis,
|
||||
buildXAxis2,
|
||||
positionXAxisLabels,
|
||||
positionXAxis2Labels,
|
||||
buildYAxis,
|
||||
formatTicksText,
|
||||
buildGrid,
|
||||
drawCashIn,
|
||||
drawCashOut
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
d3.select(ref.current).selectAll('*').remove()
|
||||
drawChart()
|
||||
}, [drawChart])
|
||||
|
||||
return <svg ref={ref} />
|
||||
}
|
||||
|
||||
export default memo(
|
||||
Graph,
|
||||
(prev, next) =>
|
||||
R.equals(prev.period, next.period) &&
|
||||
R.equals(prev.selectedMachine, next.selectedMachine)
|
||||
)
|
||||
3
packages/admin-ui/src/pages/Analytics/index.js
Normal file
3
packages/admin-ui/src/pages/Analytics/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Analytics from './Analytics'
|
||||
|
||||
export default Analytics
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
.welcomeBackground {
|
||||
background: var(--ghost) url(/wizard-background.svg) no-repeat fixed center center;
|
||||
background-size: cover;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: -50vw;
|
||||
margin-right: -50vw;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 2.5em 4em;
|
||||
width: 575px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.titleWrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 30px
|
||||
}
|
||||
|
||||
.icon {
|
||||
transform: scale(1.5);
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.infoWrapper {
|
||||
margin-bottom: 3vh;
|
||||
}
|
||||
|
||||
.info2 {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.qrCodeWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 3vh;
|
||||
}
|
||||
|
||||
.secretWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.secretLabel {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.secret {
|
||||
margin-right: 35px;
|
||||
}
|
||||
|
||||
.hiddenSecret {
|
||||
margin-right: 35px;
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.confirm2FAInput {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
125
packages/admin-ui/src/pages/Authentication/Input2FAState.jsx
Normal file
125
packages/admin-ui/src/pages/Authentication/Input2FAState.jsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useMutation, useLazyQuery, gql } from '@apollo/client'
|
||||
import { Form, Formik } from 'formik'
|
||||
import React, { useContext, useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { TL1, P } from 'src/components/typography'
|
||||
|
||||
import AppContext from 'src/AppContext'
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { CodeInput } from 'src/components/inputs/base'
|
||||
|
||||
import { STATES } from './states'
|
||||
|
||||
const INPUT_2FA = gql`
|
||||
mutation input2FA(
|
||||
$username: String!
|
||||
$password: String!
|
||||
$code: String!
|
||||
$rememberMe: Boolean!
|
||||
) {
|
||||
input2FA(
|
||||
username: $username
|
||||
password: $password
|
||||
code: $code
|
||||
rememberMe: $rememberMe
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_USER_DATA = gql`
|
||||
{
|
||||
userData {
|
||||
id
|
||||
username
|
||||
role
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Input2FAState = ({ state, dispatch }) => {
|
||||
const history = useHistory()
|
||||
const { setUserData } = useContext(AppContext)
|
||||
|
||||
const [invalidToken, setInvalidToken] = useState(false)
|
||||
|
||||
const [getUserData, { error: queryError }] = useLazyQuery(GET_USER_DATA, {
|
||||
onCompleted: ({ userData }) => {
|
||||
setUserData(userData)
|
||||
history.push('/')
|
||||
}
|
||||
})
|
||||
|
||||
const [input2FA, { error: mutationError }] = useMutation(INPUT_2FA, {
|
||||
onCompleted: ({ input2FA: success }) => {
|
||||
if (success) {
|
||||
return getUserData()
|
||||
}
|
||||
return setInvalidToken(true)
|
||||
}
|
||||
})
|
||||
|
||||
const handle2FAChange = value => {
|
||||
dispatch({
|
||||
type: STATES.INPUT_2FA,
|
||||
payload: {
|
||||
twoFAField: value
|
||||
}
|
||||
})
|
||||
setInvalidToken(false)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (state.twoFAField.length !== 6) {
|
||||
setInvalidToken(true)
|
||||
return
|
||||
}
|
||||
|
||||
const options = {
|
||||
variables: {
|
||||
username: state.clientField,
|
||||
password: state.passwordField,
|
||||
code: state.twoFAField,
|
||||
rememberMe: state.rememberMeField
|
||||
}
|
||||
}
|
||||
|
||||
input2FA(options)
|
||||
}
|
||||
|
||||
const getErrorMsg = () => {
|
||||
if (queryError) return 'Internal server error'
|
||||
if (state.twoFAField.length !== 6 && invalidToken)
|
||||
return 'The code should have 6 characters!'
|
||||
if (mutationError || invalidToken)
|
||||
return 'Code is invalid. Please try again.'
|
||||
return null
|
||||
}
|
||||
|
||||
const errorMessage = getErrorMsg()
|
||||
|
||||
return (
|
||||
<>
|
||||
<TL1 className="mb-8">Enter your two-factor authentication code</TL1>
|
||||
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
|
||||
<Formik onSubmit={() => {}} initialValues={{}}>
|
||||
<Form>
|
||||
<CodeInput
|
||||
name="2fa"
|
||||
value={state.twoFAField}
|
||||
onChange={handle2FAChange}
|
||||
numInputs={6}
|
||||
error={invalidToken}
|
||||
/>
|
||||
<div className="mt-9">
|
||||
{errorMessage && <P className="text-tomato">{errorMessage}</P>}
|
||||
<Button onClick={handleSubmit} buttonClassName="w-full">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Formik>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Input2FAState
|
||||
207
packages/admin-ui/src/pages/Authentication/InputFIDOState.jsx
Normal file
207
packages/admin-ui/src/pages/Authentication/InputFIDOState.jsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { useMutation, useLazyQuery, gql } from '@apollo/client'
|
||||
import { startAssertion } from '@simplewebauthn/browser'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import React, { useState, useContext } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { H2, Label2, P } from 'src/components/typography'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import AppContext from 'src/AppContext'
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { Checkbox, TextInput } from 'src/components/inputs/formik'
|
||||
|
||||
const GET_USER_DATA = gql`
|
||||
{
|
||||
userData {
|
||||
id
|
||||
username
|
||||
role
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
localClient: Yup.string()
|
||||
.required('Client field is required!')
|
||||
.email('Username field should be in an email format!'),
|
||||
localRememberMe: Yup.boolean()
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
localClient: '',
|
||||
localRememberMe: false
|
||||
}
|
||||
|
||||
const InputFIDOState = ({ state, strategy }) => {
|
||||
const GENERATE_ASSERTION = gql`
|
||||
query generateAssertionOptions($username: String!${
|
||||
strategy === 'FIDO2FA' ? `, $password: String!` : ``
|
||||
}, $domain: String!) {
|
||||
generateAssertionOptions(username: $username${
|
||||
strategy === 'FIDO2FA' ? `, password: $password` : ``
|
||||
}, domain: $domain)
|
||||
}
|
||||
`
|
||||
|
||||
const VALIDATE_ASSERTION = gql`
|
||||
mutation validateAssertion(
|
||||
$username: String!
|
||||
${strategy === 'FIDO2FA' ? `, $password: String!` : ``}
|
||||
$rememberMe: Boolean!
|
||||
$assertionResponse: JSONObject!
|
||||
$domain: String!
|
||||
) {
|
||||
validateAssertion(
|
||||
username: $username
|
||||
${strategy === 'FIDO2FA' ? `password: $password` : ``}
|
||||
rememberMe: $rememberMe
|
||||
assertionResponse: $assertionResponse
|
||||
domain: $domain
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const history = useHistory()
|
||||
const { setUserData } = useContext(AppContext)
|
||||
|
||||
const [localClientField, setLocalClientField] = useState('')
|
||||
const [localRememberMeField, setLocalRememberMeField] = useState(false)
|
||||
const [invalidUsername, setInvalidUsername] = useState(false)
|
||||
const [invalidToken, setInvalidToken] = useState(false)
|
||||
|
||||
const [validateAssertion, { error: mutationError }] = useMutation(
|
||||
VALIDATE_ASSERTION,
|
||||
{
|
||||
onCompleted: ({ validateAssertion: success }) => {
|
||||
success ? getUserData() : setInvalidToken(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const [assertionOptions, { error: assertionQueryError }] = useLazyQuery(
|
||||
GENERATE_ASSERTION,
|
||||
{
|
||||
variables:
|
||||
strategy === 'FIDO2FA'
|
||||
? {
|
||||
username: state.clientField,
|
||||
password: state.passwordField,
|
||||
domain: window.location.hostname
|
||||
}
|
||||
: {
|
||||
username: localClientField,
|
||||
domain: window.location.hostname
|
||||
},
|
||||
onCompleted: ({ generateAssertionOptions: options }) => {
|
||||
startAssertion(options)
|
||||
.then(res => {
|
||||
const variables =
|
||||
strategy === 'FIDO2FA'
|
||||
? {
|
||||
username: state.clientField,
|
||||
password: state.passwordField,
|
||||
rememberMe: state.rememberMeField,
|
||||
assertionResponse: res,
|
||||
domain: window.location.hostname
|
||||
}
|
||||
: {
|
||||
username: localClientField,
|
||||
rememberMe: localRememberMeField,
|
||||
assertionResponse: res,
|
||||
domain: window.location.hostname
|
||||
}
|
||||
validateAssertion({
|
||||
variables
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
setInvalidToken(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const [getUserData, { error: queryError }] = useLazyQuery(GET_USER_DATA, {
|
||||
onCompleted: ({ userData }) => {
|
||||
setUserData(userData)
|
||||
history.push('/')
|
||||
}
|
||||
})
|
||||
|
||||
const getErrorMsg = (formikErrors, formikTouched) => {
|
||||
if (!formikErrors || !formikTouched) return null
|
||||
if (assertionQueryError || queryError || mutationError)
|
||||
return 'Internal server error'
|
||||
if (formikErrors.client && formikTouched.client) return formikErrors.client
|
||||
if (invalidUsername) return 'Invalid login.'
|
||||
if (invalidToken) return 'Code is invalid. Please try again.'
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{strategy === 'FIDOPasswordless' && (
|
||||
<Formik
|
||||
validationSchema={validationSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={values => {
|
||||
setInvalidUsername(false)
|
||||
setLocalClientField(values.localClient)
|
||||
setLocalRememberMeField(values.localRememberMe)
|
||||
assertionOptions()
|
||||
}}>
|
||||
{({ errors, touched }) => (
|
||||
<Form id="fido-form">
|
||||
<Field
|
||||
name="localClient"
|
||||
label="Client"
|
||||
size="lg"
|
||||
component={TextInput}
|
||||
fullWidth
|
||||
autoFocus
|
||||
className="-mt-4 mb-6"
|
||||
error={getErrorMsg(errors, touched)}
|
||||
onKeyUp={() => {
|
||||
if (invalidUsername) setInvalidUsername(false)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-9 flex">
|
||||
<Field
|
||||
name="localRememberMe"
|
||||
className="-ml-2 transform-[scale(1.5)]"
|
||||
component={Checkbox}
|
||||
/>
|
||||
<Label2>Keep me logged in</Label2>
|
||||
</div>
|
||||
<div className="mt-9">
|
||||
{getErrorMsg(errors, touched) && (
|
||||
<P className="text-tomato">{getErrorMsg(errors, touched)}</P>
|
||||
)}
|
||||
<Button type="submit" form="fido-form" buttonClassName="w-full">
|
||||
Use FIDO
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
{strategy === 'FIDO2FA' && (
|
||||
<>
|
||||
<H2 className="mb-8">
|
||||
Insert your hardware key and follow the instructions
|
||||
</H2>
|
||||
<Button
|
||||
type="button"
|
||||
form="fido-form"
|
||||
onClick={() => assertionOptions()}
|
||||
buttonClassName="w-full">
|
||||
Use FIDO
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputFIDOState
|
||||
23
packages/admin-ui/src/pages/Authentication/Login.jsx
Normal file
23
packages/admin-ui/src/pages/Authentication/Login.jsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import Grid from '@mui/material/Grid'
|
||||
import React from 'react'
|
||||
|
||||
import LoginCard from './LoginCard'
|
||||
import classes from './Authentication.module.css'
|
||||
|
||||
const Login = () => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={classes.welcomeBackground}>
|
||||
<Grid>
|
||||
<LoginCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
68
packages/admin-ui/src/pages/Authentication/LoginCard.jsx
Normal file
68
packages/admin-ui/src/pages/Authentication/LoginCard.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import Paper from '@mui/material/Paper'
|
||||
import React, { useReducer } from 'react'
|
||||
import { H5 } from 'src/components/typography'
|
||||
import Logo from 'src/styling/icons/menu/logo.svg?react'
|
||||
|
||||
import Input2FAState from './Input2FAState'
|
||||
import InputFIDOState from './InputFIDOState'
|
||||
import LoginState from './LoginState'
|
||||
import Setup2FAState from './Setup2FAState'
|
||||
import { STATES } from './states'
|
||||
import classes from './Authentication.module.css'
|
||||
|
||||
// FIDO2FA, FIDOPasswordless or FIDOUsernameless
|
||||
const AUTHENTICATION_STRATEGY = 'FIDO2FA'
|
||||
|
||||
const initialState = {
|
||||
twoFAField: '',
|
||||
clientField: '',
|
||||
passwordField: '',
|
||||
rememberMeField: false,
|
||||
loginState: STATES.LOGIN
|
||||
}
|
||||
|
||||
const reducer = (state, action) => {
|
||||
const { type, payload } = action
|
||||
return { ...state, ...payload, loginState: type }
|
||||
}
|
||||
|
||||
const LoginCard = () => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
|
||||
const renderState = () => {
|
||||
switch (state.loginState) {
|
||||
case STATES.LOGIN:
|
||||
return (
|
||||
<LoginState
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
strategy={AUTHENTICATION_STRATEGY}
|
||||
/>
|
||||
)
|
||||
case STATES.INPUT_2FA:
|
||||
return <Input2FAState state={state} dispatch={dispatch} />
|
||||
case STATES.SETUP_2FA:
|
||||
return <Setup2FAState state={state} dispatch={dispatch} />
|
||||
case STATES.FIDO:
|
||||
return (
|
||||
<InputFIDOState state={state} strategy={AUTHENTICATION_STRATEGY} />
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper elevation={1}>
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.titleWrapper}>
|
||||
<Logo className={classes.icon} />
|
||||
<h3 className={classes.title}>Lamassu Admin</h3>
|
||||
</div>
|
||||
{renderState()}
|
||||
</div>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginCard
|
||||
227
packages/admin-ui/src/pages/Authentication/LoginState.jsx
Normal file
227
packages/admin-ui/src/pages/Authentication/LoginState.jsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { useMutation, useLazyQuery, gql } from '@apollo/client'
|
||||
import { startAssertion } from '@simplewebauthn/browser'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import React, { useContext } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Label3, P } from 'src/components/typography'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import AppContext from 'src/AppContext'
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { Checkbox, SecretInput, TextInput } from 'src/components/inputs/formik'
|
||||
|
||||
const LOGIN = gql`
|
||||
mutation login($username: String!, $password: String!) {
|
||||
login(username: $username, password: $password)
|
||||
}
|
||||
`
|
||||
|
||||
const GENERATE_ASSERTION = gql`
|
||||
query generateAssertionOptions($domain: String!) {
|
||||
generateAssertionOptions(domain: $domain)
|
||||
}
|
||||
`
|
||||
|
||||
const VALIDATE_ASSERTION = gql`
|
||||
mutation validateAssertion(
|
||||
$assertionResponse: JSONObject!
|
||||
$domain: String!
|
||||
) {
|
||||
validateAssertion(assertionResponse: $assertionResponse, domain: $domain)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_USER_DATA = gql`
|
||||
{
|
||||
userData {
|
||||
id
|
||||
username
|
||||
role
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
email: Yup.string().label('Email').required().email(),
|
||||
password: Yup.string().required('Password field is required'),
|
||||
rememberMe: Yup.boolean()
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false
|
||||
}
|
||||
|
||||
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
|
||||
if (!formikErrors || !formikTouched) return null
|
||||
if (mutationError) return 'Invalid email/password combination'
|
||||
if (formikErrors.email && formikTouched.email) return formikErrors.email
|
||||
if (formikErrors.password && formikTouched.password)
|
||||
return formikErrors.password
|
||||
return null
|
||||
}
|
||||
|
||||
const LoginState = ({ state, dispatch, strategy }) => {
|
||||
const history = useHistory()
|
||||
const { setUserData } = useContext(AppContext)
|
||||
|
||||
const [login, { error: loginMutationError }] = useMutation(LOGIN)
|
||||
|
||||
const submitLogin = async (username, password, rememberMe) => {
|
||||
const options = {
|
||||
variables: {
|
||||
username,
|
||||
password
|
||||
}
|
||||
}
|
||||
const { data: loginResponse } = await login(options)
|
||||
|
||||
if (!loginResponse.login) return
|
||||
|
||||
return dispatch({
|
||||
type: loginResponse.login,
|
||||
payload: {
|
||||
clientField: username,
|
||||
passwordField: password,
|
||||
rememberMeField: rememberMe
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const [validateAssertion, { error: FIDOMutationError }] = useMutation(
|
||||
VALIDATE_ASSERTION,
|
||||
{
|
||||
onCompleted: ({ validateAssertion: success }) => success && getUserData()
|
||||
}
|
||||
)
|
||||
|
||||
const [assertionOptions, { error: assertionQueryError }] = useLazyQuery(
|
||||
GENERATE_ASSERTION,
|
||||
{
|
||||
onCompleted: ({ generateAssertionOptions: options }) => {
|
||||
startAssertion(options)
|
||||
.then(res => {
|
||||
validateAssertion({
|
||||
variables: {
|
||||
assertionResponse: res,
|
||||
domain: window.location.hostname
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const [getUserData, { error: userDataQueryError }] = useLazyQuery(
|
||||
GET_USER_DATA,
|
||||
{
|
||||
onCompleted: ({ userData }) => {
|
||||
setUserData(userData)
|
||||
history.push('/')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={validationSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={values =>
|
||||
submitLogin(values.email, values.password, values.rememberMe)
|
||||
}>
|
||||
{({ errors, touched }) => (
|
||||
<Form id="login-form">
|
||||
<Field
|
||||
name="email"
|
||||
label="Email"
|
||||
size="lg"
|
||||
component={TextInput}
|
||||
fullWidth
|
||||
autoFocus
|
||||
className="-mt-4 mb-6"
|
||||
error={getErrorMsg(
|
||||
errors,
|
||||
touched,
|
||||
loginMutationError ||
|
||||
FIDOMutationError ||
|
||||
assertionQueryError ||
|
||||
userDataQueryError
|
||||
)}
|
||||
/>
|
||||
<Field
|
||||
name="password"
|
||||
size="lg"
|
||||
component={SecretInput}
|
||||
label="Password"
|
||||
fullWidth
|
||||
error={getErrorMsg(
|
||||
errors,
|
||||
touched,
|
||||
loginMutationError ||
|
||||
FIDOMutationError ||
|
||||
assertionQueryError ||
|
||||
userDataQueryError
|
||||
)}
|
||||
/>
|
||||
<div className="mt-9 flex">
|
||||
<Field
|
||||
name="rememberMe"
|
||||
className="-ml-2 transform-[scale(1.5)]"
|
||||
component={Checkbox}
|
||||
size="medium"
|
||||
/>
|
||||
<Label3>Keep me logged in</Label3>
|
||||
</div>
|
||||
<div className="mt-15">
|
||||
{getErrorMsg(
|
||||
errors,
|
||||
touched,
|
||||
loginMutationError ||
|
||||
FIDOMutationError ||
|
||||
assertionQueryError ||
|
||||
userDataQueryError
|
||||
) && (
|
||||
<P className="text-tomato">
|
||||
{getErrorMsg(
|
||||
errors,
|
||||
touched,
|
||||
loginMutationError ||
|
||||
FIDOMutationError ||
|
||||
assertionQueryError ||
|
||||
userDataQueryError
|
||||
)}
|
||||
</P>
|
||||
)}
|
||||
{strategy !== 'FIDO2FA' && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
return strategy === 'FIDOUsernameless'
|
||||
? assertionOptions({
|
||||
variables: { domain: window.location.hostname }
|
||||
})
|
||||
: dispatch({
|
||||
type: 'FIDO',
|
||||
payload: {}
|
||||
})
|
||||
}}
|
||||
buttonClassName="w-full"
|
||||
className="mb-3">
|
||||
I have a hardware key
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" form="login-form" buttonClassName="w-full">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginState
|
||||
217
packages/admin-ui/src/pages/Authentication/Register.jsx
Normal file
217
packages/admin-ui/src/pages/Authentication/Register.jsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import React, { useReducer } from 'react'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { H2, Label3, P } from 'src/components/typography'
|
||||
import Logo from 'src/styling/icons/menu/logo.svg?react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { SecretInput } from 'src/components/inputs/formik'
|
||||
import classes from './Authentication.module.css'
|
||||
|
||||
const QueryParams = () => new URLSearchParams(useLocation().search)
|
||||
|
||||
const VALIDATE_REGISTER_LINK = gql`
|
||||
query validateRegisterLink($token: String!) {
|
||||
validateRegisterLink(token: $token) {
|
||||
username
|
||||
role
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const REGISTER = gql`
|
||||
mutation register(
|
||||
$token: String!
|
||||
$username: String!
|
||||
$password: String!
|
||||
$role: String!
|
||||
) {
|
||||
register(
|
||||
token: $token
|
||||
username: $username
|
||||
password: $password
|
||||
role: $role
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8
|
||||
const validationSchema = Yup.object({
|
||||
password: Yup.string()
|
||||
.required('A password is required')
|
||||
.min(
|
||||
PASSWORD_MIN_LENGTH,
|
||||
`Your password must contain at least ${PASSWORD_MIN_LENGTH} characters`
|
||||
),
|
||||
confirmPassword: Yup.string()
|
||||
.required('Please confirm the password')
|
||||
.oneOf([Yup.ref('password')], 'Passwords must match')
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
username: null,
|
||||
role: null,
|
||||
result: ''
|
||||
}
|
||||
|
||||
const reducer = (state, action) => {
|
||||
const { type, payload } = action
|
||||
return { ...state, ...payload, result: type }
|
||||
}
|
||||
|
||||
const getErrorMsg = (
|
||||
formikErrors,
|
||||
formikTouched,
|
||||
queryError,
|
||||
mutationError
|
||||
) => {
|
||||
if (!formikErrors || !formikTouched) return null
|
||||
if (queryError || mutationError) return 'Internal server error'
|
||||
if (formikErrors.password && formikTouched.password)
|
||||
return formikErrors.password
|
||||
if (formikErrors.confirmPassword && formikTouched.confirmPassword)
|
||||
return formikErrors.confirmPassword
|
||||
return null
|
||||
}
|
||||
|
||||
const Register = () => {
|
||||
const history = useHistory()
|
||||
const token = QueryParams().get('t')
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
|
||||
const queryOptions = {
|
||||
variables: { token: token },
|
||||
onCompleted: ({ validateRegisterLink: info }) => {
|
||||
if (!info) {
|
||||
return dispatch({
|
||||
type: 'failure'
|
||||
})
|
||||
}
|
||||
dispatch({
|
||||
type: 'success',
|
||||
payload: {
|
||||
username: info.username,
|
||||
role: info.role
|
||||
}
|
||||
})
|
||||
},
|
||||
onError: () =>
|
||||
dispatch({
|
||||
type: 'failure'
|
||||
})
|
||||
}
|
||||
|
||||
const { error: queryError, loading } = useQuery(
|
||||
VALIDATE_REGISTER_LINK,
|
||||
queryOptions
|
||||
)
|
||||
|
||||
const [register, { error: mutationError }] = useMutation(REGISTER, {
|
||||
onCompleted: ({ register: success }) => {
|
||||
if (success) history.push('/wizard', { fromAuthRegister: true })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={classes.welcomeBackground}>
|
||||
<Grid>
|
||||
<div>
|
||||
<Paper elevation={1}>
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.titleWrapper}>
|
||||
<Logo className={classes.icon} />
|
||||
<H2 className={classes.title}>Lamassu Admin</H2>
|
||||
</div>
|
||||
{!loading && state.result === 'success' && (
|
||||
<Formik
|
||||
validationSchema={validationSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={values => {
|
||||
register({
|
||||
variables: {
|
||||
token: token,
|
||||
username: state.username,
|
||||
password: values.password,
|
||||
role: state.role
|
||||
}
|
||||
})
|
||||
}}>
|
||||
{({ errors, touched }) => (
|
||||
<Form id="register-form">
|
||||
<Field
|
||||
name="password"
|
||||
label="Insert a password"
|
||||
autoFocus
|
||||
component={SecretInput}
|
||||
size="lg"
|
||||
fullWidth
|
||||
className="-mt-4 mb-6"
|
||||
/>
|
||||
<Field
|
||||
name="confirmPassword"
|
||||
label="Confirm your password"
|
||||
component={SecretInput}
|
||||
size="lg"
|
||||
fullWidth
|
||||
/>
|
||||
<div className="mt-15">
|
||||
{getErrorMsg(
|
||||
errors,
|
||||
touched,
|
||||
queryError,
|
||||
mutationError
|
||||
) && (
|
||||
<P className="text-tomato">
|
||||
{getErrorMsg(
|
||||
errors,
|
||||
touched,
|
||||
queryError,
|
||||
mutationError
|
||||
)}
|
||||
</P>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="register-form"
|
||||
buttonClassName="w-full">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
{!loading && state.result === 'failure' && (
|
||||
<>
|
||||
<Label3>Link has expired</Label3>
|
||||
<Label3>
|
||||
To obtain a new link, run the command{' '}
|
||||
<strong>lamassu-register</strong> in your server’s terminal.
|
||||
</Label3>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
204
packages/admin-ui/src/pages/Authentication/Reset2FA.jsx
Normal file
204
packages/admin-ui/src/pages/Authentication/Reset2FA.jsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import { Form, Formik } from 'formik'
|
||||
import { QRCodeSVG as QRCode } from 'qrcode.react'
|
||||
import React, { useReducer, useState } from 'react'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { H2, Label2, Label3, P } from 'src/components/typography'
|
||||
import Logo from 'src/styling/icons/menu/logo.svg?react'
|
||||
|
||||
import { ActionButton, Button } from 'src/components/buttons'
|
||||
import { CodeInput } from 'src/components/inputs/base'
|
||||
import { primaryColor } from 'src/styling/variables'
|
||||
|
||||
import classes from './Authentication.module.css'
|
||||
|
||||
const VALIDATE_RESET_2FA_LINK = gql`
|
||||
query validateReset2FALink($token: String!) {
|
||||
validateReset2FALink(token: $token) {
|
||||
user_id
|
||||
secret
|
||||
otpauth
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RESET_2FA = gql`
|
||||
mutation reset2FA($token: String!, $userID: ID!, $code: String!) {
|
||||
reset2FA(token: $token, userID: $userID, code: $code)
|
||||
}
|
||||
`
|
||||
|
||||
const initialState = {
|
||||
userID: null,
|
||||
secret: null,
|
||||
otpauth: null,
|
||||
result: null
|
||||
}
|
||||
|
||||
const reducer = (state, action) => {
|
||||
const { type, payload } = action
|
||||
return { ...state, ...payload, result: type }
|
||||
}
|
||||
|
||||
const Reset2FA = () => {
|
||||
const history = useHistory()
|
||||
const QueryParams = () => new URLSearchParams(useLocation().search)
|
||||
const token = QueryParams().get('t')
|
||||
|
||||
const [isShowing, setShowing] = useState(false)
|
||||
const [invalidToken, setInvalidToken] = useState(false)
|
||||
const [twoFAConfirmation, setTwoFAConfirmation] = useState('')
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
|
||||
const handle2FAChange = value => {
|
||||
setTwoFAConfirmation(value)
|
||||
setInvalidToken(false)
|
||||
}
|
||||
|
||||
const { error: queryError, loading } = useQuery(VALIDATE_RESET_2FA_LINK, {
|
||||
variables: { token: token },
|
||||
onCompleted: ({ validateReset2FALink: info }) => {
|
||||
if (!info) {
|
||||
dispatch({
|
||||
type: 'failure'
|
||||
})
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'success',
|
||||
payload: {
|
||||
userID: info.user_id,
|
||||
secret: info.secret,
|
||||
otpauth: info.otpauth
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
dispatch({
|
||||
type: 'failure'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const [reset2FA, { error: mutationError }] = useMutation(RESET_2FA, {
|
||||
onCompleted: ({ reset2FA: success }) => {
|
||||
success ? history.push('/') : setInvalidToken(true)
|
||||
}
|
||||
})
|
||||
|
||||
const getErrorMsg = () => {
|
||||
if (queryError) return 'Internal server error'
|
||||
if (twoFAConfirmation.length !== 6 && invalidToken)
|
||||
return 'The code should have 6 characters!'
|
||||
if (mutationError || invalidToken)
|
||||
return 'Code is invalid. Please try again.'
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (twoFAConfirmation.length !== 6) {
|
||||
setInvalidToken(true)
|
||||
return
|
||||
}
|
||||
reset2FA({
|
||||
variables: {
|
||||
token: token,
|
||||
userID: state.userID,
|
||||
code: twoFAConfirmation
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={classes.welcomeBackground}>
|
||||
<Grid>
|
||||
<div>
|
||||
<Paper elevation={1}>
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.titleWrapper}>
|
||||
<Logo className={classes.icon} />
|
||||
<H2 className={classes.title}>Lamassu Admin</H2>
|
||||
</div>
|
||||
{!loading && state.result === 'success' && (
|
||||
<>
|
||||
<div className={classes.infoWrapper}>
|
||||
<Label2 className={classes.info2}>
|
||||
To finish this process, please scan the following QR code
|
||||
or insert the secret further below on an authentication
|
||||
app of your choice, such Google Authenticator or Authy.
|
||||
</Label2>
|
||||
</div>
|
||||
<div className={classes.qrCodeWrapper}>
|
||||
<QRCode
|
||||
size={240}
|
||||
fgColor={primaryColor}
|
||||
value={state.otpauth}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.secretWrapper}>
|
||||
<Label2 className={classes.secretLabel}>
|
||||
Your secret:
|
||||
</Label2>
|
||||
<Label2
|
||||
className={
|
||||
isShowing ? classes.secret : classes.hiddenSecret
|
||||
}>
|
||||
{state.secret}
|
||||
</Label2>
|
||||
<ActionButton
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setShowing(!isShowing)
|
||||
}}>
|
||||
{isShowing ? 'Hide' : 'Show'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className={classes.confirm2FAInput}>
|
||||
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
|
||||
<Formik onSubmit={() => {}} initialValues={{}}>
|
||||
<Form>
|
||||
<CodeInput
|
||||
name="2fa"
|
||||
value={twoFAConfirmation}
|
||||
onChange={handle2FAChange}
|
||||
numInputs={6}
|
||||
error={invalidToken}
|
||||
/>
|
||||
<div className="mt-9">
|
||||
{getErrorMsg() && (
|
||||
<P className="text-tomato">{getErrorMsg()}</P>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
buttonClassName="w-full">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!loading && state.result === 'failure' && (
|
||||
<>
|
||||
<Label3>Link has expired</Label3>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
export default Reset2FA
|
||||
167
packages/admin-ui/src/pages/Authentication/ResetPassword.jsx
Normal file
167
packages/admin-ui/src/pages/Authentication/ResetPassword.jsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import React, { useState } from 'react'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { H2, Label3, P } from 'src/components/typography'
|
||||
import Logo from 'src/styling/icons/menu/logo.svg?react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { SecretInput } from 'src/components/inputs/formik/'
|
||||
|
||||
import classes from './Authentication.module.css'
|
||||
|
||||
const VALIDATE_RESET_PASSWORD_LINK = gql`
|
||||
query validateResetPasswordLink($token: String!) {
|
||||
validateResetPasswordLink(token: $token) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RESET_PASSWORD = gql`
|
||||
mutation resetPassword($token: String!, $userID: ID!, $newPassword: String!) {
|
||||
resetPassword(token: $token, userID: $userID, newPassword: $newPassword)
|
||||
}
|
||||
`
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
password: Yup.string()
|
||||
.required('A new password is required')
|
||||
.test(
|
||||
'len',
|
||||
'New password must contain more than 8 characters',
|
||||
val => val.length >= 8
|
||||
),
|
||||
confirmPassword: Yup.string().oneOf(
|
||||
[Yup.ref('password'), null],
|
||||
'Passwords must match'
|
||||
)
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
|
||||
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
|
||||
if (!formikErrors || !formikTouched) return null
|
||||
if (mutationError) return 'Internal server error'
|
||||
if (formikErrors.password && formikTouched.password)
|
||||
return formikErrors.password
|
||||
if (formikErrors.confirmPassword && formikTouched.confirmPassword)
|
||||
return formikErrors.confirmPassword
|
||||
return null
|
||||
}
|
||||
|
||||
const ResetPassword = () => {
|
||||
const history = useHistory()
|
||||
const QueryParams = () => new URLSearchParams(useLocation().search)
|
||||
const token = QueryParams().get('t')
|
||||
const [userID, setUserID] = useState(null)
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const [wasSuccessful, setSuccess] = useState(false)
|
||||
|
||||
useQuery(VALIDATE_RESET_PASSWORD_LINK, {
|
||||
variables: { token: token },
|
||||
onCompleted: ({ validateResetPasswordLink: info }) => {
|
||||
setLoading(false)
|
||||
if (!info) {
|
||||
setSuccess(false)
|
||||
} else {
|
||||
setSuccess(true)
|
||||
setUserID(info.id)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setLoading(false)
|
||||
setSuccess(false)
|
||||
}
|
||||
})
|
||||
|
||||
const [resetPassword, { error }] = useMutation(RESET_PASSWORD, {
|
||||
onCompleted: ({ resetPassword: success }) => {
|
||||
if (success) history.push('/')
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={classes.welcomeBackground}>
|
||||
<Grid>
|
||||
<div>
|
||||
<Paper elevation={1}>
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.titleWrapper}>
|
||||
<Logo className={classes.icon} />
|
||||
<H2 className={classes.title}>Lamassu Admin</H2>
|
||||
</div>
|
||||
{!isLoading && wasSuccessful && (
|
||||
<Formik
|
||||
validationSchema={validationSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={values => {
|
||||
resetPassword({
|
||||
variables: {
|
||||
token: token,
|
||||
userID: userID,
|
||||
newPassword: values.confirmPassword
|
||||
}
|
||||
})
|
||||
}}>
|
||||
{({ errors, touched }) => (
|
||||
<Form id="reset-password">
|
||||
<Field
|
||||
name="password"
|
||||
autoFocus
|
||||
size="lg"
|
||||
component={SecretInput}
|
||||
label="New password"
|
||||
fullWidth
|
||||
className="-mt-4 mb-6"
|
||||
/>
|
||||
<Field
|
||||
name="confirmPassword"
|
||||
size="lg"
|
||||
component={SecretInput}
|
||||
label="Confirm your password"
|
||||
fullWidth
|
||||
/>
|
||||
<div className="mt-15">
|
||||
{getErrorMsg(errors, touched, error) && (
|
||||
<P className="text-tomato">
|
||||
{getErrorMsg(errors, touched, error)}
|
||||
</P>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="reset-password"
|
||||
buttonClassName="w-full">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
{!isLoading && !wasSuccessful && (
|
||||
<>
|
||||
<Label3>Link has expired</Label3>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResetPassword
|
||||
174
packages/admin-ui/src/pages/Authentication/Setup2FAState.jsx
Normal file
174
packages/admin-ui/src/pages/Authentication/Setup2FAState.jsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useMutation, useQuery, useLazyQuery, gql } from '@apollo/client'
|
||||
import { Form, Formik } from 'formik'
|
||||
import { QRCodeSVG as QRCode } from 'qrcode.react'
|
||||
import React, { useContext, useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Label3, P } from 'src/components/typography'
|
||||
|
||||
import AppContext from 'src/AppContext'
|
||||
import { ActionButton, Button } from 'src/components/buttons'
|
||||
import { CodeInput } from 'src/components/inputs/base'
|
||||
import { primaryColor } from 'src/styling/variables'
|
||||
|
||||
import classes from './Authentication.module.css'
|
||||
|
||||
const SETUP_2FA = gql`
|
||||
mutation setup2FA(
|
||||
$username: String!
|
||||
$password: String!
|
||||
$rememberMe: Boolean!
|
||||
$codeConfirmation: String!
|
||||
) {
|
||||
setup2FA(
|
||||
username: $username
|
||||
password: $password
|
||||
rememberMe: $rememberMe
|
||||
codeConfirmation: $codeConfirmation
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_2FA_SECRET = gql`
|
||||
query get2FASecret($username: String!, $password: String!) {
|
||||
get2FASecret(username: $username, password: $password) {
|
||||
secret
|
||||
otpauth
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_USER_DATA = gql`
|
||||
{
|
||||
userData {
|
||||
id
|
||||
username
|
||||
role
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Setup2FAState = ({ state, dispatch }) => {
|
||||
const history = useHistory()
|
||||
const { setUserData } = useContext(AppContext)
|
||||
|
||||
const [secret, setSecret] = useState(null)
|
||||
const [otpauth, setOtpauth] = useState(null)
|
||||
const [isShowing, setShowing] = useState(false)
|
||||
|
||||
const [invalidToken, setInvalidToken] = useState(false)
|
||||
const [twoFAConfirmation, setTwoFAConfirmation] = useState('')
|
||||
|
||||
const handle2FAChange = value => {
|
||||
setTwoFAConfirmation(value)
|
||||
setInvalidToken(false)
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
variables: { username: state.clientField, password: state.passwordField },
|
||||
onCompleted: ({ get2FASecret }) => {
|
||||
setSecret(get2FASecret.secret)
|
||||
setOtpauth(get2FASecret.otpauth)
|
||||
}
|
||||
}
|
||||
|
||||
const mutationOptions = {
|
||||
variables: {
|
||||
username: state.clientField,
|
||||
password: state.passwordField,
|
||||
rememberMe: state.rememberMeField,
|
||||
codeConfirmation: twoFAConfirmation
|
||||
}
|
||||
}
|
||||
|
||||
const { error: queryError } = useQuery(GET_2FA_SECRET, queryOptions)
|
||||
|
||||
const [getUserData] = useLazyQuery(GET_USER_DATA, {
|
||||
onCompleted: ({ userData }) => {
|
||||
setUserData(userData)
|
||||
history.push('/')
|
||||
}
|
||||
})
|
||||
|
||||
const [setup2FA, { error: mutationError }] = useMutation(SETUP_2FA, {
|
||||
onCompleted: ({ setup2FA: success }) => {
|
||||
success ? getUserData() : setInvalidToken(true)
|
||||
}
|
||||
})
|
||||
|
||||
const getErrorMsg = () => {
|
||||
if (mutationError || queryError) return 'Internal server error.'
|
||||
if (twoFAConfirmation.length !== 6 && invalidToken)
|
||||
return 'The code should have 6 characters!'
|
||||
if (invalidToken) return 'Code is invalid. Please try again.'
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (twoFAConfirmation.length !== 6) {
|
||||
setInvalidToken(true)
|
||||
return
|
||||
}
|
||||
setup2FA(mutationOptions)
|
||||
}
|
||||
|
||||
return (
|
||||
secret &&
|
||||
otpauth && (
|
||||
<>
|
||||
<div className={classes.infoWrapper}>
|
||||
<Label3 className={classes.info2}>
|
||||
This account does not yet have two-factor authentication enabled. To
|
||||
secure the admin, two-factor authentication is required.
|
||||
</Label3>
|
||||
<Label3 className={classes.info2}>
|
||||
To complete the registration process, scan the following QR code or
|
||||
insert the secret below on a 2FA app, such as Google Authenticator
|
||||
or AndOTP.
|
||||
</Label3>
|
||||
</div>
|
||||
<div className={classes.qrCodeWrapper}>
|
||||
<QRCode size={240} fgColor={primaryColor} value={otpauth} />
|
||||
</div>
|
||||
<div className={classes.secretWrapper}>
|
||||
<Label3 className={classes.secretLabel}>Your secret:</Label3>
|
||||
<Label3 className={isShowing ? classes.secret : classes.hiddenSecret}>
|
||||
{secret}
|
||||
</Label3>
|
||||
<ActionButton
|
||||
disabled={!secret && !otpauth}
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setShowing(!isShowing)
|
||||
}}>
|
||||
{isShowing ? 'Hide' : 'Show'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className={classes.confirm2FAInput}>
|
||||
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
|
||||
<Formik onSubmit={() => {}} initialValues={{}}>
|
||||
<Form>
|
||||
<CodeInput
|
||||
name="2fa"
|
||||
value={twoFAConfirmation}
|
||||
onChange={handle2FAChange}
|
||||
numInputs={6}
|
||||
error={invalidToken}
|
||||
shouldAutoFocus
|
||||
/>
|
||||
<div className="mt-9">
|
||||
{getErrorMsg() && (
|
||||
<P className="text-tomato">{getErrorMsg()}</P>
|
||||
)}
|
||||
<Button onClick={handleSubmit} buttonClassName="w-full">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default Setup2FAState
|
||||
8
packages/admin-ui/src/pages/Authentication/states.js
Normal file
8
packages/admin-ui/src/pages/Authentication/states.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const STATES = {
|
||||
LOGIN: 'LOGIN',
|
||||
SETUP_2FA: 'SETUP2FA',
|
||||
INPUT_2FA: 'INPUT2FA',
|
||||
FIDO: 'FIDO'
|
||||
}
|
||||
|
||||
export { STATES }
|
||||
313
packages/admin-ui/src/pages/Blacklist/Blacklist.jsx
Normal file
313
packages/admin-ui/src/pages/Blacklist/Blacklist.jsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import SvgIcon from '@mui/material/SvgIcon'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { HelpTooltip } from 'src/components/Tooltip'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import { H2, Label2, P, Info3, Info2 } from 'src/components/typography'
|
||||
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
|
||||
import ReverseSettingsIcon from 'src/styling/icons/circle buttons/settings/white.svg?react'
|
||||
import SettingsIcon from 'src/styling/icons/circle buttons/settings/zodiac.svg?react'
|
||||
|
||||
import { Link, Button, SupportLinkButton } from 'src/components/buttons'
|
||||
import { fromNamespace, toNamespace } from 'src/utils/config'
|
||||
|
||||
import BlackListAdvanced from './BlacklistAdvanced'
|
||||
import BlackListModal from './BlacklistModal'
|
||||
import BlacklistTable from './BlacklistTable'
|
||||
|
||||
const DELETE_ROW = gql`
|
||||
mutation DeleteBlacklistRow($address: String!) {
|
||||
deleteBlacklistRow(address: $address) {
|
||||
address
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_BLACKLIST = gql`
|
||||
query getBlacklistData {
|
||||
blacklist {
|
||||
address
|
||||
}
|
||||
cryptoCurrencies {
|
||||
display
|
||||
code
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SAVE_CONFIG = gql`
|
||||
mutation Save($config: JSONObject) {
|
||||
saveConfig(config: $config)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_INFO = gql`
|
||||
query getData {
|
||||
config
|
||||
}
|
||||
`
|
||||
|
||||
const ADD_ROW = gql`
|
||||
mutation InsertBlacklistRow($address: String!) {
|
||||
insertBlacklistRow(address: $address) {
|
||||
address
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_BLACKLIST_MESSAGES = gql`
|
||||
query getBlacklistMessages {
|
||||
blacklistMessages {
|
||||
id
|
||||
label
|
||||
content
|
||||
allowToggle
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const EDIT_BLACKLIST_MESSAGE = gql`
|
||||
mutation editBlacklistMessage($id: ID, $content: String) {
|
||||
editBlacklistMessage(id: $id, content: $content) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PaperWalletDialog = ({ onConfirmed, onDissmised, open, props }) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
aria-labelledby="form-dialog-title"
|
||||
PaperProps={{
|
||||
style: {
|
||||
borderRadius: 8,
|
||||
minWidth: 656,
|
||||
bottom: 125,
|
||||
right: 7
|
||||
}
|
||||
}}
|
||||
{...props}>
|
||||
<div className="p-2">
|
||||
<DialogTitle className="flex flex-col">
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={onDissmised}
|
||||
className="-mt-2 -mr-4 ml-auto">
|
||||
<SvgIcon>
|
||||
<CloseIcon />
|
||||
</SvgIcon>
|
||||
</IconButton>
|
||||
<H2 noMargin>{'Are you sure you want to enable this?'}</H2>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Info3>{`This mode means that only paper wallets will be printed for users, and they won't be permitted to scan an address from their own wallet.`}</Info3>
|
||||
<Info3>{`This mode is only useful for countries like Switzerland which mandates such a feature.\n`}</Info3>
|
||||
<Info2>{`Don't enable this if you want users to be able to scan an address of their choosing.`}</Info2>
|
||||
<div className="flex justify-end mt-8">
|
||||
<Button onClick={() => onConfirmed(true)}>Confirm</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const Blacklist = () => {
|
||||
const { data: blacklistResponse } = useQuery(GET_BLACKLIST)
|
||||
const { data: configData } = useQuery(GET_INFO)
|
||||
const { data: messagesResponse, refetch } = useQuery(GET_BLACKLIST_MESSAGES)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [errorMsg, setErrorMsg] = useState(null)
|
||||
const [editMessageError, setEditMessageError] = useState(null)
|
||||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
const [confirmDialog, setConfirmDialog] = useState(false)
|
||||
const [advancedSettings, setAdvancedSettings] = useState(false)
|
||||
|
||||
const [deleteEntry] = useMutation(DELETE_ROW, {
|
||||
onError: ({ message }) => {
|
||||
const errorMessage = message ?? 'Error while deleting row'
|
||||
setErrorMsg(errorMessage)
|
||||
},
|
||||
onCompleted: () => setDeleteDialog(false),
|
||||
refetchQueries: () => ['getBlacklistData']
|
||||
})
|
||||
|
||||
const [addEntry] = useMutation(ADD_ROW, {
|
||||
refetchQueries: () => ['getBlacklistData']
|
||||
})
|
||||
|
||||
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||
refetchQueries: () => ['getData']
|
||||
})
|
||||
|
||||
const [editMessage] = useMutation(EDIT_BLACKLIST_MESSAGE, {
|
||||
onError: e => setEditMessageError(e),
|
||||
refetchQueries: () => ['getBlacklistData']
|
||||
})
|
||||
|
||||
const blacklistData = R.path(['blacklist'])(blacklistResponse) ?? []
|
||||
|
||||
const complianceConfig =
|
||||
configData?.config && fromNamespace('compliance')(configData.config)
|
||||
|
||||
const rejectAddressReuse = !!complianceConfig?.rejectAddressReuse
|
||||
|
||||
const enablePaperWalletOnly = !!complianceConfig?.enablePaperWalletOnly
|
||||
|
||||
const addressReuseSave = rawConfig => {
|
||||
const config = toNamespace('compliance')(rawConfig)
|
||||
return saveConfig({ variables: { config } })
|
||||
}
|
||||
|
||||
const handleDeleteEntry = address => {
|
||||
deleteEntry({ variables: { address } })
|
||||
}
|
||||
|
||||
const handleConfirmDialog = confirm => {
|
||||
addressReuseSave({
|
||||
enablePaperWalletOnly: confirm
|
||||
})
|
||||
setConfirmDialog(false)
|
||||
}
|
||||
|
||||
const addToBlacklist = async address => {
|
||||
setErrorMsg(null)
|
||||
try {
|
||||
const res = await addEntry({ variables: { address } })
|
||||
if (!res?.errors) {
|
||||
return setShowModal(false)
|
||||
}
|
||||
const duplicateKeyError = res?.errors?.some(e => {
|
||||
return e.message.includes('duplicate')
|
||||
})
|
||||
if (duplicateKeyError) {
|
||||
setErrorMsg('This address is already being blocked')
|
||||
} else {
|
||||
setErrorMsg(`Server error${': ' + res?.errors[0]?.message}`)
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMsg('Server error')
|
||||
}
|
||||
}
|
||||
|
||||
const editBlacklistMessage = r => {
|
||||
editMessage({
|
||||
variables: {
|
||||
id: r.id,
|
||||
content: r.content
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperWalletDialog
|
||||
open={confirmDialog}
|
||||
onConfirmed={handleConfirmDialog}
|
||||
onDissmised={() => {
|
||||
setConfirmDialog(false)
|
||||
}}
|
||||
/>
|
||||
<TitleSection
|
||||
title="Blacklisted addresses"
|
||||
buttons={[
|
||||
{
|
||||
text: 'Advanced settings',
|
||||
icon: SettingsIcon,
|
||||
inverseIcon: ReverseSettingsIcon,
|
||||
toggle: setAdvancedSettings
|
||||
}
|
||||
]}>
|
||||
{!advancedSettings && (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-end mr-4">
|
||||
<P>Enable paper wallet (only)</P>
|
||||
<Switch
|
||||
checked={enablePaperWalletOnly}
|
||||
onChange={e =>
|
||||
enablePaperWalletOnly
|
||||
? addressReuseSave({
|
||||
enablePaperWalletOnly: e.target.checked
|
||||
})
|
||||
: setConfirmDialog(true)
|
||||
}
|
||||
value={enablePaperWalletOnly}
|
||||
/>
|
||||
<Label2>{enablePaperWalletOnly ? 'On' : 'Off'}</Label2>
|
||||
<HelpTooltip width={304}>
|
||||
<P>
|
||||
The "Enable paper wallet (only)" option means that only paper
|
||||
wallets will be printed for users, and they won't be permitted
|
||||
to scan an address from their own wallet.
|
||||
</P>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mr-4">
|
||||
<P>Reject reused addresses</P>
|
||||
<Switch
|
||||
checked={rejectAddressReuse}
|
||||
onChange={event => {
|
||||
addressReuseSave({ rejectAddressReuse: event.target.checked })
|
||||
}}
|
||||
value={rejectAddressReuse}
|
||||
/>
|
||||
<Label2>{rejectAddressReuse ? 'On' : 'Off'}</Label2>
|
||||
<HelpTooltip width={304}>
|
||||
<P>
|
||||
For details about rejecting address reuse, please read the
|
||||
relevant knowledgebase article:
|
||||
</P>
|
||||
<SupportLinkButton
|
||||
link="https://support.lamassu.is/hc/en-us/articles/360033622211-Reject-Address-Reuse"
|
||||
label="Reject Address Reuse"
|
||||
/>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<Link color="primary" onClick={() => setShowModal(true)}>
|
||||
Blacklist new addresses
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</TitleSection>
|
||||
{!advancedSettings && (
|
||||
<div className="flex flex-col flex-1">
|
||||
<BlacklistTable
|
||||
data={blacklistData}
|
||||
handleDeleteEntry={handleDeleteEntry}
|
||||
errorMessage={errorMsg}
|
||||
setErrorMessage={setErrorMsg}
|
||||
deleteDialog={deleteDialog}
|
||||
setDeleteDialog={setDeleteDialog}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{advancedSettings && (
|
||||
<BlackListAdvanced
|
||||
data={messagesResponse}
|
||||
editBlacklistMessage={editBlacklistMessage}
|
||||
mutationError={editMessageError}
|
||||
onClose={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
{showModal && (
|
||||
<BlackListModal
|
||||
onClose={() => {
|
||||
setErrorMsg(null)
|
||||
setShowModal(false)
|
||||
}}
|
||||
errorMsg={errorMsg}
|
||||
addToBlacklist={addToBlacklist}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Blacklist
|
||||
172
packages/admin-ui/src/pages/Blacklist/BlacklistAdvanced.jsx
Normal file
172
packages/admin-ui/src/pages/Blacklist/BlacklistAdvanced.jsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import IconButton from '@mui/material/IconButton'
|
||||
import SvgIcon from '@mui/material/SvgIcon'
|
||||
import { Form, Formik, Field } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import Modal from 'src/components/Modal'
|
||||
import DataTable from 'src/components/tables/DataTable'
|
||||
import DisabledDeleteIcon from 'src/styling/icons/action/delete/disabled.svg?react'
|
||||
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
|
||||
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
|
||||
import DefaultIconReverse from 'src/styling/icons/button/retry/white.svg?react'
|
||||
import DefaultIcon from 'src/styling/icons/button/retry/zodiac.svg?react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { ActionButton, Button } from 'src/components/buttons'
|
||||
import { TextInput } from 'src/components/inputs/formik'
|
||||
|
||||
const DEFAULT_MESSAGE = `This address may be associated with a deceptive offer or a prohibited group. Please make sure you're using an address from your own wallet.`
|
||||
|
||||
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
|
||||
if (mutationError) return 'Internal server error'
|
||||
if (!formikErrors || !formikTouched) return null
|
||||
if (formikErrors.event && formikTouched.event) return formikErrors.event
|
||||
if (formikErrors.message && formikTouched.message) return formikErrors.message
|
||||
return null
|
||||
}
|
||||
|
||||
const BlacklistAdvanced = ({
|
||||
data,
|
||||
editBlacklistMessage,
|
||||
onClose,
|
||||
mutationError
|
||||
}) => {
|
||||
const [selectedMessage, setSelectedMessage] = useState(null)
|
||||
|
||||
const elements = [
|
||||
{
|
||||
name: 'label',
|
||||
header: 'Label',
|
||||
width: 250,
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
view: it => R.path(['label'], it)
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
header: 'Content',
|
||||
width: 690,
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
view: it => R.path(['content'], it)
|
||||
},
|
||||
{
|
||||
name: 'edit',
|
||||
header: 'Edit',
|
||||
width: 130,
|
||||
textAlign: 'center',
|
||||
size: 'sm',
|
||||
view: it => (
|
||||
<IconButton className="pl-3" onClick={() => setSelectedMessage(it)}>
|
||||
<SvgIcon>
|
||||
<EditIcon />
|
||||
</SvgIcon>
|
||||
</IconButton>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'deleteButton',
|
||||
header: 'Delete',
|
||||
width: 130,
|
||||
textAlign: 'center',
|
||||
size: 'sm',
|
||||
view: it => (
|
||||
<IconButton
|
||||
className="pl-3"
|
||||
disabled={
|
||||
!R.isNil(R.path(['allowToggle'], it)) &&
|
||||
!R.path(['allowToggle'], it)
|
||||
}>
|
||||
<SvgIcon>
|
||||
{R.path(['allowToggle'], it) ? (
|
||||
<DeleteIcon />
|
||||
) : (
|
||||
<DisabledDeleteIcon />
|
||||
)}
|
||||
</SvgIcon>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const handleModalClose = () => {
|
||||
setSelectedMessage(null)
|
||||
}
|
||||
|
||||
const handleSubmit = values => {
|
||||
editBlacklistMessage(values)
|
||||
handleModalClose()
|
||||
!R.isNil(onClose) && onClose()
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
label: !R.isNil(selectedMessage) ? selectedMessage.label : '',
|
||||
content: !R.isNil(selectedMessage) ? selectedMessage.content : ''
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
label: Yup.string().required('A label is required!'),
|
||||
content: Yup.string().required('The message content is required!').trim()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={R.path(['blacklistMessages'], data)}
|
||||
elements={elements}
|
||||
emptyText="No blacklisted addresses so far"
|
||||
name="blacklistTable"
|
||||
/>
|
||||
{selectedMessage && (
|
||||
<Modal
|
||||
title={`Blacklist message - ${selectedMessage?.label}`}
|
||||
open={true}
|
||||
width={676}
|
||||
height={400}
|
||||
handleClose={handleModalClose}>
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={values =>
|
||||
handleSubmit({ id: selectedMessage.id, ...values })
|
||||
}>
|
||||
{({ errors, touched, setFieldValue }) => (
|
||||
<Form className="flex flex-col h-full gap-5 py-5">
|
||||
<ActionButton
|
||||
color="primary"
|
||||
Icon={DefaultIcon}
|
||||
InverseIcon={DefaultIconReverse}
|
||||
className="w-36"
|
||||
type="button"
|
||||
onClick={() => setFieldValue('content', DEFAULT_MESSAGE)}>
|
||||
Reset to default
|
||||
</ActionButton>
|
||||
<Field
|
||||
name="content"
|
||||
label="Message content"
|
||||
fullWidth
|
||||
multiline={true}
|
||||
rows={6}
|
||||
component={TextInput}
|
||||
/>
|
||||
<div className="flex flex-row ml-auto mt-auto">
|
||||
{getErrorMsg(errors, touched, mutationError) && (
|
||||
<ErrorMessage>
|
||||
{getErrorMsg(errors, touched, mutationError)}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
<Button type="submit">Confirm</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlacklistAdvanced
|
||||
62
packages/admin-ui/src/pages/Blacklist/BlacklistModal.jsx
Normal file
62
packages/admin-ui/src/pages/Blacklist/BlacklistModal.jsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Formik, Form, Field } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import Modal from 'src/components/Modal'
|
||||
import { H3 } from 'src/components/typography'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Link } from 'src/components/buttons'
|
||||
import { TextInput } from 'src/components/inputs/formik'
|
||||
|
||||
const BlackListModal = ({ onClose, addToBlacklist, errorMsg }) => {
|
||||
const handleAddToBlacklist = address => {
|
||||
addToBlacklist(address)
|
||||
}
|
||||
|
||||
const placeholderAddress = '1ADwinnimZKGgQ3dpyfoUZvJh4p1UWSSpD'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
closeOnBackdropClick={true}
|
||||
width={676}
|
||||
height={200}
|
||||
handleClose={onClose}
|
||||
open={true}>
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
initialValues={{
|
||||
address: ''
|
||||
}}
|
||||
validationSchema={Yup.object({
|
||||
address: Yup.string().trim().required('An address is required')
|
||||
})}
|
||||
onSubmit={({ address }) => {
|
||||
handleAddToBlacklist(address.trim())
|
||||
}}>
|
||||
<Form id="address-form" className="flex flex-col">
|
||||
<H3 className="mt-auto mb-2">Blacklist new address</H3>
|
||||
<Field
|
||||
name="address"
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
label="Paste new address to blacklist here"
|
||||
placeholder={`ex: ${placeholderAddress}`}
|
||||
component={TextInput}
|
||||
/>
|
||||
<div className="flex flex-row mt-auto">
|
||||
{!R.isNil(errorMsg) && <ErrorMessage>{errorMsg}</ErrorMessage>}
|
||||
<div className="flex ml-auto mt-12">
|
||||
<Link type="submit" form="address-form">
|
||||
Blacklist address
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlackListModal
|
||||
78
packages/admin-ui/src/pages/Blacklist/BlacklistTable.jsx
Normal file
78
packages/admin-ui/src/pages/Blacklist/BlacklistTable.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import IconButton from '@mui/material/IconButton'
|
||||
import SvgIcon from '@mui/material/SvgIcon'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { DeleteDialog } from 'src/components/DeleteDialog'
|
||||
import DataTable from 'src/components/tables/DataTable'
|
||||
import CopyToClipboard from 'src/components/CopyToClipboard.jsx'
|
||||
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
|
||||
|
||||
const BlacklistTable = ({
|
||||
data,
|
||||
handleDeleteEntry,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
deleteDialog,
|
||||
setDeleteDialog
|
||||
}) => {
|
||||
const [toBeDeleted, setToBeDeleted] = useState()
|
||||
|
||||
const elements = [
|
||||
{
|
||||
name: 'address',
|
||||
header: 'Address',
|
||||
width: 1070,
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
view: it => (
|
||||
<div className="ml-2">
|
||||
<CopyToClipboard>{R.path(['address'], it)}</CopyToClipboard>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'deleteButton',
|
||||
header: 'Delete',
|
||||
width: 130,
|
||||
textAlign: 'center',
|
||||
size: 'sm',
|
||||
view: it => (
|
||||
<IconButton
|
||||
className="pl-3"
|
||||
onClick={() => {
|
||||
setDeleteDialog(true)
|
||||
setToBeDeleted(it)
|
||||
}}>
|
||||
<SvgIcon>
|
||||
<DeleteIcon />
|
||||
</SvgIcon>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={data}
|
||||
elements={elements}
|
||||
emptyText="No blacklisted addresses so far"
|
||||
name="blacklistTable"
|
||||
/>
|
||||
<DeleteDialog
|
||||
open={deleteDialog}
|
||||
onDismissed={() => {
|
||||
setDeleteDialog(false)
|
||||
setErrorMessage(null)
|
||||
}}
|
||||
onConfirmed={() => {
|
||||
setErrorMessage(null)
|
||||
handleDeleteEntry(R.path(['address'], toBeDeleted))
|
||||
}}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlacklistTable
|
||||
3
packages/admin-ui/src/pages/Blacklist/index.js
Normal file
3
packages/admin-ui/src/pages/Blacklist/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Blacklist from './Blacklist'
|
||||
|
||||
export default Blacklist
|
||||
154
packages/admin-ui/src/pages/Cashout/Cashout.jsx
Normal file
154
packages/admin-ui/src/pages/Cashout/Cashout.jsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { HelpTooltip } from 'src/components/Tooltip'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import { P, Label2 } from 'src/components/typography'
|
||||
|
||||
import { SupportLinkButton } from 'src/components/buttons'
|
||||
import { NamespacedTable as EditableTable } from 'src/components/editableTable'
|
||||
import { EmptyTable } from 'src/components/table'
|
||||
import { fromNamespace, toNamespace } from 'src/utils/config'
|
||||
|
||||
import Wizard from './Wizard'
|
||||
import { DenominationsSchema, getElements } from './helper'
|
||||
|
||||
const SAVE_CONFIG = gql`
|
||||
mutation Save($config: JSONObject) {
|
||||
saveConfig(config: $config)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_INFO = gql`
|
||||
query getData {
|
||||
machines {
|
||||
name
|
||||
deviceId
|
||||
cashUnits {
|
||||
cashbox
|
||||
cassette1
|
||||
cassette2
|
||||
cassette3
|
||||
cassette4
|
||||
recycler1
|
||||
recycler2
|
||||
recycler3
|
||||
recycler4
|
||||
recycler5
|
||||
recycler6
|
||||
}
|
||||
numberOfCassettes
|
||||
numberOfRecyclers
|
||||
}
|
||||
config
|
||||
}
|
||||
`
|
||||
|
||||
const CashOut = ({ name: SCREEN_KEY }) => {
|
||||
const [wizard, setWizard] = useState(false)
|
||||
const { data, loading } = useQuery(GET_INFO)
|
||||
|
||||
const [saveConfig, { error }] = useMutation(SAVE_CONFIG, {
|
||||
onCompleted: () => setWizard(false),
|
||||
refetchQueries: () => ['getData']
|
||||
})
|
||||
|
||||
const save = (rawConfig, accounts) => {
|
||||
const config = toNamespace(SCREEN_KEY)(rawConfig)
|
||||
return saveConfig({ variables: { config, accounts } })
|
||||
}
|
||||
|
||||
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
|
||||
|
||||
const fudgeFactorActive = config?.fudgeFactorActive ?? false
|
||||
const locale = data?.config && fromNamespace('locale')(data.config)
|
||||
const machines = data?.machines ?? []
|
||||
|
||||
const onToggle = id => {
|
||||
const namespaced = fromNamespace(id)(config)
|
||||
if (!DenominationsSchema.isValidSync(namespaced)) return setWizard(id)
|
||||
save(toNamespace(id, { active: !namespaced?.active }))
|
||||
}
|
||||
|
||||
const wasNeverEnabled = it => R.compose(R.length, R.keys)(it) === 1
|
||||
|
||||
return (
|
||||
!loading && (
|
||||
<>
|
||||
<TitleSection
|
||||
title="Cash-out"
|
||||
appendix={
|
||||
<HelpTooltip width={320}>
|
||||
<P>
|
||||
For details on configuring cash-out, please read the relevant
|
||||
knowledgebase article:
|
||||
</P>
|
||||
<SupportLinkButton
|
||||
link="https://support.lamassu.is/hc/en-us/articles/115003720192-Enabling-cash-out-on-the-admin"
|
||||
label="Enabling cash-out on the admin"
|
||||
bottomSpace="1"
|
||||
/>
|
||||
</HelpTooltip>
|
||||
}>
|
||||
<div className="flex items-center">
|
||||
<P>Transaction fudge factor</P>
|
||||
<Switch
|
||||
checked={fudgeFactorActive}
|
||||
onChange={event => {
|
||||
save({ fudgeFactorActive: event.target.checked })
|
||||
}}
|
||||
value={fudgeFactorActive}
|
||||
/>
|
||||
<Label2 className="m-1 w-6">
|
||||
{fudgeFactorActive ? 'On' : 'Off'}
|
||||
</Label2>
|
||||
<HelpTooltip width={304}>
|
||||
<P>
|
||||
Automatically accept customer deposits as complete if their
|
||||
received amount is 100 crypto atoms or less.
|
||||
</P>
|
||||
<P>
|
||||
(Crypto atoms are the smallest unit in each cryptocurrency.
|
||||
E.g., satoshis in Bitcoin, or wei in Ethereum.)
|
||||
</P>
|
||||
<P>For details please read the relevant knowledgebase article:</P>
|
||||
<SupportLinkButton
|
||||
link="https://support.lamassu.is/hc/en-us/articles/360050838011-Automatically-accepting-undersent-deposits-with-Fudge-Factor-"
|
||||
label="Lamassu Support Article"
|
||||
bottomSpace="1"
|
||||
/>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
</TitleSection>
|
||||
<EditableTable
|
||||
namespaces={R.map(R.path(['deviceId']))(machines)}
|
||||
data={config}
|
||||
stripeWhen={wasNeverEnabled}
|
||||
enableEdit
|
||||
editWidth={95}
|
||||
enableToggle
|
||||
toggleWidth={100}
|
||||
onToggle={onToggle}
|
||||
save={save}
|
||||
error={error?.message}
|
||||
validationSchema={DenominationsSchema}
|
||||
disableRowEdit={R.compose(R.not, R.path(['active']))}
|
||||
elements={getElements(machines, locale)}
|
||||
/>
|
||||
{R.isEmpty(machines) && <EmptyTable message="No machines so far" />}
|
||||
{wizard && (
|
||||
<Wizard
|
||||
machine={R.find(R.propEq('deviceId', wizard))(machines)}
|
||||
onClose={() => setWizard(false)}
|
||||
save={save}
|
||||
error={error?.message}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default CashOut
|
||||
153
packages/admin-ui/src/pages/Cashout/Wizard.jsx
Normal file
153
packages/admin-ui/src/pages/Cashout/Wizard.jsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import Modal from 'src/components/Modal'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Autocomplete } from 'src/components/inputs/formik'
|
||||
import denominations from 'src/utils/bill-denominations'
|
||||
import { getBillOptions } from 'src/utils/bill-options'
|
||||
import { toNamespace } from 'src/utils/config'
|
||||
import { transformNumber } from 'src/utils/number'
|
||||
|
||||
import WizardSplash from './WizardSplash'
|
||||
import WizardStep from './WizardStep'
|
||||
import { DenominationsSchema } from './helper'
|
||||
|
||||
const MODAL_WIDTH = 554
|
||||
const MODAL_HEIGHT = 520
|
||||
|
||||
const Wizard = ({ machine, locale, onClose, save, error }) => {
|
||||
// Each stacker counts as two steps, one for front and another for rear
|
||||
const LAST_STEP = machine.numberOfCassettes + machine.numberOfRecyclers + 1
|
||||
const [{ step, config }, setState] = useState({
|
||||
step: 0,
|
||||
config: { active: true }
|
||||
})
|
||||
|
||||
const options = getBillOptions(locale, denominations)
|
||||
|
||||
const title = `Enable cash-out`
|
||||
const isLastStep = step === LAST_STEP
|
||||
|
||||
const onContinue = async it => {
|
||||
if (isLastStep) {
|
||||
return save(
|
||||
toNamespace(
|
||||
machine.deviceId,
|
||||
DenominationsSchema.cast(config, { assert: false })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const newConfig = R.merge(config, it)
|
||||
|
||||
setState({
|
||||
step: step + 1,
|
||||
config: newConfig
|
||||
})
|
||||
}
|
||||
|
||||
const steps = R.concat(
|
||||
R.map(
|
||||
it => ({
|
||||
model: 'cassette',
|
||||
type: `cassette${it}`,
|
||||
display: `Cassette ${it}`,
|
||||
component: Autocomplete,
|
||||
inputProps: {
|
||||
options: options,
|
||||
labelProp: 'display',
|
||||
valueProp: 'code'
|
||||
}
|
||||
}),
|
||||
R.range(1, machine.numberOfCassettes + 1)
|
||||
),
|
||||
R.map(
|
||||
it => ({
|
||||
type: `recycler${it}`,
|
||||
model: 'recycler',
|
||||
display: `Recycler ${it}`,
|
||||
component: Autocomplete,
|
||||
inputProps: {
|
||||
options: options,
|
||||
labelProp: 'display',
|
||||
valueProp: 'code'
|
||||
}
|
||||
}),
|
||||
R.range(1, machine.numberOfRecyclers + 1)
|
||||
)
|
||||
)
|
||||
|
||||
const schema = () =>
|
||||
Yup.object().shape({
|
||||
cassette1:
|
||||
machine.numberOfCassettes >= 1 && step >= 1
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable(),
|
||||
cassette2:
|
||||
machine.numberOfCassettes >= 2 && step >= 2
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable(),
|
||||
cassette3:
|
||||
machine.numberOfCassettes >= 3 && step >= 3
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable(),
|
||||
cassette4:
|
||||
machine.numberOfCassettes >= 4 && step >= 4
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable(),
|
||||
recycler1:
|
||||
machine.numberOfRecyclers >= 1 && step >= machine.numberOfCassettes + 1
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable(),
|
||||
recycler2:
|
||||
machine.numberOfRecyclers >= 2 && step >= machine.numberOfCassettes + 2
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable(),
|
||||
recycler3:
|
||||
machine.numberOfRecyclers >= 3 && step >= machine.numberOfCassettes + 3
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable(),
|
||||
recycler4:
|
||||
machine.numberOfRecyclers >= 4 && step >= machine.numberOfCassettes + 4
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable(),
|
||||
recycler5:
|
||||
machine.numberOfRecyclers >= 5 && step >= machine.numberOfCassettes + 5
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable(),
|
||||
recycler6:
|
||||
machine.numberOfRecyclers >= 6 && step >= machine.numberOfCassettes + 6
|
||||
? Yup.number().required()
|
||||
: Yup.number().transform(transformNumber).nullable()
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={step === 0 ? null : title}
|
||||
handleClose={onClose}
|
||||
width={MODAL_WIDTH}
|
||||
height={MODAL_HEIGHT}
|
||||
open={true}>
|
||||
{step === 0 && (
|
||||
<WizardSplash name={machine.name} onContinue={() => onContinue()} />
|
||||
)}
|
||||
{step !== 0 && (
|
||||
<WizardStep
|
||||
step={step}
|
||||
name={machine.name}
|
||||
numberOfCassettes={machine.numberOfCassettes}
|
||||
error={error}
|
||||
isLastStep={isLastStep}
|
||||
steps={steps}
|
||||
fiatCurrency={locale.fiatCurrency}
|
||||
options={options}
|
||||
schema={schema()}
|
||||
onContinue={onContinue}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default Wizard
|
||||
34
packages/admin-ui/src/pages/Cashout/WizardSplash.jsx
Normal file
34
packages/admin-ui/src/pages/Cashout/WizardSplash.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react'
|
||||
import { H1, P, Info2 } from 'src/components/typography'
|
||||
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
|
||||
const WizardSplash = ({ name, onContinue }) => {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 px-8 gap-18">
|
||||
<div>
|
||||
<H1 className="text-neon text-center mb-3 mt-8">
|
||||
<TxOutIcon className="align-bottom mr-3 w-6 h-7" />
|
||||
<span>Enable cash-out</span>
|
||||
</H1>
|
||||
<Info2 noMargin className="mb-10 text-center">
|
||||
{name}
|
||||
</Info2>
|
||||
<P>
|
||||
You are about to activate cash-out functionality on your {name}{' '}
|
||||
machine which will allow your customers to sell crypto to you.
|
||||
</P>
|
||||
<P>
|
||||
In order to activate cash-out for this machine, please enter the
|
||||
denominations for the machine.
|
||||
</P>
|
||||
</div>
|
||||
<Button className="mx-auto" onClick={onContinue}>
|
||||
Start configuration
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WizardSplash
|
||||
153
packages/admin-ui/src/pages/Cashout/WizardStep.jsx
Normal file
153
packages/admin-ui/src/pages/Cashout/WizardStep.jsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { Formik, Form, Field } from 'formik'
|
||||
import React from 'react'
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import Stepper from 'src/components/Stepper'
|
||||
import { Info2, H4, P, Info1, Label1 } from 'src/components/typography'
|
||||
import WarningIcon from 'src/styling/icons/warning-icon/comet.svg?react'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { NumberInput } from 'src/components/inputs/formik'
|
||||
import cassetteOne from 'src/styling/icons/cassettes/cashout-cassette-1.svg'
|
||||
import cassetteTwo from 'src/styling/icons/cassettes/cashout-cassette-2.svg'
|
||||
import tejo3CassetteOne from 'src/styling/icons/cassettes/tejo/3-cassettes/3-cassettes-open-1-left.svg'
|
||||
import tejo3CassetteTwo from 'src/styling/icons/cassettes/tejo/3-cassettes/3-cassettes-open-2-left.svg'
|
||||
import tejo3CassetteThree from 'src/styling/icons/cassettes/tejo/3-cassettes/3-cassettes-open-3-left.svg'
|
||||
import tejo4CassetteOne from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-1-left.svg'
|
||||
import tejo4CassetteTwo from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-2-left.svg'
|
||||
import tejo4CassetteThree from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-3-left.svg'
|
||||
import tejo4CassetteFour from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-4-left.svg'
|
||||
|
||||
const getCassetesArtworks = () => ({
|
||||
1: {
|
||||
1: cassetteOne
|
||||
},
|
||||
2: {
|
||||
1: cassetteOne,
|
||||
2: cassetteTwo
|
||||
},
|
||||
3: {
|
||||
1: tejo3CassetteOne,
|
||||
2: tejo3CassetteTwo,
|
||||
3: tejo3CassetteThree
|
||||
},
|
||||
4: {
|
||||
1: tejo4CassetteOne,
|
||||
2: tejo4CassetteTwo,
|
||||
3: tejo4CassetteThree,
|
||||
4: tejo4CassetteFour
|
||||
}
|
||||
})
|
||||
|
||||
const WizardStep = ({
|
||||
name,
|
||||
step,
|
||||
schema,
|
||||
error,
|
||||
isLastStep,
|
||||
onContinue,
|
||||
steps,
|
||||
fiatCurrency,
|
||||
options,
|
||||
numberOfCassettes
|
||||
}) => {
|
||||
const label = isLastStep ? 'Finish' : 'Next'
|
||||
const cassetteIcon = getCassetesArtworks()[numberOfCassettes]
|
||||
return (
|
||||
<>
|
||||
<div className="pb-8">
|
||||
<Info2 className="m-0 mb-3">{name}</Info2>
|
||||
<Stepper steps={steps.length + 1} currentStep={step} />
|
||||
</div>
|
||||
|
||||
{!isLastStep && (
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
onSubmit={onContinue}
|
||||
initialValues={{
|
||||
cassette1: '',
|
||||
cassette2: '',
|
||||
cassette3: '',
|
||||
cassette4: ''
|
||||
}}
|
||||
enableReinitialize
|
||||
validationSchema={schema}>
|
||||
<Form className="flex flex-col justify-between grow-2 pb-8">
|
||||
<div className="flex">
|
||||
{steps.map(
|
||||
({ type, display, component }, idx) =>
|
||||
1 + idx === step && (
|
||||
<div key={idx} className="flex-1">
|
||||
<H4 noMargin>Edit {display}</H4>
|
||||
|
||||
<Label1>Choose bill denomination</Label1>
|
||||
<div className="flex items-center justify-end w-33">
|
||||
<Field
|
||||
className="w-full"
|
||||
type="text"
|
||||
size="lg"
|
||||
autoFocus={1 + idx === step}
|
||||
component={
|
||||
options?.length > 0 ? component : NumberInput
|
||||
}
|
||||
fullWidth
|
||||
decimalPlaces={0}
|
||||
name={type}
|
||||
options={options}
|
||||
valueProp={'code'}
|
||||
labelProp={'display'}></Field>
|
||||
<Info1 noMargin className="pl-4">
|
||||
{fiatCurrency}
|
||||
</Info1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<img
|
||||
className="relative -top-5 right-4"
|
||||
alt="cassette"
|
||||
width="148"
|
||||
height="205"
|
||||
src={cassetteIcon ? cassetteIcon[step] : null}></img>
|
||||
</div>
|
||||
|
||||
<Button className="self-end" type="submit">
|
||||
{label}
|
||||
</Button>
|
||||
</Form>
|
||||
</Formik>
|
||||
)}
|
||||
|
||||
{isLastStep && (
|
||||
<div className="flex flex-col justify-between grow-2 pb-8">
|
||||
<div>
|
||||
<Info2 className="m-0 mb-3">Cash Cassette Bill Count</Info2>
|
||||
<P>
|
||||
<WarningIcon className="float-left mr-4 mb-12" />
|
||||
When enabling cash-out, your bill count will be automatically set
|
||||
to zero. Make sure you physically put cash inside the cash
|
||||
cassettes to allow the machine to dispense it to your users. If
|
||||
you already did, make sure you set the correct cash cassette bill
|
||||
count for this machine on your Cash boxes & cassettes tab under
|
||||
Maintenance.
|
||||
</P>
|
||||
<Info2 className="m-0 mb-3">Default Commissions</Info2>
|
||||
<P>
|
||||
<WarningIcon className="float-left mr-4 mb-12" />
|
||||
When enabling cash-out, default commissions will be set. To change
|
||||
commissions for this machine, please go to the Commissions tab
|
||||
under Settings where you can set exceptions for each of the
|
||||
available cryptocurrencies.
|
||||
</P>
|
||||
</div>
|
||||
{error && <ErrorMessage>Failed to save</ErrorMessage>}
|
||||
<Button className="self-end" onClick={() => onContinue()}>
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WizardStep
|
||||
196
packages/admin-ui/src/pages/Cashout/helper.js
Normal file
196
packages/admin-ui/src/pages/Cashout/helper.js
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import * as R from 'ramda'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Autocomplete, NumberInput } from 'src/components/inputs/formik'
|
||||
import { bold } from 'src/styling/helpers'
|
||||
import denominations from 'src/utils/bill-denominations'
|
||||
import { getBillOptions } from 'src/utils/bill-options'
|
||||
import { CURRENCY_MAX } from 'src/utils/constants'
|
||||
import { transformNumber } from 'src/utils/number'
|
||||
|
||||
const widthsByNumberOfUnits = {
|
||||
2: { machine: 325, cassette: 340 },
|
||||
3: { machine: 300, cassette: 235 },
|
||||
4: { machine: 205, cassette: 200 },
|
||||
5: { machine: 180, cassette: 165 },
|
||||
6: { machine: 165, cassette: 140 },
|
||||
7: { machine: 130, cassette: 125 }
|
||||
}
|
||||
|
||||
const denominationKeys = [
|
||||
'cassette1',
|
||||
'cassette2',
|
||||
'cassette3',
|
||||
'cassette4',
|
||||
'recycler1',
|
||||
'recycler2',
|
||||
'recycler3',
|
||||
'recycler4',
|
||||
'recycler5',
|
||||
'recycler6'
|
||||
]
|
||||
|
||||
const DenominationsSchema = Yup.object()
|
||||
.shape({
|
||||
cassette1: Yup.number()
|
||||
.label('Cassette 1')
|
||||
.min(1)
|
||||
.nullable()
|
||||
.max(CURRENCY_MAX),
|
||||
cassette2: Yup.number()
|
||||
.label('Cassette 2')
|
||||
.min(1)
|
||||
.max(CURRENCY_MAX)
|
||||
.nullable()
|
||||
.transform(transformNumber),
|
||||
cassette3: Yup.number()
|
||||
.label('Cassette 3')
|
||||
.min(1)
|
||||
.max(CURRENCY_MAX)
|
||||
.nullable()
|
||||
.transform(transformNumber),
|
||||
cassette4: Yup.number()
|
||||
.label('Cassette 4')
|
||||
.min(1)
|
||||
.max(CURRENCY_MAX)
|
||||
.nullable()
|
||||
.transform(transformNumber),
|
||||
recycler1: Yup.number()
|
||||
.label('Recycler 1')
|
||||
.min(1)
|
||||
.max(CURRENCY_MAX)
|
||||
.nullable()
|
||||
.transform(transformNumber),
|
||||
recycler2: Yup.number()
|
||||
.label('Recycler 2')
|
||||
.min(1)
|
||||
.max(CURRENCY_MAX)
|
||||
.nullable()
|
||||
.transform(transformNumber),
|
||||
recycler3: Yup.number()
|
||||
.label('Recycler 3')
|
||||
.min(1)
|
||||
.max(CURRENCY_MAX)
|
||||
.nullable()
|
||||
.transform(transformNumber),
|
||||
recycler4: Yup.number()
|
||||
.label('Recycler 4')
|
||||
.min(1)
|
||||
.max(CURRENCY_MAX)
|
||||
.nullable()
|
||||
.transform(transformNumber),
|
||||
recycler5: Yup.number()
|
||||
.label('Recycler 5')
|
||||
.min(1)
|
||||
.max(CURRENCY_MAX)
|
||||
.nullable()
|
||||
.transform(transformNumber),
|
||||
recycler6: Yup.number()
|
||||
.label('Recycler 6')
|
||||
.min(1)
|
||||
.max(CURRENCY_MAX)
|
||||
.nullable()
|
||||
.transform(transformNumber)
|
||||
})
|
||||
.test((values, context) =>
|
||||
R.any(key => !R.isNil(values[key]), denominationKeys)
|
||||
? true
|
||||
: context.createError({
|
||||
path: '',
|
||||
message:
|
||||
'The recyclers or at least one of the cassettes must have a value'
|
||||
})
|
||||
)
|
||||
|
||||
const getElements = (machines, locale = {}) => {
|
||||
const fiatCurrency = R.prop('fiatCurrency')(locale)
|
||||
const maxNumberOfCassettes = Math.max(
|
||||
...R.map(it => it.numberOfCassettes, machines),
|
||||
0
|
||||
)
|
||||
const maxNumberOfRecyclers = Math.max(
|
||||
...R.map(it => it.numberOfRecyclers, machines),
|
||||
0
|
||||
)
|
||||
const numberOfCashUnits =
|
||||
maxNumberOfCassettes + Math.ceil(maxNumberOfRecyclers / 2)
|
||||
|
||||
const options = getBillOptions(locale, denominations)
|
||||
const cassetteProps =
|
||||
options?.length > 0
|
||||
? {
|
||||
options: options,
|
||||
labelProp: 'display',
|
||||
valueProp: 'code',
|
||||
className: 'w-full'
|
||||
}
|
||||
: { decimalPlaces: 0 }
|
||||
|
||||
const elements = [
|
||||
{
|
||||
name: 'id',
|
||||
header: 'Machine',
|
||||
width: widthsByNumberOfUnits[numberOfCashUnits]?.machine,
|
||||
view: it => machines.find(({ deviceId }) => deviceId === it).name,
|
||||
size: 'sm',
|
||||
editable: false
|
||||
}
|
||||
]
|
||||
|
||||
R.until(
|
||||
R.gt(R.__, maxNumberOfCassettes),
|
||||
it => {
|
||||
elements.push({
|
||||
name: `cassette${it}`,
|
||||
header: `Cassette ${it}`,
|
||||
size: 'sm',
|
||||
stripe: true,
|
||||
textAlign: 'right',
|
||||
width: widthsByNumberOfUnits[numberOfCashUnits]?.cassette,
|
||||
suffix: fiatCurrency,
|
||||
bold: bold,
|
||||
view: it => it,
|
||||
input: options?.length > 0 ? Autocomplete : NumberInput,
|
||||
inputProps: cassetteProps,
|
||||
doubleHeader: 'Denominations of Cassettes & Recyclers',
|
||||
isHidden: machine =>
|
||||
it >
|
||||
machines.find(({ deviceId }) => deviceId === machine.id)
|
||||
.numberOfCassettes
|
||||
})
|
||||
return R.add(1, it)
|
||||
},
|
||||
1
|
||||
)
|
||||
|
||||
R.until(
|
||||
R.gt(R.__, Math.ceil(maxNumberOfRecyclers / 2)),
|
||||
it => {
|
||||
elements.push({
|
||||
names: [`recycler${it * 2 - 1}`, `recycler${it * 2}`],
|
||||
header: `Recyclers ${it * 2 - 1} - ${it * 2}`,
|
||||
size: 'sm',
|
||||
stripe: true,
|
||||
textAlign: 'right',
|
||||
width: widthsByNumberOfUnits[numberOfCashUnits]?.cassette,
|
||||
suffix: fiatCurrency,
|
||||
bold: bold,
|
||||
input: options?.length > 0 ? Autocomplete : NumberInput,
|
||||
inputProps: cassetteProps,
|
||||
doubleHeader: 'Denominations of Cassettes & Recyclers',
|
||||
isHidden: machine =>
|
||||
it >
|
||||
Math.ceil(
|
||||
machines.find(({ deviceId }) => deviceId === machine.id)
|
||||
.numberOfRecyclers / 2
|
||||
)
|
||||
})
|
||||
return R.add(1, it)
|
||||
},
|
||||
1
|
||||
)
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
export { DenominationsSchema, getElements }
|
||||
3
packages/admin-ui/src/pages/Cashout/index.js
Normal file
3
packages/admin-ui/src/pages/Cashout/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Cashout from './Cashout'
|
||||
|
||||
export default Cashout
|
||||
159
packages/admin-ui/src/pages/Commissions/Commissions.jsx
Normal file
159
packages/admin-ui/src/pages/Commissions/Commissions.jsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { HelpTooltip } from 'src/components/Tooltip'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import ReverseListingViewIcon from 'src/styling/icons/circle buttons/listing-view/white.svg?react'
|
||||
import ListingViewIcon from 'src/styling/icons/circle buttons/listing-view/zodiac.svg?react'
|
||||
import OverrideLabelIcon from 'src/styling/icons/status/spring2.svg?react'
|
||||
|
||||
import { SupportLinkButton } from 'src/components/buttons'
|
||||
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
|
||||
|
||||
import { P } from '../../components/typography'
|
||||
|
||||
import CommissionsDetails from './components/CommissionsDetails'
|
||||
import CommissionsList from './components/CommissionsList'
|
||||
|
||||
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 removeCoinFromOverride = crypto => override =>
|
||||
R.mergeRight(override, {
|
||||
cryptoCurrencies: R.without([crypto], override.cryptoCurrencies)
|
||||
})
|
||||
|
||||
const Commissions = ({ name: SCREEN_KEY }) => {
|
||||
const [showMachines, setShowMachines] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const { data, loading } = useQuery(GET_DATA)
|
||||
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||
refetchQueries: () => ['getData'],
|
||||
onError: error => setError(error)
|
||||
})
|
||||
|
||||
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
|
||||
const localeConfig =
|
||||
data?.config && fromNamespace(namespaces.LOCALE)(data.config)
|
||||
|
||||
const currency = R.prop('fiatCurrency')(localeConfig)
|
||||
const overrides = R.prop('overrides')(config)
|
||||
|
||||
const save = it => {
|
||||
const config = toNamespace(SCREEN_KEY)(it.commissions[0])
|
||||
return saveConfig({ variables: { config } })
|
||||
}
|
||||
|
||||
const saveOverrides = it => {
|
||||
const config = toNamespace(SCREEN_KEY)(it)
|
||||
setError(null)
|
||||
return saveConfig({ variables: { config } })
|
||||
}
|
||||
|
||||
const saveOverridesFromList = it => (_, override) => {
|
||||
const cryptoOverridden = R.path(['cryptoCurrencies', 0], override)
|
||||
|
||||
const sameMachine = R.eqProps('machine', override)
|
||||
const notSameOverride = it => !R.eqProps('cryptoCurrencies', override, it)
|
||||
|
||||
const filterMachine = R.filter(R.both(sameMachine, notSameOverride))
|
||||
const removeCoin = removeCoinFromOverride(cryptoOverridden)
|
||||
|
||||
const machineOverrides = R.map(removeCoin)(filterMachine(it))
|
||||
|
||||
const overrides = machineOverrides.concat(
|
||||
R.filter(it => !sameMachine(it), it)
|
||||
)
|
||||
|
||||
const config = {
|
||||
commissions_overrides: R.prepend(override, overrides)
|
||||
}
|
||||
|
||||
return saveConfig({ variables: { config } })
|
||||
}
|
||||
|
||||
const labels = showMachines
|
||||
? [
|
||||
{
|
||||
label: 'Override value',
|
||||
icon: <OverrideLabelIcon />
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleSection
|
||||
title="Commissions"
|
||||
labels={labels}
|
||||
buttons={[
|
||||
{
|
||||
text: 'List view',
|
||||
icon: ListingViewIcon,
|
||||
inverseIcon: ReverseListingViewIcon,
|
||||
toggle: setShowMachines
|
||||
}
|
||||
]}
|
||||
iconClassName="ml-1"
|
||||
appendix={
|
||||
<HelpTooltip width={320}>
|
||||
<P>
|
||||
For details about commissions, please read the relevant
|
||||
knowledgebase articles:
|
||||
</P>
|
||||
<SupportLinkButton
|
||||
link="https://support.lamassu.is/hc/en-us/articles/115001211752-Fixed-fees-Minimum-transaction"
|
||||
label="Fixed fees & Minimum transaction"
|
||||
bottomSpace="1"
|
||||
/>
|
||||
<SupportLinkButton
|
||||
link="https://support.lamassu.is/hc/en-us/articles/360061558352-Commissions-and-Profit-Calculations"
|
||||
label="Commissions and Profit Calculations"
|
||||
bottomSpace="1"
|
||||
/>
|
||||
</HelpTooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
{!showMachines && !loading && (
|
||||
<CommissionsDetails
|
||||
config={config}
|
||||
locale={localeConfig}
|
||||
currency={currency}
|
||||
data={data}
|
||||
error={error}
|
||||
save={save}
|
||||
saveOverrides={saveOverrides}
|
||||
/>
|
||||
)}
|
||||
{showMachines && !loading && (
|
||||
<CommissionsList
|
||||
config={config}
|
||||
localeConfig={localeConfig}
|
||||
currency={currency}
|
||||
data={data}
|
||||
error={error}
|
||||
saveOverrides={saveOverridesFromList(overrides)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Commissions
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import * as R from 'ramda'
|
||||
import React, { useState, memo } from 'react'
|
||||
import Section from 'src/components/layout/Section'
|
||||
import {
|
||||
mainFields,
|
||||
overrides,
|
||||
getSchema,
|
||||
getOverridesSchema,
|
||||
defaults,
|
||||
overridesDefaults,
|
||||
getOrder
|
||||
} from 'src/pages/Commissions/helper'
|
||||
|
||||
import { Table as EditableTable } from 'src/components/editableTable'
|
||||
|
||||
const CommissionsDetails = memo(
|
||||
({ config, locale, currency, data, error, save, saveOverrides }) => {
|
||||
const [isEditingDefault, setEditingDefault] = useState(false)
|
||||
const [isEditingOverrides, setEditingOverrides] = useState(false)
|
||||
|
||||
const commission = config && !R.isEmpty(config) ? config : defaults
|
||||
const commissionOverrides = commission?.overrides ?? []
|
||||
|
||||
const orderedCommissionsOverrides = R.sortWith([
|
||||
R.ascend(getOrder),
|
||||
R.ascend(R.prop('machine'))
|
||||
])(commissionOverrides)
|
||||
|
||||
const onEditingDefault = (it, editing) => setEditingDefault(editing)
|
||||
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
title="Default setup"
|
||||
rowSize="lg"
|
||||
titleLg
|
||||
name="commissions"
|
||||
enableEdit
|
||||
initialValues={commission}
|
||||
save={save}
|
||||
validationSchema={getSchema(locale)}
|
||||
data={R.of(commission)}
|
||||
elements={mainFields(currency)}
|
||||
setEditing={onEditingDefault}
|
||||
forceDisable={isEditingOverrides}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
title="Overrides"
|
||||
titleLg
|
||||
name="overrides"
|
||||
enableDelete
|
||||
enableEdit
|
||||
enableCreate
|
||||
groupBy={getOrder}
|
||||
initialValues={overridesDefaults}
|
||||
save={saveOverrides}
|
||||
validationSchema={getOverridesSchema(
|
||||
orderedCommissionsOverrides,
|
||||
data,
|
||||
locale
|
||||
)}
|
||||
data={orderedCommissionsOverrides}
|
||||
elements={overrides(data, currency, orderedCommissionsOverrides)}
|
||||
setEditing={onEditingOverrides}
|
||||
forceDisable={isEditingDefault}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default CommissionsDetails
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import * as R from 'ramda'
|
||||
import React, { memo, useState } from 'react'
|
||||
import {
|
||||
overridesDefaults,
|
||||
getCommissions,
|
||||
getListCommissionsSchema,
|
||||
commissionsList
|
||||
} from 'src/pages/Commissions/helper'
|
||||
|
||||
import { Table as EditableTable } from 'src/components/editableTable'
|
||||
import { Select } from 'src/components/inputs'
|
||||
|
||||
const SHOW_ALL = {
|
||||
code: 'SHOW_ALL',
|
||||
display: 'Show all'
|
||||
}
|
||||
|
||||
const ORDER_OPTIONS = [
|
||||
{
|
||||
code: 'machine',
|
||||
display: 'Machine name'
|
||||
},
|
||||
{
|
||||
code: 'cryptoCurrencies',
|
||||
display: 'Cryptocurrency'
|
||||
},
|
||||
{
|
||||
code: 'cashIn',
|
||||
display: 'Cash-in'
|
||||
},
|
||||
{
|
||||
code: 'cashOut',
|
||||
display: 'Cash-out'
|
||||
},
|
||||
{
|
||||
code: 'fixedFee',
|
||||
display: 'Fixed fee'
|
||||
},
|
||||
{
|
||||
code: 'minimumTx',
|
||||
display: 'Minimum Tx'
|
||||
}
|
||||
]
|
||||
|
||||
const getElement = (code, display) => ({
|
||||
code: code,
|
||||
display: display || code
|
||||
})
|
||||
|
||||
const sortCommissionsBy = prop => {
|
||||
switch (prop) {
|
||||
case ORDER_OPTIONS[0]:
|
||||
return R.sortBy(R.find(R.propEq('code', R.prop('machine'))))
|
||||
case ORDER_OPTIONS[1]:
|
||||
return R.sortBy(R.path(['cryptoCurrencies', 0]))
|
||||
default:
|
||||
return R.sortBy(R.prop(prop.code))
|
||||
}
|
||||
}
|
||||
|
||||
const filterCommissions = (coinFilter, machineFilter) =>
|
||||
R.compose(
|
||||
R.filter(
|
||||
it => (machineFilter === SHOW_ALL) | (machineFilter.code === it.machine)
|
||||
),
|
||||
R.filter(
|
||||
it =>
|
||||
(coinFilter === SHOW_ALL) | (coinFilter.code === it.cryptoCurrencies[0])
|
||||
)
|
||||
)
|
||||
|
||||
const CommissionsList = memo(
|
||||
({ config, localeConfig, currency, data, error, saveOverrides }) => {
|
||||
const [machineFilter, setMachineFilter] = useState(SHOW_ALL)
|
||||
const [coinFilter, setCoinFilter] = useState(SHOW_ALL)
|
||||
const [orderProp, setOrderProp] = useState(ORDER_OPTIONS[0])
|
||||
|
||||
const coins = R.prop('cryptoCurrencies', localeConfig) ?? []
|
||||
|
||||
const getMachineCoins = deviceId => {
|
||||
const override = R.prop('overrides', localeConfig)?.find(
|
||||
R.propEq('machine', deviceId)
|
||||
)
|
||||
|
||||
const machineCoins = override
|
||||
? R.prop('cryptoCurrencies', override)
|
||||
: coins
|
||||
|
||||
return R.xprod([deviceId], machineCoins)
|
||||
}
|
||||
|
||||
const getMachineElement = it =>
|
||||
getElement(R.prop('deviceId', it), R.prop('name', it))
|
||||
|
||||
const cryptoData = R.map(getElement)(coins)
|
||||
|
||||
const machineData = R.sortBy(
|
||||
R.prop('display'),
|
||||
R.map(getMachineElement)(R.prop('machines', data))
|
||||
)
|
||||
|
||||
const machinesCoinsTuples = R.unnest(
|
||||
R.map(getMachineCoins)(machineData.map(R.prop('code')))
|
||||
)
|
||||
|
||||
const commissions = R.map(([deviceId, cryptoCode]) =>
|
||||
getCommissions(cryptoCode, deviceId, config)
|
||||
)(machinesCoinsTuples)
|
||||
|
||||
const tableData = R.compose(
|
||||
sortCommissionsBy(orderProp),
|
||||
filterCommissions(coinFilter, machineFilter)
|
||||
)(commissions)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex mb-6">
|
||||
<Select
|
||||
className="mr-5"
|
||||
onSelectedItemChange={setMachineFilter}
|
||||
label="Machines"
|
||||
default={SHOW_ALL}
|
||||
items={[SHOW_ALL].concat(machineData)}
|
||||
selectedItem={machineFilter}
|
||||
/>
|
||||
<Select
|
||||
className="mr-5"
|
||||
onSelectedItemChange={setCoinFilter}
|
||||
label="Cryptocurrency"
|
||||
default={SHOW_ALL}
|
||||
items={[SHOW_ALL].concat(cryptoData)}
|
||||
selectedItem={coinFilter}
|
||||
/>
|
||||
<Select
|
||||
onSelectedItemChange={setOrderProp}
|
||||
label="Sort by"
|
||||
default={ORDER_OPTIONS[0]}
|
||||
items={ORDER_OPTIONS}
|
||||
selectedItem={orderProp}
|
||||
defaultAsFilter
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 w-full max-h-[70vh] overflow-y-auto">
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
name="comissionsList"
|
||||
enableEdit
|
||||
save={saveOverrides}
|
||||
initialValues={overridesDefaults}
|
||||
validationSchema={getListCommissionsSchema(localeConfig)}
|
||||
data={tableData}
|
||||
elements={commissionsList(data, currency)}
|
||||
orderedBy={orderProp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default CommissionsList
|
||||
614
packages/admin-ui/src/pages/Commissions/helper.jsx
Normal file
614
packages/admin-ui/src/pages/Commissions/helper.jsx
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
|
||||
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Autocomplete, NumberInput } from 'src/components/inputs/formik'
|
||||
import { bold } from 'src/styling/helpers'
|
||||
import { primaryColor, secondaryColorDark } from 'src/styling/variables'
|
||||
import denominations from 'src/utils/bill-denominations'
|
||||
import { getBillOptions } from 'src/utils/bill-options'
|
||||
import { CURRENCY_MAX } from 'src/utils/constants'
|
||||
|
||||
const ALL_MACHINES = {
|
||||
name: 'All Machines',
|
||||
deviceId: 'ALL_MACHINES'
|
||||
}
|
||||
|
||||
const ALL_COINS = {
|
||||
display: 'All Coins',
|
||||
code: 'ALL_COINS'
|
||||
}
|
||||
|
||||
const cashInAndOutHeaderStyle = { marginLeft: 6, whiteSpace: 'nowrap' }
|
||||
|
||||
const cashInHeader = (
|
||||
<div>
|
||||
<TxInIcon />
|
||||
<span style={cashInAndOutHeaderStyle}>Cash-in</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const cashOutHeader = (
|
||||
<div>
|
||||
<TxOutIcon />
|
||||
<span style={cashInAndOutHeaderStyle}>Cash-out</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const getView = (data, code, compare) => it => {
|
||||
if (!data) return ''
|
||||
|
||||
// The following boolean should come undefined if it is rendering an unpaired machine
|
||||
const attribute = R.find(R.propEq(compare ?? 'code', it))(data)
|
||||
|
||||
return attribute ? R.prop(code, attribute) : 'Unpaired machine'
|
||||
}
|
||||
|
||||
const displayCodeArray = data => it => {
|
||||
if (!it) return it
|
||||
|
||||
return R.compose(R.join(', '), R.map(getView(data, 'display')))(it)
|
||||
}
|
||||
|
||||
const onCryptoChange = (prev, curr, setValue) => {
|
||||
const hasAllCoins = R.includes(ALL_COINS.code)(curr)
|
||||
const hadAllCoins = R.includes(ALL_COINS.code)(prev)
|
||||
|
||||
if (hasAllCoins && hadAllCoins && R.length(curr) > 1) {
|
||||
return setValue(R.reject(R.equals(ALL_COINS.code))(curr))
|
||||
}
|
||||
|
||||
if (hasAllCoins && !hadAllCoins) {
|
||||
return setValue([ALL_COINS.code])
|
||||
}
|
||||
|
||||
setValue(curr)
|
||||
}
|
||||
|
||||
const getOverridesFields = (getData, currency, auxElements) => {
|
||||
const machineData = [ALL_MACHINES].concat(getData(['machines']))
|
||||
const rawCryptos = getData(['cryptoCurrencies'])
|
||||
const cryptoData = [ALL_COINS].concat(
|
||||
R.map(it => ({ display: it.code, code: it.code }))(rawCryptos ?? [])
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'machine',
|
||||
width: 196,
|
||||
size: 'sm',
|
||||
view: getView(machineData, 'name', 'deviceId'),
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: machineData,
|
||||
valueProp: 'deviceId',
|
||||
labelProp: 'name'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cryptoCurrencies',
|
||||
width: 145,
|
||||
size: 'sm',
|
||||
view: displayCodeArray(cryptoData),
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: cryptoData,
|
||||
valueProp: 'code',
|
||||
labelProp: 'display',
|
||||
multiple: true,
|
||||
onChange: onCryptoChange,
|
||||
shouldStayOpen: true
|
||||
}
|
||||
},
|
||||
{
|
||||
header: cashInHeader,
|
||||
name: 'cashIn',
|
||||
display: 'Cash-in',
|
||||
width: 123,
|
||||
input: NumberInput,
|
||||
textAlign: 'right',
|
||||
suffix: '%',
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
header: cashOutHeader,
|
||||
name: 'cashOut',
|
||||
display: 'Cash-out',
|
||||
width: 127,
|
||||
input: NumberInput,
|
||||
textAlign: 'right',
|
||||
suffix: '%',
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fixedFee',
|
||||
display: 'Fixed fee',
|
||||
width: 126,
|
||||
input: NumberInput,
|
||||
doubleHeader: 'Cash-in only',
|
||||
textAlign: 'right',
|
||||
suffix: currency,
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'minimumTx',
|
||||
display: 'Minimum Tx',
|
||||
width: 140,
|
||||
doubleHeader: 'Cash-in only',
|
||||
textAlign: 'center',
|
||||
editingAlign: 'right',
|
||||
input: NumberInput,
|
||||
suffix: currency,
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cashOutFixedFee',
|
||||
display: 'Fixed fee',
|
||||
width: 134,
|
||||
doubleHeader: 'Cash-out only',
|
||||
textAlign: 'center',
|
||||
editingAlign: 'right',
|
||||
input: NumberInput,
|
||||
suffix: currency,
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mainFields = currency => [
|
||||
{
|
||||
header: cashInHeader,
|
||||
name: 'cashIn',
|
||||
display: 'Cash-in',
|
||||
width: 169,
|
||||
size: 'lg',
|
||||
editingAlign: 'right',
|
||||
input: NumberInput,
|
||||
suffix: '%',
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
header: cashOutHeader,
|
||||
name: 'cashOut',
|
||||
display: 'Cash-out',
|
||||
width: 169,
|
||||
size: 'lg',
|
||||
editingAlign: 'right',
|
||||
input: NumberInput,
|
||||
suffix: '%',
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fixedFee',
|
||||
display: 'Fixed fee',
|
||||
width: 169,
|
||||
size: 'lg',
|
||||
doubleHeader: 'Cash-in only',
|
||||
textAlign: 'center',
|
||||
editingAlign: 'right',
|
||||
input: NumberInput,
|
||||
suffix: currency,
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'minimumTx',
|
||||
display: 'Minimum Tx',
|
||||
width: 169,
|
||||
size: 'lg',
|
||||
doubleHeader: 'Cash-in only',
|
||||
textAlign: 'center',
|
||||
editingAlign: 'right',
|
||||
input: NumberInput,
|
||||
suffix: currency,
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cashOutFixedFee',
|
||||
display: 'Fixed fee',
|
||||
width: 169,
|
||||
size: 'lg',
|
||||
doubleHeader: 'Cash-out only',
|
||||
textAlign: 'center',
|
||||
editingAlign: 'right',
|
||||
input: NumberInput,
|
||||
suffix: currency,
|
||||
bold: bold,
|
||||
inputProps: {
|
||||
decimalPlaces: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const overrides = (auxData, currency, auxElements) => {
|
||||
const getData = R.path(R.__, auxData)
|
||||
|
||||
return getOverridesFields(getData, currency, auxElements)
|
||||
}
|
||||
|
||||
const percentMin = -15
|
||||
const percentMax = 100
|
||||
const getSchema = locale => {
|
||||
const bills = getBillOptions(locale, denominations).map(it => it.code)
|
||||
const highestBill = R.isEmpty(bills) ? CURRENCY_MAX : Math.max(...bills)
|
||||
|
||||
return Yup.object().shape({
|
||||
cashIn: Yup.number()
|
||||
.label('Cash-in')
|
||||
.min(percentMin)
|
||||
.max(percentMax)
|
||||
.required(),
|
||||
cashOut: Yup.number()
|
||||
.label('Cash-out')
|
||||
.min(percentMin)
|
||||
.max(percentMax)
|
||||
.required(),
|
||||
fixedFee: Yup.number()
|
||||
.label('Cash-in fixed fee')
|
||||
.min(0)
|
||||
.max(highestBill)
|
||||
.required(),
|
||||
minimumTx: Yup.number()
|
||||
.label('Minimum Tx')
|
||||
.min(0)
|
||||
.max(highestBill)
|
||||
.required(),
|
||||
cashOutFixedFee: Yup.number()
|
||||
.label('Cash-out fixed fee')
|
||||
.min(0)
|
||||
.max(highestBill)
|
||||
.required()
|
||||
})
|
||||
}
|
||||
|
||||
const getAlreadyUsed = (id, machine, values) => {
|
||||
const getCrypto = R.prop('cryptoCurrencies')
|
||||
const getMachineId = R.prop('machine')
|
||||
|
||||
const filteredOverrides = R.filter(R.propEq('machine', machine))(values)
|
||||
const originalValue = R.find(R.propEq('id', id))(values)
|
||||
|
||||
const originalCryptos = getCrypto(originalValue)
|
||||
const originalMachineId = getMachineId(originalValue)
|
||||
|
||||
const alreadyUsed = R.compose(
|
||||
R.uniq,
|
||||
R.flatten,
|
||||
R.map(getCrypto)
|
||||
)(filteredOverrides)
|
||||
|
||||
if (machine !== originalMachineId) return alreadyUsed ?? []
|
||||
|
||||
return R.difference(alreadyUsed, originalCryptos)
|
||||
}
|
||||
|
||||
const getOverridesSchema = (values, rawData, locale) => {
|
||||
const getData = R.path(R.__, rawData)
|
||||
const machineData = [ALL_MACHINES].concat(getData(['machines']))
|
||||
const rawCryptos = getData(['cryptoCurrencies'])
|
||||
const cryptoData = [ALL_COINS].concat(
|
||||
R.map(it => ({ display: it.code, code: it.code }))(rawCryptos ?? [])
|
||||
)
|
||||
|
||||
const bills = getBillOptions(locale, denominations).map(it =>
|
||||
parseInt(it.code)
|
||||
)
|
||||
const highestBill = R.isEmpty(bills) ? CURRENCY_MAX : Math.max(...bills)
|
||||
|
||||
return Yup.object().shape({
|
||||
machine: Yup.string().nullable().label('Machine').required(),
|
||||
cryptoCurrencies: Yup.array()
|
||||
.test({
|
||||
test() {
|
||||
const { id, machine, cryptoCurrencies } = this.parent
|
||||
const alreadyUsed = getAlreadyUsed(id, machine, values)
|
||||
|
||||
const isAllMachines = machine === ALL_MACHINES.deviceId
|
||||
const isAllCoins = R.includes(ALL_COINS.code, cryptoCurrencies)
|
||||
if (isAllMachines && isAllCoins) {
|
||||
return this.createError({
|
||||
message: `All machines and all coins should be configured in the default setup table`
|
||||
})
|
||||
}
|
||||
|
||||
const repeated = R.intersection(alreadyUsed, cryptoCurrencies)
|
||||
if (!R.isEmpty(repeated)) {
|
||||
const codes = displayCodeArray(cryptoData)(repeated)
|
||||
const machineView = getView(
|
||||
machineData,
|
||||
'name',
|
||||
'deviceId'
|
||||
)(machine)
|
||||
|
||||
const message = `${codes} already overridden for machine: ${machineView}`
|
||||
|
||||
return this.createError({ message })
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
.label('Crypto currencies')
|
||||
.required()
|
||||
.min(1),
|
||||
cashIn: Yup.number()
|
||||
.label('Cash-in')
|
||||
.min(percentMin)
|
||||
.max(percentMax)
|
||||
.required(),
|
||||
cashOut: Yup.number()
|
||||
.label('Cash-out')
|
||||
.min(percentMin)
|
||||
.max(percentMax)
|
||||
.required(),
|
||||
fixedFee: Yup.number()
|
||||
.label('Cash-in fixed fee')
|
||||
.min(0)
|
||||
.max(highestBill)
|
||||
.required(),
|
||||
minimumTx: Yup.number()
|
||||
.label('Minimum Tx')
|
||||
.min(0)
|
||||
.max(highestBill)
|
||||
.required(),
|
||||
cashOutFixedFee: Yup.number()
|
||||
.label('Cash-out fixed fee')
|
||||
.min(0)
|
||||
.max(highestBill)
|
||||
.required()
|
||||
})
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
cashIn: '',
|
||||
cashOut: '',
|
||||
fixedFee: '',
|
||||
minimumTx: '',
|
||||
cashOutFixedFee: ''
|
||||
}
|
||||
|
||||
const overridesDefaults = {
|
||||
machine: null,
|
||||
cryptoCurrencies: [],
|
||||
cashIn: '',
|
||||
cashOut: '',
|
||||
fixedFee: '',
|
||||
minimumTx: '',
|
||||
cashOutFixedFee: ''
|
||||
}
|
||||
|
||||
const getOrder = ({ machine, cryptoCurrencies }) => {
|
||||
const isAllMachines = machine === ALL_MACHINES.deviceId
|
||||
const isAllCoins = R.contains(ALL_COINS.code, cryptoCurrencies)
|
||||
|
||||
if (isAllMachines && isAllCoins) return 0
|
||||
if (isAllMachines) return 1
|
||||
if (isAllCoins) return 2
|
||||
|
||||
return 3
|
||||
}
|
||||
|
||||
const createCommissions = (cryptoCode, deviceId, isDefault, config) => {
|
||||
return {
|
||||
minimumTx: config.minimumTx,
|
||||
fixedFee: config.fixedFee,
|
||||
cashOut: config.cashOut,
|
||||
cashIn: config.cashIn,
|
||||
cashOutFixedFee: config.cashOutFixedFee,
|
||||
machine: deviceId,
|
||||
cryptoCurrencies: [cryptoCode],
|
||||
default: isDefault,
|
||||
id: uuidv4()
|
||||
}
|
||||
}
|
||||
|
||||
const getCommissions = (cryptoCode, deviceId, config) => {
|
||||
const overrides = R.prop('overrides', config) ?? []
|
||||
|
||||
if (!overrides && R.isEmpty(overrides)) {
|
||||
return createCommissions(cryptoCode, deviceId, true, config)
|
||||
}
|
||||
|
||||
const specificOverride = R.find(
|
||||
it => it.machine === deviceId && R.includes(cryptoCode)(it.cryptoCurrencies)
|
||||
)(overrides)
|
||||
|
||||
if (specificOverride !== undefined)
|
||||
return createCommissions(cryptoCode, deviceId, false, specificOverride)
|
||||
|
||||
const machineOverride = R.find(
|
||||
it =>
|
||||
it.machine === deviceId && R.includes('ALL_COINS')(it.cryptoCurrencies)
|
||||
)(overrides)
|
||||
|
||||
if (machineOverride !== undefined)
|
||||
return createCommissions(cryptoCode, deviceId, false, machineOverride)
|
||||
|
||||
const coinOverride = R.find(
|
||||
it =>
|
||||
it.machine === 'ALL_MACHINES' &&
|
||||
R.includes(cryptoCode)(it.cryptoCurrencies)
|
||||
)(overrides)
|
||||
|
||||
if (coinOverride !== undefined)
|
||||
return createCommissions(cryptoCode, deviceId, false, coinOverride)
|
||||
|
||||
return createCommissions(cryptoCode, deviceId, true, config)
|
||||
}
|
||||
|
||||
const getListCommissionsSchema = locale => {
|
||||
const bills = getBillOptions(locale, denominations).map(it =>
|
||||
parseInt(it.code)
|
||||
)
|
||||
const highestBill = R.isEmpty(bills) ? CURRENCY_MAX : Math.max(...bills)
|
||||
|
||||
return Yup.object().shape({
|
||||
machine: Yup.string().label('Machine').required(),
|
||||
cryptoCurrencies: Yup.array().label('Crypto currency').required().min(1),
|
||||
cashIn: Yup.number()
|
||||
.label('Cash-in')
|
||||
.min(percentMin)
|
||||
.max(percentMax)
|
||||
.required(),
|
||||
cashOut: Yup.number()
|
||||
.label('Cash-out')
|
||||
.min(percentMin)
|
||||
.max(percentMax)
|
||||
.required(),
|
||||
fixedFee: Yup.number()
|
||||
.label('Cash-in fixed fee')
|
||||
.min(0)
|
||||
.max(highestBill)
|
||||
.required(),
|
||||
minimumTx: Yup.number()
|
||||
.label('Minimum Tx')
|
||||
.min(0)
|
||||
.max(highestBill)
|
||||
.required(),
|
||||
cashOutFixedFee: Yup.number()
|
||||
.label('Cash-out fixed fee')
|
||||
.min(0)
|
||||
.max(highestBill)
|
||||
.required()
|
||||
})
|
||||
}
|
||||
|
||||
const getTextStyle = (obj, isEditing) => {
|
||||
return { color: obj.default ? primaryColor : secondaryColorDark }
|
||||
}
|
||||
|
||||
const commissionsList = (auxData, currency, auxElements) => {
|
||||
const getData = R.path(R.__, auxData)
|
||||
|
||||
return getListCommissionsFields(getData, currency, defaults)
|
||||
}
|
||||
|
||||
const getListCommissionsFields = (getData, currency, defaults) => {
|
||||
const machineData = [ALL_MACHINES].concat(getData(['machines']))
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'machine',
|
||||
width: 196,
|
||||
size: 'sm',
|
||||
view: getView(machineData, 'name', 'deviceId'),
|
||||
editable: false
|
||||
},
|
||||
{
|
||||
name: 'cryptoCurrencies',
|
||||
display: 'Crypto Currency',
|
||||
width: 150,
|
||||
view: R.prop(0),
|
||||
size: 'sm',
|
||||
editable: false
|
||||
},
|
||||
{
|
||||
header: cashInHeader,
|
||||
name: 'cashIn',
|
||||
display: 'Cash-in',
|
||||
width: 120,
|
||||
input: NumberInput,
|
||||
textAlign: 'right',
|
||||
suffix: '%',
|
||||
textStyle: obj => getTextStyle(obj),
|
||||
inputProps: {
|
||||
decimalPlaces: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
header: cashOutHeader,
|
||||
name: 'cashOut',
|
||||
display: 'Cash-out',
|
||||
width: 126,
|
||||
input: NumberInput,
|
||||
textAlign: 'right',
|
||||
greenText: true,
|
||||
suffix: '%',
|
||||
textStyle: obj => getTextStyle(obj),
|
||||
inputProps: {
|
||||
decimalPlaces: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fixedFee',
|
||||
display: 'Fixed fee',
|
||||
width: 140,
|
||||
input: NumberInput,
|
||||
doubleHeader: 'Cash-in only',
|
||||
textAlign: 'right',
|
||||
suffix: currency,
|
||||
textStyle: obj => getTextStyle(obj),
|
||||
inputProps: {
|
||||
decimalPlaces: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'minimumTx',
|
||||
display: 'Minimum Tx',
|
||||
width: 140,
|
||||
input: NumberInput,
|
||||
doubleHeader: 'Cash-in only',
|
||||
textAlign: 'right',
|
||||
suffix: currency,
|
||||
textStyle: obj => getTextStyle(obj),
|
||||
inputProps: {
|
||||
decimalPlaces: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cashOutFixedFee',
|
||||
display: 'Fixed fee',
|
||||
width: 140,
|
||||
input: NumberInput,
|
||||
doubleHeader: 'Cash-out only',
|
||||
textAlign: 'center',
|
||||
editingAlign: 'right',
|
||||
suffix: currency,
|
||||
textStyle: obj => getTextStyle(obj),
|
||||
inputProps: {
|
||||
decimalPlaces: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export {
|
||||
mainFields,
|
||||
overrides,
|
||||
getSchema,
|
||||
getOverridesSchema,
|
||||
defaults,
|
||||
overridesDefaults,
|
||||
getOrder,
|
||||
getCommissions,
|
||||
getListCommissionsSchema,
|
||||
commissionsList
|
||||
}
|
||||
3
packages/admin-ui/src/pages/Commissions/index.js
Normal file
3
packages/admin-ui/src/pages/Commissions/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Commissions from './Commissions'
|
||||
|
||||
export default Commissions
|
||||
548
packages/admin-ui/src/pages/Customers/CustomerData.jsx
Normal file
548
packages/admin-ui/src/pages/Customers/CustomerData.jsx
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
import * as R from 'ramda'
|
||||
import { useState, React } from 'react'
|
||||
import ImagePopper from 'src/components/ImagePopper'
|
||||
import { H3, Info3 } from 'src/components/typography'
|
||||
import CardIcon from 'src/styling/icons/ID/card/comet.svg?react'
|
||||
import PhoneIcon from 'src/styling/icons/ID/phone/comet.svg?react'
|
||||
import EditIcon from 'src/styling/icons/action/edit/comet.svg?react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { TextInput } from 'src/components/inputs/formik'
|
||||
import {
|
||||
OVERRIDE_AUTHORIZED,
|
||||
OVERRIDE_REJECTED
|
||||
} from 'src/pages/Customers/components/consts'
|
||||
import { onlyFirstToUpper } from 'src/utils/string'
|
||||
|
||||
import { EditableCard } from './components'
|
||||
import {
|
||||
customerDataElements,
|
||||
customerDataSchemas,
|
||||
formatDates,
|
||||
tryFormatDate,
|
||||
getFormattedPhone
|
||||
} from './helper'
|
||||
|
||||
const IMAGE_WIDTH = 165
|
||||
const IMAGE_HEIGHT = 32
|
||||
const POPUP_IMAGE_WIDTH = 360
|
||||
const POPUP_IMAGE_HEIGHT = 240
|
||||
|
||||
const Photo = ({ src }) => {
|
||||
return (
|
||||
<ImagePopper
|
||||
src={src}
|
||||
width={IMAGE_WIDTH}
|
||||
height={IMAGE_HEIGHT}
|
||||
popupWidth={POPUP_IMAGE_WIDTH}
|
||||
popupHeight={POPUP_IMAGE_HEIGHT}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomerData = ({
|
||||
locale,
|
||||
customer = {},
|
||||
updateCustomer,
|
||||
replacePhoto,
|
||||
editCustomer,
|
||||
deleteEditedData,
|
||||
updateCustomRequest,
|
||||
authorizeCustomRequest,
|
||||
updateCustomEntry,
|
||||
checkAgainstSanctions
|
||||
}) => {
|
||||
const [previewPhoto, setPreviewPhoto] = useState(null)
|
||||
const [previewCard, setPreviewCard] = useState(null)
|
||||
|
||||
const idData = R.path(['idCardData'])(customer)
|
||||
const rawExpirationDate = R.path(['expirationDate'])(idData)
|
||||
const rawDob = R.path(['dateOfBirth'])(idData)
|
||||
|
||||
const sanctions = R.path(['sanctions'])(customer)
|
||||
const sanctionsAt = R.path(['sanctionsAt'])(customer)
|
||||
const sanctionsDisplay = !sanctionsAt
|
||||
? 'Not checked yet'
|
||||
: sanctions
|
||||
? 'Passed'
|
||||
: 'Failed'
|
||||
|
||||
const sortByName = R.sortBy(
|
||||
R.compose(R.toLower, R.path(['customInfoRequest', 'customRequest', 'name']))
|
||||
)
|
||||
|
||||
const customFields = []
|
||||
const customRequirements = []
|
||||
const customInfoRequests = sortByName(
|
||||
R.path(['customInfoRequests'])(customer) ?? []
|
||||
)
|
||||
|
||||
const phone = R.path(['phone'])(customer)
|
||||
const email = R.path(['email'])(customer)
|
||||
const smsData = R.path(['subscriberInfo'])(customer)
|
||||
|
||||
const isEven = elem => elem % 2 === 0
|
||||
|
||||
const getVisibleCards = R.filter(elem => elem.isAvailable)
|
||||
|
||||
const initialValues = {
|
||||
idCardData: {
|
||||
firstName: R.path(['firstName'])(idData) ?? '',
|
||||
lastName: R.path(['lastName'])(idData) ?? '',
|
||||
documentNumber: R.path(['documentNumber'])(idData) ?? '',
|
||||
dateOfBirth: tryFormatDate(rawDob),
|
||||
gender: R.path(['gender'])(idData) ?? '',
|
||||
country: R.path(['country'])(idData) ?? '',
|
||||
expirationDate: tryFormatDate(rawExpirationDate)
|
||||
},
|
||||
usSsn: {
|
||||
usSsn: customer.usSsn ?? ''
|
||||
},
|
||||
frontCamera: {
|
||||
frontCamera: null
|
||||
},
|
||||
idCardPhoto: {
|
||||
idCardPhoto: null
|
||||
},
|
||||
email: {
|
||||
email
|
||||
},
|
||||
smsData: {
|
||||
phoneNumber: getFormattedPhone(phone, locale.country)
|
||||
}
|
||||
}
|
||||
|
||||
const smsDataElements = [
|
||||
{
|
||||
name: 'phoneNumber',
|
||||
label: 'Phone number',
|
||||
component: TextInput,
|
||||
editable: false
|
||||
}
|
||||
]
|
||||
|
||||
const smsDataSchema = {
|
||||
smsData: Yup.lazy(values => {
|
||||
const additionalData = R.omit(['phoneNumber'])(values)
|
||||
const fields = R.keys(additionalData)
|
||||
if (R.length(fields) === 2) {
|
||||
return Yup.object().shape({
|
||||
[R.head(fields)]: Yup.string().required(),
|
||||
[R.last(fields)]: Yup.string().required()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
fields: customerDataElements.idCardData,
|
||||
title: 'ID Scan',
|
||||
titleIcon: <CardIcon />,
|
||||
state: R.path(['idCardDataOverride'])(customer),
|
||||
authorize: () =>
|
||||
updateCustomer({ idCardDataOverride: OVERRIDE_AUTHORIZED }),
|
||||
reject: () => updateCustomer({ idCardDataOverride: OVERRIDE_REJECTED }),
|
||||
deleteEditedData: () => deleteEditedData({ idCardData: null }),
|
||||
save: values =>
|
||||
editCustomer({
|
||||
idCardData: R.merge(idData, formatDates(values))
|
||||
}),
|
||||
validationSchema: customerDataSchemas.idCardData,
|
||||
checkAgainstSanctions: () =>
|
||||
checkAgainstSanctions({
|
||||
variables: {
|
||||
customerId: R.path(['id'])(customer)
|
||||
}
|
||||
}),
|
||||
initialValues: initialValues.idCardData,
|
||||
isAvailable: !R.isNil(idData),
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
fields: smsDataElements,
|
||||
title: 'SMS data',
|
||||
titleIcon: <PhoneIcon />,
|
||||
state: R.path(['phoneOverride'])(customer),
|
||||
authorize: () => updateCustomer({ phoneOverride: OVERRIDE_AUTHORIZED }),
|
||||
reject: () => updateCustomer({ phoneOverride: OVERRIDE_REJECTED }),
|
||||
save: values => {
|
||||
editCustomer({
|
||||
subscriberInfo: {
|
||||
result: R.merge(smsData, R.omit(['phoneNumber'])(values))
|
||||
}
|
||||
})
|
||||
},
|
||||
validationSchema: smsDataSchema.smsData,
|
||||
initialValues: initialValues.smsData,
|
||||
isAvailable: !R.isNil(phone),
|
||||
hasAdditionalData: !R.isNil(smsData) && !R.isEmpty(smsData),
|
||||
editable: false
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
fields: customerDataElements.email,
|
||||
titleIcon: <CardIcon />,
|
||||
// state: R.path(['emailOverride'])(customer),
|
||||
// authorize: () => updateCustomer({ emailOverride: OVERRIDE_AUTHORIZED }),
|
||||
// reject: () => updateCustomer({ emailOverride: OVERRIDE_REJECTED }),
|
||||
save: values => editCustomer(values),
|
||||
deleteEditedData: () => deleteEditedData({ email: null }),
|
||||
initialValues: initialValues.email,
|
||||
isAvailable: !R.isNil(customer.email),
|
||||
editable: false
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
titleIcon: <EditIcon />,
|
||||
isAvailable: false,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
title: 'Sanctions check',
|
||||
titleIcon: <EditIcon />,
|
||||
state: R.path(['sanctionsOverride'])(customer),
|
||||
authorize: () =>
|
||||
updateCustomer({ sanctionsOverride: OVERRIDE_AUTHORIZED }),
|
||||
reject: () => updateCustomer({ sanctionsOverride: OVERRIDE_REJECTED }),
|
||||
children: () => <Info3>{sanctionsDisplay}</Info3>,
|
||||
isAvailable: !R.isNil(sanctions),
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
fields: customerDataElements.frontCamera,
|
||||
title: 'Front facing camera',
|
||||
titleIcon: <EditIcon />,
|
||||
state: R.path(['frontCameraOverride'])(customer),
|
||||
authorize: () =>
|
||||
updateCustomer({ frontCameraOverride: OVERRIDE_AUTHORIZED }),
|
||||
reject: () => updateCustomer({ frontCameraOverride: OVERRIDE_REJECTED }),
|
||||
save: values => {
|
||||
setPreviewPhoto(null)
|
||||
return replacePhoto({
|
||||
newPhoto: values.frontCamera,
|
||||
photoType: 'frontCamera'
|
||||
})
|
||||
},
|
||||
cancel: () => setPreviewPhoto(null),
|
||||
deleteEditedData: () => deleteEditedData({ frontCamera: null }),
|
||||
children: values => {
|
||||
if (values.frontCamera !== previewPhoto) {
|
||||
setPreviewPhoto(values.frontCamera)
|
||||
}
|
||||
|
||||
return customer.frontCameraPath ? (
|
||||
<Photo
|
||||
src={
|
||||
!R.isNil(previewPhoto)
|
||||
? URL.createObjectURL(previewPhoto)
|
||||
: `/front-camera-photo/${R.path(['frontCameraPath'])(customer)}`
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
},
|
||||
hasImage: true,
|
||||
validationSchema: customerDataSchemas.frontCamera,
|
||||
initialValues: initialValues.frontCamera,
|
||||
isAvailable: !R.isNil(customer.frontCameraPath),
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
fields: customerDataElements.idCardPhoto,
|
||||
title: 'ID card image',
|
||||
titleIcon: <EditIcon />,
|
||||
state: R.path(['idCardPhotoOverride'])(customer),
|
||||
authorize: () =>
|
||||
updateCustomer({ idCardPhotoOverride: OVERRIDE_AUTHORIZED }),
|
||||
reject: () => updateCustomer({ idCardPhotoOverride: OVERRIDE_REJECTED }),
|
||||
save: values => {
|
||||
setPreviewCard(null)
|
||||
return replacePhoto({
|
||||
newPhoto: values.idCardPhoto,
|
||||
photoType: 'idCardPhoto'
|
||||
})
|
||||
},
|
||||
cancel: () => setPreviewCard(null),
|
||||
deleteEditedData: () => deleteEditedData({ idCardPhoto: null }),
|
||||
children: values => {
|
||||
if (values.idCardPhoto !== previewCard) {
|
||||
setPreviewCard(values.idCardPhoto)
|
||||
}
|
||||
|
||||
return customer.idCardPhotoPath ? (
|
||||
<Photo
|
||||
src={
|
||||
!R.isNil(previewCard)
|
||||
? URL.createObjectURL(previewCard)
|
||||
: `/id-card-photo/${R.path(['idCardPhotoPath'])(customer)}`
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
},
|
||||
hasImage: true,
|
||||
validationSchema: customerDataSchemas.idCardPhoto,
|
||||
initialValues: initialValues.idCardPhoto,
|
||||
isAvailable: !R.isNil(customer.idCardPhotoPath),
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
fields: customerDataElements.usSsn,
|
||||
title: 'US SSN',
|
||||
titleIcon: <CardIcon />,
|
||||
state: R.path(['usSsnOverride'])(customer),
|
||||
authorize: () => updateCustomer({ usSsnOverride: OVERRIDE_AUTHORIZED }),
|
||||
reject: () => updateCustomer({ usSsnOverride: OVERRIDE_REJECTED }),
|
||||
save: values => editCustomer(values),
|
||||
children: () => {},
|
||||
deleteEditedData: () => deleteEditedData({ usSsn: null }),
|
||||
validationSchema: customerDataSchemas.usSsn,
|
||||
initialValues: initialValues.usSsn,
|
||||
isAvailable: !R.isNil(customer.usSsn),
|
||||
editable: true
|
||||
}
|
||||
]
|
||||
|
||||
R.forEach(it => {
|
||||
customRequirements.push({
|
||||
fields: [
|
||||
{
|
||||
name: it.customInfoRequest.id,
|
||||
label: it.customInfoRequest.customRequest.name,
|
||||
value: it.customerData.data ?? '',
|
||||
component: TextInput,
|
||||
editable: true
|
||||
}
|
||||
],
|
||||
title: it.customInfoRequest.customRequest.name,
|
||||
titleIcon: <CardIcon />,
|
||||
state: R.path(['override'])(it),
|
||||
authorize: () =>
|
||||
authorizeCustomRequest({
|
||||
variables: {
|
||||
customerId: it.customerId,
|
||||
infoRequestId: it.customInfoRequest.id,
|
||||
override: OVERRIDE_AUTHORIZED
|
||||
}
|
||||
}),
|
||||
reject: () =>
|
||||
authorizeCustomRequest({
|
||||
variables: {
|
||||
customerId: it.customerId,
|
||||
infoRequestId: it.customInfoRequest.id,
|
||||
override: OVERRIDE_REJECTED
|
||||
}
|
||||
}),
|
||||
save: values => {
|
||||
updateCustomRequest({
|
||||
variables: {
|
||||
customerId: it.customerId,
|
||||
infoRequestId: it.customInfoRequest.id,
|
||||
data: {
|
||||
info_request_id: it.customInfoRequest.id,
|
||||
data: values[it.customInfoRequest.id]
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
deleteEditedData: () => {},
|
||||
validationSchema: Yup.object().shape({
|
||||
[it.customInfoRequest.id]: Yup.string()
|
||||
}),
|
||||
initialValues: {
|
||||
[it.customInfoRequest.id]: it.customerData.data ?? ''
|
||||
}
|
||||
})
|
||||
}, customInfoRequests)
|
||||
|
||||
R.forEach(
|
||||
it => {
|
||||
customFields.push({
|
||||
fields: [
|
||||
{
|
||||
name: it.label,
|
||||
label: it.label,
|
||||
value: it.value ?? '',
|
||||
component: TextInput,
|
||||
editable: true
|
||||
}
|
||||
],
|
||||
title: it.label,
|
||||
titleIcon: <EditIcon />,
|
||||
save: values => {
|
||||
updateCustomEntry({
|
||||
fieldId: it.id,
|
||||
value: values[it.label]
|
||||
})
|
||||
},
|
||||
deleteEditedData: () => {},
|
||||
validationSchema: Yup.object().shape({
|
||||
[it.label]: Yup.string()
|
||||
}),
|
||||
initialValues: {
|
||||
[it.label]: it.value ?? ''
|
||||
}
|
||||
})
|
||||
},
|
||||
R.path(['customFields'])(customer) ?? []
|
||||
)
|
||||
|
||||
R.forEach(
|
||||
it => {
|
||||
initialValues.smsData[it] = smsData[it]
|
||||
smsDataElements.push({
|
||||
name: it,
|
||||
label: onlyFirstToUpper(it),
|
||||
component: TextInput,
|
||||
editable: false
|
||||
})
|
||||
},
|
||||
R.keys(smsData) ?? []
|
||||
)
|
||||
|
||||
const externalCompliance = R.map(it => ({
|
||||
fields: [
|
||||
{
|
||||
name: 'externalId',
|
||||
label: 'Third Party ID',
|
||||
editable: false
|
||||
},
|
||||
{
|
||||
name: 'lastKnownStatus',
|
||||
label: 'Last Known Status',
|
||||
editable: false
|
||||
},
|
||||
{
|
||||
name: 'lastUpdated',
|
||||
label: 'Last Updated',
|
||||
editable: false
|
||||
}
|
||||
],
|
||||
titleIcon: <CardIcon />,
|
||||
title: `External Info [${it.service}]`,
|
||||
initialValues: it ?? {
|
||||
externalId: '',
|
||||
lastKnownStatus: '',
|
||||
lastUpdated: ''
|
||||
}
|
||||
}))(customer.externalCompliance ?? [])
|
||||
|
||||
const editableCard = (
|
||||
{
|
||||
title,
|
||||
authorize,
|
||||
reject,
|
||||
state,
|
||||
titleIcon,
|
||||
fields,
|
||||
save,
|
||||
cancel,
|
||||
deleteEditedData,
|
||||
children,
|
||||
validationSchema,
|
||||
initialValues,
|
||||
hasImage,
|
||||
hasAdditionalData,
|
||||
editable,
|
||||
checkAgainstSanctions
|
||||
},
|
||||
idx
|
||||
) => {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<EditableCard
|
||||
title={title}
|
||||
key={idx}
|
||||
authorize={authorize}
|
||||
reject={reject}
|
||||
state={state}
|
||||
titleIcon={titleIcon}
|
||||
hasImage={hasImage}
|
||||
hasAdditionalData={hasAdditionalData}
|
||||
fields={fields}
|
||||
validationSchema={validationSchema}
|
||||
initialValues={initialValues}
|
||||
save={save}
|
||||
cancel={cancel}
|
||||
deleteEditedData={deleteEditedData}
|
||||
checkAgainstSanctions={checkAgainstSanctions}
|
||||
editable={editable}>
|
||||
{children}
|
||||
</EditableCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const nonEditableCard = (
|
||||
{ title, state, titleIcon, fields, hasImage, initialValues, children },
|
||||
idx
|
||||
) => {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<EditableCard
|
||||
title={title}
|
||||
key={idx}
|
||||
state={state}
|
||||
initialValues={initialValues}
|
||||
titleIcon={titleIcon}
|
||||
editable={false}
|
||||
hasImage={hasImage}
|
||||
fields={fields}>
|
||||
{children}
|
||||
</EditableCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleCards = getVisibleCards(cards)
|
||||
|
||||
const Separator = ({ title }) => (
|
||||
<div className="w-full my-4 col-span-all">
|
||||
<div className="flex items-center">
|
||||
<div className="h-px bg-comet grow-1"></div>
|
||||
<span className="mx-4 text-comet font-medium">{title}</span>
|
||||
<div className="h-px bg-comet grow-5"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<H3 className="mt-1 mb-7">{'Customer data'}</H3>
|
||||
<div>
|
||||
{customer && (
|
||||
<div className="columns-2 gap-4">
|
||||
{visibleCards.map((elem, idx) => {
|
||||
return editableCard(elem, idx)
|
||||
})}
|
||||
{!R.isEmpty(customFields) && (
|
||||
<>
|
||||
<Separator title="Custom data entry" />
|
||||
{customFields.map((elem, idx) => {
|
||||
return editableCard(elem, idx)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{!R.isEmpty(customRequirements) && (
|
||||
<>
|
||||
<Separator title="Custom requirements" />
|
||||
{customRequirements.map((elem, idx) => {
|
||||
return editableCard(elem, idx)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{!R.isEmpty(externalCompliance) && (
|
||||
<>
|
||||
<Separator title="External compliance information" />
|
||||
{externalCompliance.map((elem, idx) => {
|
||||
return nonEditableCard(elem, idx)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerData
|
||||
84
packages/admin-ui/src/pages/Customers/CustomerNotes.jsx
Normal file
84
packages/admin-ui/src/pages/Customers/CustomerNotes.jsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import * as R from 'ramda'
|
||||
import { React, useState } from 'react'
|
||||
import { H3 } from 'src/components/typography'
|
||||
|
||||
import NewNoteCard from './components/notes/NewNoteCard'
|
||||
import NewNoteModal from './components/notes/NewNoteModal'
|
||||
import NoteCard from './components/notes/NoteCard'
|
||||
import NoteEdit from './components/notes/NoteEdit'
|
||||
|
||||
const CustomerNotes = ({
|
||||
customer,
|
||||
createNote,
|
||||
deleteNote,
|
||||
editNote,
|
||||
timezone
|
||||
}) => {
|
||||
const [openModal, setOpenModal] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
const customerNotes = R.sort(
|
||||
(a, b) => new Date(b?.created).getTime() - new Date(a?.created).getTime(),
|
||||
customer.notes ?? []
|
||||
)
|
||||
|
||||
const handleModalClose = () => {
|
||||
setOpenModal(false)
|
||||
}
|
||||
|
||||
const handleModalSubmit = it => {
|
||||
createNote(it)
|
||||
return handleModalClose()
|
||||
}
|
||||
|
||||
const cancelNoteEditing = () => {
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const submitNoteEditing = it => {
|
||||
if (!R.equals(it.newContent, it.oldContent)) {
|
||||
editNote({
|
||||
noteId: it.noteId,
|
||||
newContent: it.newContent
|
||||
})
|
||||
}
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<H3 className="mt-1 mb-7">{'Notes'}</H3>
|
||||
{R.isNil(editing) && (
|
||||
<div className="grid grid-cols-[repeat(4,_200px)] gap-5 auto-rows-[200px]">
|
||||
<NewNoteCard setOpenModal={setOpenModal} />
|
||||
{customerNotes.map((it, idx) => (
|
||||
<NoteCard
|
||||
key={idx}
|
||||
note={it}
|
||||
deleteNote={deleteNote}
|
||||
handleClick={setEditing}
|
||||
timezone={timezone}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!R.isNil(editing) && (
|
||||
<NoteEdit
|
||||
note={editing}
|
||||
cancel={cancelNoteEditing}
|
||||
edit={submitNoteEditing}
|
||||
timezone={timezone}
|
||||
/>
|
||||
)}
|
||||
{openModal && (
|
||||
<NewNoteModal
|
||||
showModal={openModal}
|
||||
onClose={handleModalClose}
|
||||
onSubmit={handleModalSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerNotes
|
||||
74
packages/admin-ui/src/pages/Customers/CustomerPhotos.jsx
Normal file
74
packages/admin-ui/src/pages/Customers/CustomerPhotos.jsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import Paper from '@mui/material/Paper'
|
||||
import { format } from 'date-fns/fp'
|
||||
import * as R from 'ramda'
|
||||
import { React, useState } from 'react'
|
||||
import { InformativeDialog } from 'src/components/InformativeDialog'
|
||||
import { Label2, H3 } from 'src/components/typography'
|
||||
import CameraIcon from 'src/styling/icons/ID/photo/comet.svg?react'
|
||||
|
||||
import PhotosCarousel from './components/PhotosCarousel'
|
||||
|
||||
const CustomerPhotos = ({ photosData, timezone }) => {
|
||||
const [photosDialog, setPhotosDialog] = useState(false)
|
||||
const [photoClickedIndex, setPhotoClickIndex] = useState(null)
|
||||
const orderedPhotosData = !R.isNil(photoClickedIndex)
|
||||
? R.compose(R.flatten, R.reverse, R.splitAt(photoClickedIndex))(photosData)
|
||||
: photosData
|
||||
|
||||
return (
|
||||
<div>
|
||||
<H3 className="mt-1 mb-7">{'Photos & files'}</H3>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{photosData.map((elem, idx) => (
|
||||
<PhotoCard
|
||||
key={idx}
|
||||
date={elem.date}
|
||||
src={`/${elem.photoDir}/${elem.path}`}
|
||||
setPhotosDialog={setPhotosDialog}
|
||||
setPhotoClickIndex={setPhotoClickIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<InformativeDialog
|
||||
open={photosDialog}
|
||||
title={`Photo roll`}
|
||||
data={
|
||||
<PhotosCarousel photosData={orderedPhotosData} timezone={timezone} />
|
||||
}
|
||||
onDissmised={() => {
|
||||
setPhotosDialog(false)
|
||||
setPhotoClickIndex(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PhotoCard = ({
|
||||
idx,
|
||||
date,
|
||||
src,
|
||||
setPhotosDialog,
|
||||
setPhotoClickIndex
|
||||
}) => {
|
||||
return (
|
||||
<Paper
|
||||
className="cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
setPhotoClickIndex(idx)
|
||||
setPhotosDialog(true)
|
||||
}}>
|
||||
<img
|
||||
className="w-56 h-50 object-cover object-center block"
|
||||
src={src}
|
||||
alt=""
|
||||
/>
|
||||
<div className="flex p-3 gap-3">
|
||||
<CameraIcon />
|
||||
<Label2 noMargin>{format('yyyy-MM-dd', new Date(date))}</Label2>
|
||||
</div>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerPhotos
|
||||
685
packages/admin-ui/src/pages/Customers/CustomerProfile.jsx
Normal file
685
packages/admin-ui/src/pages/Customers/CustomerProfile.jsx
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
import { useQuery, useMutation, useLazyQuery, gql } from '@apollo/client'
|
||||
import Breadcrumbs from '@mui/material/Breadcrumbs'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useState } from 'react'
|
||||
import { useHistory, useParams } from 'react-router-dom'
|
||||
import { Label1, Label2 } from 'src/components/typography'
|
||||
import AuthorizeReversedIcon from 'src/styling/icons/button/authorize/white.svg?react'
|
||||
import AuthorizeIcon from 'src/styling/icons/button/authorize/zodiac.svg?react'
|
||||
import BlockReversedIcon from 'src/styling/icons/button/block/white.svg?react'
|
||||
import BlockIcon from 'src/styling/icons/button/block/zodiac.svg?react'
|
||||
import DataReversedIcon from 'src/styling/icons/button/data/white.svg?react'
|
||||
import DataIcon from 'src/styling/icons/button/data/zodiac.svg?react'
|
||||
|
||||
import { ActionButton } from 'src/components/buttons'
|
||||
import {
|
||||
OVERRIDE_AUTHORIZED,
|
||||
OVERRIDE_REJECTED
|
||||
} from 'src/pages/Customers/components/consts'
|
||||
// TODO: Enable for next release
|
||||
// import DiscountReversedIcon from 'src/styling/icons/button/discount/white.svg?react'
|
||||
// import Discount from 'src/styling/icons/button/discount/zodiac.svg?react'
|
||||
import { fromNamespace, namespaces } from 'src/utils/config'
|
||||
|
||||
import CustomerData from './CustomerData'
|
||||
import CustomerNotes from './CustomerNotes'
|
||||
import CustomerPhotos from './CustomerPhotos'
|
||||
import {
|
||||
CustomerDetails,
|
||||
TransactionsList,
|
||||
CustomerSidebar,
|
||||
Wizard
|
||||
} from './components'
|
||||
import { getFormattedPhone, getName, formatPhotosData } from './helper'
|
||||
|
||||
const GET_CUSTOMER = gql`
|
||||
query customer($customerId: ID!) {
|
||||
config
|
||||
customer(customerId: $customerId) {
|
||||
id
|
||||
authorizedOverride
|
||||
frontCameraPath
|
||||
frontCameraAt
|
||||
frontCameraOverride
|
||||
phone
|
||||
email
|
||||
isAnonymous
|
||||
smsOverride
|
||||
idCardData
|
||||
idCardDataOverride
|
||||
idCardDataExpiration
|
||||
idCardPhotoPath
|
||||
idCardPhotoOverride
|
||||
idCardPhotoAt
|
||||
usSsn
|
||||
usSsnOverride
|
||||
sanctions
|
||||
sanctionsAt
|
||||
sanctionsOverride
|
||||
totalTxs
|
||||
totalSpent
|
||||
lastActive
|
||||
lastUsedMachineName
|
||||
lastTxFiat
|
||||
lastTxFiatCode
|
||||
lastTxClass
|
||||
daysSuspended
|
||||
isSuspended
|
||||
isTestCustomer
|
||||
subscriberInfo
|
||||
phoneOverride
|
||||
externalCompliance
|
||||
customFields {
|
||||
id
|
||||
label
|
||||
value
|
||||
}
|
||||
notes {
|
||||
id
|
||||
customerId
|
||||
title
|
||||
content
|
||||
created
|
||||
lastEditedAt
|
||||
}
|
||||
transactions {
|
||||
txClass
|
||||
id
|
||||
fiat
|
||||
fiatCode
|
||||
cryptoAtoms
|
||||
cryptoCode
|
||||
created
|
||||
machineName
|
||||
errorMessage: error
|
||||
error: errorCode
|
||||
txCustomerPhotoAt
|
||||
txCustomerPhotoPath
|
||||
}
|
||||
customInfoRequests {
|
||||
customerId
|
||||
override
|
||||
overrideBy
|
||||
overrideAt
|
||||
customerData
|
||||
customInfoRequest {
|
||||
id
|
||||
enabled
|
||||
customRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SET_CUSTOMER = gql`
|
||||
mutation setCustomer($customerId: ID!, $customerInput: CustomerInput) {
|
||||
setCustomer(customerId: $customerId, customerInput: $customerInput) {
|
||||
id
|
||||
authorizedOverride
|
||||
frontCameraPath
|
||||
frontCameraOverride
|
||||
phone
|
||||
email
|
||||
smsOverride
|
||||
idCardData
|
||||
idCardDataOverride
|
||||
idCardDataExpiration
|
||||
idCardPhotoPath
|
||||
idCardPhotoOverride
|
||||
usSsn
|
||||
usSsnOverride
|
||||
sanctions
|
||||
sanctionsAt
|
||||
sanctionsOverride
|
||||
totalTxs
|
||||
totalSpent
|
||||
lastActive
|
||||
lastTxFiat
|
||||
lastTxFiatCode
|
||||
lastTxClass
|
||||
phoneOverride
|
||||
externalCompliance
|
||||
}
|
||||
}
|
||||
`
|
||||
const EDIT_CUSTOMER = gql`
|
||||
mutation editCustomer($customerId: ID!, $customerEdit: CustomerEdit) {
|
||||
editCustomer(customerId: $customerId, customerEdit: $customerEdit) {
|
||||
id
|
||||
idCardData
|
||||
usSsn
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const REPLACE_CUSTOMER_PHOTO = gql`
|
||||
mutation replacePhoto(
|
||||
$customerId: ID!
|
||||
$photoType: String
|
||||
$newPhoto: Upload
|
||||
) {
|
||||
replacePhoto(
|
||||
customerId: $customerId
|
||||
photoType: $photoType
|
||||
newPhoto: $newPhoto
|
||||
) {
|
||||
id
|
||||
newPhoto
|
||||
photoType
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DELETE_EDITED_CUSTOMER = gql`
|
||||
mutation deleteEditedData($customerId: ID!, $customerEdit: CustomerEdit) {
|
||||
deleteEditedData(customerId: $customerId, customerEdit: $customerEdit) {
|
||||
id
|
||||
frontCameraPath
|
||||
idCardData
|
||||
idCardPhotoPath
|
||||
usSsn
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SET_AUTHORIZED_REQUEST = gql`
|
||||
mutation setAuthorizedCustomRequest(
|
||||
$customerId: ID!
|
||||
$infoRequestId: ID!
|
||||
$override: String!
|
||||
) {
|
||||
setAuthorizedCustomRequest(
|
||||
customerId: $customerId
|
||||
infoRequestId: $infoRequestId
|
||||
override: $override
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const SET_CUSTOMER_CUSTOM_INFO_REQUEST = gql`
|
||||
mutation setCustomerCustomInfoRequest(
|
||||
$customerId: ID!
|
||||
$infoRequestId: ID!
|
||||
$data: JSON!
|
||||
) {
|
||||
setCustomerCustomInfoRequest(
|
||||
customerId: $customerId
|
||||
infoRequestId: $infoRequestId
|
||||
data: $data
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const CREATE_NOTE = gql`
|
||||
mutation createCustomerNote(
|
||||
$customerId: ID!
|
||||
$title: String!
|
||||
$content: String!
|
||||
) {
|
||||
createCustomerNote(
|
||||
customerId: $customerId
|
||||
title: $title
|
||||
content: $content
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const DELETE_NOTE = gql`
|
||||
mutation deleteCustomerNote($noteId: ID!) {
|
||||
deleteCustomerNote(noteId: $noteId)
|
||||
}
|
||||
`
|
||||
|
||||
const EDIT_NOTE = gql`
|
||||
mutation editCustomerNote($noteId: ID!, $newContent: String!) {
|
||||
editCustomerNote(noteId: $noteId, newContent: $newContent)
|
||||
}
|
||||
`
|
||||
|
||||
const ENABLE_TEST_CUSTOMER = gql`
|
||||
mutation enableTestCustomer($customerId: ID!) {
|
||||
enableTestCustomer(customerId: $customerId)
|
||||
}
|
||||
`
|
||||
|
||||
const DISABLE_TEST_CUSTOMER = gql`
|
||||
mutation disableTestCustomer($customerId: ID!) {
|
||||
disableTestCustomer(customerId: $customerId)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
config
|
||||
}
|
||||
`
|
||||
|
||||
const SET_CUSTOM_ENTRY = gql`
|
||||
mutation addCustomField($customerId: ID!, $label: String!, $value: String!) {
|
||||
addCustomField(customerId: $customerId, label: $label, value: $value)
|
||||
}
|
||||
`
|
||||
|
||||
const EDIT_CUSTOM_ENTRY = gql`
|
||||
mutation saveCustomField($customerId: ID!, $fieldId: ID!, $value: String!) {
|
||||
saveCustomField(customerId: $customerId, fieldId: $fieldId, value: $value)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_ACTIVE_CUSTOM_REQUESTS = gql`
|
||||
query customInfoRequests($onlyEnabled: Boolean) {
|
||||
customInfoRequests(onlyEnabled: $onlyEnabled) {
|
||||
id
|
||||
customRequest
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CHECK_AGAINST_SANCTIONS = gql`
|
||||
query checkAgainstSanctions($customerId: ID) {
|
||||
checkAgainstSanctions(customerId: $customerId) {
|
||||
ofacSanctioned
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CustomerProfile = memo(() => {
|
||||
const history = useHistory()
|
||||
|
||||
const [showCompliance, setShowCompliance] = useState(false)
|
||||
const [wizard, setWizard] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [clickedItem, setClickedItem] = useState('overview')
|
||||
const { id: customerId } = useParams()
|
||||
|
||||
const {
|
||||
data: customerResponse,
|
||||
refetch: getCustomer,
|
||||
loading: customerLoading
|
||||
} = useQuery(GET_CUSTOMER, {
|
||||
variables: { customerId }
|
||||
})
|
||||
|
||||
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
|
||||
|
||||
const { data: activeCustomRequests } = useQuery(GET_ACTIVE_CUSTOM_REQUESTS, {
|
||||
variables: {
|
||||
onlyEnabled: true
|
||||
}
|
||||
})
|
||||
|
||||
const [setCustomEntry] = useMutation(SET_CUSTOM_ENTRY, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [editCustomEntry] = useMutation(EDIT_CUSTOM_ENTRY, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [replaceCustomerPhoto] = useMutation(REPLACE_CUSTOMER_PHOTO, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [editCustomerData] = useMutation(EDIT_CUSTOMER, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [deleteCustomerEditedData] = useMutation(DELETE_EDITED_CUSTOMER, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [setCustomer] = useMutation(SET_CUSTOMER, {
|
||||
onCompleted: () => {
|
||||
getCustomer()
|
||||
},
|
||||
onError: error => setError(error)
|
||||
})
|
||||
|
||||
const [authorizeCustomRequest] = useMutation(SET_AUTHORIZED_REQUEST, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [setCustomerCustomInfoRequest] = useMutation(
|
||||
SET_CUSTOMER_CUSTOM_INFO_REQUEST,
|
||||
{
|
||||
onCompleted: () => getCustomer()
|
||||
}
|
||||
)
|
||||
|
||||
const [createNote] = useMutation(CREATE_NOTE, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [deleteNote] = useMutation(DELETE_NOTE, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [editNote] = useMutation(EDIT_NOTE, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const saveCustomEntry = it => {
|
||||
setCustomEntry({
|
||||
variables: {
|
||||
customerId,
|
||||
label: it.title,
|
||||
value: it.data
|
||||
}
|
||||
})
|
||||
setWizard(null)
|
||||
}
|
||||
|
||||
const updateCustomEntry = it => {
|
||||
editCustomEntry({
|
||||
variables: {
|
||||
customerId,
|
||||
fieldId: it.fieldId,
|
||||
value: it.value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const [enableTestCustomer] = useMutation(ENABLE_TEST_CUSTOMER, {
|
||||
variables: { customerId },
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [disableTestCustomer] = useMutation(DISABLE_TEST_CUSTOMER, {
|
||||
variables: { customerId },
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const [checkAgainstSanctions] = useLazyQuery(CHECK_AGAINST_SANCTIONS, {
|
||||
onCompleted: () => getCustomer()
|
||||
})
|
||||
|
||||
const updateCustomer = it =>
|
||||
setCustomer({
|
||||
variables: {
|
||||
customerId,
|
||||
customerInput: it
|
||||
}
|
||||
})
|
||||
|
||||
const replacePhoto = it => {
|
||||
replaceCustomerPhoto({
|
||||
variables: {
|
||||
customerId,
|
||||
newPhoto: it.newPhoto,
|
||||
photoType: it.photoType
|
||||
}
|
||||
})
|
||||
setWizard(null)
|
||||
}
|
||||
|
||||
const editCustomer = it => {
|
||||
editCustomerData({
|
||||
variables: {
|
||||
customerId,
|
||||
customerEdit: it
|
||||
}
|
||||
})
|
||||
setWizard(null)
|
||||
}
|
||||
|
||||
const deleteEditedData = it =>
|
||||
deleteCustomerEditedData({
|
||||
variables: {
|
||||
customerId,
|
||||
customerEdit: it
|
||||
}
|
||||
})
|
||||
|
||||
const createCustomerNote = it =>
|
||||
createNote({
|
||||
variables: {
|
||||
customerId,
|
||||
title: it.title,
|
||||
content: it.content
|
||||
}
|
||||
})
|
||||
|
||||
const deleteCustomerNote = it =>
|
||||
deleteNote({
|
||||
variables: {
|
||||
noteId: it.noteId
|
||||
}
|
||||
})
|
||||
|
||||
const editCustomerNote = it =>
|
||||
editNote({
|
||||
variables: {
|
||||
noteId: it.noteId,
|
||||
newContent: it.newContent
|
||||
}
|
||||
})
|
||||
|
||||
const onClickSidebarItem = code => setClickedItem(code)
|
||||
|
||||
const configData = R.path(['config'])(customerResponse) ?? []
|
||||
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
|
||||
const customerData = R.path(['customer'])(customerResponse) ?? []
|
||||
const rawTransactions = R.path(['transactions'])(customerData) ?? []
|
||||
const sortedTransactions = R.sort(R.descend(R.prop('cryptoAtoms')))(
|
||||
rawTransactions
|
||||
)
|
||||
const name = getName(customerData)
|
||||
const blocked =
|
||||
R.path(['authorizedOverride'])(customerData) === OVERRIDE_REJECTED
|
||||
|
||||
const isSuspended = customerData.isSuspended
|
||||
const isCustomerData = clickedItem === 'customerData'
|
||||
const isOverview = clickedItem === 'overview'
|
||||
const isNotes = clickedItem === 'notes'
|
||||
const isPhotos = clickedItem === 'photos'
|
||||
|
||||
const frontCameraData = R.pick(['frontCameraPath', 'frontCameraAt'])(
|
||||
customerData
|
||||
)
|
||||
const txPhotosData =
|
||||
sortedTransactions &&
|
||||
R.map(R.pick(['id', 'txCustomerPhotoPath', 'txCustomerPhotoAt']))(
|
||||
sortedTransactions
|
||||
)
|
||||
|
||||
const photosData = formatPhotosData(R.append(frontCameraData, txPhotosData))
|
||||
const IDphotoData = customerData.idCardPhotoPath
|
||||
? [
|
||||
{
|
||||
photoDir: 'id-card-photo',
|
||||
path: customerData.idCardPhotoPath,
|
||||
date: customerData.idCardPhotoAt
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
const loading = customerLoading || configLoading
|
||||
|
||||
const timezone = R.path(['config', 'locale_timezone'], configResponse)
|
||||
|
||||
const customInfoRequirementOptions =
|
||||
activeCustomRequests?.customInfoRequests?.map(it => ({
|
||||
value: it.id,
|
||||
display: it.customRequest.name
|
||||
})) ?? []
|
||||
|
||||
const email = R.path(['email'])(customerData)
|
||||
const phone = R.path(['phone'])(customerData)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs
|
||||
className="my-5"
|
||||
separator={<NavigateNextIcon fontSize="small" />}
|
||||
aria-label="breadcrumb">
|
||||
<Label1
|
||||
noMargin
|
||||
className="cursor-pointer text-comet"
|
||||
onClick={() => history.push('/compliance/customers')}>
|
||||
Customers
|
||||
</Label1>
|
||||
<Label2 noMargin className="cursor-pointer text-comet">
|
||||
{name.length
|
||||
? name
|
||||
: email?.length
|
||||
? email
|
||||
: getFormattedPhone(phone, locale.country)}
|
||||
</Label2>
|
||||
</Breadcrumbs>
|
||||
<div className="flex gap-20">
|
||||
<div className="w-55 flex flex-col gap-6">
|
||||
{!loading && !customerData.isAnonymous && (
|
||||
<>
|
||||
<CustomerSidebar
|
||||
isSelected={code => code === clickedItem}
|
||||
onClick={onClickSidebarItem}
|
||||
/>
|
||||
<div>
|
||||
<Label1 noMargin className="text-comet my-1">
|
||||
Actions
|
||||
</Label1>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ActionButton
|
||||
center
|
||||
color="primary"
|
||||
Icon={DataIcon}
|
||||
InverseIcon={DataReversedIcon}
|
||||
onClick={() => setWizard(true)}>
|
||||
{`Manual data entry`}
|
||||
</ActionButton>
|
||||
{/* <ActionButton
|
||||
color="primary"
|
||||
Icon={Discount}
|
||||
InverseIcon={DiscountReversedIcon}
|
||||
onClick={() => {}}>
|
||||
{`Add individual discount`}
|
||||
</ActionButton> */}
|
||||
{isSuspended && (
|
||||
<ActionButton
|
||||
center
|
||||
color="primary"
|
||||
Icon={AuthorizeIcon}
|
||||
InverseIcon={AuthorizeReversedIcon}
|
||||
onClick={() =>
|
||||
updateCustomer({
|
||||
suspendedUntil: null
|
||||
})
|
||||
}>
|
||||
{`Unsuspend customer`}
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
color="primary"
|
||||
center
|
||||
Icon={blocked ? AuthorizeIcon : BlockIcon}
|
||||
InverseIcon={
|
||||
blocked ? AuthorizeReversedIcon : BlockReversedIcon
|
||||
}
|
||||
onClick={() =>
|
||||
updateCustomer({
|
||||
authorizedOverride: blocked
|
||||
? OVERRIDE_AUTHORIZED
|
||||
: OVERRIDE_REJECTED
|
||||
})
|
||||
}>
|
||||
{`${blocked ? 'Authorize' : 'Block'} customer`}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label1 className="text-comet my-1">
|
||||
{`Special user status`}
|
||||
</Label1>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center bg-zircon px-1 rounded-lg">
|
||||
<Switch
|
||||
checked={!!R.path(['isTestCustomer'])(customerData)}
|
||||
value={!!R.path(['isTestCustomer'])(customerData)}
|
||||
onChange={() =>
|
||||
R.path(['isTestCustomer'])(customerData)
|
||||
? disableTestCustomer()
|
||||
: enableTestCustomer()
|
||||
}
|
||||
/>
|
||||
<Label1 noMargin>Test user</Label1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{isOverview && (
|
||||
<div>
|
||||
<div className="flex justify-between mb-5">
|
||||
<CustomerDetails
|
||||
customer={customerData}
|
||||
photosData={photosData}
|
||||
locale={locale}
|
||||
setShowCompliance={() => setShowCompliance(!showCompliance)}
|
||||
timezone={timezone}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TransactionsList
|
||||
customer={customerData}
|
||||
data={sortedTransactions}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isCustomerData && (
|
||||
<div>
|
||||
<CustomerData
|
||||
locale={locale}
|
||||
customer={customerData}
|
||||
updateCustomer={updateCustomer}
|
||||
replacePhoto={replacePhoto}
|
||||
editCustomer={editCustomer}
|
||||
deleteEditedData={deleteEditedData}
|
||||
updateCustomRequest={setCustomerCustomInfoRequest}
|
||||
authorizeCustomRequest={authorizeCustomRequest}
|
||||
updateCustomEntry={updateCustomEntry}
|
||||
checkAgainstSanctions={checkAgainstSanctions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isNotes && (
|
||||
<div>
|
||||
<CustomerNotes
|
||||
customer={customerData}
|
||||
createNote={createCustomerNote}
|
||||
deleteNote={deleteCustomerNote}
|
||||
editNote={editCustomerNote}
|
||||
timezone={timezone}></CustomerNotes>
|
||||
</div>
|
||||
)}
|
||||
{isPhotos && (
|
||||
<div>
|
||||
<CustomerPhotos
|
||||
photosData={R.concat(photosData, IDphotoData)}
|
||||
timezone={timezone}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{wizard && (
|
||||
<Wizard
|
||||
error={error?.message}
|
||||
save={saveCustomEntry}
|
||||
addPhoto={replacePhoto}
|
||||
addCustomerData={editCustomer}
|
||||
onClose={() => setWizard(null)}
|
||||
customInfoRequirementOptions={customInfoRequirementOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default CustomerProfile
|
||||
256
packages/admin-ui/src/pages/Customers/Customers.jsx
Normal file
256
packages/admin-ui/src/pages/Customers/Customers.jsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import SearchBox from 'src/components/SearchBox'
|
||||
import SearchFilter from 'src/components/SearchFilter'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
|
||||
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
|
||||
|
||||
import { Link } from 'src/components/buttons'
|
||||
import { fromNamespace, namespaces } from 'src/utils/config'
|
||||
|
||||
import CustomersList from './CustomersList'
|
||||
import CreateCustomerModal from './components/CreateCustomerModal'
|
||||
import { getAuthorizedStatus } from './helper'
|
||||
|
||||
const GET_CUSTOMER_FILTERS = gql`
|
||||
query filters {
|
||||
customerFilters {
|
||||
type
|
||||
value
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_CUSTOMERS = gql`
|
||||
query configAndCustomers(
|
||||
$phone: String
|
||||
$name: String
|
||||
$email: String
|
||||
$address: String
|
||||
$id: String
|
||||
) {
|
||||
config
|
||||
customers(
|
||||
phone: $phone
|
||||
email: $email
|
||||
name: $name
|
||||
address: $address
|
||||
id: $id
|
||||
) {
|
||||
id
|
||||
idCardData
|
||||
phone
|
||||
email
|
||||
totalTxs
|
||||
totalSpent
|
||||
lastActive
|
||||
lastTxFiat
|
||||
lastTxFiatCode
|
||||
lastTxClass
|
||||
authorizedOverride
|
||||
frontCameraPath
|
||||
frontCameraOverride
|
||||
idCardPhotoPath
|
||||
idCardPhotoOverride
|
||||
idCardData
|
||||
idCardDataOverride
|
||||
usSsn
|
||||
usSsnOverride
|
||||
sanctions
|
||||
sanctionsOverride
|
||||
daysSuspended
|
||||
isSuspended
|
||||
customInfoRequests {
|
||||
customerId
|
||||
infoRequestId
|
||||
override
|
||||
overrideAt
|
||||
overrideBy
|
||||
customerData
|
||||
customInfoRequest {
|
||||
id
|
||||
enabled
|
||||
customRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
customInfoRequests {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CREATE_CUSTOMER = gql`
|
||||
mutation createCustomer($phoneNumber: String) {
|
||||
createCustomer(phoneNumber: $phoneNumber) {
|
||||
phone
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const getFiltersObj = filters =>
|
||||
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters)
|
||||
|
||||
const Customers = () => {
|
||||
const history = useHistory()
|
||||
|
||||
const handleCustomerClicked = customer =>
|
||||
history.push(`/compliance/customer/${customer.id}`)
|
||||
|
||||
const [filteredCustomers, setFilteredCustomers] = useState([])
|
||||
const [variables, setVariables] = useState({})
|
||||
const [filters, setFilters] = useState([])
|
||||
const [showCreationModal, setShowCreationModal] = useState(false)
|
||||
|
||||
const {
|
||||
data: customersResponse,
|
||||
loading: customerLoading,
|
||||
refetch
|
||||
} = useQuery(GET_CUSTOMERS, {
|
||||
variables,
|
||||
onCompleted: data => setFilteredCustomers(R.path(['customers'])(data))
|
||||
})
|
||||
|
||||
const { data: filtersResponse, loading: loadingFilters } =
|
||||
useQuery(GET_CUSTOMER_FILTERS)
|
||||
|
||||
const [createNewCustomer] = useMutation(CREATE_CUSTOMER, {
|
||||
onCompleted: () => setShowCreationModal(false),
|
||||
refetchQueries: () => [
|
||||
{
|
||||
query: GET_CUSTOMERS,
|
||||
variables
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const configData = R.path(['config'])(customersResponse) ?? []
|
||||
const customRequirementsData =
|
||||
R.path(['customInfoRequests'], customersResponse) ?? []
|
||||
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
|
||||
const triggers = configData && fromNamespace(namespaces.TRIGGERS, configData)
|
||||
|
||||
const setAuthorizedStatus = c =>
|
||||
R.assoc(
|
||||
'authorizedStatus',
|
||||
getAuthorizedStatus(c, triggers, customRequirementsData),
|
||||
c
|
||||
)
|
||||
|
||||
const byAuthorized = c => (c.authorizedStatus.label === 'Pending' ? 0 : 1)
|
||||
const byLastActive = c => new Date(R.prop('lastActive', c) ?? '0')
|
||||
const customersData = R.pipe(
|
||||
R.map(setAuthorizedStatus),
|
||||
R.sortWith([R.ascend(byAuthorized), R.descend(byLastActive)])
|
||||
)(filteredCustomers ?? [])
|
||||
|
||||
const onFilterChange = filters => {
|
||||
const filtersObject = getFiltersObj(filters)
|
||||
|
||||
setFilters(filters)
|
||||
|
||||
setVariables({
|
||||
phone: filtersObject.phone,
|
||||
name: filtersObject.name,
|
||||
email: filtersObject.email,
|
||||
address: filtersObject.address,
|
||||
id: filtersObject.id
|
||||
})
|
||||
|
||||
refetch && refetch()
|
||||
}
|
||||
|
||||
const onFilterDelete = filter => {
|
||||
const newFilters = R.filter(
|
||||
f => !R.whereEq(R.pick(['type', 'value'], f), filter)
|
||||
)(filters)
|
||||
|
||||
setFilters(newFilters)
|
||||
|
||||
const filtersObject = getFiltersObj(newFilters)
|
||||
|
||||
setVariables({
|
||||
phone: filtersObject.phone,
|
||||
name: filtersObject.name,
|
||||
email: filtersObject.email,
|
||||
address: filtersObject.address,
|
||||
id: filtersObject.id
|
||||
})
|
||||
|
||||
refetch && refetch()
|
||||
}
|
||||
|
||||
const deleteAllFilters = () => {
|
||||
setFilters([])
|
||||
const filtersObject = getFiltersObj([])
|
||||
|
||||
setVariables({
|
||||
phone: filtersObject.phone,
|
||||
name: filtersObject.name,
|
||||
email: filtersObject.email,
|
||||
address: filtersObject.address,
|
||||
id: filtersObject.id
|
||||
})
|
||||
|
||||
refetch && refetch()
|
||||
}
|
||||
|
||||
const filterOptions = R.path(['customerFilters'])(filtersResponse)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleSection
|
||||
title="Customers"
|
||||
appendix={
|
||||
<div className="flex ml-4">
|
||||
<SearchBox
|
||||
loading={loadingFilters}
|
||||
filters={filters}
|
||||
options={filterOptions}
|
||||
inputPlaceholder={'Search customers'}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
appendixRight={
|
||||
<div className="flex">
|
||||
<Link color="primary" onClick={() => setShowCreationModal(true)}>
|
||||
Add new user
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
labels={[
|
||||
{ label: 'Cash-in', icon: <TxInIcon /> },
|
||||
{ label: 'Cash-out', icon: <TxOutIcon /> }
|
||||
]}
|
||||
/>
|
||||
{filters.length > 0 && (
|
||||
<SearchFilter
|
||||
entries={customersData.length}
|
||||
filters={filters}
|
||||
onFilterDelete={onFilterDelete}
|
||||
deleteAllFilters={deleteAllFilters}
|
||||
/>
|
||||
)}
|
||||
<CustomersList
|
||||
data={customersData}
|
||||
locale={locale}
|
||||
onClick={handleCustomerClicked}
|
||||
loading={customerLoading}
|
||||
triggers={triggers}
|
||||
customRequests={customRequirementsData}
|
||||
/>
|
||||
<CreateCustomerModal
|
||||
showModal={showCreationModal}
|
||||
handleClose={() => setShowCreationModal(false)}
|
||||
locale={locale}
|
||||
onSubmit={createNewCustomer}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Customers
|
||||
87
packages/admin-ui/src/pages/Customers/CustomersList.jsx
Normal file
87
packages/admin-ui/src/pages/Customers/CustomersList.jsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { format } from 'date-fns/fp'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import { MainStatus } from 'src/components/Status'
|
||||
import DataTable from 'src/components/tables/DataTable'
|
||||
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
|
||||
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
|
||||
|
||||
import { getFormattedPhone, getName } from './helper'
|
||||
|
||||
const CustomersList = ({
|
||||
data,
|
||||
locale,
|
||||
onClick,
|
||||
loading,
|
||||
triggers,
|
||||
customRequests
|
||||
}) => {
|
||||
const elements = [
|
||||
{
|
||||
header: 'Phone/email',
|
||||
width: 199,
|
||||
view: it => `${getFormattedPhone(it.phone, locale.country) || ''}
|
||||
${it.email || ''}`
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
width: 241,
|
||||
view: getName
|
||||
},
|
||||
{
|
||||
header: 'Total Txs',
|
||||
width: 126,
|
||||
textAlign: 'right',
|
||||
view: it => `${Number.parseInt(it.totalTxs)}`
|
||||
},
|
||||
{
|
||||
header: 'Total spent',
|
||||
width: 152,
|
||||
textAlign: 'right',
|
||||
view: it =>
|
||||
`${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}`
|
||||
},
|
||||
{
|
||||
header: 'Last active',
|
||||
width: 133,
|
||||
view: it =>
|
||||
(it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? ''
|
||||
},
|
||||
{
|
||||
header: 'Last transaction',
|
||||
width: 161,
|
||||
textAlign: 'right',
|
||||
view: it => {
|
||||
const hasLastTx = !R.isNil(it.lastTxFiatCode)
|
||||
const LastTxIcon = it.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon
|
||||
const lastIcon = <LastTxIcon className="ml-3" />
|
||||
return (
|
||||
<>
|
||||
{hasLastTx &&
|
||||
`${parseFloat(it.lastTxFiat)} ${it.lastTxFiatCode ?? ''}`}
|
||||
{hasLastTx && lastIcon}
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
width: 191,
|
||||
view: it => <MainStatus statuses={[it.authorizedStatus]} />
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
loading={loading}
|
||||
emptyText="No customers so far"
|
||||
elements={elements}
|
||||
data={data}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomersList
|
||||
130
packages/admin-ui/src/pages/Customers/Wizard.jsx
Normal file
130
packages/admin-ui/src/pages/Customers/Wizard.jsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Form, Formik } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState, Fragment } from 'react'
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import Modal from 'src/components/Modal'
|
||||
import Stepper from 'src/components/Stepper'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
|
||||
import {
|
||||
entryType,
|
||||
customElements,
|
||||
requirementElements,
|
||||
formatDates,
|
||||
REQUIREMENT,
|
||||
ID_CARD_DATA
|
||||
} from './helper'
|
||||
|
||||
const LAST_STEP = 2
|
||||
|
||||
const getStep = (step, selectedValues) => {
|
||||
const elements =
|
||||
selectedValues?.entryType === REQUIREMENT &&
|
||||
!R.isNil(selectedValues?.requirement)
|
||||
? requirementElements[selectedValues?.requirement]
|
||||
: customElements[selectedValues?.dataType]
|
||||
|
||||
switch (step) {
|
||||
case 1:
|
||||
return entryType
|
||||
case 2:
|
||||
return elements
|
||||
default:
|
||||
return Fragment
|
||||
}
|
||||
}
|
||||
|
||||
const Wizard = ({
|
||||
onClose,
|
||||
save,
|
||||
error,
|
||||
customInfoRequirementOptions,
|
||||
addCustomerData,
|
||||
addPhoto
|
||||
}) => {
|
||||
const [selectedValues, setSelectedValues] = useState(null)
|
||||
|
||||
const [{ step, config }, setState] = useState({
|
||||
step: 1
|
||||
})
|
||||
|
||||
const isIdCardData = values => values?.requirement === ID_CARD_DATA
|
||||
const formatCustomerData = (it, newConfig) =>
|
||||
isIdCardData(newConfig) ? { [newConfig.requirement]: formatDates(it) } : it
|
||||
|
||||
const isLastStep = step === LAST_STEP
|
||||
const stepOptions = getStep(step, selectedValues)
|
||||
|
||||
const onContinue = async it => {
|
||||
const newConfig = R.mergeRight(config, stepOptions.schema.cast(it))
|
||||
setSelectedValues(newConfig)
|
||||
|
||||
if (isLastStep) {
|
||||
switch (stepOptions.saveType) {
|
||||
case 'customerData':
|
||||
return addCustomerData(formatCustomerData(it, newConfig))
|
||||
case 'customerDataUpload':
|
||||
return addPhoto({
|
||||
newPhoto: R.head(R.values(it)),
|
||||
photoType: R.head(R.keys(it))
|
||||
})
|
||||
case 'customEntry':
|
||||
return save(newConfig)
|
||||
case 'customInfoRequirement':
|
||||
return
|
||||
// case 'customerEntryUpload':
|
||||
// break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
setState({
|
||||
step: step + 1,
|
||||
config: newConfig
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="Manual data entry"
|
||||
handleClose={onClose}
|
||||
width={520}
|
||||
height={520}
|
||||
open={true}>
|
||||
<Stepper steps={LAST_STEP} currentStep={step} className="my-4" />
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
enableReinitialize
|
||||
onSubmit={onContinue}
|
||||
initialValues={stepOptions.initialValues}
|
||||
validationSchema={stepOptions.schema}>
|
||||
{({ errors }) => (
|
||||
<Form className="h-full flex flex-col">
|
||||
<stepOptions.Component
|
||||
selectedValues={selectedValues}
|
||||
customInfoRequirementOptions={customInfoRequirementOptions}
|
||||
errors={errors}
|
||||
{...stepOptions.props}
|
||||
/>
|
||||
<div className="flex mt-auto mb-6">
|
||||
{error && <ErrorMessage>Failed to save</ErrorMessage>}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<ErrorMessage>{Object.values(errors)[0]}</ErrorMessage>
|
||||
)}
|
||||
<Button className="ml-auto" type="submit">
|
||||
{isLastStep ? 'Add Data' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Wizard
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { Field, Form, Formik } from 'formik'
|
||||
import { parsePhoneNumberWithError } from 'libphonenumber-js'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import Modal from 'src/components/Modal'
|
||||
import { H1 } from 'src/components/typography'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { TextInput } from 'src/components/inputs/formik'
|
||||
|
||||
const getValidationSchema = countryCodes =>
|
||||
Yup.object().shape({
|
||||
phoneNumber: Yup.string()
|
||||
.required('A phone number is required')
|
||||
.test('is-valid-number', 'That is not a valid phone number', value => {
|
||||
try {
|
||||
return countryCodes.some(countryCode =>
|
||||
parsePhoneNumberWithError(value, countryCode).isValid()
|
||||
)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.trim()
|
||||
})
|
||||
|
||||
const formatPhoneNumber = (countryCodes, numberStr) => {
|
||||
const matchedCountry = R.find(it => {
|
||||
const number = parsePhoneNumberWithError(numberStr, it)
|
||||
return number.isValid()
|
||||
}, countryCodes)
|
||||
|
||||
return parsePhoneNumberWithError(numberStr, matchedCountry).number
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
phoneNumber: ''
|
||||
}
|
||||
|
||||
const getErrorMsg = (formikErrors, formikTouched) => {
|
||||
if (!formikErrors || !formikTouched) return null
|
||||
if (formikErrors.phoneNumber && formikTouched.phoneNumber)
|
||||
return formikErrors.phoneNumber
|
||||
return null
|
||||
}
|
||||
|
||||
const CreateCustomerModal = ({ showModal, handleClose, onSubmit, locale }) => {
|
||||
const possibleCountries = R.append(
|
||||
locale?.country,
|
||||
R.map(it => it.country, locale?.overrides ?? [])
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
closeOnBackdropClick={true}
|
||||
width={600}
|
||||
height={300}
|
||||
handleClose={handleClose}
|
||||
open={showModal}>
|
||||
<Formik
|
||||
validationSchema={getValidationSchema(possibleCountries)}
|
||||
initialValues={initialValues}
|
||||
validateOnChange={false}
|
||||
onSubmit={values => {
|
||||
onSubmit({
|
||||
variables: {
|
||||
phoneNumber: formatPhoneNumber(
|
||||
possibleCountries,
|
||||
values.phoneNumber
|
||||
)
|
||||
}
|
||||
})
|
||||
}}>
|
||||
{({ errors, touched }) => (
|
||||
<Form
|
||||
id="customer-registration-form"
|
||||
className="flex flex-col h-full">
|
||||
<H1 className="-mt-2">Create new customer</H1>
|
||||
<Field
|
||||
component={TextInput}
|
||||
name="phoneNumber"
|
||||
width={338}
|
||||
autoFocus
|
||||
label="Phone number"
|
||||
/>
|
||||
<div className="flex flex-row mt-auto mb-6">
|
||||
{getErrorMsg(errors, touched) && (
|
||||
<ErrorMessage>{getErrorMsg(errors, touched)}</ErrorMessage>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="customer-registration-form"
|
||||
className="ml-auto">
|
||||
Finish
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateCustomerModal
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import * as R from 'ramda'
|
||||
import React, { memo } from 'react'
|
||||
import { H2, Label1, P } from 'src/components/typography'
|
||||
import IdIcon from 'src/styling/icons/ID/card/zodiac.svg?react'
|
||||
|
||||
import { getFormattedPhone, getName } from '../helper'
|
||||
|
||||
import PhotosCard from './PhotosCard'
|
||||
|
||||
const CustomerDetails = memo(({ customer, photosData, locale, timezone }) => {
|
||||
const idNumber = R.path(['idCardData', 'documentNumber'])(customer)
|
||||
const usSsn = R.path(['usSsn'])(customer)
|
||||
const name = getName(customer)
|
||||
const email = R.path(['email'])(customer)
|
||||
const phone = R.path(['phone'])(customer)
|
||||
|
||||
const elements = [
|
||||
{
|
||||
header: 'Phone number',
|
||||
size: 172,
|
||||
value: getFormattedPhone(customer.phone, locale.country)
|
||||
}
|
||||
]
|
||||
|
||||
if (idNumber)
|
||||
elements.push({
|
||||
header: 'ID number',
|
||||
size: 172,
|
||||
value: idNumber
|
||||
})
|
||||
|
||||
if (usSsn)
|
||||
elements.push({
|
||||
header: 'US SSN',
|
||||
size: 127,
|
||||
value: usSsn
|
||||
})
|
||||
|
||||
if (email)
|
||||
elements.push({
|
||||
header: 'Email',
|
||||
size: 190,
|
||||
value: email
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex gap-7">
|
||||
<PhotosCard photosData={photosData} timezone={timezone} />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<IdIcon />
|
||||
<H2 noMargin>
|
||||
{name.length
|
||||
? name
|
||||
: email?.length
|
||||
? email
|
||||
: getFormattedPhone(phone, locale.country)}
|
||||
</H2>
|
||||
</div>
|
||||
<div className="flex mt-auto">
|
||||
{elements.map(({ size, header }, idx) => (
|
||||
<Label1
|
||||
noMargin
|
||||
key={idx}
|
||||
className="mb-1 text-comet"
|
||||
style={{ width: size }}>
|
||||
{header}
|
||||
</Label1>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{elements.map(({ size, value }, idx) => (
|
||||
<P noMargin key={idx} style={{ width: size }}>
|
||||
{value}
|
||||
</P>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default CustomerDetails
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import classnames from 'classnames'
|
||||
import React from 'react'
|
||||
import CustomerDataReversedIcon from 'src/styling/icons/customer-nav/data/comet.svg?react'
|
||||
import CustomerDataIcon from 'src/styling/icons/customer-nav/data/white.svg?react'
|
||||
import NoteReversedIcon from 'src/styling/icons/customer-nav/note/comet.svg?react'
|
||||
import NoteIcon from 'src/styling/icons/customer-nav/note/white.svg?react'
|
||||
import OverviewReversedIcon from 'src/styling/icons/customer-nav/overview/comet.svg?react'
|
||||
import OverviewIcon from 'src/styling/icons/customer-nav/overview/white.svg?react'
|
||||
import PhotosReversedIcon from 'src/styling/icons/customer-nav/photos/comet.svg?react'
|
||||
import Photos from 'src/styling/icons/customer-nav/photos/white.svg?react'
|
||||
|
||||
import { P } from '/src/components/typography/index.jsx'
|
||||
|
||||
const CustomerSidebar = ({ isSelected, onClick }) => {
|
||||
const sideBarOptions = [
|
||||
{
|
||||
code: 'overview',
|
||||
display: 'Overview',
|
||||
Icon: OverviewIcon,
|
||||
InverseIcon: OverviewReversedIcon
|
||||
},
|
||||
{
|
||||
code: 'customerData',
|
||||
display: 'Customer data',
|
||||
Icon: CustomerDataIcon,
|
||||
InverseIcon: CustomerDataReversedIcon
|
||||
},
|
||||
{
|
||||
code: 'notes',
|
||||
display: 'Notes',
|
||||
Icon: NoteIcon,
|
||||
InverseIcon: NoteReversedIcon
|
||||
},
|
||||
{
|
||||
code: 'photos',
|
||||
display: 'Photos & files',
|
||||
Icon: Photos,
|
||||
InverseIcon: PhotosReversedIcon
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-sm w-55 bg-zircon overflow-hidden">
|
||||
{sideBarOptions?.map(({ Icon, InverseIcon, display, code }, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={classnames({
|
||||
'gap-4 p-4 cursor-pointer flex items-center': true,
|
||||
'bg-comet2': isSelected(code)
|
||||
})}
|
||||
onClick={() => onClick(code)}>
|
||||
{isSelected(code) ? <Icon /> : <InverseIcon />}
|
||||
<P
|
||||
noMargin
|
||||
className={classnames({
|
||||
'text-comet2': true,
|
||||
'text-white font-bold': isSelected(code)
|
||||
})}>
|
||||
{display}
|
||||
</P>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerSidebar
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
import CardContent from '@mui/material/CardContent'
|
||||
import Card from '@mui/material/Card'
|
||||
import { Form, Formik, Field as FormikField } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import { useState, React, useRef } from 'react'
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import PromptWhenDirty from 'src/components/PromptWhenDirty'
|
||||
import { MainStatus } from 'src/components/Status'
|
||||
import { Label1, P, H3 } from 'src/components/typography'
|
||||
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
|
||||
import EditReversedIcon from 'src/styling/icons/action/edit/white.svg?react'
|
||||
import AuthorizeIcon from 'src/styling/icons/button/authorize/white.svg?react'
|
||||
import BlockIcon from 'src/styling/icons/button/block/white.svg?react'
|
||||
import CancelReversedIcon from 'src/styling/icons/button/cancel/white.svg?react'
|
||||
import DataReversedIcon from 'src/styling/icons/button/data/white.svg?react'
|
||||
import DataIcon from 'src/styling/icons/button/data/zodiac.svg?react'
|
||||
import ReplaceReversedIcon from 'src/styling/icons/button/replace/white.svg?react'
|
||||
import SaveReversedIcon from 'src/styling/icons/circle buttons/save/white.svg?react'
|
||||
|
||||
import { ActionButton } from 'src/components/buttons'
|
||||
import {
|
||||
OVERRIDE_REJECTED,
|
||||
OVERRIDE_PENDING
|
||||
} from 'src/pages/Customers/components/consts'
|
||||
|
||||
const ReadOnlyField = ({ field, value }) => {
|
||||
return (
|
||||
<div className="h-12">
|
||||
<Label1 noMargin className="text-comet">
|
||||
{field.label}
|
||||
</Label1>
|
||||
<P noMargin className="overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{value}
|
||||
</P>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EditableField = ({ editing, field, value, size, ...props }) => {
|
||||
if (!editing) return <ReadOnlyField field={field} value={value} />
|
||||
return (
|
||||
<div className="h-12">
|
||||
{editing && (
|
||||
<>
|
||||
<Label1 noMargin className="text-comet">
|
||||
{field.label}
|
||||
</Label1>
|
||||
<FormikField
|
||||
inputClasses="p-0 text-sm -mt-1"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
component={field.component}
|
||||
type={field.type}
|
||||
width={size}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EditableCard = ({
|
||||
fields,
|
||||
save = () => {},
|
||||
cancel = () => {},
|
||||
authorize = () => {},
|
||||
hasImage,
|
||||
reject = () => {},
|
||||
state,
|
||||
title,
|
||||
titleIcon,
|
||||
children = () => {},
|
||||
validationSchema,
|
||||
initialValues,
|
||||
deleteEditedData,
|
||||
editable,
|
||||
checkAgainstSanctions
|
||||
}) => {
|
||||
const formRef = useRef()
|
||||
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [input, setInput] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const triggerInput = () => input.click()
|
||||
|
||||
const authorized =
|
||||
state === OVERRIDE_PENDING
|
||||
? { label: 'Pending', type: 'default' }
|
||||
: state === OVERRIDE_REJECTED
|
||||
? { label: 'Rejected', type: 'error' }
|
||||
: { label: 'Accepted', type: 'success' }
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl">
|
||||
<CardContent>
|
||||
<div className="flex justify-between h-10">
|
||||
<div className="flex mb-4 gap-4">
|
||||
{titleIcon}
|
||||
<H3 noMargin>{title}</H3>
|
||||
</div>
|
||||
{state && authorize && <MainStatus statuses={[authorized]} />}
|
||||
</div>
|
||||
{children(formRef.current?.values ?? {})}
|
||||
<Formik
|
||||
innerRef={formRef}
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
enableReinitialize
|
||||
validationSchema={validationSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={values => {
|
||||
save(values)
|
||||
setEditing(false)
|
||||
}}
|
||||
onReset={() => {
|
||||
setEditing(false)
|
||||
setError(false)
|
||||
}}>
|
||||
{({ setFieldValue }) => (
|
||||
<Form>
|
||||
<PromptWhenDirty />
|
||||
<div className="flex">
|
||||
<div className="flex flex-col w-1/2">
|
||||
{!hasImage &&
|
||||
fields?.map((field, idx) => {
|
||||
return idx >= 0 && idx < 4 ? (
|
||||
!field.editable ? (
|
||||
<ReadOnlyField
|
||||
field={field}
|
||||
value={initialValues[field.name]}
|
||||
/>
|
||||
) : (
|
||||
<EditableField
|
||||
field={field}
|
||||
value={initialValues[field.name]}
|
||||
editing={editing}
|
||||
size={180}
|
||||
/>
|
||||
)
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col w-1/2">
|
||||
{!hasImage &&
|
||||
fields?.map((field, idx) => {
|
||||
return idx >= 4 ? (
|
||||
!field.editable ? (
|
||||
<ReadOnlyField
|
||||
field={field}
|
||||
value={initialValues[field.name]}
|
||||
/>
|
||||
) : (
|
||||
<EditableField
|
||||
field={field}
|
||||
value={initialValues[field.name]}
|
||||
editing={editing}
|
||||
size={180}
|
||||
/>
|
||||
)
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-5 gap-2">
|
||||
{!editing && (
|
||||
<>
|
||||
{checkAgainstSanctions && (
|
||||
<ActionButton
|
||||
color="primary"
|
||||
type="button"
|
||||
Icon={DataIcon}
|
||||
InverseIcon={DataReversedIcon}
|
||||
onClick={() => checkAgainstSanctions()}>
|
||||
Check against OFAC sanction list
|
||||
</ActionButton>
|
||||
)}
|
||||
{editable && (
|
||||
<ActionButton
|
||||
color="primary"
|
||||
Icon={EditIcon}
|
||||
InverseIcon={EditReversedIcon}
|
||||
onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</ActionButton>
|
||||
)}
|
||||
{!editable &&
|
||||
authorize &&
|
||||
authorized.label !== 'Accepted' && (
|
||||
<ActionButton
|
||||
color="spring"
|
||||
type="button"
|
||||
Icon={AuthorizeIcon}
|
||||
InverseIcon={AuthorizeIcon}
|
||||
onClick={() => authorize()}>
|
||||
Authorize
|
||||
</ActionButton>
|
||||
)}
|
||||
{!editable &&
|
||||
authorize &&
|
||||
authorized.label !== 'Rejected' && (
|
||||
<ActionButton
|
||||
color="tomato"
|
||||
type="button"
|
||||
Icon={BlockIcon}
|
||||
InverseIcon={BlockIcon}
|
||||
onClick={() => reject()}>
|
||||
Reject
|
||||
</ActionButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editing && (
|
||||
<>
|
||||
{hasImage && state !== OVERRIDE_PENDING && (
|
||||
<ActionButton
|
||||
color="secondary"
|
||||
type="button"
|
||||
Icon={ReplaceReversedIcon}
|
||||
InverseIcon={ReplaceReversedIcon}
|
||||
onClick={() => triggerInput()}>
|
||||
{
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
alt=""
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
ref={fileInput => setInput(fileInput)}
|
||||
onChange={event => {
|
||||
// need to store it locally if we want to display it even after saving to db
|
||||
const file = R.head(event.target.files)
|
||||
if (!file) return
|
||||
setFieldValue(R.head(fields).name, file)
|
||||
}}
|
||||
/>
|
||||
Replace
|
||||
</div>
|
||||
}
|
||||
</ActionButton>
|
||||
)}
|
||||
{fields && (
|
||||
<ActionButton
|
||||
color="secondary"
|
||||
Icon={SaveReversedIcon}
|
||||
InverseIcon={SaveReversedIcon}
|
||||
type="submit">
|
||||
Save
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
color="secondary"
|
||||
Icon={CancelReversedIcon}
|
||||
InverseIcon={CancelReversedIcon}
|
||||
onClick={() => cancel()}
|
||||
type="reset">
|
||||
Cancel
|
||||
</ActionButton>
|
||||
{authorize && authorized.label !== 'Accepted' && (
|
||||
<ActionButton
|
||||
color="spring"
|
||||
type="button"
|
||||
Icon={AuthorizeIcon}
|
||||
InverseIcon={AuthorizeIcon}
|
||||
onClick={() => authorize()}>
|
||||
Authorize
|
||||
</ActionButton>
|
||||
)}
|
||||
{authorize && authorized.label !== 'Rejected' && (
|
||||
<ActionButton
|
||||
color="tomato"
|
||||
type="button"
|
||||
Icon={BlockIcon}
|
||||
InverseIcon={BlockIcon}
|
||||
onClick={() => reject()}>
|
||||
Reject
|
||||
</ActionButton>
|
||||
)}
|
||||
{error && (
|
||||
<ErrorMessage>Failed to save changes</ErrorMessage>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditableCard
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import ButtonBase from '@mui/material/ButtonBase'
|
||||
import Paper from '@mui/material/Card'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useState } from 'react'
|
||||
import { InformativeDialog } from 'src/components/InformativeDialog'
|
||||
import { Info2 } from 'src/components/typography'
|
||||
import CrossedCameraIcon from 'src/styling/icons/ID/photo/crossed-camera.svg?react'
|
||||
|
||||
import PhotosCarousel from './PhotosCarousel'
|
||||
|
||||
const PhotosCard = memo(({ photosData, timezone }) => {
|
||||
const [photosDialog, setPhotosDialog] = useState(false)
|
||||
|
||||
const sortedPhotosData = R.sortWith(
|
||||
[(a, b) => R.has('id', a) - R.has('id', b), R.descend(R.prop('date'))],
|
||||
photosData
|
||||
)
|
||||
|
||||
const singlePhoto = R.head(sortedPhotosData)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper
|
||||
className="flex justify-center items-center bg-zircon rounded-lg h-34 w-34"
|
||||
elevation={0}>
|
||||
<ButtonBase
|
||||
disabled={!singlePhoto}
|
||||
onClick={() => {
|
||||
setPhotosDialog(true)
|
||||
}}>
|
||||
{singlePhoto ? (
|
||||
<div>
|
||||
<img
|
||||
className="w-34 h-34 object-center object-cover block"
|
||||
src={`/${singlePhoto.photoDir}/${singlePhoto.path}`}
|
||||
alt=""
|
||||
/>
|
||||
<div className=""></div>
|
||||
<circle className="absolute top-0 right-0 mr-1 mt-1 bg-ghost rounded-full w-6 h-6 flex items-center justify-center">
|
||||
<Info2>{sortedPhotosData.length}</Info2>
|
||||
</circle>
|
||||
</div>
|
||||
) : (
|
||||
<CrossedCameraIcon />
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Paper>
|
||||
<InformativeDialog
|
||||
open={photosDialog}
|
||||
title={`Photo roll`}
|
||||
data={
|
||||
<PhotosCarousel photosData={sortedPhotosData} timezone={timezone} />
|
||||
}
|
||||
onDissmised={() => {
|
||||
setPhotosDialog(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default PhotosCard
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import * as R from 'ramda'
|
||||
import React, { memo, useState } from 'react'
|
||||
import { Carousel } from 'src/components/Carousel'
|
||||
import { Label1 } from 'src/components/typography'
|
||||
|
||||
import { formatDate } from 'src/utils/timezones'
|
||||
|
||||
import CopyToClipboard from '../../../components/CopyToClipboard.jsx'
|
||||
|
||||
const PhotosCarousel = memo(({ photosData, timezone }) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
|
||||
const Label = ({ children }) => {
|
||||
return (
|
||||
<Label1 noMargin className="mb-1 text-comet">
|
||||
{children}
|
||||
</Label1>
|
||||
)
|
||||
}
|
||||
|
||||
const isFaceCustomerPhoto = !R.has('id')(photosData[currentIndex])
|
||||
|
||||
const slidePhoto = index => setCurrentIndex(index)
|
||||
|
||||
// TODO hide copy to clipboard shit
|
||||
return (
|
||||
<>
|
||||
<Carousel photosData={photosData} slidePhoto={slidePhoto} />
|
||||
{!isFaceCustomerPhoto && (
|
||||
<div className="flex flex-col p-2">
|
||||
<Label>Session ID</Label>
|
||||
<CopyToClipboard>
|
||||
{photosData && photosData[currentIndex]?.id}
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-zodiac flex p-2 gap-8">
|
||||
<div>
|
||||
<>
|
||||
<Label>Date</Label>
|
||||
<div>
|
||||
{photosData &&
|
||||
formatDate(
|
||||
photosData[currentIndex]?.date,
|
||||
timezone,
|
||||
'yyyy-MM-dd HH:mm'
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Taken by</Label>
|
||||
<div>
|
||||
{!isFaceCustomerPhoto ? 'Acceptance of T&C' : 'Compliance scan'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default PhotosCarousel
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import { toUnit } from '@lamassu/coins/lightUtils'
|
||||
import BigNumber from 'bignumber.js'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import DataTable from 'src/components/tables/DataTable'
|
||||
import { H3, H4, Label1, Label2, P } from 'src/components/typography'
|
||||
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
|
||||
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
|
||||
|
||||
import { ifNotNull } from 'src/utils/nullCheck'
|
||||
import { formatDate } from 'src/utils/timezones'
|
||||
|
||||
import CopyToClipboard from '../../../components/CopyToClipboard.jsx'
|
||||
|
||||
const TransactionsList = ({ customer, data, loading }) => {
|
||||
const LastTxIcon = customer.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon
|
||||
const hasData = !(R.isEmpty(data) || R.isNil(data))
|
||||
const { lastUsedMachineName } = customer
|
||||
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
const summaryElements = [
|
||||
{
|
||||
header: 'Transactions',
|
||||
size: 127,
|
||||
value: ifNotNull(
|
||||
customer.totalTxs,
|
||||
`${Number.parseInt(customer.totalTxs)}`
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Transaction volume',
|
||||
size: 167,
|
||||
value: ifNotNull(
|
||||
customer.totalSpent,
|
||||
`${Number.parseFloat(customer.totalSpent)} ${customer.lastTxFiatCode}`
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Last active',
|
||||
size: 142,
|
||||
value:
|
||||
!R.isNil(timezone) &&
|
||||
((customer.lastActive &&
|
||||
formatDate(customer.lastActive, timezone, 'yyyy-MM-dd')) ??
|
||||
'')
|
||||
},
|
||||
{
|
||||
header: 'Last transaction',
|
||||
size: 198,
|
||||
value: ifNotNull(
|
||||
customer.lastTxFiat,
|
||||
<>
|
||||
<LastTxIcon className="mr-3" />
|
||||
{`${Number.parseFloat(customer.lastTxFiat)}
|
||||
${customer.lastTxFiatCode}`}
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Last used machine',
|
||||
size: 198,
|
||||
value: ifNotNull(lastUsedMachineName, <>{lastUsedMachineName}</>)
|
||||
}
|
||||
]
|
||||
|
||||
const tableElements = [
|
||||
{
|
||||
width: 40,
|
||||
view: it => (
|
||||
<>
|
||||
{it.txClass === 'cashOut' ? (
|
||||
<TxOutIcon className="mr-3" />
|
||||
) : (
|
||||
<TxInIcon className="mr-3" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Machine',
|
||||
width: 160,
|
||||
view: R.path(['machineName'])
|
||||
},
|
||||
{
|
||||
header: 'Transaction ID',
|
||||
width: 145,
|
||||
view: it => (
|
||||
<CopyToClipboard className="font-museo whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{it.id}
|
||||
</CopyToClipboard>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Cash',
|
||||
width: 155,
|
||||
textAlign: 'right',
|
||||
view: it => (
|
||||
<>
|
||||
{`${Number.parseFloat(it.fiat)} `}
|
||||
<Label2 inline>{it.fiatCode}</Label2>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Crypto',
|
||||
width: 145,
|
||||
textAlign: 'right',
|
||||
view: it => (
|
||||
<>
|
||||
{`${toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode).toFormat(
|
||||
5
|
||||
)} `}
|
||||
<Label2 inline>{it.cryptoCode}</Label2>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Date',
|
||||
width: 100,
|
||||
view: it => formatDate(it.created, timezone, 'yyyy‑MM‑dd')
|
||||
},
|
||||
{
|
||||
header: 'Time (h:m:s)',
|
||||
width: 130,
|
||||
view: it => formatDate(it.created, timezone, 'HH:mm:ss')
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<H3>Transactions</H3>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex mt-auto">
|
||||
{summaryElements.map(({ size, header }, idx) => (
|
||||
<Label1
|
||||
noMargin
|
||||
key={idx}
|
||||
className="text-comet mb-1 mr-6"
|
||||
style={{ width: size }}>
|
||||
{header}
|
||||
</Label1>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{summaryElements.map(({ size, value }, idx) => (
|
||||
<P noMargin key={idx} className="h-4 mr-6" style={{ width: size }}>
|
||||
{value}
|
||||
</P>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex mt-5">
|
||||
{loading ? (
|
||||
<H4>Loading</H4>
|
||||
) : hasData ? (
|
||||
<DataTable elements={tableElements} data={data} />
|
||||
) : (
|
||||
<H4>No transactions so far</H4>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransactionsList
|
||||
65
packages/admin-ui/src/pages/Customers/components/Upload.jsx
Normal file
65
packages/admin-ui/src/pages/Customers/components/Upload.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useFormikContext } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Label3, H3 } from 'src/components/typography'
|
||||
import UploadPhotoIcon from 'src/styling/icons/button/photo/zodiac-resized.svg?react'
|
||||
import UploadFileIcon from 'src/styling/icons/button/upload-file/zodiac-resized.svg?react'
|
||||
|
||||
import classes from './Upload.module.css'
|
||||
|
||||
const Upload = ({ type }) => {
|
||||
const [data, setData] = useState({})
|
||||
|
||||
const { setFieldValue } = useFormikContext()
|
||||
|
||||
const IMAGE = 'image'
|
||||
const ID_CARD_PHOTO = 'idCardPhoto'
|
||||
const FRONT_CAMERA = 'frontCamera'
|
||||
|
||||
const isImage =
|
||||
type === IMAGE || type === FRONT_CAMERA || type === ID_CARD_PHOTO
|
||||
|
||||
const onDrop = useCallback(
|
||||
acceptedData => {
|
||||
setFieldValue(type, R.head(acceptedData))
|
||||
|
||||
setData({
|
||||
preview: isImage
|
||||
? URL.createObjectURL(R.head(acceptedData))
|
||||
: R.head(acceptedData).name
|
||||
})
|
||||
},
|
||||
[isImage, type, setFieldValue]
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...getRootProps()} className="mt-10 w-112 h-30">
|
||||
{R.isEmpty(data) && (
|
||||
<div className={classes.box}>
|
||||
<input {...getInputProps()} />
|
||||
{isImage ? <UploadPhotoIcon /> : <UploadFileIcon />}
|
||||
<Label3>{`Drag and drop ${
|
||||
isImage ? 'an image' : 'a file'
|
||||
} or click to open the explorer`}</Label3>
|
||||
</div>
|
||||
)}
|
||||
{!R.isEmpty(data) && isImage && (
|
||||
<div key={data.name}>
|
||||
<img src={data.preview} className={classes.box} alt=""></img>
|
||||
</div>
|
||||
)}
|
||||
{!R.isEmpty(data) && !isImage && (
|
||||
<div className={classes.box}>
|
||||
<H3 className="mt-12 flex">{data.preview}</H3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Upload
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
.box {
|
||||
box-sizing: border-box;
|
||||
width: 450px;
|
||||
height: 120px;
|
||||
border-style: dashed;
|
||||
border-color: var(--comet);
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
background-color: var(--zircon);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
const OVERRIDE_PENDING = 'automatic'
|
||||
const OVERRIDE_AUTHORIZED = 'verified'
|
||||
const OVERRIDE_REJECTED = 'blocked'
|
||||
|
||||
export { OVERRIDE_PENDING, OVERRIDE_AUTHORIZED, OVERRIDE_REJECTED }
|
||||
16
packages/admin-ui/src/pages/Customers/components/index.js
Normal file
16
packages/admin-ui/src/pages/Customers/components/index.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import Wizard from '../Wizard'
|
||||
|
||||
import CustomerDetails from './CustomerDetails'
|
||||
import CustomerSidebar from './CustomerSidebar'
|
||||
import EditableCard from './EditableCard'
|
||||
import TransactionsList from './TransactionsList'
|
||||
import Upload from './Upload'
|
||||
|
||||
export {
|
||||
CustomerDetails,
|
||||
TransactionsList,
|
||||
CustomerSidebar,
|
||||
EditableCard,
|
||||
Wizard,
|
||||
Upload
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import Paper from '@mui/material/Paper'
|
||||
import { React } from 'react'
|
||||
import { P } from 'src/components/typography'
|
||||
import AddIcon from 'src/styling/icons/button/add/zodiac.svg?react'
|
||||
|
||||
const NewNoteCard = ({ setOpenModal }) => {
|
||||
return (
|
||||
<Paper
|
||||
className="cursor-pointer bg-zircon flex flex-col justify-center items-center"
|
||||
onClick={() => setOpenModal(true)}>
|
||||
<AddIcon width={20} height={20} />
|
||||
<P>Add new</P>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewNoteCard
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { Form, Formik, Field } from 'formik'
|
||||
import { React } from 'react'
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import Modal from 'src/components/Modal'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { TextInput } from 'src/components/inputs/formik'
|
||||
|
||||
const initialValues = {
|
||||
title: '',
|
||||
content: ''
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
title: Yup.string().required().trim().max(25),
|
||||
content: Yup.string().required()
|
||||
})
|
||||
|
||||
const NewNoteModal = ({ showModal, onClose, onSubmit, errorMsg }) => {
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="New note"
|
||||
closeOnBackdropClick={true}
|
||||
width={416}
|
||||
height={472}
|
||||
handleClose={onClose}
|
||||
open={showModal}>
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={({ title, content }) => {
|
||||
onSubmit({ title, content })
|
||||
}}>
|
||||
<Form id="note-form" className="flex flex-col h-full gap-5">
|
||||
<Field
|
||||
name="title"
|
||||
autofocus
|
||||
size="md"
|
||||
autoComplete="off"
|
||||
width={350}
|
||||
component={TextInput}
|
||||
label="Note title"
|
||||
/>
|
||||
<Field
|
||||
name="content"
|
||||
size="sm"
|
||||
autoComplete="off"
|
||||
width={350}
|
||||
component={TextInput}
|
||||
multiline={true}
|
||||
rows={11}
|
||||
label="Note content"
|
||||
/>
|
||||
<div className="flex flex-row mt-auto mb-6">
|
||||
{errorMsg && <ErrorMessage>{errorMsg}</ErrorMessage>}
|
||||
<Button
|
||||
type="submit"
|
||||
form="note-form"
|
||||
className="mt-auto ml-auto">
|
||||
Add note
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewNoteModal
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import Paper from '@mui/material/Paper'
|
||||
import * as R from 'ramda'
|
||||
import { React } from 'react'
|
||||
import { H3, P } from 'src/components/typography'
|
||||
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
|
||||
|
||||
import { formatDate } from 'src/utils/timezones'
|
||||
|
||||
const formatContent = content => {
|
||||
const fragments = R.split(/\n/)(content)
|
||||
return R.map((it, idx) => {
|
||||
if (idx === fragments.length) return <>{it}</>
|
||||
return (
|
||||
<>
|
||||
{it}
|
||||
<br />
|
||||
</>
|
||||
)
|
||||
}, fragments)
|
||||
}
|
||||
|
||||
const NoteCard = ({ note, deleteNote, handleClick, timezone }) => {
|
||||
return (
|
||||
<Paper
|
||||
className="p-2 cursor-pointer overflow-hidden text-ellipsis"
|
||||
onClick={() => handleClick(note)}>
|
||||
<div className="flex flex-row justify-between w-full">
|
||||
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis">
|
||||
<H3 noMargin>{note?.title}</H3>
|
||||
<P noMargin>{formatDate(note?.created, timezone, 'yyyy-MM-dd')}</P>
|
||||
</div>
|
||||
<div>
|
||||
<DeleteIcon
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
deleteNote({ noteId: note.id })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<P noMargin className="mt-2 line-clamp-8">
|
||||
{formatContent(note?.content)}
|
||||
</P>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteCard
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import Paper from '@mui/material/Paper'
|
||||
import { formatDurationWithOptions, intervalToDuration } from 'date-fns/fp'
|
||||
import { Form, Formik, Field } from 'formik'
|
||||
import { React, useRef } from 'react'
|
||||
import { P } from 'src/components/typography'
|
||||
import CancelIconInverse from 'src/styling/icons/button/cancel/white.svg?react'
|
||||
import CancelIcon from 'src/styling/icons/button/cancel/zodiac.svg?react'
|
||||
import SaveIconInverse from 'src/styling/icons/circle buttons/save/white.svg?react'
|
||||
import SaveIcon from 'src/styling/icons/circle buttons/save/zodiac.svg?react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { ActionButton } from 'src/components/buttons'
|
||||
import { TextInput } from 'src/components/inputs/formik'
|
||||
import { toTimezone } from 'src/utils/timezones'
|
||||
|
||||
const NoteEdit = ({ note, cancel, edit, timezone }) => {
|
||||
const formRef = useRef()
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
content: Yup.string()
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
content: note.content
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className="p-4">
|
||||
<div className="flex flex-row justify-between items-center mb-4">
|
||||
<P noMargin>
|
||||
{`Last edited `}
|
||||
{formatDurationWithOptions(
|
||||
{ delimited: ', ' },
|
||||
intervalToDuration({
|
||||
start: toTimezone(new Date(note.lastEditedAt), timezone),
|
||||
end: toTimezone(new Date(), timezone)
|
||||
})
|
||||
)}
|
||||
{` ago`}
|
||||
</P>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActionButton
|
||||
color="primary"
|
||||
type="button"
|
||||
Icon={CancelIcon}
|
||||
InverseIcon={CancelIconInverse}
|
||||
onClick={cancel}>
|
||||
{`Cancel`}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
color="primary"
|
||||
type="submit"
|
||||
form="edit-note"
|
||||
Icon={SaveIcon}
|
||||
InverseIcon={SaveIconInverse}>
|
||||
{`Save changes`}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
color="primary"
|
||||
type="button"
|
||||
Icon={CancelIcon}
|
||||
InverseIcon={CancelIconInverse}
|
||||
onClick={() => formRef.current.setFieldValue('content', '')}>
|
||||
{`Clear content`}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<Formik
|
||||
validateOnChange={false}
|
||||
validateOnBlur={false}
|
||||
validationSchema={validationSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={({ content }) =>
|
||||
edit({
|
||||
noteId: note.id,
|
||||
newContent: content,
|
||||
oldContent: note.content
|
||||
})
|
||||
}
|
||||
innerRef={formRef}>
|
||||
<Form id="edit-note">
|
||||
<Field
|
||||
name="content"
|
||||
component={TextInput}
|
||||
InputProps={{ disableUnderline: true }}
|
||||
size="sm"
|
||||
autoComplete="off"
|
||||
fullWidth
|
||||
multiline={true}
|
||||
rows={15}
|
||||
/>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteEdit
|
||||
547
packages/admin-ui/src/pages/Customers/helper.jsx
Normal file
547
packages/admin-ui/src/pages/Customers/helper.jsx
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
import React from 'react'
|
||||
import { parse, isValid, format } from 'date-fns/fp'
|
||||
import { Field, useFormikContext } from 'formik'
|
||||
import { parsePhoneNumberFromString } from 'libphonenumber-js'
|
||||
import * as R from 'ramda'
|
||||
import { H4 } from 'src/components/typography'
|
||||
import { validate as uuidValidate } from 'uuid'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import {
|
||||
RadioGroup,
|
||||
TextInput,
|
||||
Autocomplete
|
||||
} from 'src/components/inputs/formik'
|
||||
import { MANUAL } from 'src/utils/constants'
|
||||
|
||||
import { Upload } from './components'
|
||||
|
||||
const CUSTOMER_BLOCKED = 'blocked'
|
||||
const CUSTOM = 'custom'
|
||||
const REQUIREMENT = 'requirement'
|
||||
const ID_CARD_DATA = 'idCardData'
|
||||
|
||||
const getAuthorizedStatus = (it, triggers, customRequests) => {
|
||||
const fields = R.concat(
|
||||
['frontCamera', 'idCardData', 'idCardPhoto', 'email', 'usSsn', 'sanctions'],
|
||||
R.map(ite => ite.id, customRequests)
|
||||
)
|
||||
const fieldsWithPathSuffix = ['frontCamera', 'idCardPhoto']
|
||||
|
||||
const isManualField = fieldName => {
|
||||
const triggerName = R.equals(fieldName, 'frontCamera')
|
||||
? 'facephoto'
|
||||
: fieldName
|
||||
const manualOverrides = R.filter(
|
||||
ite => R.equals(R.toLower(ite.automation), MANUAL),
|
||||
triggers?.overrides ?? []
|
||||
)
|
||||
|
||||
return (
|
||||
!!R.find(
|
||||
ite => R.equals(ite.requirement, triggerName),
|
||||
manualOverrides
|
||||
) || R.equals(R.toLower(triggers.automation ?? ''), MANUAL)
|
||||
)
|
||||
}
|
||||
|
||||
const pendingFieldStatus = R.map(ite => {
|
||||
if (isManualField(ite)) {
|
||||
if (uuidValidate(ite)) {
|
||||
const request = R.find(
|
||||
iter => iter.infoRequestId === ite,
|
||||
it.customInfoRequests
|
||||
)
|
||||
return !R.isNil(request) && R.equals(request.override, 'automatic')
|
||||
}
|
||||
|
||||
const regularFieldValue = R.includes(ite, fieldsWithPathSuffix)
|
||||
? it[`${ite}Path`]
|
||||
: it[`${ite}`]
|
||||
if (R.isNil(regularFieldValue)) return false
|
||||
return R.equals(it[`${ite}Override`], 'automatic')
|
||||
}
|
||||
return false
|
||||
}, fields)
|
||||
|
||||
const rejectedFieldStatus = R.map(ite => {
|
||||
if (isManualField(ite)) {
|
||||
if (uuidValidate(ite)) {
|
||||
const request = R.find(
|
||||
iter => iter.infoRequestId === ite,
|
||||
it.customInfoRequests
|
||||
)
|
||||
return !R.isNil(request) && R.equals(request.override, 'blocked')
|
||||
}
|
||||
|
||||
const regularFieldValue = R.includes(ite, fieldsWithPathSuffix)
|
||||
? it[`${ite}Path`]
|
||||
: it[`${ite}`]
|
||||
if (R.isNil(regularFieldValue)) return false
|
||||
return R.equals(it[`${ite}Override`], 'blocked')
|
||||
}
|
||||
return false
|
||||
}, fields)
|
||||
|
||||
if (it.authorizedOverride === CUSTOMER_BLOCKED)
|
||||
return { label: 'Blocked', type: 'error' }
|
||||
if (it.isSuspended)
|
||||
return it.daysSuspended > 0
|
||||
? { label: `${it.daysSuspended} day suspension`, type: 'warning' }
|
||||
: { label: `< 1 day suspension`, type: 'warning' }
|
||||
if (R.any(ite => ite === true, rejectedFieldStatus))
|
||||
return { label: 'Rejected', type: 'error' }
|
||||
if (R.any(ite => ite === true, pendingFieldStatus))
|
||||
return { label: 'Pending', type: 'warning' }
|
||||
return { label: 'Authorized', type: 'success' }
|
||||
}
|
||||
|
||||
const getFormattedPhone = (phone, country) => {
|
||||
const phoneNumber =
|
||||
phone && country ? parsePhoneNumberFromString(phone, country) : null
|
||||
|
||||
return phoneNumber ? phoneNumber.formatInternational() : phone
|
||||
}
|
||||
|
||||
const getName = it => {
|
||||
const idData = R.path(['idCardData'])(it)
|
||||
|
||||
return `${R.path(['firstName'])(idData) ?? ''} ${
|
||||
R.path(['lastName'])(idData) ?? ''
|
||||
}`.trim()
|
||||
}
|
||||
|
||||
// Manual Entry Wizard
|
||||
|
||||
const entryOptions = [
|
||||
{ display: 'Custom entry', code: 'custom' },
|
||||
{ display: 'Populate existing requirement', code: 'requirement' }
|
||||
]
|
||||
|
||||
const dataOptions = [
|
||||
{ display: 'Text', code: 'text' }
|
||||
// TODO: Requires backend modifications to support File and Image
|
||||
// { display: 'File', code: 'file' },
|
||||
// { display: 'Image', code: 'image' }
|
||||
]
|
||||
|
||||
const requirementOptions = [
|
||||
{ display: 'ID card image', code: 'idCardPhoto' },
|
||||
{ display: 'ID data', code: 'idCardData' },
|
||||
{ display: 'US SSN', code: 'usSsn' },
|
||||
{ display: 'Email', code: 'email' },
|
||||
{ display: 'Customer camera', code: 'frontCamera' }
|
||||
]
|
||||
|
||||
const customTextOptions = [
|
||||
{ label: 'Data entry title', name: 'title' },
|
||||
{ label: 'Data entry', name: 'data' }
|
||||
]
|
||||
|
||||
const customUploadOptions = [{ label: 'Data entry title', name: 'title' }]
|
||||
|
||||
const entryTypeSchema = Yup.lazy(values => {
|
||||
if (values.entryType === 'custom') {
|
||||
return Yup.object().shape({
|
||||
entryType: Yup.string().required(),
|
||||
dataType: Yup.string().required()
|
||||
})
|
||||
} else if (values.entryType === 'requirement') {
|
||||
return Yup.object().shape({
|
||||
entryType: Yup.string().required(),
|
||||
requirement: Yup.string().required()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const customFileSchema = Yup.object().shape({
|
||||
title: Yup.string().required(),
|
||||
file: Yup.mixed().required()
|
||||
})
|
||||
|
||||
const customImageSchema = Yup.object().shape({
|
||||
title: Yup.string().required(),
|
||||
image: Yup.mixed().required()
|
||||
})
|
||||
|
||||
const customTextSchema = Yup.object().shape({
|
||||
title: Yup.string().required(),
|
||||
data: Yup.string().required()
|
||||
})
|
||||
|
||||
const updateRequirementOptions = it => [
|
||||
{
|
||||
display: 'Custom information requirement',
|
||||
code: 'custom'
|
||||
},
|
||||
...it
|
||||
]
|
||||
|
||||
const EntryType = ({ customInfoRequirementOptions }) => {
|
||||
const { values } = useFormikContext()
|
||||
|
||||
const displayCustomOptions = values.entryType === CUSTOM
|
||||
const displayRequirementOptions = values.entryType === REQUIREMENT
|
||||
|
||||
const Entry = ({ title, name, options, className }) => (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<H4>{title}</H4>
|
||||
</div>
|
||||
<Field
|
||||
component={RadioGroup}
|
||||
name={name}
|
||||
options={options}
|
||||
radioClassName="p-1 m-1"
|
||||
labelClassName={className}
|
||||
className="grid grid-cols-[182px_162px_141px]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Entry
|
||||
title="Type of entry"
|
||||
name="entryType"
|
||||
options={entryOptions}
|
||||
className="w-62"
|
||||
/>
|
||||
{displayCustomOptions && (
|
||||
<Entry title="Type of data" name="dataType" options={dataOptions} />
|
||||
)}
|
||||
{displayRequirementOptions && (
|
||||
<Entry
|
||||
title="Requirements"
|
||||
name="requirement"
|
||||
options={requirementOptions}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ManualDataEntry = ({ selectedValues, customInfoRequirementOptions }) => {
|
||||
const typeOfEntrySelected = selectedValues?.entryType
|
||||
const dataTypeSelected = selectedValues?.dataType
|
||||
const requirementSelected = selectedValues?.requirement
|
||||
|
||||
const displayRequirements = typeOfEntrySelected === 'requirement'
|
||||
|
||||
const isCustomInfoRequirement = requirementSelected === CUSTOM
|
||||
|
||||
const updatedRequirementOptions = !R.isEmpty(customInfoRequirementOptions)
|
||||
? updateRequirementOptions(requirementOptions)
|
||||
: requirementOptions
|
||||
|
||||
const requirementName = displayRequirements
|
||||
? R.find(R.propEq('code', requirementSelected))(updatedRequirementOptions)
|
||||
.display
|
||||
: ''
|
||||
|
||||
const title = displayRequirements
|
||||
? `Requirement ${requirementName}`
|
||||
: `Custom ${dataTypeSelected} entry`
|
||||
|
||||
const elements = displayRequirements
|
||||
? requirementElements[requirementSelected]
|
||||
: customElements[dataTypeSelected]
|
||||
|
||||
const upload = displayRequirements
|
||||
? requirementSelected === 'idCardPhoto' ||
|
||||
requirementSelected === 'frontCamera'
|
||||
: dataTypeSelected === 'file' || dataTypeSelected === 'image'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<H4>{title}</H4>
|
||||
</div>
|
||||
{isCustomInfoRequirement && (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
label={`Available requests`}
|
||||
className="w-37"
|
||||
isOptionEqualToValue={R.eqProps('code')}
|
||||
labelProp={'display'}
|
||||
options={customInfoRequirementOptions}
|
||||
onChange={(evt, it) => {}}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
{!upload &&
|
||||
!isCustomInfoRequirement &&
|
||||
elements.options.map(({ label, name }, idx) => (
|
||||
<Field
|
||||
key={idx}
|
||||
name={name}
|
||||
label={label}
|
||||
component={TextInput}
|
||||
width={390}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{upload && (
|
||||
<Upload
|
||||
type={
|
||||
displayRequirements ? requirementSelected : dataTypeSelected
|
||||
}></Upload>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const customElements = {
|
||||
text: {
|
||||
schema: customTextSchema,
|
||||
options: customTextOptions,
|
||||
Component: ManualDataEntry,
|
||||
initialValues: { data: '', title: '' },
|
||||
saveType: 'customEntry'
|
||||
},
|
||||
file: {
|
||||
schema: customFileSchema,
|
||||
options: customUploadOptions,
|
||||
Component: ManualDataEntry,
|
||||
initialValues: { file: null, title: '' },
|
||||
saveType: 'customEntryUpload'
|
||||
},
|
||||
image: {
|
||||
schema: customImageSchema,
|
||||
options: customUploadOptions,
|
||||
Component: ManualDataEntry,
|
||||
initialValues: { image: null, title: '' },
|
||||
saveType: 'customEntryUpload'
|
||||
}
|
||||
}
|
||||
|
||||
const entryType = {
|
||||
schema: entryTypeSchema,
|
||||
options: entryOptions,
|
||||
Component: EntryType,
|
||||
initialValues: { entryType: '' }
|
||||
}
|
||||
|
||||
// Customer data
|
||||
|
||||
const customerDataElements = {
|
||||
idCardData: [
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'First name',
|
||||
component: TextInput,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
name: 'documentNumber',
|
||||
label: 'ID number',
|
||||
component: TextInput,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
name: 'dateOfBirth',
|
||||
label: 'Birthdate',
|
||||
component: TextInput,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
name: 'gender',
|
||||
label: 'Gender',
|
||||
component: TextInput,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
label: 'Last name',
|
||||
component: TextInput,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
name: 'expirationDate',
|
||||
label: 'Expiration date',
|
||||
component: TextInput,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
label: 'Country',
|
||||
component: TextInput,
|
||||
editable: true
|
||||
}
|
||||
],
|
||||
usSsn: [
|
||||
{
|
||||
name: 'usSsn',
|
||||
label: 'US SSN',
|
||||
component: TextInput,
|
||||
size: 190,
|
||||
editable: true
|
||||
}
|
||||
],
|
||||
email: [
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
component: TextInput,
|
||||
size: 190,
|
||||
editable: false
|
||||
}
|
||||
],
|
||||
idCardPhoto: [{ name: 'idCardPhoto' }],
|
||||
frontCamera: [{ name: 'frontCamera' }]
|
||||
}
|
||||
|
||||
const customerDataSchemas = {
|
||||
idCardData: Yup.object().shape({
|
||||
firstName: Yup.string().required(),
|
||||
lastName: Yup.string().required(),
|
||||
documentNumber: Yup.string().required(),
|
||||
dateOfBirth: Yup.string()
|
||||
.test({
|
||||
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val)),
|
||||
message: 'Date must be in format YYYY-MM-DD'
|
||||
})
|
||||
.required(),
|
||||
gender: Yup.string().required(),
|
||||
country: Yup.string().required(),
|
||||
expirationDate: Yup.string()
|
||||
.test({
|
||||
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val)),
|
||||
message: 'Date must be in format YYYY-MM-DD'
|
||||
})
|
||||
.required()
|
||||
}),
|
||||
usSsn: Yup.object().shape({
|
||||
usSsn: Yup.string().required()
|
||||
}),
|
||||
idCardPhoto: Yup.object().shape({
|
||||
idCardPhoto: Yup.mixed().required()
|
||||
}),
|
||||
frontCamera: Yup.object().shape({
|
||||
frontCamera: Yup.mixed().required()
|
||||
}),
|
||||
email: Yup.object().shape({
|
||||
email: Yup.string().required()
|
||||
})
|
||||
}
|
||||
|
||||
const requirementElements = {
|
||||
idCardData: {
|
||||
schema: customerDataSchemas.idCardData,
|
||||
options: customerDataElements.idCardData,
|
||||
Component: ManualDataEntry,
|
||||
initialValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
documentNumber: '',
|
||||
dateOfBirth: '',
|
||||
gender: '',
|
||||
country: '',
|
||||
expirationDate: ''
|
||||
},
|
||||
saveType: 'customerData'
|
||||
},
|
||||
usSsn: {
|
||||
schema: customerDataSchemas.usSsn,
|
||||
options: customerDataElements.usSsn,
|
||||
Component: ManualDataEntry,
|
||||
initialValues: { usSsn: '' },
|
||||
saveType: 'customerData'
|
||||
},
|
||||
email: {
|
||||
schema: customerDataSchemas.email,
|
||||
options: customerDataElements.email,
|
||||
Component: ManualDataEntry,
|
||||
initialValues: { email: '' },
|
||||
saveType: 'customerData'
|
||||
},
|
||||
idCardPhoto: {
|
||||
schema: customerDataSchemas.idCardPhoto,
|
||||
options: customerDataElements.idCardPhoto,
|
||||
Component: ManualDataEntry,
|
||||
initialValues: { idCardPhoto: null },
|
||||
saveType: 'customerDataUpload'
|
||||
},
|
||||
frontCamera: {
|
||||
schema: customerDataSchemas.frontCamera,
|
||||
options: customerDataElements.frontCamera,
|
||||
Component: ManualDataEntry,
|
||||
initialValues: { frontCamera: null },
|
||||
saveType: 'customerDataUpload'
|
||||
},
|
||||
custom: {
|
||||
// schema: customerDataSchemas.customInfoRequirement,
|
||||
Component: ManualDataEntry,
|
||||
initialValues: { customInfoRequirement: null },
|
||||
saveType: 'customInfoRequirement'
|
||||
}
|
||||
}
|
||||
|
||||
const tryFormatDate = rawDate => {
|
||||
try {
|
||||
return (
|
||||
(rawDate &&
|
||||
format('yyyy-MM-dd')(parse(new Date(), 'yyyyMMdd', rawDate))) ??
|
||||
''
|
||||
)
|
||||
} catch (err) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const formatDates = values => {
|
||||
R.map(
|
||||
elem =>
|
||||
(values[elem] = format('yyyyMMdd')(
|
||||
parse(new Date(), 'yyyy-MM-dd', values[elem])
|
||||
))
|
||||
)(['dateOfBirth', 'expirationDate'])
|
||||
return values
|
||||
}
|
||||
|
||||
const mapKeys = pair => {
|
||||
const [key, value] = pair
|
||||
if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') {
|
||||
return ['path', value]
|
||||
}
|
||||
if (key === 'txCustomerPhotoAt' || key === 'frontCameraAt') {
|
||||
return ['date', value]
|
||||
}
|
||||
return pair
|
||||
}
|
||||
|
||||
const addPhotoDir = R.map(it => {
|
||||
const hasFrontCameraData = R.has('id')(it)
|
||||
return hasFrontCameraData
|
||||
? { ...it, photoDir: 'operator-data/customersphotos' }
|
||||
: { ...it, photoDir: 'front-camera-photo' }
|
||||
})
|
||||
|
||||
const standardizeKeys = R.map(R.compose(R.fromPairs, R.map(mapKeys), R.toPairs))
|
||||
|
||||
const filterByPhotoAvailable = R.filter(
|
||||
tx => !R.isNil(tx.date) && !R.isNil(tx.path)
|
||||
)
|
||||
|
||||
const formatPhotosData = R.compose(
|
||||
filterByPhotoAvailable,
|
||||
addPhotoDir,
|
||||
standardizeKeys
|
||||
)
|
||||
|
||||
export {
|
||||
getAuthorizedStatus,
|
||||
getFormattedPhone,
|
||||
getName,
|
||||
entryType,
|
||||
customElements,
|
||||
requirementElements,
|
||||
formatPhotosData,
|
||||
customerDataElements,
|
||||
customerDataSchemas,
|
||||
formatDates,
|
||||
tryFormatDate,
|
||||
REQUIREMENT,
|
||||
CUSTOM,
|
||||
ID_CARD_DATA
|
||||
}
|
||||
4
packages/admin-ui/src/pages/Customers/index.js
Normal file
4
packages/admin-ui/src/pages/Customers/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import CustomerProfile from './CustomerProfile'
|
||||
import Customers from './Customers'
|
||||
|
||||
export { Customers, CustomerProfile }
|
||||
93
packages/admin-ui/src/pages/Dashboard/Alerts/Alerts.jsx
Normal file
93
packages/admin-ui/src/pages/Dashboard/Alerts/Alerts.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import Button from '@mui/material/Button'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import classnames from 'classnames'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import { cardState } from 'src/components/CollapsibleCard'
|
||||
import { Label1, H4 } from 'src/components/typography'
|
||||
|
||||
import AlertsTable from './AlertsTable'
|
||||
|
||||
const NUM_TO_RENDER = 3
|
||||
|
||||
const GET_ALERTS = gql`
|
||||
query getAlerts {
|
||||
alerts {
|
||||
id
|
||||
type
|
||||
detail
|
||||
message
|
||||
created
|
||||
read
|
||||
valid
|
||||
}
|
||||
machines {
|
||||
deviceId
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Alerts = ({ onReset, onExpand, size }) => {
|
||||
const showAllItems = size === cardState.EXPANDED
|
||||
const { data } = useQuery(GET_ALERTS)
|
||||
const alerts = R.path(['alerts'])(data) ?? []
|
||||
const machines = R.compose(
|
||||
R.map(R.prop('name')),
|
||||
R.indexBy(R.prop('deviceId'))
|
||||
)(data?.machines ?? [])
|
||||
const alertsLength = alerts.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<H4 noMargin>{`Alerts (${alertsLength})`}</H4>
|
||||
{showAllItems && (
|
||||
<Label1 noMargin className="-mt-1">
|
||||
<Button
|
||||
onClick={onReset}
|
||||
size="small"
|
||||
disableRipple
|
||||
disableFocusRipple
|
||||
className="p-0 text-zodiac normal-case">
|
||||
{'Show less'}
|
||||
</Button>
|
||||
</Label1>
|
||||
)}
|
||||
</div>
|
||||
<Grid
|
||||
className={classnames({ 'm-0': true, 'max-h-115': showAllItems })}
|
||||
container
|
||||
spacing={1}>
|
||||
<Grid item xs={12}>
|
||||
{!alerts.length && (
|
||||
<Label1 className="text-comet -ml-1 h-30">
|
||||
No new alerts. Your system is running smoothly.
|
||||
</Label1>
|
||||
)}
|
||||
<AlertsTable
|
||||
numToRender={showAllItems ? alerts.length : NUM_TO_RENDER}
|
||||
alerts={alerts}
|
||||
machines={machines}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{!showAllItems && alertsLength > NUM_TO_RENDER && (
|
||||
<Grid item xs={12}>
|
||||
<Label1 className="text-center mb-0">
|
||||
<Button
|
||||
onClick={() => onExpand('alerts')}
|
||||
size="small"
|
||||
disableRipple
|
||||
disableFocusRipple
|
||||
className="p-0 text-zodiac normal-case">
|
||||
{`Show all (${alerts.length})`}
|
||||
</Button>
|
||||
</Label1>
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default Alerts
|
||||
57
packages/admin-ui/src/pages/Dashboard/Alerts/AlertsTable.jsx
Normal file
57
packages/admin-ui/src/pages/Dashboard/Alerts/AlertsTable.jsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { P } from 'src/components/typography/index'
|
||||
import Wrench from 'src/styling/icons/action/wrench/zodiac.svg?react'
|
||||
import CashBoxEmpty from 'src/styling/icons/cassettes/cashbox-empty.svg?react'
|
||||
import AlertLinkIcon from 'src/styling/icons/month arrows/right.svg?react'
|
||||
import WarningIcon from 'src/styling/icons/warning-icon/tomato.svg?react'
|
||||
|
||||
const icons = {
|
||||
error: <WarningIcon style={{ height: 20, width: 20, marginRight: 12 }} />,
|
||||
fiatBalance: (
|
||||
<CashBoxEmpty style={{ height: 18, width: 18, marginRight: 14 }} />
|
||||
)
|
||||
}
|
||||
|
||||
const links = {
|
||||
error: '/maintenance/machine-status',
|
||||
fiatBalance: '/maintenance/cash-cassettes',
|
||||
cryptoBalance: '/maintenance/funding'
|
||||
}
|
||||
|
||||
const AlertsTable = ({ numToRender, alerts, machines }) => {
|
||||
const history = useHistory()
|
||||
const alertsToRender = R.slice(0, numToRender, alerts)
|
||||
|
||||
const alertMessage = alert => {
|
||||
const deviceId = alert.detail.deviceId
|
||||
if (!deviceId) return `${alert.message}`
|
||||
|
||||
const deviceName = R.defaultTo('Unpaired device', machines[deviceId])
|
||||
return `${alert.message} - ${deviceName}`
|
||||
}
|
||||
|
||||
return (
|
||||
<List dense className="max-h-116 overflow-y-auto overflow-x-hidden">
|
||||
{alertsToRender.map((alert, idx) => {
|
||||
return (
|
||||
<ListItem key={idx}>
|
||||
{icons[alert.type] || (
|
||||
<Wrench style={{ height: 23, width: 23, marginRight: 8 }} />
|
||||
)}
|
||||
<P className="my-2">{alertMessage(alert)}</P>
|
||||
<AlertLinkIcon
|
||||
className="ml-auto cursor-pointer"
|
||||
onClick={() => history.push(links[alert.type] || '/dashboard')}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertsTable
|
||||
2
packages/admin-ui/src/pages/Dashboard/Alerts/index.js
Normal file
2
packages/admin-ui/src/pages/Dashboard/Alerts/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Alerts from './Alerts'
|
||||
export default Alerts
|
||||
103
packages/admin-ui/src/pages/Dashboard/Dashboard.jsx
Normal file
103
packages/admin-ui/src/pages/Dashboard/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import { H1, Info2, TL2, Label1 } from 'src/components/typography'
|
||||
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
|
||||
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import AddMachine from 'src/pages/AddMachine'
|
||||
import { errorColor } from 'src/styling/variables'
|
||||
|
||||
import Footer from './Footer'
|
||||
import RightSide from './RightSide'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import SystemPerformance from './SystemPerformance/index.js'
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
machines {
|
||||
name
|
||||
}
|
||||
serverVersion
|
||||
}
|
||||
`
|
||||
|
||||
const Dashboard = () => {
|
||||
const history = useHistory()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data, loading } = useQuery(GET_DATA)
|
||||
|
||||
const onPaired = machine => {
|
||||
setOpen(false)
|
||||
history.push('/maintenance/machine-status', { id: machine.deviceId })
|
||||
}
|
||||
|
||||
return !loading ? (
|
||||
!R.isEmpty(data.machines) ? (
|
||||
<>
|
||||
<TitleSection title="Dashboard">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<TxInIcon />
|
||||
<span>Cash-in</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TxOutIcon />
|
||||
<span>Cash-out</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width={12} height={12}>
|
||||
<rect width={12} height={12} rx={3} fill={errorColor} />
|
||||
</svg>
|
||||
<span>Action Required</span>
|
||||
</div>
|
||||
</div>
|
||||
</TitleSection>
|
||||
<div className="flex mb-30 gap-4">
|
||||
<div className="flex flex-col flex-1">
|
||||
<Paper className="p-6">
|
||||
<SystemPerformance />
|
||||
</Paper>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<RightSide />
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{open && (
|
||||
<AddMachine close={() => setOpen(false)} onPaired={onPaired} />
|
||||
)}
|
||||
<TitleSection title="Dashboard">
|
||||
<div className="flex flex-row">
|
||||
<span>
|
||||
<TL2 className="inline">{data?.serverVersion}</TL2>{' '}
|
||||
<Label1 className="inline"> server version</Label1>
|
||||
</span>
|
||||
</div>
|
||||
</TitleSection>
|
||||
<div className="h-75 bg-zircon border-zircon2 border-2">
|
||||
<div className="flex flex-col h-full justify-center items-center gap-6">
|
||||
<H1 className="text-comet2">No machines on your system yet</H1>
|
||||
<Info2 className="text-comet2">
|
||||
To fully take advantage of Lamassu Admin, add a new machine to
|
||||
your system
|
||||
</Info2>
|
||||
<Button onClick={() => setOpen(true)}>+ Add new machine</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
101
packages/admin-ui/src/pages/Dashboard/Footer/Footer.jsx
Normal file
101
packages/admin-ui/src/pages/Dashboard/Footer/Footer.jsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import BigNumber from 'bignumber.js'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import { Label2 } from 'src/components/typography'
|
||||
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
|
||||
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
|
||||
|
||||
import { fromNamespace } from 'src/utils/config'
|
||||
|
||||
import classes from './Footer.module.css'
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
cryptoRates
|
||||
cryptoCurrencies {
|
||||
code
|
||||
display
|
||||
}
|
||||
config
|
||||
accountsConfig {
|
||||
code
|
||||
display
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
BigNumber.config({ ROUNDING_MODE: BigNumber.ROUND_HALF_UP })
|
||||
|
||||
const Footer = () => {
|
||||
const { data } = useQuery(GET_DATA)
|
||||
|
||||
const withCommissions = R.path(['cryptoRates', 'withCommissions'])(data) ?? {}
|
||||
const config = R.path(['config'])(data) ?? {}
|
||||
// const canExpand = R.keys(withCommissions).length > 4
|
||||
|
||||
const wallets = fromNamespace('wallets')(config)
|
||||
const cryptoCurrencies = R.path(['cryptoCurrencies'])(data) ?? []
|
||||
const accountsConfig = R.path(['accountsConfig'])(data) ?? []
|
||||
const localeFiatCurrency = R.path(['locale_fiatCurrency'])(config) ?? ''
|
||||
|
||||
const renderFooterItem = key => {
|
||||
const idx = R.findIndex(R.propEq('code', key))(cryptoCurrencies)
|
||||
const tickerCode = wallets[`${key}_ticker`]
|
||||
const tickerIdx = R.findIndex(R.propEq('code', tickerCode))(accountsConfig)
|
||||
|
||||
const tickerName = tickerIdx > -1 ? accountsConfig[tickerIdx].display : ''
|
||||
|
||||
const cashInNoCommission = parseFloat(
|
||||
R.path(['cryptoRates', 'withoutCommissions', key, 'cashIn'])(data)
|
||||
)
|
||||
const cashOutNoCommission = parseFloat(
|
||||
R.path(['cryptoRates', 'withoutCommissions', key, 'cashOut'])(data)
|
||||
)
|
||||
|
||||
const avgOfAskBid = new BigNumber(
|
||||
(cashInNoCommission + cashOutNoCommission) / 2
|
||||
).toFormat(2)
|
||||
const cashIn = new BigNumber(
|
||||
parseFloat(
|
||||
R.path(['cryptoRates', 'withCommissions', key, 'cashIn'])(data)
|
||||
)
|
||||
).toFormat(2)
|
||||
const cashOut = new BigNumber(
|
||||
parseFloat(
|
||||
R.path(['cryptoRates', 'withCommissions', key, 'cashOut'])(data)
|
||||
)
|
||||
).toFormat(2)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-1/4">
|
||||
<Label2 className="text-comet mt-3 mb-2">
|
||||
{cryptoCurrencies[idx].display}
|
||||
</Label2>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-1">
|
||||
<TxInIcon />
|
||||
<Label2 noMargin>{`${cashIn} ${localeFiatCurrency}`}</Label2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TxOutIcon />
|
||||
<Label2 noMargin>{`${cashOut} ${localeFiatCurrency}`}</Label2>
|
||||
</div>
|
||||
</div>
|
||||
<Label2 className="text-comet mt-2">
|
||||
{`${tickerName}: ${avgOfAskBid} ${localeFiatCurrency}`}
|
||||
</Label2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.footer1}>
|
||||
<div className={classes.content1}>
|
||||
{R.keys(withCommissions).map(key => renderFooterItem(key))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
.footer1 {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
background-color: white;
|
||||
text-align: left;
|
||||
z-index: 1;
|
||||
box-shadow: 0 -1px 10px 0 rgba(50, 50, 50, 0.1);
|
||||
min-height: 48px;
|
||||
transition: min-height 0.5s ease-out;
|
||||
}
|
||||
|
||||
.footer1:hover {
|
||||
transition: min-height 0.5s ease-in;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.content1 {
|
||||
width: 1200px;
|
||||
max-height: 100px;
|
||||
background-color: white;
|
||||
z-index: 2;
|
||||
bottom: -8px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
2
packages/admin-ui/src/pages/Dashboard/Footer/index.js
Normal file
2
packages/admin-ui/src/pages/Dashboard/Footer/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Footer from './Footer'
|
||||
export default Footer
|
||||
89
packages/admin-ui/src/pages/Dashboard/RightSide.jsx
Normal file
89
packages/admin-ui/src/pages/Dashboard/RightSide.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import Button from '@mui/material/Button'
|
||||
import classnames from 'classnames'
|
||||
import React, { useState } from 'react'
|
||||
import CollapsibleCard, { cardState } from 'src/components/CollapsibleCard'
|
||||
import { H4, Label1 } from 'src/components/typography'
|
||||
|
||||
import Alerts from './Alerts'
|
||||
import SystemStatus from './SystemStatus'
|
||||
|
||||
const ShrunkCard = ({ title, buttonName, onUnshrink }) => {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<H4 className="mt-0">{title}</H4>
|
||||
<Label1 className="text-center my-0">
|
||||
<Button
|
||||
onClick={onUnshrink}
|
||||
size="small"
|
||||
disableRipple
|
||||
disableFocusRipple
|
||||
className="p-0 text-zodiac normal-case">
|
||||
{buttonName}
|
||||
</Button>
|
||||
</Label1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RightSide = () => {
|
||||
const [systemStatusSize, setSystemStatusSize] = useState(cardState.DEFAULT)
|
||||
const [alertsSize, setAlertsSize] = useState(cardState.DEFAULT)
|
||||
|
||||
const onReset = () => {
|
||||
setAlertsSize(cardState.DEFAULT)
|
||||
setSystemStatusSize(cardState.DEFAULT)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<CollapsibleCard
|
||||
className={classnames({
|
||||
'flex-[0.1]': alertsSize === cardState.SHRUNK,
|
||||
'flex-[0.9]': alertsSize === cardState.EXPANDED
|
||||
})}
|
||||
state={alertsSize}
|
||||
shrunkComponent={
|
||||
<ShrunkCard
|
||||
title={'Alerts'}
|
||||
buttonName={'Show alerts'}
|
||||
onUnshrink={onReset}
|
||||
/>
|
||||
}>
|
||||
<Alerts
|
||||
onExpand={() => {
|
||||
setAlertsSize(cardState.EXPANDED)
|
||||
setSystemStatusSize(cardState.SHRUNK)
|
||||
}}
|
||||
onReset={onReset}
|
||||
size={alertsSize}
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
<CollapsibleCard
|
||||
className={classnames({
|
||||
'flex-[0.1]': systemStatusSize === cardState.SHRUNK,
|
||||
'flex-1': systemStatusSize === cardState.DEFAULT,
|
||||
'flex-[0.9]': systemStatusSize === cardState.EXPANDED
|
||||
})}
|
||||
state={systemStatusSize}
|
||||
shrunkComponent={
|
||||
<ShrunkCard
|
||||
title={'System status'}
|
||||
buttonName={'Show machines'}
|
||||
onUnshrink={onReset}
|
||||
/>
|
||||
}>
|
||||
<SystemStatus
|
||||
onExpand={() => {
|
||||
setSystemStatusSize(cardState.EXPANDED)
|
||||
setAlertsSize(cardState.SHRUNK)
|
||||
}}
|
||||
onReset={onReset}
|
||||
size={systemStatusSize}
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RightSide
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import classnames from 'classnames'
|
||||
import React from 'react'
|
||||
import { Label1 } from 'src/components/typography/index'
|
||||
|
||||
const PercentageChart = ({ cashIn, cashOut }) => {
|
||||
const value = cashIn || cashOut !== 0 ? cashIn : 50
|
||||
|
||||
const buildPercentageView = value => {
|
||||
if (value <= 15) return
|
||||
return <Label1 className="text-white">{value}%</Label1>
|
||||
}
|
||||
|
||||
const percentageClasses = {
|
||||
'h-35 rounded-sm flex items-center justify-center': true,
|
||||
'min-w-2 rounded-xs': value < 5 && value > 0
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-35 gap-1">
|
||||
<div
|
||||
className={classnames(percentageClasses, 'bg-java')}
|
||||
style={{ width: `${value}%` }}>
|
||||
{buildPercentageView(value, 'cashIn')}
|
||||
</div>
|
||||
<div
|
||||
className={classnames(percentageClasses, 'bg-neon')}
|
||||
style={{ width: `${100 - value}%` }}>
|
||||
{buildPercentageView(100 - value, 'cashOut')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PercentageChart
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import * as d3 from 'd3'
|
||||
import * as R from 'ramda'
|
||||
import React, { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
const transactionProfit = R.prop('profit')
|
||||
|
||||
const mockPoint = (tx, offsetMs, profit) => {
|
||||
const date = new Date(new Date(tx.created).getTime() + offsetMs).toISOString()
|
||||
return { created: date, profit }
|
||||
}
|
||||
|
||||
// if we're viewing transactions for the past day, then we group by hour. If not, we group by day
|
||||
const formatDay = ({ created }) =>
|
||||
new Date(created).toISOString().substring(0, 10)
|
||||
const formatHour = ({ created }) =>
|
||||
new Date(created).toISOString().substring(0, 13)
|
||||
|
||||
const reducer = (acc, tx) => {
|
||||
const currentProfit = acc.profit || 0
|
||||
return { ...tx, profit: currentProfit + transactionProfit(tx) }
|
||||
}
|
||||
|
||||
const timeFrameMS = {
|
||||
Day: 24 * 3600 * 1000,
|
||||
Week: 7 * 24 * 3600 * 1000,
|
||||
Month: 30 * 24 * 3600 * 1000
|
||||
}
|
||||
|
||||
const RefLineChart = ({
|
||||
data: realData,
|
||||
previousTimeData,
|
||||
previousProfit,
|
||||
timeFrame
|
||||
}) => {
|
||||
const svgRef = useRef()
|
||||
|
||||
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 = 140 - margin.top - margin.bottom
|
||||
|
||||
const massageData = () => {
|
||||
// if we're viewing transactions for the past day, then we group by hour. If not, we group by day
|
||||
const method = timeFrame === 'Day' ? formatHour : formatDay
|
||||
|
||||
const aggregatedTX = R.values(R.reduceBy(reducer, [], method, realData))
|
||||
// if no point exists, then return 2 points at y = 0
|
||||
if (!aggregatedTX.length && !previousTimeData.length) {
|
||||
const mockPoint1 = { created: new Date().toISOString(), profit: 0 }
|
||||
const mockPoint2 = mockPoint(mockPoint1, -3600000, 0)
|
||||
return [[mockPoint1, mockPoint2], true]
|
||||
}
|
||||
// if this time period has no txs, but previous time period has, then % change is -100%
|
||||
if (!aggregatedTX.length && previousTimeData.length) {
|
||||
const mockPoint1 = {
|
||||
created: new Date().toISOString(),
|
||||
profit: 0
|
||||
}
|
||||
const mockPoint2 = mockPoint(mockPoint1, -timeFrameMS[timeFrame], 1)
|
||||
return [[mockPoint1, mockPoint2], false]
|
||||
}
|
||||
// if this time period has txs, but previous doesn't, then % change is +100%
|
||||
if (aggregatedTX.length && !previousTimeData.length) {
|
||||
const mockPoint1 = {
|
||||
created: new Date().toISOString(),
|
||||
profit: 1
|
||||
}
|
||||
const mockPoint2 = mockPoint(mockPoint1, -timeFrameMS[timeFrame], 0)
|
||||
return [[mockPoint1, mockPoint2], false]
|
||||
}
|
||||
// if only one point exists, create point on the left - otherwise the line won't be drawn
|
||||
if (aggregatedTX.length === 1) {
|
||||
return [
|
||||
R.append(
|
||||
{
|
||||
created: new Date(
|
||||
Date.now() - timeFrameMS[timeFrame]
|
||||
).toISOString(),
|
||||
profit: previousProfit
|
||||
},
|
||||
aggregatedTX
|
||||
),
|
||||
false
|
||||
]
|
||||
}
|
||||
// the boolean value is for zeroProfit. It makes the line render at y = 0 instead of y = 50% of container height
|
||||
return [aggregatedTX, false]
|
||||
}
|
||||
|
||||
/* Important step to make the graph look good!
|
||||
This function groups transactions by either day or hour depending on the time frame
|
||||
This makes the line look smooth and not all wonky when there are many transactions in a given time
|
||||
*/
|
||||
const [data, zeroProfit] = 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', 'var(--ghost)')
|
||||
.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: 'var(--zircon)' },
|
||||
{ offset: '25%', color: 'var(--zircon)' },
|
||||
{ offset: '100%', color: 'var(--ghost)' }
|
||||
])
|
||||
.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, 40])
|
||||
.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', 'var(--zodiac)')
|
||||
}, [realData, timeFrame, previousTimeData, previousProfit])
|
||||
|
||||
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,348 @@
|
|||
import BigNumber from 'bignumber.js'
|
||||
import * as d3 from 'd3'
|
||||
import { getTimezoneOffset } from 'date-fns-tz'
|
||||
import { add, format, startOfWeek, startOfYear } from 'date-fns/fp'
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { numberToFiatAmount } from 'src/utils/number'
|
||||
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
|
||||
|
||||
const Graph = ({ data, timeFrame, timezone }) => {
|
||||
const ref = useRef(null)
|
||||
|
||||
const GRAPH_HEIGHT = 250
|
||||
const GRAPH_WIDTH = 555
|
||||
const GRAPH_MARGIN = useMemo(
|
||||
() => ({
|
||||
top: 20,
|
||||
right: 3.5,
|
||||
bottom: 27,
|
||||
left: 33.5
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const offset = getTimezoneOffset(timezone)
|
||||
const NOW = Date.now() + offset
|
||||
|
||||
const periodDomains = {
|
||||
Day: [NOW - DAY, NOW],
|
||||
Week: [NOW - WEEK, NOW],
|
||||
Month: [NOW - MONTH, NOW]
|
||||
}
|
||||
|
||||
const dataPoints = useMemo(
|
||||
() => ({
|
||||
Day: {
|
||||
freq: 24,
|
||||
step: 60 * 60 * 1000,
|
||||
tick: d3.utcHour.every(4),
|
||||
labelFormat: '%H:%M'
|
||||
},
|
||||
Week: {
|
||||
freq: 7,
|
||||
step: 24 * 60 * 60 * 1000,
|
||||
tick: d3.utcDay.every(1),
|
||||
labelFormat: '%a %d'
|
||||
},
|
||||
Month: {
|
||||
freq: 30,
|
||||
step: 24 * 60 * 60 * 1000,
|
||||
tick: d3.utcDay.every(2),
|
||||
labelFormat: '%d'
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const filterDay = useCallback(
|
||||
x => (timeFrame === 'Day' ? x.getUTCHours() === 0 : x.getUTCDate() === 1),
|
||||
[timeFrame]
|
||||
)
|
||||
|
||||
const getPastAndCurrentDayLabels = useCallback(d => {
|
||||
const currentDate = new Date(d)
|
||||
const currentDateDay = currentDate.getUTCDate()
|
||||
const currentDateWeekday = currentDate.getUTCDay()
|
||||
const currentDateMonth = currentDate.getUTCMonth()
|
||||
|
||||
const previousDate = new Date(currentDate.getTime())
|
||||
previousDate.setUTCDate(currentDateDay - 1)
|
||||
|
||||
const previousDateDay = previousDate.getUTCDate()
|
||||
const previousDateWeekday = previousDate.getUTCDay()
|
||||
const previousDateMonth = previousDate.getUTCMonth()
|
||||
|
||||
const daysOfWeek = Array.from(Array(7)).map((_, i) =>
|
||||
format('EEE', add({ days: i }, startOfWeek(new Date())))
|
||||
)
|
||||
|
||||
const months = Array.from(Array(12)).map((_, i) =>
|
||||
format('LLL', add({ months: i }, startOfYear(new Date())))
|
||||
)
|
||||
|
||||
return {
|
||||
previous:
|
||||
currentDateMonth !== previousDateMonth
|
||||
? months[previousDateMonth]
|
||||
: `${daysOfWeek[previousDateWeekday]} ${previousDateDay}`,
|
||||
current:
|
||||
currentDateMonth !== previousDateMonth
|
||||
? months[currentDateMonth]
|
||||
: `${daysOfWeek[currentDateWeekday]} ${currentDateDay}`
|
||||
}
|
||||
}, [])
|
||||
|
||||
const buildTicks = useCallback(
|
||||
domain => {
|
||||
const points = []
|
||||
|
||||
const roundDate = d => {
|
||||
const step = dataPoints[timeFrame].step
|
||||
return new Date(Math.ceil(d.valueOf() / step) * step)
|
||||
}
|
||||
|
||||
for (let i = 0; i <= dataPoints[timeFrame].freq; i++) {
|
||||
const stepDate = new Date(NOW - i * dataPoints[timeFrame].step)
|
||||
if (roundDate(stepDate) > domain[1]) continue
|
||||
if (stepDate < domain[0]) continue
|
||||
points.push(roundDate(stepDate))
|
||||
}
|
||||
|
||||
return points
|
||||
},
|
||||
[NOW, dataPoints, timeFrame]
|
||||
)
|
||||
|
||||
const x = d3
|
||||
.scaleUtc()
|
||||
.domain(periodDomains[timeFrame])
|
||||
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
|
||||
|
||||
const y = d3
|
||||
.scaleLinear()
|
||||
.domain([
|
||||
0,
|
||||
(d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.05
|
||||
])
|
||||
.nice()
|
||||
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
|
||||
|
||||
const buildBackground = useCallback(
|
||||
g => {
|
||||
g.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', GRAPH_MARGIN.top)
|
||||
.attr('width', GRAPH_WIDTH)
|
||||
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.top - GRAPH_MARGIN.bottom)
|
||||
.attr('fill', 'var(--ghost)')
|
||||
},
|
||||
[GRAPH_MARGIN]
|
||||
)
|
||||
|
||||
const buildXAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
|
||||
)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.ticks(dataPoints[timeFrame].tick)
|
||||
.tickFormat(d => {
|
||||
return d3.timeFormat(dataPoints[timeFrame].labelFormat)(
|
||||
d.getTime() + d.getTimezoneOffset() * MINUTE
|
||||
)
|
||||
})
|
||||
)
|
||||
.call(g => g.select('.domain').remove()),
|
||||
[GRAPH_MARGIN, dataPoints, timeFrame, x]
|
||||
)
|
||||
|
||||
const buildYAxis = useCallback(
|
||||
g =>
|
||||
g
|
||||
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(y)
|
||||
.ticks(5)
|
||||
.tickFormat(d => {
|
||||
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
|
||||
|
||||
return numberToFiatAmount(d)
|
||||
})
|
||||
)
|
||||
.call(g => g.select('.domain').remove())
|
||||
.selectAll('text')
|
||||
.attr('dy', '-0.25rem'),
|
||||
[GRAPH_MARGIN, y]
|
||||
)
|
||||
|
||||
const buildGrid = useCallback(
|
||||
g => {
|
||||
g.attr('stroke', 'var(--zircon2)')
|
||||
.attr('fill', 'var(--zircon2)')
|
||||
// Vertical lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(buildTicks(x.domain()))
|
||||
.join('line')
|
||||
.attr('x1', d => 0.5 + x(d))
|
||||
.attr('x2', d => 0.5 + x(d))
|
||||
.attr('y1', GRAPH_MARGIN.top)
|
||||
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
|
||||
.attr('stroke-width', 1)
|
||||
)
|
||||
// Horizontal lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(d3.axisLeft(y).scale().ticks(5))
|
||||
.join('line')
|
||||
.attr('y1', d => 0.5 + y(d))
|
||||
.attr('y2', d => 0.5 + y(d))
|
||||
.attr('x1', GRAPH_MARGIN.left)
|
||||
.attr('x2', GRAPH_WIDTH)
|
||||
)
|
||||
// Thick vertical lines
|
||||
.call(g =>
|
||||
g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(buildTicks(x.domain()).filter(filterDay))
|
||||
.join('line')
|
||||
.attr('class', 'dateSeparator')
|
||||
.attr('x1', d => 0.5 + x(d))
|
||||
.attr('x2', d => 0.5 + x(d))
|
||||
.attr('y1', GRAPH_MARGIN.top - 10)
|
||||
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
|
||||
.attr('stroke-width', 2)
|
||||
.join('text')
|
||||
)
|
||||
// Left side breakpoint label
|
||||
.call(g => {
|
||||
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
|
||||
|
||||
if (!separator) return
|
||||
|
||||
const breakpoint = buildTicks(x.domain()).filter(filterDay)
|
||||
|
||||
const labels = getPastAndCurrentDayLabels(breakpoint)
|
||||
|
||||
return g
|
||||
.append('text')
|
||||
.attr('x', separator.x - 7)
|
||||
.attr('y', separator.y)
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('dy', '.25em')
|
||||
.text(labels.previous)
|
||||
})
|
||||
// Right side breakpoint label
|
||||
.call(g => {
|
||||
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
|
||||
|
||||
if (!separator) return
|
||||
|
||||
const breakpoint = buildTicks(x.domain()).filter(filterDay)
|
||||
|
||||
const labels = getPastAndCurrentDayLabels(breakpoint)
|
||||
|
||||
return g
|
||||
.append('text')
|
||||
.attr('x', separator.x + 7)
|
||||
.attr('y', separator.y)
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('dy', '.25em')
|
||||
.text(labels.current)
|
||||
})
|
||||
},
|
||||
[GRAPH_MARGIN, buildTicks, getPastAndCurrentDayLabels, x, y, filterDay]
|
||||
)
|
||||
|
||||
const formatTicksText = useCallback(
|
||||
() =>
|
||||
d3
|
||||
.selectAll('.tick text')
|
||||
.style('stroke', 'var(--comet)')
|
||||
.style('fill', 'var(--comet)')
|
||||
.style('stroke-width', 0)
|
||||
.style('font-family', 'var(--museo)'),
|
||||
[]
|
||||
)
|
||||
|
||||
const formatText = useCallback(
|
||||
() =>
|
||||
d3
|
||||
.selectAll('text')
|
||||
.style('stroke', 'var(--comet)')
|
||||
.style('fill', 'var(--comet)')
|
||||
.style('stroke-width', 0)
|
||||
.style('font-family', 'var(--museo)'),
|
||||
[]
|
||||
)
|
||||
|
||||
const formatTicks = useCallback(() => {
|
||||
d3.selectAll('.tick line')
|
||||
.style('stroke', 'transparent')
|
||||
.style('fill', 'transparent')
|
||||
}, [])
|
||||
|
||||
const drawData = useCallback(
|
||||
g => {
|
||||
g.selectAll('circle')
|
||||
.data(data)
|
||||
.join('circle')
|
||||
.attr('cx', d => {
|
||||
const created = new Date(d.created)
|
||||
return x(created.setTime(created.getTime() + offset))
|
||||
})
|
||||
.attr('cy', d => y(new BigNumber(d.fiat).toNumber()))
|
||||
.attr('fill', d =>
|
||||
d.txClass === 'cashIn' ? 'var(--java)' : 'var(--neon)'
|
||||
)
|
||||
.attr('r', 3.5)
|
||||
},
|
||||
[data, offset, x, y]
|
||||
)
|
||||
|
||||
const drawChart = useCallback(() => {
|
||||
const svg = d3
|
||||
.select(ref.current)
|
||||
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
|
||||
|
||||
svg.append('g').call(buildBackground)
|
||||
svg.append('g').call(buildGrid)
|
||||
svg.append('g').call(buildXAxis)
|
||||
svg.append('g').call(buildYAxis)
|
||||
svg.append('g').call(formatTicksText)
|
||||
svg.append('g').call(formatText)
|
||||
svg.append('g').call(formatTicks)
|
||||
svg.append('g').call(drawData)
|
||||
|
||||
return svg.node()
|
||||
}, [
|
||||
buildBackground,
|
||||
buildGrid,
|
||||
buildXAxis,
|
||||
buildYAxis,
|
||||
drawData,
|
||||
formatText,
|
||||
formatTicks,
|
||||
formatTicksText
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
d3.select(ref.current).selectAll('*').remove()
|
||||
drawChart()
|
||||
}, [drawChart])
|
||||
|
||||
return <svg ref={ref} />
|
||||
}
|
||||
|
||||
export default Graph
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react'
|
||||
import { Info1, Label1 } from 'src/components/typography/index'
|
||||
const InfoWithLabel = ({ info, label }) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Info1 className="mb-0">{info}</Info1>
|
||||
<Label1 className="m-0">{label}</Label1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoWithLabel
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import classnames from 'classnames'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { H4 } from 'src/components/typography'
|
||||
|
||||
const ranges = ['Month', 'Week', 'Day']
|
||||
|
||||
const Nav = ({ handleSetRange, showPicker }) => {
|
||||
const [clickedItem, setClickedItem] = useState('Day')
|
||||
|
||||
const isSelected = R.equals(clickedItem)
|
||||
const handleClick = range => {
|
||||
setClickedItem(range)
|
||||
handleSetRange(range)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<H4 noMargin>{'System performance'}</H4>
|
||||
{showPicker && (
|
||||
<div className="flex gap-6">
|
||||
{ranges.map((it, idx) => {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={e => handleClick(e.target.innerText)}
|
||||
className={classnames({
|
||||
'cursor-pointer text-comet': true,
|
||||
'font-bold text-zodiac border-b-zodiac border-b-2':
|
||||
isSelected(it)
|
||||
})}>
|
||||
{it}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Nav
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import BigNumber from 'bignumber.js'
|
||||
import classnames from 'classnames'
|
||||
import { isAfter } from 'date-fns/fp'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { Info2, Label1, Label2, P } from 'src/components/typography/index'
|
||||
import PercentDownIcon from 'src/styling/icons/dashboard/down.svg?react'
|
||||
import PercentNeutralIcon from 'src/styling/icons/dashboard/equal.svg?react'
|
||||
import PercentUpIcon from 'src/styling/icons/dashboard/up.svg?react'
|
||||
|
||||
import { EmptyTable } from 'src/components/table'
|
||||
import { java, neon } from 'src/styling/variables'
|
||||
import { fromNamespace } from 'src/utils/config'
|
||||
import { DAY, WEEK, MONTH } from 'src/utils/time'
|
||||
import { timezones } from 'src/utils/timezone-list'
|
||||
import { toTimezone } from 'src/utils/timezones'
|
||||
|
||||
import PercentageChart from './Graphs/PercentageChart'
|
||||
import LineChart from './Graphs/RefLineChart'
|
||||
import Scatterplot from './Graphs/RefScatterplot'
|
||||
import InfoWithLabel from './InfoWithLabel'
|
||||
import Nav from './Nav'
|
||||
|
||||
BigNumber.config({ ROUNDING_MODE: BigNumber.ROUND_HALF_UP })
|
||||
|
||||
const getFiats = R.map(R.prop('fiat'))
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData($excludeTestingCustomers: Boolean) {
|
||||
transactions(excludeTestingCustomers: $excludeTestingCustomers) {
|
||||
fiatCode
|
||||
fiat
|
||||
fixedFee
|
||||
commissionPercentage
|
||||
created
|
||||
txClass
|
||||
error
|
||||
profit
|
||||
dispense
|
||||
sendConfirmed
|
||||
}
|
||||
fiatRates {
|
||||
code
|
||||
name
|
||||
rate
|
||||
}
|
||||
config
|
||||
}
|
||||
`
|
||||
|
||||
const SystemPerformance = () => {
|
||||
const [selectedRange, setSelectedRange] = useState('Day')
|
||||
const { data, loading } = useQuery(GET_DATA, {
|
||||
variables: { excludeTestingCustomers: true }
|
||||
})
|
||||
const fiatLocale = fromNamespace('locale')(data?.config).fiatCurrency
|
||||
const timezone = fromNamespace('locale')(data?.config).timezone
|
||||
|
||||
const NOW = Date.now()
|
||||
|
||||
const periodDomains = {
|
||||
Day: [NOW - DAY, NOW],
|
||||
Week: [NOW - WEEK, NOW],
|
||||
Month: [NOW - MONTH, NOW]
|
||||
}
|
||||
|
||||
const isInRangeAndNoError = getLastTimePeriod => t => {
|
||||
if (t.error !== null) return false
|
||||
if (t.txClass === 'cashOut' && !t.dispense) return false
|
||||
if (t.txClass === 'cashIn' && !t.sendConfirmed) return false
|
||||
if (!getLastTimePeriod) {
|
||||
return (
|
||||
t.error === null &&
|
||||
isAfter(
|
||||
toTimezone(t.created, timezone),
|
||||
toTimezone(periodDomains[selectedRange][1], timezone)
|
||||
) &&
|
||||
isAfter(
|
||||
toTimezone(periodDomains[selectedRange][0], timezone),
|
||||
toTimezone(t.created, timezone)
|
||||
)
|
||||
)
|
||||
}
|
||||
return (
|
||||
t.error === null &&
|
||||
isAfter(
|
||||
toTimezone(periodDomains[selectedRange][1], timezone),
|
||||
toTimezone(t.created, timezone)
|
||||
) &&
|
||||
isAfter(
|
||||
toTimezone(t.created, timezone),
|
||||
toTimezone(periodDomains[selectedRange][0], timezone)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const convertFiatToLocale = item => {
|
||||
if (item.fiatCode === fiatLocale) return item
|
||||
const itemRate = R.find(R.propEq('code', item.fiatCode))(data.fiatRates)
|
||||
const localeRate = R.find(R.propEq('code', fiatLocale))(data.fiatRates)
|
||||
const multiplier = localeRate.rate / itemRate.rate
|
||||
return { ...item, fiat: parseFloat(item.fiat) * multiplier }
|
||||
}
|
||||
|
||||
const transactionsToShow = R.map(convertFiatToLocale)(
|
||||
R.filter(isInRangeAndNoError(false), data?.transactions ?? [])
|
||||
)
|
||||
const transactionsLastTimePeriod = R.map(convertFiatToLocale)(
|
||||
R.filter(isInRangeAndNoError(true), data?.transactions ?? [])
|
||||
)
|
||||
|
||||
const getNumTransactions = () => {
|
||||
return R.length(transactionsToShow)
|
||||
}
|
||||
|
||||
const getFiatVolume = () =>
|
||||
new BigNumber(R.sum(getFiats(transactionsToShow))).toFormat(2)
|
||||
|
||||
const getProfit = transactions => {
|
||||
return R.reduce(
|
||||
(acc, value) => acc.plus(value.profit),
|
||||
new BigNumber(0),
|
||||
transactions
|
||||
)
|
||||
}
|
||||
|
||||
const getPercentChange = () => {
|
||||
const thisTimePeriodProfit = getProfit(transactionsToShow)
|
||||
const previousTimePeriodProfit = getProfit(transactionsLastTimePeriod)
|
||||
|
||||
if (thisTimePeriodProfit.eq(previousTimePeriodProfit)) return 0
|
||||
if (previousTimePeriodProfit.eq(0)) return 100
|
||||
|
||||
return thisTimePeriodProfit
|
||||
.minus(previousTimePeriodProfit)
|
||||
.times(100)
|
||||
.div(previousTimePeriodProfit)
|
||||
.toNumber()
|
||||
}
|
||||
|
||||
const getDirectionPercent = () => {
|
||||
const [cashIn, cashOut] = R.partition(R.propEq('txClass', 'cashIn'))(
|
||||
transactionsToShow
|
||||
)
|
||||
const totalLength = cashIn.length + cashOut.length
|
||||
if (totalLength === 0) {
|
||||
return { cashIn: 0, cashOut: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
cashIn: Math.round((cashIn.length / totalLength) * 100),
|
||||
cashOut: Math.round((cashOut.length / totalLength) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
const percentChange = getPercentChange()
|
||||
|
||||
const percentageClasses = {
|
||||
'text-tomato': percentChange < 0,
|
||||
'text-spring4': percentChange > 0,
|
||||
'text-comet': percentChange === 0,
|
||||
'flex items-center justify-center gap-1': true
|
||||
}
|
||||
|
||||
const getPercentageIcon = () => {
|
||||
const className = 'w-4 h-4'
|
||||
if (percentChange === 0) return <PercentNeutralIcon className={className} />
|
||||
if (percentChange > 0) return <PercentUpIcon className={className} />
|
||||
return <PercentDownIcon className={className} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav
|
||||
showPicker={!loading && !R.isEmpty(data.transactions)}
|
||||
handleSetRange={setSelectedRange}
|
||||
/>
|
||||
{!loading && R.isEmpty(data.transactions) && (
|
||||
<EmptyTable className="pt-10" message="No transactions so far" />
|
||||
)}
|
||||
{!loading && !R.isEmpty(data.transactions) && (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex gap-16">
|
||||
<InfoWithLabel info={getNumTransactions()} label={'transactions'} />
|
||||
<InfoWithLabel
|
||||
info={getFiatVolume()}
|
||||
label={`${data?.config.locale_fiatCurrency} volume`}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-62">
|
||||
<div className="flex justify-between mb-4">
|
||||
<Label2 noMargin>Transactions</Label2>
|
||||
<div className="flex items-center">
|
||||
<P noMargin>
|
||||
{timezones[timezone]?.short ?? timezones[timezone]?.long}{' '}
|
||||
timezone
|
||||
</P>
|
||||
<span className="h-4 w-[1px] bg-comet2 mr-4 ml-8" />
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex items-center">
|
||||
<svg width={8} height={8}>
|
||||
<rect width={8} height={8} rx={4} fill={java} />
|
||||
</svg>
|
||||
<Label1 noMargin className="ml-2">
|
||||
In
|
||||
</Label1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg width={8} height={8}>
|
||||
<rect width={8} height={8} rx={4} fill={neon} />
|
||||
</svg>
|
||||
<Label1 noMargin className="ml-2">
|
||||
Out
|
||||
</Label1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Scatterplot
|
||||
timeFrame={selectedRange}
|
||||
data={transactionsToShow}
|
||||
timezone={timezone}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-62">
|
||||
<div className="flex-2">
|
||||
<Label2 noMargin className="mb-4">
|
||||
Profit from commissions
|
||||
</Label2>
|
||||
<div className="flex justify-between mt-6 mr-7 -mb-8 ml-4 relative">
|
||||
<Info2 noMargin>
|
||||
{`${getProfit(transactionsToShow).toFormat(2)} ${
|
||||
data?.config.locale_fiatCurrency
|
||||
}`}
|
||||
</Info2>
|
||||
<Info2 noMargin className={classnames(percentageClasses)}>
|
||||
{getPercentageIcon()}
|
||||
{`${new BigNumber(percentChange).toFormat(2)}%`}
|
||||
</Info2>
|
||||
</div>
|
||||
<LineChart
|
||||
timeFrame={selectedRange}
|
||||
data={transactionsToShow}
|
||||
previousTimeData={transactionsLastTimePeriod}
|
||||
previousProfit={getProfit(transactionsLastTimePeriod)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Label2 noMargin>Direction</Label2>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex items-center">
|
||||
<svg width={8} height={8}>
|
||||
<rect width={8} height={8} rx={2} fill={java} />
|
||||
</svg>
|
||||
<Label1 noMargin className="ml-2">
|
||||
In
|
||||
</Label1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<svg width={8} height={8}>
|
||||
<rect width={8} height={8} rx={2} fill={neon} />
|
||||
</svg>
|
||||
<Label1 noMargin className="ml-2">
|
||||
Out
|
||||
</Label1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PercentageChart
|
||||
cashIn={getDirectionPercent().cashIn}
|
||||
cashOut={getDirectionPercent().cashOut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemPerformance
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import SystemPerformance from './SystemPerformance'
|
||||
export default SystemPerformance
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import Table from '@mui/material/Table'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableContainer from '@mui/material/TableContainer'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Status } from 'src/components/Status'
|
||||
import { Label2, TL2 } from 'src/components/typography'
|
||||
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
|
||||
import MachineLinkIcon from 'src/styling/icons/month arrows/right.svg?react'
|
||||
|
||||
import { fromNamespace } from 'src/utils/config'
|
||||
|
||||
// percentage threshold where below this number the text in the cash cassettes percentage turns red
|
||||
const PERCENTAGE_THRESHOLD = 20
|
||||
|
||||
const GET_CONFIG = gql`
|
||||
query getConfig {
|
||||
config
|
||||
}
|
||||
`
|
||||
|
||||
const StyledCell = styled(TableCell)({
|
||||
borderBottom: '4px solid white',
|
||||
padding: 0,
|
||||
paddingLeft: '15px'
|
||||
})
|
||||
|
||||
const HeaderCell = styled(TableCell)({
|
||||
borderBottom: '4px solid white',
|
||||
padding: 0,
|
||||
paddingLeft: '15px',
|
||||
backgroundColor: 'white'
|
||||
})
|
||||
|
||||
const MachinesTable = ({ machines = [], numToRender }) => {
|
||||
const history = useHistory()
|
||||
|
||||
const { data } = useQuery(GET_CONFIG)
|
||||
const fillingPercentageSettings = fromNamespace(
|
||||
'notifications',
|
||||
R.path(['config'], data) ?? {}
|
||||
)
|
||||
|
||||
const getPercent = (notes, capacity = 500) => {
|
||||
return Math.round((notes / capacity) * 100)
|
||||
}
|
||||
|
||||
const makePercentageText = (cassetteIdx, notes, capacity = 500) => {
|
||||
const percent = getPercent(notes, capacity)
|
||||
const percentageThreshold = R.pipe(
|
||||
R.path([`fillingPercentageCassette${cassetteIdx}`]),
|
||||
R.defaultTo(PERCENTAGE_THRESHOLD)
|
||||
)(fillingPercentageSettings)
|
||||
return percent < percentageThreshold ? (
|
||||
<TL2 className="text-tomato">{`${percent}%`}</TL2>
|
||||
) : (
|
||||
<TL2>{`${percent}%`}</TL2>
|
||||
)
|
||||
}
|
||||
|
||||
const redirect = ({ name, deviceId }) => {
|
||||
return history.push(`/machines/${deviceId}`, {
|
||||
selectedMachine: name
|
||||
})
|
||||
}
|
||||
|
||||
const maxNumberOfCassettes = Math.max(
|
||||
...R.map(it => it.numberOfCassettes, machines),
|
||||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<TableContainer className="max-h-110">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<HeaderCell>
|
||||
<div className="flex items-center">
|
||||
<Label2 noMargin className="text-comet">
|
||||
Machines
|
||||
</Label2>
|
||||
</div>
|
||||
</HeaderCell>
|
||||
<HeaderCell>
|
||||
<div className="flex items-center">
|
||||
<Label2 noMargin className="text-comet">
|
||||
Status
|
||||
</Label2>
|
||||
</div>
|
||||
</HeaderCell>
|
||||
{R.times(R.identity, maxNumberOfCassettes).map((it, idx) => (
|
||||
<HeaderCell key={idx}>
|
||||
<div className="flex items-center whitespace-pre">
|
||||
<TxOutIcon />
|
||||
<Label2 noMargin className="text-comet">
|
||||
{' '}
|
||||
{it + 1}
|
||||
</Label2>
|
||||
</div>
|
||||
</HeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{machines.map((machine, idx) => {
|
||||
if (idx < numToRender) {
|
||||
return (
|
||||
<TableRow
|
||||
onClick={() => redirect(machine)}
|
||||
className="boder-b-0 bg-ghost"
|
||||
key={machine.deviceId + idx}>
|
||||
<TableCell
|
||||
sx={{
|
||||
borderBottom: '4px solid white',
|
||||
padding: 0,
|
||||
paddingLeft: '15px'
|
||||
}}
|
||||
align="left">
|
||||
<div className="flex items-center">
|
||||
<TL2>{machine.name}</TL2>
|
||||
<MachineLinkIcon
|
||||
className="cursor-pointer ml-2"
|
||||
onClick={() => redirect(machine)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<StyledCell>
|
||||
<Status status={machine.statuses[0]} />
|
||||
</StyledCell>
|
||||
{R.range(1, maxNumberOfCassettes + 1).map((it, idx) =>
|
||||
machine.numberOfCassettes >= it ? (
|
||||
<StyledCell key={idx} align="left">
|
||||
{makePercentageText(
|
||||
it,
|
||||
machine.cashUnits[`cassette${it}`]
|
||||
)}
|
||||
</StyledCell>
|
||||
) : (
|
||||
<StyledCell key={idx} align="left">
|
||||
<TL2>{`— %`}</TL2>
|
||||
</StyledCell>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default MachinesTable
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
backgroundColor,
|
||||
offColor,
|
||||
errorColor,
|
||||
primaryColor
|
||||
} from 'src/styling/variables'
|
||||
|
||||
const styles = {
|
||||
label: {
|
||||
margin: 0,
|
||||
color: offColor
|
||||
},
|
||||
row: {
|
||||
backgroundColor: backgroundColor,
|
||||
borderBottom: 'none'
|
||||
},
|
||||
clickableRow: {
|
||||
cursor: 'pointer'
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'pre'
|
||||
},
|
||||
error: {
|
||||
color: errorColor
|
||||
},
|
||||
button: {
|
||||
color: primaryColor,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent'
|
||||
},
|
||||
marginBottom: -40
|
||||
},
|
||||
buttonLabel: {
|
||||
position: 'absolute',
|
||||
bottom: 160,
|
||||
marginBottom: 0
|
||||
},
|
||||
statusHeader: {
|
||||
marginLeft: 2
|
||||
},
|
||||
tableBody: {
|
||||
overflow: 'auto'
|
||||
},
|
||||
tl2: {
|
||||
display: 'inline'
|
||||
},
|
||||
label1: {
|
||||
display: 'inline'
|
||||
},
|
||||
machinesTableContainer: {
|
||||
height: 220
|
||||
},
|
||||
expandedMachinesTableContainer: {
|
||||
height: 414
|
||||
},
|
||||
centerLabel: {
|
||||
marginBottom: 0,
|
||||
padding: 0,
|
||||
textAlign: 'center'
|
||||
},
|
||||
machineNameWrapper: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
machineRedirectIcon: {
|
||||
marginLeft: 10
|
||||
}
|
||||
}
|
||||
|
||||
export default styles
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import Button from '@mui/material/Button'
|
||||
import classnames from 'classnames'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import { cardState as cardState_ } from 'src/components/CollapsibleCard'
|
||||
import { H4, TL2, Label1 } from 'src/components/typography'
|
||||
|
||||
import MachinesTable from './MachinesTable'
|
||||
|
||||
// number of machines in the table to render on page load
|
||||
const NUM_TO_RENDER = 4
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
machines {
|
||||
name
|
||||
deviceId
|
||||
cashUnits {
|
||||
cashbox
|
||||
cassette1
|
||||
cassette2
|
||||
cassette3
|
||||
cassette4
|
||||
recycler1
|
||||
recycler2
|
||||
recycler3
|
||||
recycler4
|
||||
recycler5
|
||||
recycler6
|
||||
}
|
||||
numberOfCassettes
|
||||
numberOfRecyclers
|
||||
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 = ({ onReset, onExpand, size }) => {
|
||||
const { data, loading } = useQuery(GET_DATA)
|
||||
|
||||
const machines = R.path(['machines'])(data) ?? []
|
||||
const showAllItems = size === cardState_.EXPANDED
|
||||
|
||||
const machinesTableContainerClasses = {
|
||||
'h-55': !showAllItems,
|
||||
'h-103': showAllItems
|
||||
}
|
||||
// const uptime = data?.uptime ?? [{}]
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<H4 className="mt-0">System status</H4>
|
||||
{showAllItems && (
|
||||
<Label1 noMargin className="-mt-1">
|
||||
<Button
|
||||
onClick={onReset}
|
||||
size="small"
|
||||
disableRipple
|
||||
disableFocusRipple
|
||||
className="p-0 text-zodiac normal-case">
|
||||
{'Show less'}
|
||||
</Button>
|
||||
</Label1>
|
||||
)}
|
||||
</div>
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<TL2 className="inline">{data?.serverVersion}</TL2>
|
||||
<Label1 className="inline"> server version</Label1>
|
||||
</div>
|
||||
<div className={classnames(machinesTableContainerClasses)}>
|
||||
<MachinesTable
|
||||
numToRender={showAllItems ? Infinity : NUM_TO_RENDER}
|
||||
machines={machines}
|
||||
/>
|
||||
</div>
|
||||
{!showAllItems && machines.length > NUM_TO_RENDER && (
|
||||
<div>
|
||||
<Label1 className="text-center mb-0">
|
||||
<Button
|
||||
onClick={() => onExpand()}
|
||||
size="small"
|
||||
disableRipple
|
||||
disableFocusRipple
|
||||
className="p-0 text-zodiac normal-case">
|
||||
{`Show all (${machines.length})`}
|
||||
</Button>
|
||||
</Label1>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemStatus
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import SystemStatus from './SystemStatus'
|
||||
export default SystemStatus
|
||||
2
packages/admin-ui/src/pages/Dashboard/index.js
Normal file
2
packages/admin-ui/src/pages/Dashboard/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Dashboard from './Dashboard'
|
||||
export default Dashboard
|
||||
303
packages/admin-ui/src/pages/Funding/Funding.jsx
Normal file
303
packages/admin-ui/src/pages/Funding/Funding.jsx
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import { formatCryptoAddress } from '@lamassu/coins/lightUtils'
|
||||
import BigNumber from 'bignumber.js'
|
||||
import classnames from 'classnames'
|
||||
import { format } from 'date-fns/fp'
|
||||
import { QRCodeSVG as QRCode } from 'qrcode.react'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import TableLabel from 'src/pages/Funding/TableLabel.jsx'
|
||||
import Title from 'src/components/Title.jsx'
|
||||
import {
|
||||
Tr,
|
||||
Td,
|
||||
THead,
|
||||
TBody,
|
||||
Table
|
||||
} from 'src/components/fake-table/Table.jsx'
|
||||
import Sidebar from 'src/components/layout/Sidebar.jsx'
|
||||
import {
|
||||
H3,
|
||||
Info1,
|
||||
Info2,
|
||||
Info3,
|
||||
Label1,
|
||||
Label3
|
||||
} from 'src/components/typography/index.jsx'
|
||||
import CopyToClipboard from 'src/components/CopyToClipboard.jsx'
|
||||
|
||||
import { primaryColor } from 'src/styling/variables.js'
|
||||
|
||||
import classes from './Funding.module.css'
|
||||
|
||||
const NODE_NOT_CONNECTED_ERR =
|
||||
"Couldn't establish connection with the node. Make sure it is installed and try again"
|
||||
|
||||
const sizes = {
|
||||
big: 165,
|
||||
time: 140,
|
||||
date: 130
|
||||
}
|
||||
|
||||
const GET_FUNDING = gql`
|
||||
{
|
||||
funding {
|
||||
cryptoCode
|
||||
errorMsg
|
||||
fundingAddress
|
||||
fundingAddressUrl
|
||||
confirmedBalance
|
||||
pending
|
||||
fiatConfirmedBalance
|
||||
fiatPending
|
||||
fiatCode
|
||||
display
|
||||
unitScale
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const formatAddress = (cryptoCode = '', address = '') =>
|
||||
formatCryptoAddress(cryptoCode, address).replace(/(.{4})/g, '$1 ')
|
||||
const sumReducer = (acc, value) => acc.plus(value)
|
||||
const formatNumber = it => new BigNumber(it).toFormat(2)
|
||||
|
||||
const getConfirmedTotal = list => {
|
||||
return formatNumber(
|
||||
list
|
||||
.filter(it => !it.errorMsg)
|
||||
.map(it => new BigNumber(it.fiatConfirmedBalance))
|
||||
.reduce(sumReducer, new BigNumber(0))
|
||||
)
|
||||
}
|
||||
|
||||
const getPendingTotal = list => {
|
||||
return formatNumber(
|
||||
list
|
||||
.filter(it => !it.errorMsg)
|
||||
.map(it => new BigNumber(it.fiatPending))
|
||||
.reduce(sumReducer, new BigNumber(0))
|
||||
)
|
||||
}
|
||||
|
||||
const Funding = () => {
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [viewHistory] = useState(false)
|
||||
const fundingHistory = [
|
||||
{
|
||||
cryptoAmount: 2.0,
|
||||
balance: 10.23,
|
||||
fiatValue: 1000.0,
|
||||
date: new Date(),
|
||||
performedBy: null,
|
||||
pending: true
|
||||
},
|
||||
{
|
||||
cryptoAmount: 10.0,
|
||||
balance: 12.23,
|
||||
fiatValue: 12000.0,
|
||||
date: new Date(),
|
||||
performedBy: null
|
||||
},
|
||||
{
|
||||
cryptoAmount: 5.0,
|
||||
balance: 5.0,
|
||||
fiatValue: 50000.0,
|
||||
date: new Date(),
|
||||
performedBy: null
|
||||
}
|
||||
]
|
||||
|
||||
const isSelected = it => {
|
||||
return selected && selected.cryptoCode === it.cryptoCode
|
||||
}
|
||||
|
||||
const { data: fundingResponse, loading } = useQuery(GET_FUNDING)
|
||||
const funding = R.path(['funding'])(fundingResponse) ?? []
|
||||
|
||||
if (funding.length && !selected) {
|
||||
setSelected(funding[0])
|
||||
}
|
||||
|
||||
const itemRender = (it, active) => {
|
||||
const itemClass = {
|
||||
[classes.item]: true,
|
||||
[classes.inactiveItem]: !active
|
||||
}
|
||||
const wrapperClass = {
|
||||
[classes.itemWrapper]: true,
|
||||
[classes.error]: it.errorMsg
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classnames(wrapperClass)}>
|
||||
<div className={classes.firstItem}>{it.display}</div>
|
||||
{!it.errorMsg && (
|
||||
<>
|
||||
<div className={classnames(itemClass)}>
|
||||
{formatNumber(it.fiatConfirmedBalance)} {it.fiatCode}
|
||||
</div>
|
||||
<div className={classnames(itemClass)}>
|
||||
{it.confirmedBalance} {it.cryptoCode}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const pendingTotal = getPendingTotal(funding)
|
||||
const signIfPositive = num => (num >= 0 ? '+' : '')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Title>Funding</Title>
|
||||
{/* <button onClick={it => setViewHistory(!viewHistory)}>history</button> */}
|
||||
</div>
|
||||
<div className={classes.wrapper}>
|
||||
<Sidebar
|
||||
data={funding}
|
||||
isSelected={isSelected}
|
||||
onClick={setSelected}
|
||||
displayName={it => it.display}
|
||||
itemRender={itemRender}
|
||||
loading={loading}>
|
||||
{funding.length && (
|
||||
<div className={classes.total}>
|
||||
<Label1 className={classes.totalTitle}>
|
||||
Total crypto balance
|
||||
</Label1>
|
||||
<Info1 noMargin>
|
||||
{getConfirmedTotal(funding)}
|
||||
{funding[0].fiatCode}
|
||||
</Info1>
|
||||
<Label1 className={classes.totalPending}>
|
||||
({signIfPositive(pendingTotal)} {pendingTotal} pending)
|
||||
</Label1>
|
||||
</div>
|
||||
)}
|
||||
</Sidebar>
|
||||
{selected && !viewHistory && selected.errorMsg && (
|
||||
<div className={classes.main}>
|
||||
<div className={classes.firstSide}>
|
||||
<Info3 className={classes.error}>
|
||||
{R.includes('ECONNREFUSED', selected.errorMsg)
|
||||
? NODE_NOT_CONNECTED_ERR
|
||||
: selected.errorMsg}
|
||||
</Info3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected && !viewHistory && !selected.errorMsg && (
|
||||
<div className={classes.main}>
|
||||
<div className={classes.firstSide}>
|
||||
<H3>Balance</H3>
|
||||
<div className={classes.coinTotal}>
|
||||
<Info1 inline noMargin>
|
||||
{`${selected.confirmedBalance} ${selected.cryptoCode}`}
|
||||
</Info1>
|
||||
<Info2 inline noMargin className="ml-2">
|
||||
{`(${signIfPositive(selected.pending)} ${
|
||||
selected.pending
|
||||
} pending)`}
|
||||
</Info2>
|
||||
</div>
|
||||
|
||||
<div className={classes.coinTotal}>
|
||||
<Info3 inline noMargin>
|
||||
{`= ${formatNumber(selected.fiatConfirmedBalance)} ${
|
||||
selected.fiatCode
|
||||
}`}
|
||||
</Info3>
|
||||
<Label3 inline noMargin className="ml-2">
|
||||
{`(${signIfPositive(selected.fiatPending)} ${formatNumber(
|
||||
selected.fiatPending
|
||||
)} pending)`}
|
||||
</Label3>
|
||||
</div>
|
||||
|
||||
<H3 className={classes.topSpacer}>Address</H3>
|
||||
<div className={classes.addressWrapper}>
|
||||
<div className={classes.mono}>
|
||||
<strong>
|
||||
<CopyToClipboard
|
||||
buttonClassname={classes.copyToClipboard}
|
||||
key={selected.cryptoCode}>
|
||||
{formatAddress(
|
||||
selected.cryptoCode,
|
||||
selected.fundingAddress
|
||||
)}
|
||||
</CopyToClipboard>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.secondSide}>
|
||||
<Label1>Scan to send {selected.display}</Label1>
|
||||
<QRCode
|
||||
size={240}
|
||||
fgColor={primaryColor}
|
||||
value={selected.fundingAddressUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected && viewHistory && (
|
||||
<div>
|
||||
<TableLabel
|
||||
className={classes.tableLabel}
|
||||
label="Pending"
|
||||
color="#cacaca"
|
||||
/>
|
||||
<Table className={classes.table}>
|
||||
<THead>
|
||||
<Td header width={sizes.big}>
|
||||
Amount Entered
|
||||
</Td>
|
||||
<Td header width={sizes.big}>
|
||||
Balance After
|
||||
</Td>
|
||||
<Td header width={sizes.big}>
|
||||
Cash Value
|
||||
</Td>
|
||||
<Td header width={sizes.date}>
|
||||
Date
|
||||
</Td>
|
||||
<Td header width={sizes.time}>
|
||||
Time (h:m:s)
|
||||
</Td>
|
||||
<Td header width={sizes.big}>
|
||||
Performed By
|
||||
</Td>
|
||||
</THead>
|
||||
<TBody>
|
||||
{fundingHistory.map((it, idx) => (
|
||||
<Tr
|
||||
key={idx}
|
||||
className={classnames({ [classes.pending]: it.pending })}>
|
||||
<Td width={sizes.big}>
|
||||
{it.cryptoAmount} {selected.cryptoCode}
|
||||
</Td>
|
||||
<Td width={sizes.big}>
|
||||
{it.balance} {selected.cryptoCode}
|
||||
</Td>
|
||||
<Td width={sizes.big}>
|
||||
{it.fiatValue} {selected.fiatCode}
|
||||
</Td>
|
||||
<Td width={sizes.date}>{format('yyyy-MM-dd', it.date)}</Td>
|
||||
<Td width={sizes.time}>{format('hh:mm:ss', it.date)}</Td>
|
||||
<Td width={sizes.big}>add</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Funding
|
||||
107
packages/admin-ui/src/pages/Funding/Funding.module.css
Normal file
107
packages/admin-ui/src/pages/Funding/Funding.module.css
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
.wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.firstSide {
|
||||
margin: 0 64px 0 48px;
|
||||
}
|
||||
|
||||
.secondSide {
|
||||
margin-top: -29px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--tomato);
|
||||
}
|
||||
|
||||
.coinTotal {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.topSpacer {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.addressWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
background-color: var(--zircon);
|
||||
}
|
||||
|
||||
.address {
|
||||
width: 375px;
|
||||
margin: 12px 24px;
|
||||
}
|
||||
|
||||
.itemWrapper {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-family: var(--museo);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.inactiveItem {
|
||||
color: var(--comet);
|
||||
}
|
||||
|
||||
.firstItem {
|
||||
font-weight: 700;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.total {
|
||||
margin-top: auto;
|
||||
text-align: right;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.totalPending {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.totalTitle {
|
||||
color: var(--comet);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: 8px;
|
||||
margin-left: 48px;
|
||||
}
|
||||
|
||||
.tableLabel {
|
||||
justify-content: end;
|
||||
margin-top: -38px;
|
||||
}
|
||||
|
||||
.pending {
|
||||
background-color: var(--zircon);
|
||||
}
|
||||
|
||||
.copyToClipboard {
|
||||
margin-left: auto;
|
||||
padding-top: 6px;
|
||||
padding-left: 15px;
|
||||
margin-right: -11px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--bpmono);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
width: 375px;
|
||||
margin: 12px 24px;
|
||||
}
|
||||
20
packages/admin-ui/src/pages/Funding/TableLabel.jsx
Normal file
20
packages/admin-ui/src/pages/Funding/TableLabel.jsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import classnames from 'classnames'
|
||||
import React from 'react'
|
||||
|
||||
import { Label1 } from '../../components/typography/index.jsx'
|
||||
|
||||
const TableLabel = ({ className, label, color, ...props }) => {
|
||||
return (
|
||||
<div className={classnames('flex items-center', className)} {...props}>
|
||||
{color && (
|
||||
<div
|
||||
className="rounded-sm h-3 w-3 mr-2"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<Label1 {...props}>{label}</Label1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TableLabel
|
||||
255
packages/admin-ui/src/pages/Locales/Locales.jsx
Normal file
255
packages/admin-ui/src/pages/Locales/Locales.jsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import Modal from 'src/components/Modal'
|
||||
import { HelpTooltip } from 'src/components/Tooltip'
|
||||
import Section from 'src/components/layout/Section'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import { P } from 'src/components/typography'
|
||||
import _schemas from 'src/pages/Services/schemas'
|
||||
import Wizard from 'src/pages/Wallet/Wizard'
|
||||
import { WalletSchema } from 'src/pages/Wallet/helper'
|
||||
|
||||
import { Link, SupportLinkButton } from 'src/components/buttons'
|
||||
import { Table as EditableTable } from 'src/components/editableTable'
|
||||
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
|
||||
|
||||
import {
|
||||
mainFields,
|
||||
overrides,
|
||||
LocaleSchema,
|
||||
OverridesSchema,
|
||||
localeDefaults,
|
||||
overridesDefaults
|
||||
} from './helper'
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
config
|
||||
accounts
|
||||
accountsConfig {
|
||||
code
|
||||
display
|
||||
class
|
||||
cryptos
|
||||
}
|
||||
currencies {
|
||||
code
|
||||
display
|
||||
}
|
||||
countries {
|
||||
code
|
||||
display
|
||||
}
|
||||
cryptoCurrencies {
|
||||
code
|
||||
display
|
||||
isBeta
|
||||
}
|
||||
languages {
|
||||
code
|
||||
display
|
||||
}
|
||||
machines {
|
||||
name
|
||||
deviceId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SAVE_CONFIG = gql`
|
||||
mutation Save($config: JSONObject, $accounts: JSONObject) {
|
||||
saveConfig(config: $config)
|
||||
saveAccounts(accounts: $accounts)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_MARKETS = gql`
|
||||
query getMarkets {
|
||||
getMarkets
|
||||
}
|
||||
`
|
||||
|
||||
const FiatCurrencyChangeAlert = ({ open, close, save }) => {
|
||||
return (
|
||||
<Modal
|
||||
title={'Change fiat currency?'}
|
||||
handleClose={close}
|
||||
width={450}
|
||||
height={310}
|
||||
open={open}>
|
||||
<P>
|
||||
Please note that all values you set that were based on your prior fiat
|
||||
currency are still the same. If you need to adjust these to reflect the
|
||||
new fiat currency (such as minimum transaction amounts, fixed fees, and
|
||||
compliance triggers, for example), please do so now.
|
||||
</P>
|
||||
<P>
|
||||
Also, if you have cash-out enabled, you must define new dispenser bill
|
||||
counts for the new currency for cash-out on the new currency to work.
|
||||
</P>
|
||||
<div className="ml-auto">
|
||||
<Link onClick={close} color="secondary">
|
||||
Cancel
|
||||
</Link>
|
||||
<Link className="ml-5" onClick={save} color="primary">
|
||||
Save
|
||||
</Link>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const Locales = ({ name: SCREEN_KEY }) => {
|
||||
const [wizard, setWizard] = useState(false)
|
||||
const [onChangeFunction, setOnChangeFunction] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [isEditingDefault, setEditingDefault] = useState(false)
|
||||
const [isEditingOverrides, setEditingOverrides] = useState(false)
|
||||
const { data } = useQuery(GET_DATA)
|
||||
const { data: marketsData } = useQuery(GET_MARKETS)
|
||||
const schemas = _schemas(marketsData?.getMarkets)
|
||||
|
||||
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||
onCompleted: () => setWizard(false),
|
||||
refetchQueries: () => ['getData'],
|
||||
onError: error => setError(error)
|
||||
})
|
||||
|
||||
const [dataToSave, setDataToSave] = useState(null)
|
||||
|
||||
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
|
||||
const wallets = data?.config && fromNamespace(namespaces.WALLETS)(data.config)
|
||||
|
||||
const accountsConfig = data?.accountsConfig
|
||||
const accounts = data?.accounts ?? []
|
||||
const cryptoCurrencies = data?.cryptoCurrencies ?? []
|
||||
const locale = config && !R.isEmpty(config) ? config : localeDefaults
|
||||
const localeOverrides = locale.overrides ?? []
|
||||
|
||||
const handleSave = it => {
|
||||
const newConfig = toNamespace(SCREEN_KEY)(it.locale[0])
|
||||
|
||||
if (
|
||||
config.fiatCurrency &&
|
||||
newConfig.locale_fiatCurrency !== config.fiatCurrency
|
||||
)
|
||||
return setDataToSave(newConfig)
|
||||
|
||||
return save(newConfig)
|
||||
}
|
||||
|
||||
const save = (config, accounts) => {
|
||||
setDataToSave(null)
|
||||
return saveConfig({ variables: { config, accounts } })
|
||||
}
|
||||
|
||||
const saveOverrides = it => {
|
||||
const config = toNamespace(SCREEN_KEY)(it)
|
||||
setError(null)
|
||||
return saveConfig({ variables: { config } })
|
||||
}
|
||||
|
||||
const onChangeCoin = (prev, curr, setValue) => {
|
||||
const coin = R.difference(curr, prev)[0]
|
||||
if (!coin) return setValue(curr)
|
||||
|
||||
const namespaced = fromNamespace(coin)(wallets)
|
||||
if (!WalletSchema.isValidSync(namespaced)) {
|
||||
setOnChangeFunction(() => () => setValue(curr))
|
||||
setWizard(coin)
|
||||
return
|
||||
}
|
||||
|
||||
setValue(curr)
|
||||
}
|
||||
|
||||
const onEditingDefault = (it, editing) => setEditingDefault(editing)
|
||||
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
|
||||
|
||||
const wizardSave = (config, accounts) =>
|
||||
save(toNamespace(namespaces.WALLETS)(config), accounts).then(it => {
|
||||
onChangeFunction()
|
||||
setOnChangeFunction(null)
|
||||
return it
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<FiatCurrencyChangeAlert
|
||||
open={dataToSave}
|
||||
close={() => setDataToSave(null)}
|
||||
save={() => dataToSave && save(dataToSave)}
|
||||
/>
|
||||
<TitleSection
|
||||
title="Locales"
|
||||
appendix={
|
||||
<HelpTooltip width={320}>
|
||||
<P>
|
||||
For details on configuring languages, please read the relevant
|
||||
knowledgebase article:
|
||||
</P>
|
||||
<SupportLinkButton
|
||||
link="https://support.lamassu.is/hc/en-us/articles/360016257471-Setting-multiple-machine-languages"
|
||||
label="Setting multiple machine languages"
|
||||
bottomSpace="1"
|
||||
/>
|
||||
</HelpTooltip>
|
||||
}
|
||||
/>
|
||||
<Section>
|
||||
<EditableTable
|
||||
title="Default settings"
|
||||
error={error?.message}
|
||||
titleLg
|
||||
name="locale"
|
||||
enableEdit
|
||||
initialValues={locale}
|
||||
save={handleSave}
|
||||
validationSchema={LocaleSchema}
|
||||
data={R.of(locale)}
|
||||
elements={mainFields(data, onChangeCoin)}
|
||||
setEditing={onEditingDefault}
|
||||
forceDisable={isEditingOverrides}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
title="Overrides"
|
||||
titleLg
|
||||
name="overrides"
|
||||
enableDelete
|
||||
enableEdit
|
||||
enableCreate
|
||||
initialValues={overridesDefaults}
|
||||
save={saveOverrides}
|
||||
validationSchema={OverridesSchema}
|
||||
data={localeOverrides ?? []}
|
||||
elements={overrides(data, localeOverrides, onChangeCoin)}
|
||||
disableAdd={R.compose(R.isEmpty, R.difference)(
|
||||
data?.machines.map(m => m.deviceId) ?? [],
|
||||
localeOverrides?.map(o => o.machine) ?? []
|
||||
)}
|
||||
setEditing={onEditingOverrides}
|
||||
forceDisable={isEditingDefault}
|
||||
/>
|
||||
</Section>
|
||||
{wizard && (
|
||||
<Wizard
|
||||
schemas={schemas}
|
||||
coin={R.find(R.propEq('code', wizard))(cryptoCurrencies)}
|
||||
onClose={() => setWizard(false)}
|
||||
save={wizardSave}
|
||||
error={error?.message}
|
||||
cryptoCurrencies={cryptoCurrencies}
|
||||
userAccounts={data?.config?.accounts}
|
||||
accounts={accounts}
|
||||
accountsConfig={accountsConfig}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Locales
|
||||
192
packages/admin-ui/src/pages/Locales/helper.js
Normal file
192
packages/admin-ui/src/pages/Locales/helper.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import * as R from 'ramda'
|
||||
import Autocomplete from 'src/components/inputs/formik/Autocomplete'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { labels as timezoneList } from 'src/utils/timezone-list'
|
||||
|
||||
const getFields = (getData, names, onChange, auxElements = []) => {
|
||||
return R.filter(
|
||||
it => R.includes(it.name, names),
|
||||
allFields(getData, onChange, auxElements)
|
||||
)
|
||||
}
|
||||
|
||||
const allFields = (getData, onChange, auxElements = []) => {
|
||||
const getView = (data, code, compare) => it => {
|
||||
if (!data) return ''
|
||||
|
||||
return R.compose(
|
||||
it => `${R.prop(code)(it)} ${it?.isBeta ? '(Beta)' : ''}`,
|
||||
R.find(R.propEq(compare ?? 'code', it))
|
||||
)(data)
|
||||
}
|
||||
|
||||
const displayCodeArray = data => it => {
|
||||
if (!it) return it
|
||||
|
||||
return R.compose(R.join(', '), R.map(getView(data, 'code')))(it)
|
||||
}
|
||||
|
||||
const overriddenMachines = R.map(override => override.machine, auxElements)
|
||||
|
||||
const suggestionFilter = it =>
|
||||
R.differenceWith((x, y) => x.deviceId === y, it, overriddenMachines)
|
||||
|
||||
const machineData = getData(['machines'])
|
||||
const countryData = getData(['countries'])
|
||||
const currencyData = getData(['currencies'])
|
||||
const languageData = getData(['languages'])
|
||||
const rawCryptoData = getData(['cryptoCurrencies'])
|
||||
const cryptoData = rawCryptoData?.map(it => {
|
||||
it.codeLabel = `${it.code}${it.isBeta ? ' (Beta)' : ''}`
|
||||
return it
|
||||
})
|
||||
|
||||
const timezonesData = timezoneList
|
||||
|
||||
const findSuggestion = it => {
|
||||
const machine = R.find(R.propEq('deviceId', it.machine))(machineData)
|
||||
return machine ? [machine] : []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'machine',
|
||||
width: 200,
|
||||
size: 'sm',
|
||||
view: getView(machineData, 'name', 'deviceId'),
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: it =>
|
||||
R.concat(findSuggestion(it))(suggestionFilter(machineData)),
|
||||
valueProp: 'deviceId',
|
||||
labelProp: 'name'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
width: 200,
|
||||
size: 'sm',
|
||||
view: getView(countryData, 'display'),
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: countryData,
|
||||
valueProp: 'code',
|
||||
labelProp: 'display'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fiatCurrency',
|
||||
width: 150,
|
||||
size: 'sm',
|
||||
view: getView(currencyData, 'code'),
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: currencyData,
|
||||
valueProp: 'code',
|
||||
labelProp: 'code'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
width: 200,
|
||||
size: 'sm',
|
||||
view: displayCodeArray(languageData),
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: languageData,
|
||||
valueProp: 'code',
|
||||
labelProp: 'display',
|
||||
multiple: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cryptoCurrencies',
|
||||
width: 170,
|
||||
size: 'sm',
|
||||
view: displayCodeArray(cryptoData),
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: cryptoData,
|
||||
valueProp: 'code',
|
||||
labelProp: 'codeLabel',
|
||||
multiple: true,
|
||||
optionsLimit: null,
|
||||
onChange
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'timezone',
|
||||
width: 320,
|
||||
size: 'sm',
|
||||
view: getView(timezonesData, 'label'),
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: timezonesData,
|
||||
valueProp: 'code',
|
||||
labelProp: 'label'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mainFields = (auxData, configureCoin) => {
|
||||
const getData = R.path(R.__, auxData)
|
||||
|
||||
return getFields(
|
||||
getData,
|
||||
['country', 'fiatCurrency', 'languages', 'cryptoCurrencies', 'timezone'],
|
||||
configureCoin,
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
const overrides = (auxData, auxElements, configureCoin) => {
|
||||
const getData = R.path(R.__, auxData)
|
||||
|
||||
return getFields(
|
||||
getData,
|
||||
['machine', 'country', 'languages', 'cryptoCurrencies'],
|
||||
configureCoin,
|
||||
auxElements
|
||||
)
|
||||
}
|
||||
|
||||
const LocaleSchema = Yup.object().shape({
|
||||
country: Yup.string().label('Country').required(),
|
||||
fiatCurrency: Yup.string().label('Fiat currency').required(),
|
||||
languages: Yup.array().label('Languages').required().min(1).max(4),
|
||||
cryptoCurrencies: Yup.array().label('Crypto currencies').required().min(1),
|
||||
timezone: Yup.string().label('Timezone').required()
|
||||
})
|
||||
|
||||
const OverridesSchema = Yup.object().shape({
|
||||
machine: Yup.string().label('Machine').required(),
|
||||
country: Yup.string().label('Country').required(),
|
||||
languages: Yup.array().label('Languages').required().min(1).max(4),
|
||||
cryptoCurrencies: Yup.array().label('Crypto currencies').required().min(1)
|
||||
})
|
||||
|
||||
const localeDefaults = {
|
||||
country: '',
|
||||
fiatCurrency: '',
|
||||
languages: [],
|
||||
cryptoCurrencies: [],
|
||||
timezone: ''
|
||||
}
|
||||
|
||||
const overridesDefaults = {
|
||||
machine: '',
|
||||
country: '',
|
||||
languages: [],
|
||||
cryptoCurrencies: []
|
||||
}
|
||||
|
||||
export {
|
||||
mainFields,
|
||||
overrides,
|
||||
LocaleSchema,
|
||||
OverridesSchema,
|
||||
localeDefaults,
|
||||
overridesDefaults
|
||||
}
|
||||
3
packages/admin-ui/src/pages/Locales/index.js
Normal file
3
packages/admin-ui/src/pages/Locales/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Locales from './Locales'
|
||||
|
||||
export default Locales
|
||||
74
packages/admin-ui/src/pages/Logs/Logs.module.css
Normal file
74
packages/admin-ui/src/pages/Logs/Logs.module.css
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
.titleWrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
flex: 1;
|
||||
margin-left: 40px;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
max-width: 78%;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.table {
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.dateColumn {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.levelColumn {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.fillColumn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shareButton {
|
||||
margin: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.shareIcon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.titleAndButtonsContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.buttonsWrapper {
|
||||
display: flex;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.buttonsWrapper > * {
|
||||
margin: auto 6px;
|
||||
}
|
||||
168
packages/admin-ui/src/pages/Logs/MachineLogs.jsx
Normal file
168
packages/admin-ui/src/pages/Logs/MachineLogs.jsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper.jsx'
|
||||
import Title from 'src/components/Title.jsx'
|
||||
import Sidebar from 'src/components/layout/Sidebar.jsx'
|
||||
import { Info3, H4 } from 'src/components/typography/index.jsx'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableCell
|
||||
} from 'src/components/table/index.js'
|
||||
import { formatDate } from 'src/utils/timezones.js'
|
||||
|
||||
import classes from './Logs.module.css'
|
||||
|
||||
const GET_MACHINES = gql`
|
||||
{
|
||||
machines {
|
||||
name
|
||||
deviceId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const NUM_LOG_RESULTS = 500
|
||||
|
||||
const GET_MACHINE_LOGS_CSV = gql`
|
||||
query MachineLogs(
|
||||
$deviceId: ID!
|
||||
$limit: Int
|
||||
$from: DateTimeISO
|
||||
$until: DateTimeISO
|
||||
$timezone: String
|
||||
) {
|
||||
machineLogsCsv(
|
||||
deviceId: $deviceId
|
||||
limit: $limit
|
||||
from: $from
|
||||
until: $until
|
||||
timezone: $timezone
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_MACHINE_LOGS = gql`
|
||||
query MachineLogs(
|
||||
$deviceId: ID!
|
||||
$limit: Int
|
||||
$from: DateTimeISO
|
||||
$until: DateTimeISO
|
||||
) {
|
||||
machineLogs(
|
||||
deviceId: $deviceId
|
||||
limit: $limit
|
||||
from: $from
|
||||
until: $until
|
||||
) {
|
||||
logLevel
|
||||
id
|
||||
timestamp
|
||||
message
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
config
|
||||
}
|
||||
`
|
||||
|
||||
const Logs = () => {
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [saveMessage, setSaveMessage] = useState(null)
|
||||
|
||||
const deviceId = selected?.deviceId
|
||||
|
||||
const { data: machineResponse, loading: machinesLoading } =
|
||||
useQuery(GET_MACHINES)
|
||||
|
||||
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
|
||||
const timezone = R.path(['config', 'locale_timezone'], configResponse)
|
||||
|
||||
const { data: logsResponse, loading: logsLoading } = useQuery(
|
||||
GET_MACHINE_LOGS,
|
||||
{
|
||||
variables: { deviceId, limit: NUM_LOG_RESULTS },
|
||||
skip: !selected,
|
||||
onCompleted: () => setSaveMessage('')
|
||||
}
|
||||
)
|
||||
|
||||
if (machineResponse?.machines?.length && !selected) {
|
||||
setSelected(machineResponse?.machines[0])
|
||||
}
|
||||
|
||||
const isSelected = it => {
|
||||
return R.path(['deviceId'])(selected) === it.deviceId
|
||||
}
|
||||
|
||||
const loading = machinesLoading || configLoading || logsLoading
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.titleWrapper}>
|
||||
<div className={classes.titleAndButtonsContainer}>
|
||||
<Title>Machine logs</Title>
|
||||
{logsResponse && (
|
||||
<div className={classes.buttonsWrapper}>
|
||||
<LogsDowloaderPopover
|
||||
title="Download logs"
|
||||
name={selected.name}
|
||||
query={GET_MACHINE_LOGS_CSV}
|
||||
args={{ deviceId, timezone }}
|
||||
getLogs={logs => R.path(['machineLogsCsv'])(logs)}
|
||||
timezone={timezone}
|
||||
/>
|
||||
<Info3>{saveMessage}</Info3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.wrapper}>
|
||||
<Sidebar
|
||||
displayName={it => it.name}
|
||||
data={machineResponse?.machines || []}
|
||||
isSelected={isSelected}
|
||||
onClick={setSelected}
|
||||
/>
|
||||
<div className={classes.tableWrapper}>
|
||||
<Table className={classes.table}>
|
||||
<TableHead>
|
||||
<TableRow header>
|
||||
<TableHeader className={classes.dateColumn}>Date</TableHeader>
|
||||
<TableHeader className={classes.levelColumn}>Level</TableHeader>
|
||||
<TableHeader className={classes.fillColumn} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logsResponse &&
|
||||
logsResponse.machineLogs.map((log, idx) => (
|
||||
<TableRow key={idx} size="sm">
|
||||
<TableCell>
|
||||
{timezone &&
|
||||
formatDate(log.timestamp, timezone, 'yyyy-MM-dd HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell>{log.logLevel}</TableCell>
|
||||
<TableCell>{log.message}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{loading && <H4>{'Loading...'}</H4>}
|
||||
{!loading && !logsResponse?.machineLogs?.length && (
|
||||
<H4>{'No activity so far'}</H4>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logs
|
||||
197
packages/admin-ui/src/pages/Logs/ServerLogs.jsx
Normal file
197
packages/admin-ui/src/pages/Logs/ServerLogs.jsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper.jsx'
|
||||
import Title from 'src/components/Title.jsx'
|
||||
import Uptime from 'src/pages/Logs/Uptime.jsx'
|
||||
import { Info3, H4 } from 'src/components/typography/index.jsx'
|
||||
|
||||
import { Select } from 'src/components/inputs/index.js'
|
||||
import {
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableCell
|
||||
} from 'src/components/table/index.js'
|
||||
import { startCase } from 'src/utils/string.js'
|
||||
import { formatDate } from 'src/utils/timezones.js'
|
||||
|
||||
import logsClasses from './Logs.module.css'
|
||||
import classes from './ServerLogs.module.css'
|
||||
|
||||
const SHOW_ALL = { code: 'SHOW_ALL', display: 'Show all' }
|
||||
|
||||
const NUM_LOG_RESULTS = 500
|
||||
|
||||
const GET_CSV = gql`
|
||||
query ServerData(
|
||||
$limit: Int
|
||||
$from: DateTimeISO
|
||||
$until: DateTimeISO
|
||||
$timezone: String
|
||||
) {
|
||||
serverLogsCsv(
|
||||
limit: $limit
|
||||
from: $from
|
||||
until: $until
|
||||
timezone: $timezone
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_SERVER_DATA = gql`
|
||||
query ServerData($limit: Int, $from: DateTimeISO, $until: DateTimeISO) {
|
||||
serverVersion
|
||||
uptime {
|
||||
name
|
||||
state
|
||||
uptime
|
||||
}
|
||||
serverLogs(limit: $limit, from: $from, until: $until) {
|
||||
logLevel
|
||||
id
|
||||
timestamp
|
||||
message
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
config
|
||||
}
|
||||
`
|
||||
|
||||
const Logs = () => {
|
||||
const tableEl = useRef()
|
||||
|
||||
const [saveMessage, setSaveMessage] = useState(null)
|
||||
const [logLevel, setLogLevel] = useState(SHOW_ALL)
|
||||
|
||||
const { data, loading: dataLoading } = useQuery(GET_SERVER_DATA, {
|
||||
onCompleted: () => setSaveMessage(''),
|
||||
variables: {
|
||||
limit: NUM_LOG_RESULTS
|
||||
}
|
||||
})
|
||||
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
|
||||
const timezone = R.path(['config', 'locale_timezone'], configResponse)
|
||||
|
||||
const defaultLogLevels = [
|
||||
{ code: 'error', display: 'Error' },
|
||||
{ code: 'info', display: 'Info' },
|
||||
{ code: 'debug', display: 'Debug' }
|
||||
]
|
||||
const serverVersion = data?.serverVersion
|
||||
const processStates = data?.uptime ?? []
|
||||
|
||||
const getLogLevels = R.compose(
|
||||
R.prepend(SHOW_ALL),
|
||||
R.uniq,
|
||||
R.concat(defaultLogLevels),
|
||||
R.map(it => ({
|
||||
code: R.path(['logLevel'])(it),
|
||||
display: startCase(R.path(['logLevel'])(it))
|
||||
})),
|
||||
R.path(['serverLogs'])
|
||||
)
|
||||
|
||||
const handleLogLevelChange = logLevel => {
|
||||
if (tableEl.current) tableEl.current.scrollTo(0, 0)
|
||||
|
||||
setLogLevel(logLevel)
|
||||
}
|
||||
|
||||
const loading = dataLoading || configLoading
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={logsClasses.titleWrapper}>
|
||||
<div className={logsClasses.titleAndButtonsContainer}>
|
||||
<Title>Server</Title>
|
||||
{data && (
|
||||
<div className={logsClasses.buttonsWrapper}>
|
||||
<LogsDowloaderPopover
|
||||
title="Download logs"
|
||||
name="server-logs"
|
||||
query={GET_CSV}
|
||||
args={{ timezone }}
|
||||
logs={data.serverLogs}
|
||||
getLogs={logs => R.path(['serverLogsCsv'])(logs)}
|
||||
timezone={timezone}
|
||||
/>
|
||||
<Info3>{saveMessage}</Info3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.serverVersion}>
|
||||
{serverVersion && <span>Server version: v{serverVersion}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.headerLine2}>
|
||||
{data && (
|
||||
<Select
|
||||
onSelectedItemChange={handleLogLevelChange}
|
||||
label="Level"
|
||||
items={getLogLevels(data)}
|
||||
default={SHOW_ALL}
|
||||
selectedItem={logLevel}
|
||||
/>
|
||||
)}
|
||||
<div className={classes.uptimeContainer}>
|
||||
{processStates &&
|
||||
processStates.map((process, idx) => (
|
||||
<Uptime key={idx} process={process} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={logsClasses.wrapper}>
|
||||
<div ref={tableEl} className={classes.serverTableWrapper}>
|
||||
<Table className={logsClasses.table}>
|
||||
<TableHead>
|
||||
<TableRow header>
|
||||
<TableHeader className={logsClasses.dateColumn}>
|
||||
Date
|
||||
</TableHeader>
|
||||
<TableHeader className={logsClasses.levelColumn}>
|
||||
Level
|
||||
</TableHeader>
|
||||
<TableHeader className={logsClasses.fillColumn} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data &&
|
||||
data.serverLogs
|
||||
.filter(
|
||||
log =>
|
||||
logLevel === SHOW_ALL || log.logLevel === logLevel.code
|
||||
)
|
||||
.map((log, idx) => (
|
||||
<TableRow key={idx} size="sm">
|
||||
<TableCell>
|
||||
{timezone &&
|
||||
formatDate(
|
||||
log.timestamp,
|
||||
timezone,
|
||||
'yyyy-MM-dd HH:mm'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{log.logLevel}</TableCell>
|
||||
<TableCell>{log.message}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{loading && <H4>{'Loading...'}</H4>}
|
||||
{!loading && !data?.serverLogs?.length && (
|
||||
<H4>{'No activity so far'}</H4>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logs
|
||||
20
packages/admin-ui/src/pages/Logs/ServerLogs.module.css
Normal file
20
packages/admin-ui/src/pages/Logs/ServerLogs.module.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.serverTableWrapper {
|
||||
composes: tableWrapper from 'Logs.module.css';
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.serverVersion {
|
||||
color: var(--comet);
|
||||
margin: auto 0 auto 0;
|
||||
}
|
||||
|
||||
.headerLine2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.uptimeContainer {
|
||||
margin: auto 0 auto 0;
|
||||
}
|
||||
33
packages/admin-ui/src/pages/Logs/Uptime.jsx
Normal file
33
packages/admin-ui/src/pages/Logs/Uptime.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import Chip from '@mui/material/Chip'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
|
||||
import { onlyFirstToUpper } from 'src/utils/string.js'
|
||||
import { Label1 } from 'src/components/typography/index.jsx'
|
||||
|
||||
const Uptime = ({ process }) => {
|
||||
const uptime = 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`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-block min-w-26 my-0 mx-5">
|
||||
<Label1 noMargin className="pl-1 color-comet">
|
||||
{R.toLower(process.name)}
|
||||
</Label1>
|
||||
<Chip
|
||||
color={process.state === 'RUNNING' ? 'success' : 'error'}
|
||||
label={
|
||||
process.state === 'RUNNING'
|
||||
? `Running for ${uptime(process.uptime)}`
|
||||
: onlyFirstToUpper(process.state)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Uptime
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { Form, Formik, Field } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import Modal from 'src/components/Modal'
|
||||
import { HelpTooltip } from 'src/components/Tooltip'
|
||||
import { H1, H3, P } from 'src/components/typography'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { NumberInput, Autocomplete } from 'src/components/inputs/formik'
|
||||
|
||||
const initialValues = {
|
||||
customer: '',
|
||||
discount: ''
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
customer: Yup.string().required('A customer is required!'),
|
||||
discount: Yup.number()
|
||||
.required('A discount rate is required!')
|
||||
.min(0, 'Discount rate should be a positive number!')
|
||||
.max(100, 'Discount rate should have a maximum value of 100%!')
|
||||
})
|
||||
|
||||
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
|
||||
if (!formikErrors || !formikTouched) return null
|
||||
if (mutationError) return 'Internal server error'
|
||||
if (formikErrors.customer && formikTouched.customer)
|
||||
return formikErrors.customer
|
||||
if (formikErrors.discount && formikTouched.discount)
|
||||
return formikErrors.discount
|
||||
return null
|
||||
}
|
||||
|
||||
const IndividualDiscountModal = ({
|
||||
showModal,
|
||||
setShowModal,
|
||||
onClose,
|
||||
creationError,
|
||||
addDiscount,
|
||||
customers
|
||||
}) => {
|
||||
const handleAddDiscount = (customer, discount) => {
|
||||
addDiscount({
|
||||
variables: {
|
||||
customerId: customer,
|
||||
discount: parseInt(discount)
|
||||
}
|
||||
})
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showModal && (
|
||||
<Modal
|
||||
title="Add individual customer discount"
|
||||
closeOnBackdropClick={true}
|
||||
width={600}
|
||||
height={500}
|
||||
handleClose={onClose}
|
||||
open={true}>
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={({ customer, discount }) => {
|
||||
handleAddDiscount(customer, discount)
|
||||
}}>
|
||||
{({ errors, touched }) => (
|
||||
<Form
|
||||
id="individual-discount-form"
|
||||
className="flex flex-col h-full gap-5">
|
||||
<div className="mt-2 w-88">
|
||||
<Field
|
||||
name="customer"
|
||||
label="Select a customer"
|
||||
component={Autocomplete}
|
||||
fullWidth
|
||||
options={R.map(it => ({
|
||||
code: it.id,
|
||||
display: `${it?.idCardData?.firstName ?? ``}${
|
||||
it?.idCardData?.firstName && it?.idCardData?.lastName
|
||||
? ` `
|
||||
: ``
|
||||
}${it?.idCardData?.lastName ?? ``} (${it.phone})`
|
||||
}))(customers)}
|
||||
labelProp="display"
|
||||
valueProp="code"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<H3>Define discount rate</H3>
|
||||
<HelpTooltip width={304}>
|
||||
<P>
|
||||
This is a percentage discount off of your existing
|
||||
commission rates for a customer entering this code at
|
||||
the machine.
|
||||
</P>
|
||||
<P>
|
||||
For instance, if you charge 8% commissions, and this
|
||||
code is set for 50%, then you'll instead be charging 4%
|
||||
on transactions using the code.
|
||||
</P>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Field
|
||||
name="discount"
|
||||
size="lg"
|
||||
autoComplete="off"
|
||||
width={50}
|
||||
decimalScale={0}
|
||||
component={NumberInput}
|
||||
/>
|
||||
<H1 className="ml-2 mt-4 font-bold inline">%</H1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex mt-auto mb-6">
|
||||
{getErrorMsg(errors, touched, creationError) && (
|
||||
<ErrorMessage>
|
||||
{getErrorMsg(errors, touched, creationError)}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="individual-discount-form"
|
||||
className="ml-auto">
|
||||
Add discount
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndividualDiscountModal
|
||||
204
packages/admin-ui/src/pages/LoyaltyPanel/IndividualDiscounts.jsx
Normal file
204
packages/admin-ui/src/pages/LoyaltyPanel/IndividualDiscounts.jsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import IconButton from '@mui/material/IconButton'
|
||||
import SvgIcon from '@mui/material/SvgIcon'
|
||||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import { Link, Button } from 'src/components/buttons'
|
||||
import { DeleteDialog } from 'src/components/DeleteDialog'
|
||||
import DataTable from 'src/components/tables/DataTable'
|
||||
import { Label3, TL1 } from 'src/components/typography'
|
||||
import PhoneIdIcon from 'src/styling/icons/ID/phone/zodiac.svg?react'
|
||||
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
|
||||
|
||||
import IndividualDiscountModal from './IndividualDiscountModal'
|
||||
import classnames from 'classnames'
|
||||
|
||||
const GET_INDIVIDUAL_DISCOUNTS = gql`
|
||||
query individualDiscounts {
|
||||
individualDiscounts {
|
||||
id
|
||||
customer {
|
||||
id
|
||||
phone
|
||||
idCardData
|
||||
}
|
||||
discount
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DELETE_DISCOUNT = gql`
|
||||
mutation deleteIndividualDiscount($discountId: ID!) {
|
||||
deleteIndividualDiscount(discountId: $discountId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CREATE_DISCOUNT = gql`
|
||||
mutation createIndividualDiscount($customerId: ID!, $discount: Int!) {
|
||||
createIndividualDiscount(customerId: $customerId, discount: $discount) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_CUSTOMERS = gql`
|
||||
{
|
||||
customers {
|
||||
id
|
||||
phone
|
||||
idCardData
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const IndividualDiscounts = () => {
|
||||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
const [toBeDeleted, setToBeDeleted] = useState()
|
||||
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const toggleModal = () => setShowModal(!showModal)
|
||||
|
||||
const { data: discountResponse, loading } = useQuery(GET_INDIVIDUAL_DISCOUNTS)
|
||||
const { data: customerData, loading: customerLoading } =
|
||||
useQuery(GET_CUSTOMERS)
|
||||
|
||||
const [createDiscount, { error: creationError }] = useMutation(
|
||||
CREATE_DISCOUNT,
|
||||
{
|
||||
refetchQueries: () => ['individualDiscounts']
|
||||
}
|
||||
)
|
||||
|
||||
const [deleteDiscount] = useMutation(DELETE_DISCOUNT, {
|
||||
onError: ({ message }) => {
|
||||
const errorMessage = message ?? 'Error while deleting row'
|
||||
setErrorMsg(errorMessage)
|
||||
},
|
||||
onCompleted: () => setDeleteDialog(false),
|
||||
refetchQueries: () => ['individualDiscounts']
|
||||
})
|
||||
|
||||
const elements = [
|
||||
{
|
||||
header: 'Identification',
|
||||
width: 312,
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
view: t => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<PhoneIdIcon />
|
||||
<span>{t.customer.phone}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
width: 300,
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
view: t => {
|
||||
const customer = t.customer
|
||||
if (R.isNil(customer.idCardData)) {
|
||||
return <>{'-'}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<>{`${customer.idCardData.firstName ?? ``}${
|
||||
customer.idCardData.firstName && customer.idCardData.lastName
|
||||
? ` `
|
||||
: ``
|
||||
}${customer.idCardData.lastName ?? ``}`}</>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Discount rate',
|
||||
width: 220,
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
view: t => (
|
||||
<>
|
||||
<TL1 inline>{t.discount}</TL1> %
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: 'Revoke',
|
||||
width: 100,
|
||||
textAlign: 'center',
|
||||
size: 'sm',
|
||||
view: t => (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setDeleteDialog(true)
|
||||
setToBeDeleted({ variables: { discountId: t.id } })
|
||||
}}>
|
||||
<SvgIcon>
|
||||
<DeleteIcon />
|
||||
</SvgIcon>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loading && !R.isEmpty(discountResponse.individualDiscounts) && (
|
||||
<>
|
||||
<div className="flex justify-end mb-8 -mt-14">
|
||||
<Link
|
||||
color="primary"
|
||||
onClick={toggleModal}
|
||||
className={classnames({ 'cursor-wait': customerLoading })}
|
||||
disabled={customerLoading}>
|
||||
Add new code
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
elements={elements}
|
||||
data={R.path(['individualDiscounts'])(discountResponse)}
|
||||
/>
|
||||
<DeleteDialog
|
||||
open={deleteDialog}
|
||||
onDismissed={() => {
|
||||
setDeleteDialog(false)
|
||||
setErrorMsg(null)
|
||||
}}
|
||||
onConfirmed={() => {
|
||||
setErrorMsg(null)
|
||||
deleteDiscount(toBeDeleted)
|
||||
}}
|
||||
errorMessage={errorMsg}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!loading && R.isEmpty(discountResponse.individualDiscounts) && (
|
||||
<div className="flex items-start flex-col">
|
||||
<Label3>
|
||||
It seems there are no active individual customer discounts on your
|
||||
network.
|
||||
</Label3>
|
||||
<Button onClick={toggleModal}>Add individual discount</Button>
|
||||
</div>
|
||||
)}
|
||||
<IndividualDiscountModal
|
||||
showModal={showModal}
|
||||
setShowModal={setShowModal}
|
||||
onClose={() => {
|
||||
setShowModal(false)
|
||||
}}
|
||||
creationError={creationError}
|
||||
addDiscount={createDiscount}
|
||||
customers={R.path(['customers'])(customerData)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndividualDiscounts
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue