feat: use Namespaced table and wizard on Cashout

This commit is contained in:
Mauricio Navarro Miranda 2020-05-05 23:10:00 -05:00
parent af95a366c6
commit 1cf4168294
9 changed files with 337 additions and 559 deletions

View file

@ -1,23 +1,24 @@
import { useQuery, useMutation } from '@apollo/react-hooks' import { useQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core'
import { gql } from 'apollo-boost' import { gql } from 'apollo-boost'
import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import Modal from 'src/components/Modal' import { NamespacedTable as EditableTable } from 'src/components/editableTable'
import Title from 'src/components/Title' import TitleSection from 'src/components/layout/TitleSection'
import { Switch } from 'src/components/inputs' import { fromNamespace, toNamespace } from 'src/utils/config'
import { P, Info2 } from 'src/components/typography'
import { mainStyles } from 'src/pages/Transactions/Transactions.styles'
import commonStyles from 'src/pages/common.styles'
import { ReactComponent as HelpIcon } from 'src/styling/icons/action/help/zodiac.svg'
import { spacer, zircon } from 'src/styling/variables'
import Table from './CashoutTable'
import Wizard from './Wizard' import Wizard from './Wizard'
import WizardSplash from './WizardSplash' import { DenominationsSchema, getElements } from './helper'
const GET_MACHINES_AND_CONFIG = gql` const SAVE_CONFIG = gql`
{ mutation Save($config: JSONObject, $accounts: [JSONObject]) {
saveConfig(config: $config)
saveAccounts(accounts: $accounts)
}
`
const GET_INFO = gql`
query getData {
machines { machines {
name name
deviceId deviceId
@ -29,218 +30,62 @@ const GET_MACHINES_AND_CONFIG = gql`
} }
` `
const SAVE_CONFIG = gql` const CashOut = ({ name: SCREEN_KEY }) => {
mutation Save($config: JSONObject) { const [wizard, setWizard] = useState(false)
saveConfig(config: $config) const [error, setError] = useState(false)
} const { data } = useQuery(GET_INFO)
`
const useStyles = makeStyles({ const [saveConfig] = useMutation(SAVE_CONFIG, {
...mainStyles, onCompleted: () => setWizard(false),
commonStyles, onError: () => setError(true),
help: { refetchQueries: () => ['getData']
width: 20,
height: 20,
marginLeft: spacer * 2
},
disabledDrawing: {
position: 'relative',
display: 'flex',
alignItems: 'center',
'& > div': {
position: 'absolute',
backgroundColor: zircon,
height: 36,
width: 678
}
},
modal: {
width: 544
},
switchErrorMessage: {
margin: [['auto', 0, 'auto', 20]]
}
})
const Cashboxes = () => {
const [machines, setMachines] = useState([])
const [config, setConfig] = useState({})
const [modalContent, setModalContent] = useState(null)
const [modalOpen, setModalOpen] = useState(false)
const classes = useStyles()
const { refetch } = useQuery(GET_MACHINES_AND_CONFIG, {
onCompleted: ({ machines, config }) => {
setMachines(
machines.map(m => ({
...m,
currency: config.fiatCurrency ?? { code: 'N/D' },
cashOutDenominations: (config.cashOutDenominations ?? {})[m.deviceId]
}))
)
setConfig(config)
}
}) })
const [saveConfig] = useMutation(SAVE_CONFIG) const save = (rawConfig, accounts) => {
const config = toNamespace(SCREEN_KEY)(rawConfig)
setError(false)
const saveCashoutConfig = machine => return saveConfig({ variables: { config, accounts } })
saveConfig({
variables: {
config: {
...config,
cashOutDenominations: {
...config.cashOutDenominations,
[machine.deviceId]: machine.cashOutDenominations
}
}
}
})
const handleEnable = machine => event => {
setModalContent(
<WizardSplash
handleModalNavigation={handleModalNavigation(machine)}
machine={machine}
/>
)
setModalOpen(true)
} }
const handleEditClick = row => { const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
setModalOpen(true) const locale = data?.config && fromNamespace('locale')(data.config)
handleModalNavigation(row)(1) 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 handleModalClose = () => {
setModalOpen(false)
setModalContent(null)
}
const handleModalNavigation = machine => currentPage => {
switch (currentPage) {
case 1:
setModalContent(
<Wizard
handleModalNavigation={handleModalNavigation}
pageName="Edit Cassette 1 (Top)"
machine={machine}
currentStage={1}
/>
)
break
case 2:
setModalContent(
<Wizard
machine={machine}
handleModalNavigation={handleModalNavigation}
pageName="Edit Cassette 2"
currentStage={2}
/>
)
break
case 3:
setModalContent(
<Wizard
machine={machine}
handleModalNavigation={handleModalNavigation}
pageName="Cashout Bill Count"
currentStage={3}
/>
)
break
case 4:
// save
return saveCashoutConfig(machine)
.then(refetch)
.then(() => {
setModalOpen(false)
setModalContent(null)
})
default:
break
}
return new Promise(() => {})
}
const elements = [
{
header: 'Machine',
size: 254,
textAlign: 'left',
view: m => m.name
},
{
header: 'Cassette 1 (Top)',
size: 265,
textAlign: 'left',
view: ({ cashOutDenominations, currency }) => (
<>
{cashOutDenominations && cashOutDenominations.top && (
<Info2>
{cashOutDenominations.top} {currency.code}
</Info2>
)}
</>
)
},
{
header: 'Cassette 2',
size: 265,
textAlign: 'left',
view: ({ cashOutDenominations, currency }) => (
<>
{cashOutDenominations && cashOutDenominations.bottom && (
<Info2>
{cashOutDenominations.bottom} {currency.code}
</Info2>
)}
</>
)
},
{
header: 'Edit',
size: 265,
textAlign: 'left'
},
{
header: 'Enable',
size: 151,
textAlign: 'right'
}
]
return ( return (
<> <>
<div className={classes.titleWrapper}> <TitleSection title="Cash-out" error={error} />
<div className={classes.titleAndButtonsContainer}> <EditableTable
<Title>Cash-out</Title> name="test"
</div> namespaces={R.map(R.path(['deviceId']))(machines)}
<div> data={config}
<P> stripeWhen={it => !DenominationsSchema.isValidSync(it)}
Transaction fudge factor <Switch checked={true} /> On{' '} enableEdit
<HelpIcon className={classes.help} /> editWidth={134}
</P> enableToggle
</div> toggleWidth={109}
</div> onToggle={onToggle}
<Table save={save}
elements={elements} validationSchema={DenominationsSchema}
data={machines} disableRowEdit={R.compose(R.not, R.path(['active']))}
handleEnable={handleEnable} elements={getElements(machines, locale)}
handleEditClick={handleEditClick}
/> />
<Modal {wizard && (
aria-labelledby="simple-modal-title" <Wizard
aria-describedby="simple-modal-description" machine={R.find(R.propEq('deviceId', wizard))(machines)}
open={modalOpen} onClose={() => setWizard(false)}
handleClose={handleModalClose} save={save}
className={classes.modal}> error={error}
{modalContent} />
</Modal> )}
</> </>
) )
} }
export default Cashboxes export default CashOut

View file

@ -1,132 +0,0 @@
import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames'
import React, { useState } from 'react'
import {
Th,
Tr,
Td,
THead,
TBody,
Table
} from 'src/components/fake-table/Table'
import { Switch } from 'src/components/inputs'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { spacer } from 'src/styling/variables'
const styles = {
expandButton: {
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
padding: 4
},
row: {
borderRadius: 0
},
link: {
marginLeft: spacer
}
}
const useStyles = makeStyles(styles)
const Row = ({
id,
elements,
data,
active,
rowAction,
onSave,
handleEnable,
...props
}) => {
const classes = useStyles()
return (
<Tr
className={classnames(classes.row)}
error={data.error}
errorMessage={data.errorMessage}>
{elements
.slice(0, -2)
.map(
(
{ size, className, textAlign, view = it => it?.toString() },
idx
) => (
<Td
key={idx}
size={size}
className={className}
textAlign={textAlign}>
{view({ ...data, editing: active })}
</Td>
)
)}
<Td
size={elements[elements.length - 2].size}
textAlign={elements[elements.length - 2].textAlign}>
{data.cashOutDenominations && (
<button
onClick={() => rowAction(data)}
className={classes.expandButton}>
<EditIcon />
</button>
)}
</Td>
<Td
size={elements[elements.length - 1].size}
textAlign={elements[elements.length - 1].textAlign}>
<Switch
checked={data.cashOutDenominations}
onChange={handleEnable(data)}
/>
</Td>
</Tr>
)
}
/* rows = [{ columns = [{ name, value, className, textAlign, size }], details, className, error, errorMessage }]
* Don't forget to include the size of the last (expand button) column!
*/
const CashOutTable = ({
elements = [],
data = [],
Details,
className,
onSave,
handleEditClick,
handleEnable,
...props
}) => {
const [active] = useState(null)
return (
<Table>
<THead>
{elements.map(({ size, className, textAlign, header }, idx) => (
<Th key={idx} size={size} className={className} textAlign={textAlign}>
{header}
</Th>
))}
</THead>
<TBody>
{data.map((it, idx) => (
<Row
id={idx}
elements={elements}
data={it}
active={idx === active}
rowAction={handleEditClick}
onSave={onSave}
handleEnable={handleEnable}
/>
))}
</TBody>
</Table>
)
}
export default CashOutTable

View file

@ -1,191 +1,70 @@
import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import { makeStyles } from '@material-ui/core'
import { H1, Info2, H4, P } from 'src/components/typography' import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons' import { toNamespace } from 'src/utils/config'
import Stage from 'src/components/Stage'
import { TextInput } from 'src/components/inputs'
import ErrorMessage from 'src/components/ErrorMessage'
import { spacer } from 'src/styling/variables'
const styles = { import WizardSplash from './WizardSplash'
modalContent: { import WizardStep from './WizardStep'
display: 'flex',
flexDirection: 'column', const LAST_STEP = 3
padding: [[24, 32, 0]], const MODAL_WIDTH = 554
'& > h1': {
margin: [[0, 0, 10]] const Wizard = ({ machine, onClose, save, error }) => {
}, const [{ step, config }, setState] = useState({
'& > h4': { step: 0,
margin: [[32, 0, 32 - 9, 0]] config: { active: true }
}, })
'& > p': {
margin: 0 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))
} }
},
submitButtonWrapper: {
display: 'flex',
alignSelf: 'flex-end',
margin: [['auto', 0, 0]]
},
submitButton: {
width: 67,
padding: [[0, 0]],
margin: [['auto', 0, 24, 20]],
'&:active': {
margin: [['auto', 0, 24, 20]]
}
},
stages: {
marginTop: 10
},
texInput: {
width: spacer * 6,
marginRight: spacer * 2
}
}
const useStyles = makeStyles(styles) setState({
step: step + 1,
const SubmitButton = ({ error, label, ...props }) => { config: newConfig
const classes = useStyles() })
return (
<div className={classes.submitButtonWrapper}>
{error && <ErrorMessage>Failed to save</ErrorMessage>}
<Button {...props}>{label}</Button>
</div>
)
}
const Wizard = ({ pageName, currentStage, handleModalNavigation, machine }) => {
const [topOverride, setTopOverride] = useState(
machine?.cashOutDenominations?.top
)
const [bottomOverride, setBottomOverride] = useState(
machine?.cashOutDenominations?.bottom
)
const overrideTop = event => {
setTopOverride(Number(event.target.value))
} }
const overrideBottom = event => { const getStepData = () => {
setBottomOverride(Number(event.target.value)) switch (step) {
}
const [error, setError] = useState(null)
const classes = useStyles()
const handleNext = machine => event => {
const cashOutDenominations = { top: topOverride, bottom: bottomOverride }
const nav = handleModalNavigation({ ...machine, cashOutDenominations })(
currentStage + 1
)
nav.catch(error => setError(error))
}
const isSubmittable = currentStage => {
switch (currentStage) {
case 1: case 1:
return topOverride > 0 return { type: 'top', display: 'Cassete 1 (Top)' }
case 2: case 2:
return bottomOverride > 0 return { type: 'bottom', display: 'Cassete 2' }
case 3:
return { type: 'agreed' }
default: default:
return isSubmittable(1) && isSubmittable(2) return null
} }
} }
return ( return (
<div className={classes.modalContent}> <Modal
<H1>Enable cash-out</H1> title={step === 0 ? null : title}
<Info2>{machine.name}</Info2> handleClose={onClose}
<Stage width={MODAL_WIDTH}
stages={3} open={true}>
currentStage={currentStage} {step === 0 && (
color="spring" <WizardSplash name={machine.name} onContinue={() => onContinue()} />
className={classes.stages}
/>
{currentStage < 3 && (
<>
<H4>{pageName}</H4>
<P>Choose bill denomination</P>
</>
)} )}
<div> {step !== 0 && (
{currentStage < 3 && ( <WizardStep
<> step={step}
{currentStage === 1 && ( name={machine.name}
<TextInput error={error}
autoFocus lastStep={isLastStep}
id="confirm-input" {...getStepData()}
type="text" onContinue={onContinue}
large />
value={topOverride} )}
touched={{}} </Modal>
error={false}
InputLabelProps={{ shrink: true }}
onChange={overrideTop}
className={classes.texInput}
/>
)}
{currentStage === 2 && (
<TextInput
autoFocus
id="confirm-input"
type="text"
large
value={bottomOverride}
touched={{}}
error={false}
InputLabelProps={{ shrink: true }}
onChange={overrideBottom}
className={classes.texInput}
/>
)}
<TextInput
disabled
autoFocus
id="confirm-input"
type="text"
large
value={machine.currency.code}
touched={{}}
InputLabelProps={{ shrink: true }}
className={classes.texInput}
/>
</>
)}
{currentStage === 3 && (
<>
<H4>{pageName}</H4>
<P>
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.
</P>
<H4>{pageName}</H4>
<P>
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>
<SubmitButton
className={classes.submitButton}
label={currentStage === 3 ? 'Finish' : 'Next'}
disabled={!isSubmittable(currentStage)}
onClick={handleNext(machine)}
error={error}
/>
</div>
) )
} }

View file

@ -1,60 +1,49 @@
import React from 'react'
import { makeStyles } from '@material-ui/core' import { makeStyles } from '@material-ui/core'
import React from 'react'
import { H1, P } from 'src/components/typography'
import { Button } from 'src/components/buttons' import { Button } from 'src/components/buttons'
import { neon, spacer } from 'src/styling/variables' import { H1, P } from 'src/components/typography'
const styles = { const styles = {
logoWrapper: { logo: {
display: 'flex', maxHeight: 80,
justifyContent: 'center', maxWidth: 200
alignItems: 'center', },
height: 80, title: {
margin: [[40, 0, 24]], margin: [[24, 0, 32, 0]]
'& > svg': { },
maxHeight: '100%', text: {
width: '100%' margin: 0
} },
button: {
marginTop: 'auto',
marginBottom: 58
}, },
modalContent: { modalContent: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
padding: [[0, 66]], padding: [[0, 42]],
'& > h1': { flex: 1
color: neon,
margin: [[spacer * 8, 0, 32]]
},
'& > p': {
margin: 0
},
'& > button': {
margin: [['auto', 0, 56]],
'&:active': {
margin: [['auto', 0, 56]]
}
}
} }
} }
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const WizardSplash = ({ handleModalNavigation, machine }) => { const WizardSplash = ({ name, onContinue }) => {
const classes = useStyles() const classes = useStyles()
return ( return (
<div className={classes.modalContent}> <div className={classes.modalContent}>
<H1>Enable cash-out</H1> <H1 className={classes.title}>Enable cash-out</H1>
<P> <P className={classes.text}>
You are about to activate cash-out functionality on your {machine.name}{' '} You are about to activate cash-out functionality on your {name} machine
machine which will allow your customers to sell crypto to you. which will allow your customers to sell crypto to you.
<br />
<br /> <br />
In order to activate cash-out for this machine, please enter the In order to activate cash-out for this machine, please enter the
denominations for the machine. denominations for the machine.
</P> </P>
<Button onClick={() => handleModalNavigation(1)}> <Button className={classes.button} onClick={onContinue}>
Start configuration Start configuration
</Button> </Button>
</div> </div>

View file

@ -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 (
<>
<Info2 className={classes.title}>{name}</Info2>
<Stepper steps={3} currentStep={step} />
{display && <H4 className={classnames(subtitleClass)}>Edit {display}</H4>}
{!lastStep && (
<TextInput
label={'Choose bill denomination'}
onChange={evt =>
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 && (
<>
<P>
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.
</P>
<P>
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 className={classes.submit}>
{error && <ErrorMessage>Failed to save</ErrorMessage>}
<Button
className={classes.button}
onClick={() => iContinue({ [type]: selected })}>
{label}
</Button>
</div>
</>
)
}
export default WizardStep

View file

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

View file

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

View file

@ -68,12 +68,6 @@ const tree = [
return () => <Redirect to={this.children[0].route} /> return () => <Redirect to={this.children[0].route} />
}, },
children: [ children: [
{
key: namespaces.CASH_OUT,
label: 'Cash-out',
route: '/settings/cash-out',
component: Cashout
},
{ {
key: namespaces.COMMISSIONS, key: namespaces.COMMISSIONS,
label: 'Commissions', label: 'Commissions',
@ -86,6 +80,12 @@ const tree = [
route: '/settings/locale', route: '/settings/locale',
component: Locales component: Locales
}, },
{
key: namespaces.CASH_OUT,
label: 'Cash-out',
route: '/settings/cash-out',
component: Cashout
},
{ {
key: namespaces.SERVICES, key: namespaces.SERVICES,
label: '3rd party services', label: '3rd party services',

View file

@ -1,7 +1,7 @@
import * as R from 'ramda' import * as R from 'ramda'
const namespaces = { const namespaces = {
CASH_OUT: 'cash-out', CASH_OUT: 'denominations',
WALLETS: 'wallets', WALLETS: 'wallets',
OPERATOR_INFO: 'operatorInfo', OPERATOR_INFO: 'operatorInfo',
NOTIFICATIONS: 'notifications', NOTIFICATIONS: 'notifications',