chore: use monorepo organization

This commit is contained in:
Rafael Taranto 2025-05-12 10:52:54 +01:00
parent deaf7d6ecc
commit a687827f7e
1099 changed files with 8184 additions and 11535 deletions

View file

@ -0,0 +1,155 @@
import Chip from '@mui/material/Chip'
import * as R from 'ramda'
import React from 'react'
import { Label1, TL2 } from 'src/components/typography'
import { CashOut } from 'src/components/inputs'
import { fromNamespace } from 'src/utils/config'
import { getCashUnitCapacity, modelPrettifier } from 'src/utils/machine'
const CashUnitDetails = ({
machine,
bills,
currency,
config,
hideMachineData = false
}) => {
const billCount = R.countBy(it => it.fiat)(bills)
const fillingPercentageSettings = fromNamespace('notifications', config)
const cashout = fromNamespace('cashOut')(config)
const getCashoutSettings = id => fromNamespace(id)(cashout)
const minWidth = hideMachineData ? 'min-w-15' : 'min-w-40'
const VerticalLine = () => <span className="h-full w-[1px] bg-comet2" />
return (
<div className="flex flex-row mt-3 mb-4 gap-10 min-h-30">
{!hideMachineData && (
<div className="min-w-52">
<Label1>Machine Model</Label1>
<span>{modelPrettifier[machine.model]}</span>
</div>
)}
<div className={`flex flex-col ${minWidth}`}>
<Label1>Cash box</Label1>
{R.isEmpty(billCount) && <TL2 noMargin>Empty</TL2>}
{R.keys(billCount).map((it, idx) => (
<span className="flex items-center" key={idx}>
<TL2 className="min-w-7" noMargin>
{billCount[it]}
</TL2>
<Chip label={`${it} ${currency}`} />
</span>
))}
</div>
<div className="flex gap-5">
{machine.numberOfRecyclers === 0 &&
R.map(it => (
<>
<div className="flex flex-col gap-2">
<Label1 noMargin>{`Cassette ${it}`}</Label1>
<CashOut
width={60}
height={40}
currency={{ code: currency }}
notes={machine.cashUnits[`cassette${it}`]}
denomination={
getCashoutSettings(machine.id ?? machine.deviceId)[
`cassette${it}`
]
}
threshold={
fillingPercentageSettings[`fillingPercentageCassette${it}`]
}
capacity={getCashUnitCapacity(machine.model, 'cassette')}
/>
</div>
{it !== machine.numberOfCassettes && <VerticalLine />}
</>
))(R.range(1, machine.numberOfCassettes + 1))}
{machine.numberOfRecyclers > 0 && (
<>
<div className="flex flex-col gap-2">
<Label1 noMargin>{`Loading boxes`}</Label1>
<div className="flex flex-col gap-5">
{R.range(1, machine.numberOfCassettes + 1).map((it, idx) => (
<CashOut
key={idx}
width={60}
height={40}
currency={{ code: currency }}
notes={machine.cashUnits[`cassette${it}`]}
denomination={
getCashoutSettings(machine.id ?? machine.deviceId)[
`cassette${it}`
]
}
threshold={
fillingPercentageSettings[
`fillingPercentageCassette${it}`
]
}
capacity={getCashUnitCapacity(machine.model, 'cassette')}
/>
))}
</div>
</div>
<VerticalLine />
{R.map(it => (
<>
<div className="flex flex-col gap-2">
<Label1 noMargin>
{`Recycler ${
machine.model === 'aveiro'
? `${it} f/r`
: `${it * 2 - 1} - ${it * 2}`
}`}
</Label1>
<div className="flex flex-col gap-5">
<CashOut
width={60}
height={40}
currency={{ code: currency }}
notes={machine.cashUnits[`recycler${it * 2 - 1}`]}
denomination={
getCashoutSettings(machine.id ?? machine.deviceId)[
`recycler${it * 2 - 1}`
]
}
threshold={
fillingPercentageSettings[
`fillingPercentageRecycler${it * 2 - 1}`
]
}
capacity={getCashUnitCapacity(machine.model, 'recycler')}
/>
<CashOut
width={60}
height={40}
currency={{ code: currency }}
notes={machine.cashUnits[`recycler${it * 2}`]}
denomination={
getCashoutSettings(machine.id ?? machine.deviceId)[
`recycler${it * 2}`
]
}
threshold={
fillingPercentageSettings[
`fillingPercentageRecycler${it * 2}`
]
}
capacity={getCashUnitCapacity(machine.model, 'recycler')}
/>
</div>
</div>
{it !== machine.numberOfRecyclers / 2 && <VerticalLine />}
</>
))(R.range(1, machine.numberOfRecyclers / 2 + 1))}
</>
)}
</div>
</div>
)
}
export default CashUnitDetails

View file

@ -0,0 +1,353 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import DialogActions from '@mui/material/DialogActions'
import IconButton from '@mui/material/IconButton'
import SvgIcon from '@mui/material/SvgIcon'
import * as R from 'ramda'
import React, { useState } from 'react'
import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper'
import Modal from 'src/components/Modal'
import { HelpTooltip } from 'src/components/Tooltip'
import TitleSection from 'src/components/layout/TitleSection'
import DataTable from 'src/components/tables/DataTable'
import { P, Label1 } from 'src/components/typography'
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
import ReverseHistoryIcon from 'src/styling/icons/circle buttons/history/white.svg?react'
import HistoryIcon from 'src/styling/icons/circle buttons/history/zodiac.svg?react'
import { Button, SupportLinkButton } from 'src/components/buttons'
import { RadioGroup } from 'src/components/inputs'
import { EmptyTable } from 'src/components/table'
import { fromNamespace, toNamespace } from 'src/utils/config'
import { MANUAL, AUTOMATIC } from 'src/utils/constants'
import { onlyFirstToUpper } from 'src/utils/string'
import CashUnitDetails from './CashUnitDetails'
import CashCassettesFooter from './CashUnitsFooter'
import CashboxHistory from './CashboxHistory'
import Wizard from './Wizard/Wizard'
import helper from './helper'
const GET_MACHINES_AND_CONFIG = gql`
query getData($billFilters: JSONObject) {
machines {
name
id: deviceId
model
cashUnits {
cashbox
cassette1
cassette2
cassette3
cassette4
recycler1
recycler2
recycler3
recycler4
recycler5
recycler6
}
numberOfCassettes
numberOfRecyclers
}
unpairedMachines {
id: deviceId
name
}
config
bills(filters: $billFilters) {
id
fiat
created
deviceId
}
}
`
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
const SET_CASSETTE_BILLS = gql`
mutation MachineAction(
$deviceId: ID!
$action: MachineAction!
$cashUnits: CashUnitsInput
) {
machineAction(deviceId: $deviceId, action: $action, cashUnits: $cashUnits) {
deviceId
cashUnits {
cashbox
cassette1
cassette2
cassette3
cassette4
recycler1
recycler2
recycler3
recycler4
recycler5
recycler6
}
}
}
`
const GET_BATCHES_CSV = gql`
query cashboxBatchesCsv(
$from: DateTimeISO
$until: DateTimeISO
$timezone: String
) {
cashboxBatchesCsv(from: $from, until: $until, timezone: $timezone)
}
`
const widths = {
name: 250,
cashbox: 200,
cassettes: 575,
edit: 90
}
const CashCassettes = () => {
const [showHistory, setShowHistory] = useState(false)
const [editingSchema, setEditingSchema] = useState(null)
const [selectedRadio, setSelectedRadio] = useState(null)
const { data, loading: dataLoading } = useQuery(GET_MACHINES_AND_CONFIG, {
variables: {
billFilters: {
batch: 'none'
}
}
})
const [wizard, setWizard] = useState(false)
const [machineId, setMachineId] = useState('')
const machines = R.path(['machines'])(data) ?? []
const unpairedMachines = R.path(['unpairedMachines'])(data) ?? []
const config = R.path(['config'])(data) ?? {}
const [setCassetteBills, { error }] = useMutation(SET_CASSETTE_BILLS, {
refetchQueries: () => ['getData']
})
const [saveConfig] = useMutation(SAVE_CONFIG, {
onCompleted: () => setEditingSchema(false),
refetchQueries: () => ['getData']
})
const timezone = R.path(['config', 'locale_timezone'], data)
const bills = R.groupBy(bill => bill.deviceId)(R.path(['bills'])(data) ?? [])
const deviceIds = R.uniq(
R.map(R.prop('deviceId'))(R.path(['bills'])(data) ?? [])
)
const cashout = data?.config && fromNamespace('cashOut')(data.config)
const locale = data?.config && fromNamespace('locale')(data.config)
const fiatCurrency = locale?.fiatCurrency
const getCashoutSettings = id => fromNamespace(id)(cashout)
const onSave = (id, cashUnits) => {
return setCassetteBills({
variables: {
action: 'setCassetteBills',
deviceId: id,
cashUnits
}
})
}
const cashboxReset =
data?.config && fromNamespace('cashIn')(data.config).cashboxReset
const cashboxResetSave = rawConfig => {
const config = toNamespace('cashIn')(rawConfig)
return saveConfig({ variables: { config } })
}
const saveCashboxOption = selection => {
if (selection) {
cashboxResetSave({ cashboxReset: selection })
setEditingSchema(false)
}
}
const radioButtonOptions = [
{ display: 'Automatic', code: AUTOMATIC },
{ display: 'Manual', code: MANUAL }
]
const handleRadioButtons = evt => {
const selectedRadio = R.path(['target', 'value'])(evt)
setSelectedRadio(selectedRadio)
}
const elements = helper.getElements(
config,
bills,
setWizard,
widths,
setMachineId
)
const InnerCashUnitDetails = ({ it }) => (
<CashUnitDetails
machine={it}
bills={bills[it.id] ?? []}
currency={fiatCurrency}
config={config}
/>
)
return (
!dataLoading && (
<>
<TitleSection
title="Cash boxes & cassettes"
buttons={[
{
text: 'Cash box history',
icon: HistoryIcon,
inverseIcon: ReverseHistoryIcon,
toggle: setShowHistory
},
{
component: showHistory ? (
<LogsDowloaderPopover
className="ml-4"
title="Download logs"
name="cashboxHistory"
query={GET_BATCHES_CSV}
getLogs={logs => R.path(['cashboxBatchesCsv'])(logs)}
timezone={timezone}
args={{ timezone }}
/>
) : (
<></>
)
}
]}
className="flex items-center mr-[1px]"
appendix={
<HelpTooltip width={220}>
<P>
For details on configuring cash boxes and cassettes, please read
the relevant knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/4420839641229-Cash-Boxes-Cassettess"
label="Cash Boxes & Cassettes"
bottomSpace="1"
/>
</HelpTooltip>
}>
{!showHistory && (
<div className="flex flex-col items-end">
<Label1 noMargin className="text-comet">
Cash box resets
</Label1>
<div className="flex items-center justify-end -mr-1">
{cashboxReset && (
<P noMargin className="mr-2">
{onlyFirstToUpper(cashboxReset)}
</P>
)}
<IconButton onClick={() => setEditingSchema(true)}>
<SvgIcon>
<EditIcon />
</SvgIcon>
</IconButton>
</div>
</div>
)}
</TitleSection>
{!showHistory && (
<>
<DataTable
loading={dataLoading}
elements={elements}
data={machines}
Details={InnerCashUnitDetails}
emptyText="No machines so far"
expandable
tableClassName="mb-20"
/>
{data && R.isEmpty(machines) && (
<EmptyTable message="No machines so far" />
)}
</>
)}
{showHistory && (
<CashboxHistory
machines={R.concat(machines, unpairedMachines)}
currency={fiatCurrency}
timezone={timezone}
/>
)}
<CashCassettesFooter
currencyCode={fiatCurrency}
machines={machines}
config={config}
bills={R.path(['bills'])(data)}
deviceIds={deviceIds}
/>
{wizard && (
<Wizard
machine={R.find(R.propEq('id', machineId), machines)}
cashoutSettings={getCashoutSettings(machineId)}
onClose={() => {
setWizard(false)
}}
error={error?.message}
save={onSave}
locale={locale}
/>
)}
{editingSchema && (
<Modal
title={'Cash box resets'}
width={478}
handleClose={() => setEditingSchema(null)}
open={true}>
<P className="text-comet mt-0">
We can automatically assume you emptied a bill validator's cash
box when the machine detects that it has been removed.
</P>
<RadioGroup
name="set-automatic-reset"
value={selectedRadio ?? cashboxReset}
options={[radioButtonOptions[0]]}
onChange={handleRadioButtons}
/>
<P className="text-comet mt-0">
Assume the cash box is emptied whenever it's removed, creating a
new batch on the history screen and setting its current balance to
zero.
</P>
<RadioGroup
name="set-manual-reset"
value={selectedRadio ?? cashboxReset}
options={[radioButtonOptions[1]]}
onChange={handleRadioButtons}
/>
<P className="text-comet mt-0">
Cash boxes won't be assumed emptied when removed, nor their counts
modified. Instead, to update the count and create a new batch,
you'll click the 'Edit' button on this panel.
</P>
<DialogActions>
<Button onClick={() => saveCashboxOption(selectedRadio)}>
Confirm
</Button>
</DialogActions>
</Modal>
)}
</>
)
)
}
export default CashCassettes

View file

@ -0,0 +1,116 @@
import BigNumber from 'bignumber.js'
import * as R from 'ramda'
import React from 'react'
import { Info1, Info2, Info3 } from 'src/components/typography/index'
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 { numberToFiatAmount } from 'src/utils/number'
const CashCassettesFooter = ({
machines,
config,
currencyCode,
bills,
deviceIds
}) => {
const cashout = config && fromNamespace('cashOut')(config)
const getCashoutSettings = id => fromNamespace(id)(cashout)
const cashoutReducerFn = (
acc,
{ cashUnits: { cassette1, cassette2, cassette3, cassette4 }, id }
) => {
const cassette1Denomination = getCashoutSettings(id).cassette1 ?? 0
const cassette2Denomination = getCashoutSettings(id).cassette2 ?? 0
const cassette3Denomination = getCashoutSettings(id).cassette3 ?? 0
const cassette4Denomination = getCashoutSettings(id).cassette4 ?? 0
return [
(acc[0] += cassette1 * cassette1Denomination),
(acc[1] += cassette2 * cassette2Denomination),
(acc[2] += cassette3 * cassette3Denomination),
(acc[3] += cassette4 * cassette4Denomination)
]
}
const recyclerReducerFn = (
acc,
{
cashUnits: {
recycler1,
recycler2,
recycler3,
recycler4,
recycler5,
recycler6
},
id
}
) => {
const recycler1Denomination = getCashoutSettings(id).recycler1 ?? 0
const recycler2Denomination = getCashoutSettings(id).recycler2 ?? 0
const recycler3Denomination = getCashoutSettings(id).recycler3 ?? 0
const recycler4Denomination = getCashoutSettings(id).recycler4 ?? 0
const recycler5Denomination = getCashoutSettings(id).recycler5 ?? 0
const recycler6Denomination = getCashoutSettings(id).recycler6 ?? 0
return [
(acc[0] += recycler1 * recycler1Denomination),
(acc[1] += recycler2 * recycler2Denomination),
(acc[0] += recycler3 * recycler3Denomination),
(acc[1] += recycler4 * recycler4Denomination),
(acc[0] += recycler5 * recycler5Denomination),
(acc[1] += recycler6 * recycler6Denomination)
]
}
const totalInRecyclers = R.sum(
R.reduce(recyclerReducerFn, [0, 0, 0, 0, 0, 0], machines)
)
const totalInCassettes = R.sum(
R.reduce(cashoutReducerFn, [0, 0, 0, 0], machines)
)
const totalInCashBox = R.sum(R.map(it => it.fiat)(bills))
const total = new BigNumber(
totalInCassettes + totalInCashBox + totalInRecyclers
).toFormat(0)
return (
<div className="fixed h-16 left-0 bottom-0 w-[100vw] bg-white flex justify-around shadow-2xl">
<div className="w-300 max-h-16 flex fixed justify-around">
<Info3 className="text-comet self-center">Cash value in System</Info3>
<div className="flex">
<TxInIcon className="self-center h-5 w-5 mr-2" />
<Info2 className="self-center mr-2">Cash-in:</Info2>
<Info1 className="self-center">
{numberToFiatAmount(totalInCashBox)} {currencyCode}
</Info1>
</div>
<div className="flex gap-2">
<TxOutIcon className="self-center h-5 w-5" />
<Info2 className="self-center">Cash-out:</Info2>
<Info1 className="self-center">
{numberToFiatAmount(totalInCassettes)} {currencyCode}
</Info1>
</div>
<div className="flex gap-2">
<TxOutIcon className="self-center h-5 w-5" />
<Info2 className="self-center">Recycle:</Info2>
<Info1 className="self-center">
{numberToFiatAmount(totalInRecyclers)} {currencyCode}
</Info1>
</div>
<div className="flex gap-2">
<Info2 className="self-center">Total:</Info2>
<Info1 className="self-center">
{numberToFiatAmount(total)} {currencyCode}
</Info1>
</div>
</div>
</div>
)
}
export default CashCassettesFooter

View file

@ -0,0 +1,138 @@
import { useQuery, gql } from '@apollo/client'
import * as R from 'ramda'
import React from 'react'
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 { NumberInput } from 'src/components/inputs/formik'
import { formatDate } from 'src/utils/timezones'
const GET_BATCHES = gql`
query cashboxBatches {
cashboxBatches {
id
deviceId
created
operationType
customBillCount
performedBy
billCount
fiatTotal
}
}
`
const CashboxHistory = ({ machines, currency, timezone }) => {
const { data: batchesData, loading: batchesLoading } = useQuery(GET_BATCHES)
const loading = batchesLoading
const batches = R.path(['cashboxBatches'])(batchesData)
const getOperationRender = R.reduce(
(ret, i) =>
R.pipe(
R.assoc(
`cash-cassette-${i}-refill`,
<>
<TxOutIcon />
<span>Cash cassette {i} refill</span>
</>
),
R.assoc(
`cash-cassette-${i}-empty`,
<>
<TxOutIcon />
<span>Cash cassette {i} emptied</span>
</>
)
)(ret),
{
'cash-box-empty': (
<>
<TxInIcon />
<span>Cash box emptied</span>
</>
)
},
R.range(1, 5)
)
const elements = [
{
name: 'operation',
header: 'Operation',
width: 200,
textAlign: 'left',
view: it => (
<div className="flex items-center gap-2">
{getOperationRender[it.operationType]}
</div>
)
},
{
name: 'machine',
header: 'Machine',
width: 200,
textAlign: 'left',
view: R.pipe(
R.prop('deviceId'),
id => R.find(R.propEq('id', id), machines),
R.defaultTo({ name: <i>Unpaired device</i> }),
R.prop('name')
)
},
{
name: 'billCount',
header: 'Bill count',
width: 115,
textAlign: 'left',
input: NumberInput,
inputProps: {
decimalPlaces: 0
},
view: it =>
R.isNil(it.customBillCount) ? it.billCount : it.customBillCount
},
{
name: 'total',
header: 'Total',
width: 180,
textAlign: 'right',
view: it => (
<span>
{it.fiatTotal} {currency}
</span>
)
},
{
name: 'date',
header: 'Date',
width: 135,
textAlign: 'right',
view: it => formatDate(it.created, timezone, 'yyyy-MM-dd')
},
{
name: 'time',
header: 'Time (h:m)',
width: 125,
textAlign: 'right',
view: it => formatDate(it.created, timezone, 'HH:mm')
}
]
return (
<div className="flex flex-col flex-1 mb-20">
<DataTable
loading={loading}
name="cashboxHistory"
elements={elements}
data={batches}
emptyText="No cash box batches so far"
/>
</div>
)
}
export default CashboxHistory

View file

@ -0,0 +1,62 @@
import BigNumber from 'bignumber.js'
import React from 'react'
import MachineActions from 'src/components/machineActions/MachineActions'
import { Label1 } from 'src/components/typography/index.jsx'
import { modelPrettifier } from 'src/utils/machine'
import { formatDate } from 'src/utils/timezones'
const Label = ({ children }) => {
return <Label1 className="text-comet mb-1">{children}</Label1>
}
const MachineDetailsRow = ({ it: machine, onActionSuccess, timezone }) => {
return (
<div className="flex flex-wrap mt-3 mb-4 text-sm">
<div className="w-1/4">
<Label>Machine model</Label>
<span>{modelPrettifier[machine.model]}</span>
</div>
<div className="w-1/4">
<Label>Paired at</Label>
<span>
{timezone &&
formatDate(machine.pairedAt, timezone, 'yyyy-MM-dd HH:mm:ss')}
</span>
</div>
<div className="w-1/2 flex-1/2">
<MachineActions
machine={machine}
onActionSuccess={onActionSuccess}></MachineActions>
</div>
<div className="w-1/6">
<Label>Network speed</Label>
<span>
{machine.downloadSpeed
? new BigNumber(machine.downloadSpeed).toFixed(4).toString() +
' MB/s'
: 'unavailable'}
</span>
</div>
<div className="w-1/6">
<Label>Latency</Label>
<span>
{machine.responseTime
? new BigNumber(machine.responseTime).toFixed(3).toString() + ' ms'
: 'unavailable'}
</span>
</div>
<div className="w-1/6">
<Label>Packet loss</Label>
<span>
{machine.packetLoss
? new BigNumber(machine.packetLoss).toFixed(3).toString() + ' %'
: 'unavailable'}
</span>
</div>
</div>
)
}
export default MachineDetailsRow

View file

@ -0,0 +1,154 @@
import { useQuery, gql } from '@apollo/client'
import { formatDistance } from 'date-fns'
import * as R from 'ramda'
import React from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import { MainStatus } from 'src/components/Status'
import Title from 'src/components/Title'
import DataTable from 'src/components/tables/DataTable'
import { Label1 } from 'src/components/typography/index.jsx'
import MachineRedirectIcon from 'src/styling/icons/month arrows/right.svg?react'
import WarningIcon from 'src/styling/icons/status/pumpkin.svg?react'
import ErrorIcon from 'src/styling/icons/status/tomato.svg?react'
import MachineDetailsRow from './MachineDetailsCard'
const GET_MACHINES = gql`
{
machines {
name
deviceId
lastPing
pairedAt
version
paired
cashUnits {
cashbox
cassette1
cassette2
cassette3
cassette4
recycler1
recycler2
recycler3
recycler4
recycler5
recycler6
}
version
model
statuses {
label
type
}
downloadSpeed
responseTime
packetLoss
}
}
`
const GET_DATA = gql`
query getData {
config
}
`
const MachineStatus = () => {
const history = useHistory()
const { state } = useLocation()
const addedMachineId = state?.id
const {
data: machinesResponse,
refetch,
loading: machinesLoading
} = useQuery(GET_MACHINES)
const { data: configResponse, configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const elements = [
{
header: 'Machine name',
width: 250,
size: 'sm',
textAlign: 'left',
view: m => (
<div className="flex items-center gap-2">
{m.name}
<div
onClick={() => {
history.push(`/machines/${m.deviceId}`)
}}>
<MachineRedirectIcon />
</div>
</div>
)
},
{
header: 'Status',
width: 350,
size: 'sm',
textAlign: 'left',
view: m => <MainStatus statuses={m.statuses} />
},
{
header: 'Last ping',
width: 200,
size: 'sm',
textAlign: 'left',
view: m =>
m.lastPing
? formatDistance(new Date(m.lastPing), new Date(), {
addSuffix: true
})
: 'unknown'
},
{
header: 'Software version',
width: 200,
size: 'sm',
textAlign: 'left',
view: m => m.version || 'unknown'
}
]
const machines = R.path(['machines'])(machinesResponse) ?? []
const expandedIndex = R.findIndex(R.propEq('deviceId', addedMachineId))(
machines
)
const InnerMachineDetailsRow = ({ it }) => (
<MachineDetailsRow it={it} onActionSuccess={refetch} timezone={timezone} />
)
const loading = machinesLoading || configLoading
return (
<>
<div className="flex justify-between items-center">
<Title>Machine status</Title>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<WarningIcon />
<Label1 noMargin>Warning</Label1>
</div>
<div className="flex items-center gap-2">
<ErrorIcon />
<Label1 noMargin>Error</Label1>
</div>
</div>
</div>
<DataTable
loading={loading}
elements={elements}
data={machines}
Details={InnerMachineDetailsRow}
initialExpanded={expandedIndex}
emptyText="No machines so far"
expandable
/>
</>
)
}
export default MachineStatus

View file

@ -0,0 +1,209 @@
import * as R from 'ramda'
import React, { useState } from 'react'
import Modal from 'src/components/Modal'
import * as Yup from 'yup'
import { MAX_NUMBER_OF_CASSETTES } from 'src/utils/constants'
import {
cashUnitCapacity,
getCashUnitCapacity,
modelPrettifier
} from 'src/utils/machine'
import { defaultToZero } from 'src/utils/number'
import WizardSplash from './WizardSplash'
import WizardStep from './WizardStep'
const MODAL_WIDTH = 554
const MODAL_HEIGHT = 535
const CASSETTE_FIELDS = R.map(
it => `cassette${it}`,
R.range(1, MAX_NUMBER_OF_CASSETTES + 1)
)
const RECYCLER_FIELDS = [
'recycler1',
'recycler2',
'recycler3',
'recycler4',
'recycler5',
'recycler6'
]
const canManuallyLoadRecyclers = ({ model }) => ['grandola'].includes(model)
const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
const [{ step, config }, setState] = useState({
step: 0,
config: { active: true }
})
const isCashOutDisabled =
R.isEmpty(cashoutSettings) || !cashoutSettings?.active
const numberOfCassettes = isCashOutDisabled ? 0 : machine.numberOfCassettes
const numberOfRecyclers = machine.numberOfRecyclers
const LAST_STEP = canManuallyLoadRecyclers(machine)
? numberOfCassettes + numberOfRecyclers + 1
: numberOfCassettes + 1
const title = `Update counts`
const isLastStep = step === LAST_STEP
const buildCashUnitObj = (fields, cassetteInput) =>
R.pipe(R.pickAll(fields), R.map(defaultToZero))(cassetteInput)
const onContinue = it => {
const newConfig = R.merge(config, it)
if (isLastStep) {
const wasCashboxEmptied = [
config?.wasCashboxEmptied,
it?.wasCashboxEmptied
].includes('YES')
const cassettes = buildCashUnitObj(CASSETTE_FIELDS, it)
const recyclers = canManuallyLoadRecyclers(machine)
? buildCashUnitObj(RECYCLER_FIELDS, it)
: []
const cashUnits = {
cashbox: wasCashboxEmptied ? 0 : machine?.cashUnits.cashbox,
...cassettes,
...recyclers
}
save(machine.id, cashUnits)
return onClose()
}
setState({
step: step + 1,
config: newConfig
})
}
const makeCassetteSteps = R.pipe(
R.add(1),
R.range(1),
R.map(i => ({
type: `cassette ${i}`,
schema: Yup.object().shape({
[`cassette${i}`]: Yup.number()
.label('Bill count')
.positive()
.integer()
.required()
.min(0)
.max(
getCashUnitCapacity(machine.model, 'cassette'),
`${
modelPrettifier[machine.model]
} maximum cassette capacity is ${getCashUnitCapacity(
machine.model,
'cassette'
)} bills`
)
})
}))
)
const makeRecyclerSteps = R.pipe(
R.add(1),
R.range(1),
R.chain(i => ({
type: `recycler ${i}`,
schema: Yup.object().shape({
[`recycler${i}`]: Yup.number()
.label('Bill count')
.positive()
.integer()
.required()
.min(0)
.max(
cashUnitCapacity[machine.model].recycler,
`${modelPrettifier[machine.model]}
maximum recycler capacity is ${
cashUnitCapacity[machine.model].recycler
} bills`
)
})
}))
)
const makeCassettesInitialValues = () =>
!R.isEmpty(cashoutSettings)
? R.reduce(
(acc, value) => {
acc[`cassette${value}`] = ''
return acc
},
{},
R.range(1, numberOfCassettes + 1)
)
: {}
const makeRecyclersInitialValues = () =>
!R.isEmpty(cashoutSettings)
? R.reduce(
(acc, value) => {
acc[`recycler${value * 2 - 1}`] = ''
acc[`recycler${value * 2}`] = ''
return acc
},
{},
R.range(1, numberOfRecyclers + 1)
)
: {}
const makeInitialValues = () =>
R.merge(makeCassettesInitialValues(), makeRecyclersInitialValues())
const steps = R.pipe(
R.concat(
makeRecyclerSteps(
canManuallyLoadRecyclers(machine) ? numberOfRecyclers : 0
)
),
R.concat(makeCassetteSteps(isCashOutDisabled ? 0 : numberOfCassettes)),
R.concat([
{
type: 'cashbox',
schema: Yup.object().shape({
wasCashboxEmptied: Yup.string().required('Select one option.')
}),
cashoutRequired: false
}
])
)([])
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}
machine={machine}
cashoutSettings={cashoutSettings}
error={error}
lastStep={isLastStep}
steps={steps}
fiatCurrency={locale.fiatCurrency}
onContinue={onContinue}
initialValues={makeInitialValues()}
/>
)}
</Modal>
)
}
export default Wizard

View file

@ -0,0 +1,39 @@
import React from 'react'
import { H1, P, Info2 } from 'src/components/typography'
import WarningIcon from 'src/styling/icons/warning-icon/comet.svg?react'
import { Button } from 'src/components/buttons'
import filledCassettes from 'src/styling/icons/cassettes/both-filled.svg'
const WizardSplash = ({ name, onContinue }) => {
return (
<div className="flex flex-col items-center flex-1 pt-0 pb-12 px-8 gap-4">
<img width="148" height="196" alt="cassette" src={filledCassettes}></img>
<div className="flex flex-col items-center">
<H1 noMargin>Update counts</H1>
<Info2 noMargin className="text-comet my-1">
{name}
</Info2>
</div>
<div className="flex items-center gap-2">
<WarningIcon className="h-6 w-6" />
<P noMargin className="flex-1">
Before updating counts on Lamassu Admin, make sure you've done it
before on the machines.
</P>
</div>
<div className="flex items-center gap-2">
<WarningIcon className="h-6 w-6" />
<P noMargin className="flex-1">
For cash cassettes, please make sure you've removed the remaining
bills before adding the new ones.
</P>
</div>
<Button className="ml-auto mt-auto mb-0" onClick={onContinue}>
Get started
</Button>
</div>
)
}
export default WizardSplash

View file

@ -0,0 +1,273 @@
import classnames from 'classnames'
import { Formik, Form, Field } from 'formik'
import * as R from 'ramda'
import React from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Stepper from 'src/components/Stepper'
import { HelpTooltip } from 'src/components/Tooltip'
import { Cashbox } from 'src/components/inputs/cashbox/Cashbox'
import { Info2, H4, P, Info1 } from 'src/components/typography'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { Button } from 'src/components/buttons'
import { NumberInput, RadioGroup } from 'src/components/inputs/formik'
import cashbox from 'src/styling/icons/cassettes/acceptor-left.svg'
import cassetteOne from 'src/styling/icons/cassettes/dispenser-1.svg'
import cassetteTwo from 'src/styling/icons/cassettes/dispenser-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'
import { getCashUnitCapacity } from 'src/utils/machine'
import { numberToFiatAmount } from 'src/utils/number'
import { startCase } from 'src/utils/string'
import classes from './WizardStep.module.css'
const CASHBOX_STEP = 1
const isCashboxStep = step => step === CASHBOX_STEP
const isCassetteStep = (step, numberOfCassettes) =>
step > 1 && step <= numberOfCassettes + 1
const isRecyclerStep = (step, numberOfCassettes, numberOfRecyclers) =>
step > numberOfCassettes + 1 &&
step <= numberOfCassettes + numberOfRecyclers + 1
const cassetesArtworks = (step, numberOfCassettes, numberOfRecyclers) => {
const cassetteStepsStart = CASHBOX_STEP + 1
return isCassetteStep(step, numberOfCassettes)
? [
[cassetteOne],
[cassetteOne, cassetteTwo],
[tejo3CassetteOne, tejo3CassetteTwo, tejo3CassetteThree],
[
tejo4CassetteOne,
tejo4CassetteTwo,
tejo4CassetteThree,
tejo4CassetteFour
]
][numberOfCassettes - 1][step - cassetteStepsStart]
: [
/* TODO: Recycler artwork */
[cassetteOne],
[cassetteOne, cassetteTwo],
[tejo3CassetteOne, tejo3CassetteTwo, tejo3CassetteThree],
[
tejo4CassetteOne,
tejo4CassetteTwo,
tejo4CassetteThree,
tejo4CassetteFour
]
][numberOfRecyclers - 1][step - cassetteStepsStart]
}
const getCashUnitFieldName = (step, numberOfCassettes, numberOfRecyclers) => {
if (isCashboxStep(step)) return { name: 'cashbox', category: 'cashbox' }
const cassetteStepsStart = CASHBOX_STEP + 1
if (isCassetteStep(step, numberOfCassettes))
return {
name: `cassette${step - cassetteStepsStart + 1}`,
category: 'cassette'
}
const recyclerStepsStart = CASHBOX_STEP + numberOfCassettes + 1
if (isRecyclerStep(step, numberOfCassettes, numberOfRecyclers))
return {
name: `recycler${Math.ceil(step - recyclerStepsStart + 1)}`,
category: 'recycler'
}
}
const WizardStep = ({
step,
name,
machine,
cashoutSettings,
error,
lastStep,
steps,
fiatCurrency,
onContinue,
initialValues
}) => {
const label = lastStep ? 'Finish' : 'Confirm'
const stepOneRadioOptions = [
{ display: 'Yes', code: 'YES' },
{ display: 'No', code: 'NO' }
]
const numberOfCassettes = machine.numberOfCassettes
const numberOfRecyclers = machine.numberOfRecyclers
const { name: cashUnitField, category: cashUnitCategory } =
getCashUnitFieldName(step, numberOfCassettes, numberOfRecyclers)
const originalCashUnitCount = machine?.cashUnits?.[cashUnitField]
const cashUnitDenomination = cashoutSettings?.[cashUnitField]
const cassetteCount = values => values[cashUnitField] || originalCashUnitCount
const cassetteTotal = values => cassetteCount(values) * cashUnitDenomination
const getPercentage = R.pipe(
cassetteCount,
count =>
100 * (count / getCashUnitCapacity(machine.model, cashUnitCategory)),
R.clamp(0, 100)
)
return (
<div className="flex flex-col flex-1 pb-6 gap-6">
<div className="mb-4">
<Info2 noMargin className="mb-3">
{name}
</Info2>
<Stepper steps={steps.length} currentStep={step} />
</div>
{isCashboxStep(step) && (
<Formik
validateOnBlur={false}
validateOnChange={false}
onSubmit={onContinue}
initialValues={{ wasCashboxEmptied: '' }}
enableReinitialize
validationSchema={steps[0].schema}>
{({ errors }) => (
<Form className="flex flex-col flex-1">
<div className="flex flex-row pb-25">
<img
className={classes.stepImage}
alt={cashUnitCategory}
src={cashbox}></img>
<div className={classes.formWrapper}>
<div className={classes.verticalAlign}>
<H4 noMargin>Did you empty the cash box?</H4>
<Field
component={RadioGroup}
name="wasCashboxEmptied"
options={stepOneRadioOptions}
className={classes.horizontalAlign}
/>
{errors.wasCashboxEmptied && (
<div className="text-tomato">
{errors.wasCashboxEmptied}
</div>
)}
<div
className={classnames(
classes.horizontalAlign,
'items-center'
)}>
<P>Since previous update</P>
<HelpTooltip width={215}>
<P>
Number of bills inside the cash box, since the last
cash box changes.
</P>
</HelpTooltip>
</div>
<div
className={classnames(
classes.horizontalAlign,
'items-baseline'
)}>
<Info1 noMargin className="mr-1">
{machine?.cashUnits.cashbox}
</Info1>
<P noMargin>accepted bills</P>
</div>
</div>
</div>
</div>
<Button className="mt-auto ml-auto" type="submit">
{label}
</Button>
</Form>
)}
</Formik>
)}
{!isCashboxStep(step) && (
<Formik
validateOnBlur={false}
validateOnChange={false}
onSubmit={onContinue}
initialValues={initialValues}
enableReinitialize
validationSchema={steps[step - 1].schema}>
{({ values, errors }) => (
<Form className="flex flex-col flex-1">
<div className={classnames(classes.horizontalAlign, 'pb-6')}>
<img
className={classes.stepImage}
alt={cashUnitCategory}
src={cassetesArtworks(
step,
numberOfCassettes,
numberOfRecyclers
)}></img>
<div className={classes.formWrapper}>
<div className={classes.verticalAlign}>
<div
className={classnames(classes.horizontalAlign, 'mb-6')}>
<div
className={classnames(classes.horizontalAlign, 'mt-4')}>
<TxOutIcon />
<H4 className="ml-2 mr-6" noMargin>
{startCase(cashUnitField)} (
{cashUnitCategory === 'cassette'
? `dispenser`
: cashUnitCategory === 'recycler'
? `recycler`
: ``}
)
</H4>
</div>
<Cashbox
className="h-10 w-9"
percent={getPercentage(values)}
cashOut
/>
</div>
<H4 noMargin>Refill bill count</H4>
<div
className={classnames(
classes.horizontalAlign,
'items-baseline'
)}>
<Field
component={NumberInput}
decimalPlaces={0}
width={50}
placeholder={originalCashUnitCount.toString()}
name={cashUnitField}
className="mr-1"
autoFocus
/>
<P>
{cashUnitDenomination} {fiatCurrency} bills loaded
</P>
</div>
<P noMargin className="text-comet">
= {numberToFiatAmount(cassetteTotal(values))}{' '}
{fiatCurrency}
</P>
{!R.isEmpty(errors) && (
<ErrorMessage className="max-w-68 mt-6">
{R.head(R.values(errors))}
</ErrorMessage>
)}
</div>
</div>
</div>
<Button className="ml-auto mt-auto" type="submit">
{label}
</Button>
</Form>
)}
</Formik>
)}
</div>
)
}
export default WizardStep

View file

@ -0,0 +1,22 @@
.stepImage {
width: 148px;
height: 196px;
}
.formWrapper {
flex-basis: 100%;
display: flex;
justify-content: center;
}
.verticalAlign {
display: flex;
flex-direction: column;
margin: 0 auto;
flex-basis: auto;
}
.horizontalAlign {
display: flex;
flex-direction: row;
}

View file

@ -0,0 +1,141 @@
import IconButton from '@mui/material/IconButton'
import SvgIcon from '@mui/material/SvgIcon'
import React from 'react'
import * as R from 'ramda'
import { CashIn, CashOutLite } from 'src/components/inputs/cashbox/Cashbox'
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
import { fromNamespace } from 'src/utils/config'
import { getCashUnitCapacity } from 'src/utils/machine'
const getElements = (config, bills, setWizard, widths, setMachineId) => {
const fillingPercentageSettings = fromNamespace('notifications', config)
const locale = fromNamespace('locale')(config)
const cashout = fromNamespace('cashOut')(config)
const fiatCurrency = locale?.fiatCurrency
const getCashoutSettings = id => fromNamespace(id)(cashout)
const elements = [
{
name: 'name',
header: 'Machine',
width: widths.name,
view: m => <>{m.name}</>,
input: ({ field: { value: name } }) => <>{name}</>
},
{
name: 'cashbox',
header: 'Cashbox',
width: widths.cashbox,
view: m => (
<CashIn
currency={{ code: fiatCurrency }}
notes={m.cashUnits.cashbox}
total={R.sum(R.map(it => it.fiat, bills[m.id ?? m.deviceId] ?? []))}
width={25}
height={45}
omitInnerPercentage
/>
),
inputProps: {
decimalPlaces: 0
}
},
{
name: 'cassettes',
header: 'Cassettes & Recyclers',
width: widths.cassettes,
view: m => {
return (
<div className="flex my-2 mx-0 gap-8">
<div className="flex gap-2">
{R.range(1, m.numberOfCassettes + 1).map((it, idx) => (
<CashOutLite
key={idx}
width={'100%'}
currency={{ code: fiatCurrency }}
notes={m.cashUnits[`cassette${it}`]}
denomination={
getCashoutSettings(m.id ?? m.deviceId)[`cassette${it}`]
}
threshold={
fillingPercentageSettings[`fillingPercentageCassette${it}`]
}
capacity={getCashUnitCapacity(m.model, 'cassette')}
/>
))}
</div>
<div className="flex gap-2">
{R.map(it => (
<>
<CashOutLite
width={'100%'}
currency={{ code: fiatCurrency }}
notes={m.cashUnits[`recycler${it * 2 - 1}`]}
denomination={
getCashoutSettings(m.id ?? m.deviceId)[
`recycler${it * 2 - 1}`
]
}
threshold={
fillingPercentageSettings[
`fillingPercentageRecycler${it * 2 - 1}`
]
}
capacity={getCashUnitCapacity(m.model, 'recycler')}
/>
<CashOutLite
width={'100%'}
currency={{ code: fiatCurrency }}
notes={m.cashUnits[`recycler${it * 2}`]}
denomination={
getCashoutSettings(m.id ?? m.deviceId)[
`recycler${it * 2}`
]
}
threshold={
fillingPercentageSettings[
`fillingPercentageRecycler${it * 2}`
]
}
capacity={getCashUnitCapacity(m.model, 'recycler')}
/>
{it !== m.numberOfRecyclers / 2 && (
<span className="h-full w-[1px] bg-comet2" />
)}
</>
))(R.range(1, m.numberOfRecyclers / 2 + 1))}
</div>
</div>
)
},
inputProps: {
decimalPlaces: 0
}
},
{
name: 'edit',
header: 'Edit',
width: widths.edit,
textAlign: 'center',
view: m => {
return (
<IconButton
onClick={() => {
!R.isNil(setMachineId) && setMachineId(m.id ?? m.deviceId)
setWizard(true)
}}>
<SvgIcon>
<EditIcon />
</SvgIcon>
</IconButton>
)
}
}
]
return elements
}
export default { getElements }