Merge pull request #373 from mautematico/feat-add-cashboxes-screen

feat: add cashboxes screen
This commit is contained in:
Rafael Taranto 2020-05-09 19:57:41 +01:00 committed by GitHub
commit d7ff61d83f
11 changed files with 500 additions and 8 deletions

View file

@ -75,6 +75,11 @@ function resetCashOutBills (rec) {
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId]) return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId])
} }
function emptyCashInBills (rec) {
const sql = 'update devices set cashbox=0 where device_id=$1'
return db.none(sql, [rec.deviceId])
}
function unpair (rec) { function unpair (rec) {
return pairing.unpair(rec.deviceId) return pairing.unpair(rec.deviceId)
} }
@ -89,6 +94,7 @@ function restartServices (rec) {
function setMachine (rec) { function setMachine (rec) {
switch (rec.action) { switch (rec.action) {
case 'emptyCashInBills': return emptyCashInBills(rec)
case 'resetCashOutBills': return resetCashOutBills(rec) case 'resetCashOutBills': return resetCashOutBills(rec)
case 'unpair': return unpair(rec) case 'unpair': return unpair(rec)
case 'reboot': return reboot(rec) case 'reboot': return reboot(rec)

View file

@ -207,6 +207,7 @@ const typeDefs = gql`
} }
enum MachineAction { enum MachineAction {
emptyCashInBills
resetCashOutBills resetCashOutBills
unpair unpair
reboot reboot
@ -214,7 +215,7 @@ const typeDefs = gql`
} }
type Mutation { type Mutation {
machineAction(deviceId:ID!, action: MachineAction!): Machine machineAction(deviceId:ID!, action: MachineAction!, cassettes: [Int]): Machine
machineSupportLogs(deviceId: ID!): SupportLogsResponse machineSupportLogs(deviceId: ID!): SupportLogsResponse
serverSupportLogs: SupportLogsResponse serverSupportLogs: SupportLogsResponse
setCustomer(customerId: ID!, customerInput: CustomerInput): Customer setCustomer(customerId: ID!, customerInput: CustomerInput): Customer
@ -254,7 +255,7 @@ const resolvers = {
accounts: () => settingsLoader.getAccounts() accounts: () => settingsLoader.getAccounts()
}, },
Mutation: { Mutation: {
machineAction: (...[, { deviceId, action }]) => machineAction({ deviceId, action }), machineAction: (...[, { deviceId, action, cassettes }]) => machineAction({ deviceId, action, cassettes }),
machineSupportLogs: (...[, { deviceId }]) => supportLogs.insert(deviceId), machineSupportLogs: (...[, { deviceId }]) => supportLogs.insert(deviceId),
createPairingTotem: (...[, { name }]) => pairing.totem(name), createPairingTotem: (...[, { name }]) => pairing.totem(name),
serverSupportLogs: () => serverLogs.insert(), serverSupportLogs: () => serverLogs.insert(),

View file

@ -6,13 +6,13 @@ function getMachine (machineId) {
.then(machines => machines.find(({ deviceId }) => deviceId === machineId)) .then(machines => machines.find(({ deviceId }) => deviceId === machineId))
} }
function machineAction ({ deviceId, action }) { function machineAction ({ deviceId, action, cassettes }) {
return getMachine(deviceId) return getMachine(deviceId)
.then(machine => { .then(machine => {
if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId }) if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId })
return machine return machine
}) })
.then(machineLoader.setMachine({ deviceId, action })) .then(machineLoader.setMachine({ deviceId, action, cassettes }))
.then(getMachine(deviceId)) .then(getMachine(deviceId))
} }

View file

@ -0,0 +1,151 @@
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import Chip from 'src/components/Chip'
import { Link } from 'src/components/buttons'
import { Info2, Label1, Label2 } from 'src/components/typography'
import TextInputFormik from '../base/TextInput'
import { cashboxStyles, gridStyles } from './Cashbox.styles'
const cashboxClasses = makeStyles(cashboxStyles)
const gridClasses = makeStyles(gridStyles)
const Cashbox = ({ percent = 0, cashOut = false }) => {
const classes = cashboxClasses({ percent, cashOut })
return (
<div className={classes.cashbox}>
<div className={classes.emptyPart}>
{percent <= 50 && <Label2>{percent.toFixed(0)}%</Label2>}
</div>
<div className={classes.fullPart}>
{percent > 50 && <Label2>{percent.toFixed(0)}%</Label2>}
</div>
</div>
)
}
// https://support.lamassu.is/hc/en-us/articles/360025595552-Installing-the-Sintra-Forte
// Sintra and Sintra Forte can have up to 500 notes per cashOut box and up to 1000 per cashIn box
const CashIn = ({ capacity = 1000, notes = 0, total = 0 }) => {
const percent = (100 * notes) / capacity
const classes = gridClasses()
return (
<>
<div className={classes.row}>
<div>
<Cashbox percent={percent} />
</div>
<div className={classes.col2}>
<div>
<Info2 className={classes.noMarginText}>{notes} notes</Info2>
<Label1 className={classes.noMarginText}>{total}</Label1>
</div>
</div>
</div>
</>
)
}
const CashInFormik = ({
capacity = 1000,
onEmpty,
field: {
value: { notes, deviceId }
},
form: { setFieldValue }
}) => {
const classes = gridClasses()
return (
<>
<div className={classes.row}>
<div>
<Cashbox percent={(100 * notes) / capacity} />
</div>
<div className={classes.col2}>
<div>
<Link
onClick={() => {
onEmpty({
variables: {
deviceId,
action: 'emptyCashInBills'
}
}).then(() => setFieldValue('cashin.notes', 0))
}}
className={classes.link}
color={'primary'}>
Empty
</Link>
</div>
</div>
</div>
</>
)
}
const CashOut = ({ capacity = 500, denomination = 0, currency, notes }) => {
const percent = (100 * notes) / capacity
const classes = gridClasses()
return (
<>
<div className={classes.row}>
<div className={classes.col}>
<Cashbox percent={percent} cashOut />
</div>
<div className={(classes.col, classes.col2)}>
<div>
<Info2 className={classes.noMarginText}>
{notes} <Chip label={`${denomination} ${currency.code}`} />
</Info2>
<Label1 className={classes.noMarginText}>
{notes * denomination} {currency.code}
</Label1>
</div>
</div>
</div>
</>
)
}
const CashOutFormik = ({ capacity = 500, ...props }) => {
const {
name,
onChange,
onBlur,
value: { notes }
} = props.field
const { touched, errors } = props.form
const error = !!(touched[name] && errors[name])
const percent = (100 * notes) / capacity
const classes = gridClasses()
return (
<>
<div className={classes.row}>
<div className={classes.col}>
<Cashbox percent={percent} cashOut />
</div>
<div className={(classes.col, classes.col2)}>
<div>
<TextInputFormik
fullWidth
name={name + '.notes'}
onChange={onChange}
onBlur={onBlur}
value={notes}
error={error}
{...props}
/>
</div>
</div>
</div>
</>
)
}
export { Cashbox, CashIn, CashInFormik, CashOut, CashOutFormik }

View file

@ -0,0 +1,67 @@
import { spacer, tomato, primaryColor as zodiac } from 'src/styling/variables'
const colors = {
cashOut: {
empty: tomato,
full: zodiac
},
cashIn: {
empty: zodiac,
full: tomato
}
}
const colorPicker = ({ percent, cashOut }) =>
colors[cashOut ? 'cashOut' : 'cashIn'][percent >= 50 ? 'full' : 'empty']
const cashboxStyles = {
cashbox: {
borderColor: colorPicker,
backgroundColor: colorPicker,
height: 34,
width: 80,
border: '2px solid',
textAlign: 'end',
display: 'inline-block'
},
emptyPart: {
backgroundColor: 'white',
height: ({ percent }) => `${100 - percent}%`,
'& > p': {
color: colorPicker,
display: 'inline-block'
}
},
fullPart: {
backgroundColor: colorPicker,
height: ({ percent }) => `${percent}%`,
'& > p': {
color: 'white',
display: 'inline'
}
}
}
const gridStyles = {
row: {
height: 36,
width: 183,
display: 'grid',
gridTemplateColumns: 'repeat(2,1fr)',
gridTemplateRows: '1fr',
gridColumnGap: 18,
gridRowGap: 0
},
col2: {
width: 117
},
noMarginText: {
marginTop: 0,
marginBottom: 0
},
link: {
marginTop: spacer
}
}
export { cashboxStyles, gridStyles }

View file

@ -4,5 +4,15 @@ import RadioGroup from './base/RadioGroup'
import Select from './base/Select' import Select from './base/Select'
import Switch from './base/Switch' import Switch from './base/Switch'
import TextInput from './base/TextInput' import TextInput from './base/TextInput'
import { CashIn, CashOut } from './cashbox/Cashbox'
export { Autocomplete, TextInput, Checkbox, Switch, Select, RadioGroup } export {
Autocomplete,
TextInput,
Checkbox,
Switch,
Select,
RadioGroup,
CashIn,
CashOut
}

View file

@ -8,7 +8,7 @@ import styles from './TitleSection.styles'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const TitleSection = ({ title, error }) => { const TitleSection = ({ title, error, labels }) => {
const classes = useStyles() const classes = useStyles()
return ( return (
<div className={classes.titleWrapper}> <div className={classes.titleWrapper}>
@ -18,6 +18,7 @@ const TitleSection = ({ title, error }) => {
<ErrorMessage className={classes.error}>Failed to save</ErrorMessage> <ErrorMessage className={classes.error}>Failed to save</ErrorMessage>
)} )}
</div> </div>
<div className={classes.headerLabels}>{labels}</div>
</div> </div>
) )
} }

View file

@ -1,3 +1,5 @@
import { mainStyles } from 'src/pages/Transactions/Transactions.styles'
export default { export default {
titleWrapper: { titleWrapper: {
display: 'flex', display: 'flex',
@ -10,5 +12,6 @@ export default {
}, },
error: { error: {
marginLeft: 12 marginLeft: 12
} },
headerLabels: mainStyles.headerLabels
} }

View file

@ -0,0 +1,210 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import { gql } from 'apollo-boost'
import React, { useState } from 'react'
import * as Yup from 'yup'
import { Table as EditableTable } from 'src/components/editableTable'
import {
CashIn,
CashOut,
CashOutFormik,
CashInFormik
} from 'src/components/inputs/cashbox/Cashbox'
import TitleSection from 'src/components/layout/TitleSection'
import { ReactComponent as ErrorIcon } from 'src/styling/icons/status/tomato.svg'
const ValidationSchema = Yup.object().shape({
name: Yup.string().required('Required'),
cashin: Yup.object()
.required('Required')
.shape({
notes: Yup.number()
.required('Required')
.integer()
.min(0)
}),
cashout1: Yup.object()
.required('Required')
.shape({
notes: Yup.number()
.required('Required')
.integer()
.min(0),
denomination: Yup.number()
.required('Required')
.integer()
.default(0)
}),
cashout2: Yup.object()
.required('Required')
.shape({
notes: Yup.number()
.required('Required')
.integer()
.min(0),
denomination: Yup.number()
.required('Required')
.integer()
.default(0)
})
})
const GET_MACHINES_AND_CONFIG = gql`
{
machines {
name
deviceId
cashbox
cassette1
cassette2
}
config
}
`
const EMPTY_CASHIN_BILLS = gql`
mutation MachineAction($deviceId: ID!, $action: MachineAction!) {
machineAction(deviceId: $deviceId, action: $action) {
deviceId
cashbox
cassette1
cassette2
}
}
`
const RESET_CASHOUT_BILLS = gql`
mutation MachineAction(
$deviceId: ID!
$action: MachineAction!
$cassettes: [Int]!
) {
machineAction(deviceId: $deviceId, action: $action, cassettes: $cassettes) {
deviceId
cashbox
cassette1
cassette2
}
}
`
const Cashboxes = () => {
const [machines, setMachines] = useState([])
useQuery(GET_MACHINES_AND_CONFIG, {
onCompleted: ({ machines, config }) =>
setMachines(
machines.map(m => ({
...m,
currency: { code: config.locale_fiatCurrency ?? '' },
denominations: {
top: config[`denominations_${m.deviceId}_top`],
bottom: config[`denominations_${m.deviceId}_bottom`]
}
}))
)
})
const [resetCashOut] = useMutation(RESET_CASHOUT_BILLS, {
onError: ({ graphQLErrors, message }) => {
const errorMessage = graphQLErrors[0] ? graphQLErrors[0].message : message
// TODO: this should not be final
alert(JSON.stringify(errorMessage))
}
})
const [onEmpty] = useMutation(EMPTY_CASHIN_BILLS, {
onError: ({ graphQLErrors, message }) => {
const errorMessage = graphQLErrors[0] ? graphQLErrors[0].message : message
// TODO: this should not be final
alert(JSON.stringify(errorMessage))
}
})
const onSave = (_, { cashin, cashout1, cashout2 }) =>
resetCashOut({
variables: {
deviceId: cashin.deviceId,
action: 'resetCashOutBills',
cassettes: [Number(cashout1.notes), Number(cashout2.notes)]
}
})
const elements = [
{
name: 'name',
header: 'Machine',
width: 254,
textAlign: 'left',
view: name => <>{name}</>,
input: ({ field: { value: name } }) => <>{name}</>
},
{
name: 'cashin',
header: 'Cash-in',
width: 265,
textAlign: 'left',
view: props => <CashIn {...props} />,
input: props => <CashInFormik onEmpty={onEmpty} {...props} />
},
{
name: 'cashout1',
header: 'Cash-out 1',
width: 265,
textAlign: 'left',
view: props => <CashOut {...props} />,
input: CashOutFormik
},
{
name: 'cashout2',
header: 'Cash-out 2',
width: 265,
textAlign: 'left',
view: props => <CashOut {...props} />,
input: CashOutFormik
}
]
const data = machines.map(
({
name,
cassette1,
cassette2,
currency,
denominations: { top, bottom },
cashbox,
deviceId
}) => ({
id: deviceId,
name,
cashin: { notes: cashbox, deviceId },
cashout1: { notes: cassette1, denomination: top, currency },
cashout2: { notes: cassette2, denomination: bottom, currency }
})
)
return (
<>
<TitleSection
title="Cashboxes"
labels={
<>
<ErrorIcon />
<span>Action required</span>
</>
}
/>
<EditableTable
name="cashboxes"
enableEdit
elements={elements}
data={data}
save={onSave}
validationSchema={ValidationSchema}
/>
</>
)
}
export default Cashboxes

View file

@ -18,6 +18,8 @@ import WalletSettings from 'src/pages/Wallet/Wallet'
import MachineStatus from 'src/pages/maintenance/MachineStatus' import MachineStatus from 'src/pages/maintenance/MachineStatus'
import { namespaces } from 'src/utils/config' import { namespaces } from 'src/utils/config'
import Cashboxes from '../pages/maintenance/Cashboxes'
const tree = [ const tree = [
{ {
key: 'transactions', key: 'transactions',
@ -56,6 +58,12 @@ const tree = [
label: 'Machine Status', label: 'Machine Status',
route: '/maintenance/machine-status', route: '/maintenance/machine-status',
component: MachineStatus component: MachineStatus
},
{
key: 'cashboxes',
label: 'Cashboxes',
route: '/maintenance/cashboxes',
component: Cashboxes
} }
] ]
}, },

View file

@ -13,7 +13,13 @@ import extendJss from 'jss-plugin-extend'
import React from 'react' import React from 'react'
import { ActionButton, Button, Link } from 'src/components/buttons' import { ActionButton, Button, Link } from 'src/components/buttons'
import { TextInput, Switch } from 'src/components/inputs' import {
Radio,
TextInput,
Switch,
CashIn,
CashOut
} from 'src/components/inputs'
import { ReactComponent as AuthorizeIconReversed } from 'src/styling/icons/button/authorize/white.svg' import { ReactComponent as AuthorizeIconReversed } from 'src/styling/icons/button/authorize/white.svg'
import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/zodiac.svg' import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/zodiac.svg'
@ -178,6 +184,35 @@ story.add('ConfirmDialog', () => (
</Wrapper> </Wrapper>
)) ))
story.add('Cashbox', () => (
<Wrapper>
<div>
<CashIn percent={0} notes={0} />
<hr />
<CashIn percent={49} notes={19} />
<hr />
<CashIn percent={50} notes={20} />
<hr />
<CashIn percent={51} notes={22} />
<hr />
<CashIn percent={99} notes={39} />
<hr />
</div>
<div>
<CashOut percent={0} notes={0} denomination={20} />
<hr />
<CashOut percent={49} notes={19} denomination={20} />
<hr />
<CashOut percent={50} notes={20} denomination={20} />
<hr />
<CashOut percent={51.00001} notes={22} denomination={20} />
</div>
</Wrapper>
))
story.add('Radio', () => <Radio label="Hehe" />)
const typographyStory = storiesOf('Typography', module) const typographyStory = storiesOf('Typography', module)
typographyStory.add('H1', () => <H1>Hehehe</H1>) typographyStory.add('H1', () => <H1>Hehehe</H1>)