diff --git a/new-lamassu-admin/src/components/Stage.js b/new-lamassu-admin/src/components/Stage.js
new file mode 100644
index 00000000..916daf4e
--- /dev/null
+++ b/new-lamassu-admin/src/components/Stage.js
@@ -0,0 +1,112 @@
+import { makeStyles } from '@material-ui/core'
+import classnames from 'classnames'
+import * as R from 'ramda'
+import React, { memo } from 'react'
+
+import { ReactComponent as CompleteStageIconSpring } from 'src/styling/icons/stage/spring/complete.svg'
+import { ReactComponent as CurrentStageIconSpring } from 'src/styling/icons/stage/spring/current.svg'
+import { ReactComponent as EmptyStageIconSpring } from 'src/styling/icons/stage/spring/empty.svg'
+import { ReactComponent as CompleteStageIconZodiac } from 'src/styling/icons/stage/zodiac/complete.svg'
+import { ReactComponent as CurrentStageIconZodiac } from 'src/styling/icons/stage/zodiac/current.svg'
+import { ReactComponent as EmptyStageIconZodiac } from 'src/styling/icons/stage/zodiac/empty.svg'
+import {
+ primaryColor,
+ secondaryColor,
+ offColor,
+ disabledColor
+} from 'src/styling/variables'
+
+const styles = {
+ stages: {
+ display: 'flex',
+ alignItems: 'center'
+ },
+ wrapper: {
+ display: 'flex',
+ alignItems: 'center',
+ margin: 0
+ },
+ stage: {
+ display: 'flex',
+ height: 28,
+ width: 28,
+ zIndex: 2,
+ '& > svg': {
+ height: '100%',
+ width: '100%',
+ overflow: 'visible'
+ }
+ },
+ separator: {
+ width: 28,
+ height: 2,
+ border: [[2, 'solid']],
+ zIndex: 1
+ },
+ separatorSpring: {
+ borderColor: secondaryColor
+ },
+ separatorZodiac: {
+ borderColor: primaryColor
+ },
+ separatorSpringEmpty: {
+ borderColor: disabledColor
+ },
+ separatorZodiacEmpty: {
+ borderColor: offColor
+ }
+}
+
+const useStyles = makeStyles(styles)
+
+const Stage = memo(({ stages, currentStage, color = 'spring', className }) => {
+ if (currentStage < 1 || currentStage > stages)
+ throw Error('Value of currentStage is invalid')
+ if (stages < 1) throw Error('Value of stages is invalid')
+
+ const classes = useStyles()
+
+ const separatorClasses = {
+ [classes.separator]: true,
+ [classes.separatorSpring]: color === 'spring',
+ [classes.separatorZodiac]: color === 'zodiac'
+ }
+
+ const separatorEmptyClasses = {
+ [classes.separator]: true,
+ [classes.separatorSpringEmpty]: color === 'spring',
+ [classes.separatorZodiacEmpty]: color === 'zodiac'
+ }
+
+ return (
+
+ {R.range(1, currentStage).map(idx => (
+
+ {idx > 1 &&
}
+
+ {color === 'spring' && }
+ {color === 'zodiac' && }
+
+
+ ))}
+
+ {currentStage > 1 &&
}
+
+ {color === 'spring' && }
+ {color === 'zodiac' && }
+
+
+ {R.range(currentStage + 1, stages + 1).map(idx => (
+
+
+
+ {color === 'spring' && }
+ {color === 'zodiac' && }
+
+
+ ))}
+
+ )
+})
+
+export default Stage
diff --git a/new-lamassu-admin/src/pages/Cashout/Cashout.js b/new-lamassu-admin/src/pages/Cashout/Cashout.js
new file mode 100644
index 00000000..5a82d083
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Cashout/Cashout.js
@@ -0,0 +1,91 @@
+import { useQuery, useMutation } from '@apollo/react-hooks'
+import { gql } from 'apollo-boost'
+import * as R from 'ramda'
+import React, { useState } from 'react'
+
+import { NamespacedTable as EditableTable } from 'src/components/editableTable'
+import TitleSection from 'src/components/layout/TitleSection'
+import { fromNamespace, toNamespace } from 'src/utils/config'
+
+import Wizard from './Wizard'
+import { DenominationsSchema, getElements } from './helper'
+
+const SAVE_CONFIG = gql`
+ mutation Save($config: JSONObject, $accounts: [JSONObject]) {
+ saveConfig(config: $config)
+ saveAccounts(accounts: $accounts)
+ }
+`
+
+const GET_INFO = gql`
+ query getData {
+ machines {
+ name
+ deviceId
+ cashbox
+ cassette1
+ cassette2
+ }
+ config
+ }
+`
+
+const CashOut = ({ name: SCREEN_KEY }) => {
+ const [wizard, setWizard] = useState(false)
+ const [error, setError] = useState(false)
+ const { data } = useQuery(GET_INFO)
+
+ const [saveConfig] = useMutation(SAVE_CONFIG, {
+ onCompleted: () => setWizard(false),
+ onError: () => setError(true),
+ refetchQueries: () => ['getData']
+ })
+
+ const save = (rawConfig, accounts) => {
+ const config = toNamespace(SCREEN_KEY)(rawConfig)
+ setError(false)
+
+ return saveConfig({ variables: { config, accounts } })
+ }
+
+ const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
+ 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 }))
+ }
+
+ return (
+ <>
+
+ !DenominationsSchema.isValidSync(it)}
+ enableEdit
+ editWidth={134}
+ enableToggle
+ toggleWidth={109}
+ onToggle={onToggle}
+ save={save}
+ validationSchema={DenominationsSchema}
+ disableRowEdit={R.compose(R.not, R.path(['active']))}
+ elements={getElements(machines, locale)}
+ />
+ {wizard && (
+ setWizard(false)}
+ save={save}
+ error={error}
+ />
+ )}
+ >
+ )
+}
+
+export default CashOut
diff --git a/new-lamassu-admin/src/pages/Cashout/Wizard.js b/new-lamassu-admin/src/pages/Cashout/Wizard.js
new file mode 100644
index 00000000..a49576b3
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Cashout/Wizard.js
@@ -0,0 +1,71 @@
+import * as R from 'ramda'
+import React, { useState } from 'react'
+
+import Modal from 'src/components/Modal'
+import { toNamespace } from 'src/utils/config'
+
+import WizardSplash from './WizardSplash'
+import WizardStep from './WizardStep'
+
+const LAST_STEP = 3
+const MODAL_WIDTH = 554
+
+const Wizard = ({ machine, onClose, save, error }) => {
+ const [{ step, config }, setState] = useState({
+ step: 0,
+ config: { active: true }
+ })
+
+ const title = `Enable cash-out`
+ const isLastStep = step === LAST_STEP
+
+ const onContinue = async it => {
+ const newConfig = R.merge(config, it)
+
+ if (isLastStep) {
+ return save(toNamespace(machine.deviceId, newConfig))
+ }
+
+ setState({
+ step: step + 1,
+ config: newConfig
+ })
+ }
+
+ const getStepData = () => {
+ switch (step) {
+ case 1:
+ return { type: 'top', display: 'Cassete 1 (Top)' }
+ case 2:
+ return { type: 'bottom', display: 'Cassete 2' }
+ case 3:
+ return { type: 'agreed' }
+ default:
+ return null
+ }
+ }
+
+ return (
+
+ {step === 0 && (
+ onContinue()} />
+ )}
+ {step !== 0 && (
+
+ )}
+
+ )
+}
+
+export default Wizard
diff --git a/new-lamassu-admin/src/pages/Cashout/WizardSplash.js b/new-lamassu-admin/src/pages/Cashout/WizardSplash.js
new file mode 100644
index 00000000..1a410bab
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Cashout/WizardSplash.js
@@ -0,0 +1,53 @@
+import { makeStyles } from '@material-ui/core'
+import React from 'react'
+
+import { Button } from 'src/components/buttons'
+import { H1, P } from 'src/components/typography'
+
+const styles = {
+ logo: {
+ maxHeight: 80,
+ maxWidth: 200
+ },
+ title: {
+ margin: [[24, 0, 32, 0]]
+ },
+ text: {
+ margin: 0
+ },
+ button: {
+ marginTop: 'auto',
+ marginBottom: 58
+ },
+ modalContent: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: [[0, 42]],
+ flex: 1
+ }
+}
+
+const useStyles = makeStyles(styles)
+
+const WizardSplash = ({ name, onContinue }) => {
+ const classes = useStyles()
+
+ return (
+
+
Enable cash-out
+
+ You are about to activate cash-out functionality on your {name} machine
+ which will allow your customers to sell crypto to you.
+
+ In order to activate cash-out for this machine, please enter the
+ denominations for the machine.
+
+
+ Start configuration
+
+
+ )
+}
+
+export default WizardSplash
diff --git a/new-lamassu-admin/src/pages/Cashout/WizardStep.js b/new-lamassu-admin/src/pages/Cashout/WizardStep.js
new file mode 100644
index 00000000..e2fe99fa
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Cashout/WizardStep.js
@@ -0,0 +1,130 @@
+import { makeStyles } from '@material-ui/core'
+import classnames from 'classnames'
+import * as R from 'ramda'
+import React, { useReducer, useEffect } from 'react'
+
+import ErrorMessage from 'src/components/ErrorMessage'
+import Stepper from 'src/components/Stepper'
+import { Button } from 'src/components/buttons'
+import { TextInput } from 'src/components/inputs'
+import { Info2, H4, P } from 'src/components/typography'
+
+import styles from './WizardStep.styles'
+const useStyles = makeStyles(styles)
+
+const initialState = {
+ selected: null,
+ iError: false
+}
+
+const reducer = (state, action) => {
+ switch (action.type) {
+ case 'select':
+ return {
+ form: null,
+ selected: action.selected,
+ isNew: null,
+ iError: false
+ }
+ case 'form':
+ return {
+ form: action.form,
+ selected: action.form.code,
+ isNew: true,
+ iError: false
+ }
+ case 'error':
+ return R.merge(state, { iError: true })
+ case 'reset':
+ return initialState
+ default:
+ throw new Error()
+ }
+}
+
+const WizardStep = ({
+ type,
+ name,
+ step,
+ error,
+ lastStep,
+ onContinue,
+ display
+}) => {
+ const classes = useStyles()
+ const [{ iError, selected }, dispatch] = useReducer(reducer, initialState)
+
+ useEffect(() => {
+ dispatch({ type: 'reset' })
+ }, [step])
+
+ const iContinue = config => {
+ if (lastStep) config[type] = true
+
+ if (!config || !config[type]) {
+ return dispatch({ type: 'error' })
+ }
+
+ onContinue(config)
+ }
+
+ const label = lastStep ? 'Finish' : 'Next'
+ const subtitleClass = {
+ [classes.subtitle]: true,
+ [classes.error]: iError
+ }
+
+ return (
+ <>
+ {name}
+
+ {display && Edit {display} }
+
+ {!lastStep && (
+
+ dispatch({ type: 'select', selected: evt.target.value })
+ }
+ autoFocus
+ id="confirm-input"
+ type="text"
+ size="lg"
+ touched={{}}
+ error={false}
+ InputLabelProps={{ shrink: true }}
+ />
+ // TODO: there was a disabled link here showing the currency code; restore it
+ )}
+
+ {lastStep && (
+ <>
+
+ When enabling cash out, your bill count will be authomatically set
+ to zero. Make sure you physically put cash inside the cashboxes to
+ allow the machine to dispense it to your users. If you already did,
+ make sure you set the correct cash out bill count for this machine
+ on your Cashboxes tab under Maintenance.
+
+
+ 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.
+
+ >
+ )}
+
+
+ {error && Failed to save }
+ iContinue({ [type]: selected })}>
+ {label}
+
+
+ >
+ )
+}
+
+export default WizardStep
diff --git a/new-lamassu-admin/src/pages/Cashout/WizardStep.styles.js b/new-lamassu-admin/src/pages/Cashout/WizardStep.styles.js
new file mode 100644
index 00000000..d143ab4f
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Cashout/WizardStep.styles.js
@@ -0,0 +1,26 @@
+import { errorColor } from 'src/styling/variables'
+
+const LABEL_WIDTH = 150
+
+export default {
+ title: {
+ margin: [[0, 0, 12, 0]]
+ },
+ subtitle: {
+ margin: [[32, 0, 21, 0]]
+ },
+ error: {
+ color: errorColor
+ },
+ button: {
+ marginLeft: 'auto'
+ },
+ submit: {
+ display: 'flex',
+ flexDirection: 'row',
+ margin: [['auto', 0, 24]]
+ },
+ picker: {
+ width: LABEL_WIDTH
+ }
+}
diff --git a/new-lamassu-admin/src/pages/Cashout/helper.js b/new-lamassu-admin/src/pages/Cashout/helper.js
new file mode 100644
index 00000000..466da650
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Cashout/helper.js
@@ -0,0 +1,41 @@
+import * as Yup from 'yup'
+
+import TextInput from 'src/components/inputs/formik/TextInput'
+
+const DenominationsSchema = Yup.object().shape({
+ top: Yup.number().required('Required'),
+ bottom: Yup.number().required('Required')
+})
+
+const getElements = (machines, { fiatCurrency } = {}) => {
+ return [
+ {
+ name: 'id',
+ header: 'Machine',
+ width: 254,
+ view: it => machines.find(({ deviceId }) => deviceId === it).name,
+ size: 'sm',
+ editable: false
+ },
+ {
+ name: 'top',
+ header: 'Cassette 1 (Top)',
+ view: it => `${it} ${fiatCurrency}`,
+ size: 'sm',
+ stripe: true,
+ width: 265,
+ input: TextInput
+ },
+ {
+ name: 'bottom',
+ header: 'Cassette 2',
+ view: it => `${it} ${fiatCurrency}`,
+ size: 'sm',
+ stripe: true,
+ width: 265,
+ input: TextInput
+ }
+ ]
+}
+
+export { DenominationsSchema, getElements }
diff --git a/new-lamassu-admin/src/pages/common.styles.js b/new-lamassu-admin/src/pages/common.styles.js
new file mode 100644
index 00000000..db9958b5
--- /dev/null
+++ b/new-lamassu-admin/src/pages/common.styles.js
@@ -0,0 +1,17 @@
+export default {
+ titleWrapper: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ flexDirection: 'row'
+ },
+ titleAndButtonsContainer: {
+ display: 'flex'
+ },
+ iconButton: {
+ border: 'none',
+ outline: 0,
+ backgroundColor: 'transparent',
+ cursor: 'pointer'
+ }
+}
diff --git a/new-lamassu-admin/src/routing/routes.js b/new-lamassu-admin/src/routing/routes.js
index 28ce1b60..7266898e 100644
--- a/new-lamassu-admin/src/routing/routes.js
+++ b/new-lamassu-admin/src/routing/routes.js
@@ -3,6 +3,7 @@ import React from 'react'
import { Route, Redirect, Switch } from 'react-router-dom'
import AuthRegister from 'src/pages/AuthRegister'
+import Cashout from 'src/pages/Cashout/Cashout'
import Commissions from 'src/pages/Commissions'
import { Customers, CustomerProfile } from 'src/pages/Customers'
import Funding from 'src/pages/Funding'
@@ -87,6 +88,12 @@ const tree = [
route: '/settings/locale',
component: Locales
},
+ {
+ key: namespaces.CASH_OUT,
+ label: 'Cash-out',
+ route: '/settings/cash-out',
+ component: Cashout
+ },
{
key: namespaces.SERVICES,
label: '3rd party services',
diff --git a/new-lamassu-admin/src/utils/config.js b/new-lamassu-admin/src/utils/config.js
index c6abad75..f4f3c810 100644
--- a/new-lamassu-admin/src/utils/config.js
+++ b/new-lamassu-admin/src/utils/config.js
@@ -1,6 +1,7 @@
import * as R from 'ramda'
const namespaces = {
+ CASH_OUT: 'denominations',
WALLETS: 'wallets',
OPERATOR_INFO: 'operatorInfo',
NOTIFICATIONS: 'notifications',