diff --git a/lib/machine-loader.js b/lib/machine-loader.js index ec1f48a2b..126407efd 100644 --- a/lib/machine-loader.js +++ b/lib/machine-loader.js @@ -32,7 +32,8 @@ function toMachineObject (r) { pairedAt: new Date(r.created), lastPing: new Date(r.last_online), name: r.name, - paired: r.paired + paired: r.paired, + location: r.machine_location // TODO: we shall start using this JSON field at some point // location: r.location, } @@ -85,13 +86,21 @@ function addName (pings, events, config) { } function getMachineNames (config) { - return Promise.all([getMachines(), getConfig(config), getNetworkHeartbeat(), getNetworkPerformance()]) - .then(([rawMachines, config, heartbeat, performance]) => Promise.all( - [rawMachines, checkPings(rawMachines), dbm.machineEvents(), config, heartbeat, performance] + return Promise.all([getMachines(), getConfig(config), getNetworkHeartbeat(), getNetworkPerformance(), getMachineLocations()]) + .then(([rawMachines, config, heartbeat, performance, locations]) => Promise.all( + [rawMachines, checkPings(rawMachines), dbm.machineEvents(), config, heartbeat, performance, locations] )) - .then(([rawMachines, pings, events, config, heartbeat, performance]) => { + .then(([rawMachines, pings, events, config, heartbeat, performance, locations]) => { const mergeByDeviceId = (x, y) => _.values(_.merge(_.keyBy('deviceId', x), _.keyBy('deviceId', y))) - const machines = mergeByDeviceId(mergeByDeviceId(rawMachines, heartbeat), performance) + const mergeByLocationId = (x, y) => _.map(it => { + if (_.isNil(it.location)) return it + return { ...it, location: _.find(ite => it.location === ite.id, y) } + }, x) + const machines = _.flow([ + x => mergeByDeviceId(x, heartbeat), + x => mergeByDeviceId(x, performance), + x => mergeByLocationId(x, locations) + ])(rawMachines) return machines.map(addName(pings, events, config)) }) @@ -121,13 +130,18 @@ function getMachine (machineId, config) { }) return Promise.all([queryMachine, dbm.machineEvents(), config, getNetworkHeartbeatByDevice(machineId), getNetworkPerformanceByDevice(machineId)]) - .then(([machine, events, config, heartbeat, performance]) => { + .then(([machine, events, config, heartbeat, performance]) => Promise.all([machine, events, config, heartbeat, performance, getMachineLocation(machine.location)])) + .then(([machine, events, config, heartbeat, performance, location]) => { const pings = checkPings([machine]) const mergedMachine = { ...machine, responseTime: _.get('responseTime', heartbeat), packetLoss: _.get('packetLoss', heartbeat), - downloadSpeed: _.get('downloadSpeed', performance), + downloadSpeed: _.get('downloadSpeed', performance) + } + + if (!_.isNil(location) && !_.isEmpty(location)) { + mergedMachine.location = location } return addName(pings, events, config)(mergedMachine) @@ -193,8 +207,10 @@ function restartServices (rec) { )]) } -function setMachine (rec, operatorId) { +function setMachine (rec, operatorId, userId) { rec.operatorId = operatorId + rec.userId = userId + logMachineAction(rec) switch (rec.action) { case 'rename': return renameMachine(rec) case 'emptyCashInBills': return emptyCashInBills(rec) @@ -204,10 +220,17 @@ function setMachine (rec, operatorId) { case 'reboot': return reboot(rec) case 'shutdown': return shutdown(rec) case 'restartServices': return restartServices(rec) + case 'editLocation': return editMachineLocation(rec.location, rec.deviceId) + case 'deleteLocation': return deleteMachineLocation(rec.location.id) + case 'createLocation': return createMachineLocation(rec.location, rec.deviceId) default: throw new Error('No such action: ' + rec.action) } } +function testLocation (rec) { + console.log(rec) +} + function updateNetworkPerformance (deviceId, data) { if (_.isEmpty(data)) return Promise.resolve(true) const downloadSpeed = _.head(data) @@ -266,6 +289,58 @@ function getNetworkHeartbeatByDevice (deviceId) { .then(res => _.mapKeys(_.camelCase, _.find(it => it.device_id === deviceId, res))) } +function getMachineLocations () { + const sql = `SELECT * FROM machine_locations` + return db.any(sql) + .then(_.map(_.mapKeys(_.camelCase))) +} + +function getMachineLocation (locationId) { + const sql = `SELECT * FROM machine_locations WHERE id = $1` + return db.oneOrNone(sql, [locationId]) + .then(_.mapKeys(_.camelCase)) +} + +function editMachineLocation ({ id, label, addressLine1, addressLine2, zipCode, country }, deviceId) { + const sql = `UPDATE machine_locations SET label = $1, address_line_1 = $2, address_line_2 = $3, zip_code = $4, country = $5 WHERE id = $6` + const sql2 = `UPDATE devices SET machine_location = $1 WHERE device_id = $2` + return db.none(sql, [label, addressLine1, addressLine2, zipCode, country, id]) + .then(() => _.isNil(deviceId) ? Promise.resolve() : db.none(sql2, [id, deviceId])) +} + +function createMachineLocation ({ id, label, addressLine1, addressLine2, zipCode, country }, deviceId) { + const _id = _.isEmpty(id) ? uuid.v4() : id + const sql = `INSERT INTO machine_locations (id, label, address_line_1, address_line_2, zip_code, country) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING` + const sql2 = `UPDATE devices SET machine_location = $1 WHERE device_id = $2` + return db.none(sql, [_id, label, addressLine1, addressLine2, zipCode, country]) + .then(() => _.isNil(deviceId) ? Promise.resolve() : db.none(sql2, [_id, deviceId])) +} + +function deleteMachineLocation (id) { + return db.tx(t => { + const q1 = t.none(`UPDATE devices SET machine_location = NULL WHERE machine_location = $1`, [id]) + const q2 = t.none(`UPDATE pairing_tokens SET machine_location = NULL WHERE machine_location = $1`, [id]) + const q3 = t.none(`DELETE FROM machine_locations WHERE id = $1`, [id]) + + return t.batch([q1, q2, q3]) + }) +} + +function assignLocation (machineId, locationId) { + const sql = `UPDATE devices SET machine_location = $1 WHERE device_id = $2` + return db.none(sql, [locationId, machineId]) +} + +function logMachineAction (rec) { + const userId = rec.userId + const deviceId = rec.deviceId + const action = rec.action + const values = _.omit(['userId', 'operatorId', 'deviceId', 'action'], rec) + const sql = `INSERT INTO machine_action_logs (id, device_id, action, values, performed_by) VALUES ($1, $2, $3, $4, $5)` + // console.log([uuid.v4(), deviceId, _.kebabCase(action), values, userId]) + return db.none(sql, [uuid.v4(), deviceId, _.kebabCase(action), values, userId]) +} + module.exports = { getMachineName, getMachines, @@ -277,5 +352,11 @@ module.exports = { updateNetworkHeartbeat, getNetworkPerformance, getNetworkHeartbeat, - getConfig + getConfig, + getMachineLocations, + getMachineLocation, + editMachineLocation, + createMachineLocation, + deleteMachineLocation, + assignLocation } diff --git a/lib/new-admin/graphql/resolvers/machine.resolver.js b/lib/new-admin/graphql/resolvers/machine.resolver.js index 8c0cbe43b..2ddf6af92 100644 --- a/lib/new-admin/graphql/resolvers/machine.resolver.js +++ b/lib/new-admin/graphql/resolvers/machine.resolver.js @@ -16,11 +16,12 @@ const resolvers = { Query: { machines: () => machineLoader.getMachineNames(), machine: (...[, { deviceId }]) => machineLoader.getMachine(deviceId), - unpairedMachines: () => machineLoader.getUnpairedMachines() + unpairedMachines: () => machineLoader.getUnpairedMachines(), + machineLocations: () => machineLoader.getMachineLocations() }, Mutation: { - machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }, context]) => - machineAction({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }, context) + machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName, location }, context]) => + machineAction({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName, location }, context) } } diff --git a/lib/new-admin/graphql/resolvers/pairing.resolver.js b/lib/new-admin/graphql/resolvers/pairing.resolver.js index 510a6ef12..8946c9695 100644 --- a/lib/new-admin/graphql/resolvers/pairing.resolver.js +++ b/lib/new-admin/graphql/resolvers/pairing.resolver.js @@ -1,8 +1,10 @@ const pairing = require('../../services/pairing') +const machine = require('../../../machine-loader') const resolvers = { Mutation: { - createPairingTotem: (...[, { name }]) => pairing.totem(name) + createPairingTotem: (...[, { name, location }]) => machine.createMachineLocation(location) + .then(() => pairing.totem(name, location)) } } diff --git a/lib/new-admin/graphql/types/machine.type.js b/lib/new-admin/graphql/types/machine.type.js index da409d7b8..e1afeb3c9 100644 --- a/lib/new-admin/graphql/types/machine.type.js +++ b/lib/new-admin/graphql/types/machine.type.js @@ -25,6 +25,16 @@ const typeDef = gql` downloadSpeed: String responseTime: String packetLoss: String + location: MachineLocation + } + + type MachineLocation { + id: ID + label: String + addressLine1: String + addressLine2: String + zipCode: String + country: String } type UnpairedMachine { @@ -55,16 +65,20 @@ const typeDef = gql` reboot shutdown restartServices + editLocation + deleteLocation + createLocation } type Query { machines: [Machine] @auth machine(deviceId: ID!): Machine @auth unpairedMachines: [UnpairedMachine!]! @auth + machineLocations: [MachineLocation] @auth } type Mutation { - machineAction(deviceId:ID!, action: MachineAction!, cashbox: Int, cassette1: Int, cassette2: Int, cassette3: Int, cassette4: Int, newName: String): Machine @auth + machineAction(deviceId:ID!, action: MachineAction!, cashbox: Int, cassette1: Int, cassette2: Int, cassette3: Int, cassette4: Int, newName: String, location: JSONObject): Machine @auth } ` diff --git a/lib/new-admin/graphql/types/pairing.type.js b/lib/new-admin/graphql/types/pairing.type.js index c08c79b0c..82d65855c 100644 --- a/lib/new-admin/graphql/types/pairing.type.js +++ b/lib/new-admin/graphql/types/pairing.type.js @@ -2,7 +2,7 @@ const { gql } = require('apollo-server-express') const typeDef = gql` type Mutation { - createPairingTotem(name: String!): String @auth + createPairingTotem(name: String!, location: JSONObject!): String @auth } ` diff --git a/lib/new-admin/services/machines.js b/lib/new-admin/services/machines.js index 6f060d0e7..67c204300 100644 --- a/lib/new-admin/services/machines.js +++ b/lib/new-admin/services/machines.js @@ -6,14 +6,15 @@ function getMachine (machineId) { .then(machines => machines.find(({ deviceId }) => deviceId === machineId)) } -function machineAction ({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }, context) { +function machineAction ({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName, location }, context) { const operatorId = context.res.locals.operatorId + const userId = context.req.session.user.id return getMachine(deviceId) .then(machine => { if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId }) return machine }) - .then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2, cassette3, cassette4], newName }, operatorId)) + .then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2, cassette3, cassette4], newName, location }, operatorId, userId)) .then(getMachine(deviceId)) } diff --git a/lib/new-admin/services/pairing.js b/lib/new-admin/services/pairing.js index d267c1a92..80ce67070 100644 --- a/lib/new-admin/services/pairing.js +++ b/lib/new-admin/services/pairing.js @@ -16,7 +16,7 @@ const HOSTNAME = process.env.HOSTNAME const unpair = pairing.unpair -function totem (name) { +function totem (name, location) { return readFile(CA_PATH) .then(data => { const caHash = crypto.createHash('sha256').update(data).digest() @@ -24,9 +24,9 @@ function totem (name) { const hexToken = token.toString('hex') const caHexToken = crypto.createHash('sha256').update(hexToken).digest('hex') const buf = Buffer.concat([caHash, token, Buffer.from(HOSTNAME)]) - const sql = 'insert into pairing_tokens (token, name) values ($1, $3), ($2, $3)' + const sql = 'insert into pairing_tokens (token, name, machine_location) values ($1, $3, $4), ($2, $3, $4)' - return db.none(sql, [hexToken, caHexToken, name]) + return db.none(sql, [hexToken, caHexToken, name, location.id]) .then(() => bsAlpha.encode(buf)) }) } diff --git a/lib/pairing.js b/lib/pairing.js index f6942591b..3d65775ef 100644 --- a/lib/pairing.js +++ b/lib/pairing.js @@ -14,7 +14,7 @@ const DEFAULT_NUMBER_OF_CASSETTES = 2 function pullToken (token) { const sql = `delete from pairing_tokens where token=$1 - returning name, created < now() - interval '1 hour' as expired` + returning name, created < now() - interval '1 hour' as expired, machine_location` return db.one(sql, [token]) } @@ -41,11 +41,11 @@ function pair (token, deviceId, machineModel, numOfCassettes = DEFAULT_NUMBER_OF .then(r => { if (r.expired) return false - const insertSql = `insert into devices (device_id, name, number_of_cassettes) values ($1, $2, $3) + const insertSql = `insert into devices (device_id, name, number_of_cassettes, machine_location) values ($1, $2, $3, $4) on conflict (device_id) do update set paired=TRUE, display=TRUE` - return db.none(insertSql, [deviceId, r.name, numOfCassettes]) + return db.none(insertSql, [deviceId, r.name, numOfCassettes, r.machine_location]) .then(() => true) }) .catch(err => { diff --git a/migrations/1664411947220-machine-location.js b/migrations/1664411947220-machine-location.js new file mode 100644 index 000000000..ed82ba6e4 --- /dev/null +++ b/migrations/1664411947220-machine-location.js @@ -0,0 +1,23 @@ +var db = require('./db') + +exports.up = function (next) { + var sql = [ + `CREATE TABLE machine_locations ( + id UUID PRIMARY KEY, + label TEXT NOT NULL, + address_line_1 TEXT NOT NULL, + address_line_2 TEXT, + zip_code TEXT NOT NULL, + country TEXT NOT NULL, + created TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `ALTER TABLE devices ADD COLUMN machine_location UUID REFERENCES machine_locations(id)`, + `ALTER TABLE pairing_tokens ADD COLUMN machine_location UUID REFERENCES machine_locations(id)` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/migrations/1664748434695-machine-actions-auditing.js b/migrations/1664748434695-machine-actions-auditing.js new file mode 100644 index 000000000..dcfec3491 --- /dev/null +++ b/migrations/1664748434695-machine-actions-auditing.js @@ -0,0 +1,35 @@ +var db = require('./db') + +exports.up = function (next) { + var sql = [ + `CREATE TYPE machine_action AS ENUM ( + 'rename', + 'empty-cash-in-bills', + 'reset-cash-out-bills', + 'set-cassette-bills', + 'unpair', + 'reboot', + 'shutdown', + 'restart-services', + 'edit-location', + 'delete-location', + 'create-location', + 'disable', + 'enable' + )`, + `CREATE TABLE machine_action_logs ( + id UUID PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices(device_id), + action machine_action NOT NULL, + values JSONB NOT NULL, + performed_by UUID NOT NULL REFERENCES users(id), + performed_at TIMESTAMPTZ NOT NULL DEFAULT now() + )` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/src/components/machineActions/EditLocationModal.js b/new-lamassu-admin/src/components/machineActions/EditLocationModal.js new file mode 100644 index 000000000..529e47173 --- /dev/null +++ b/new-lamassu-admin/src/components/machineActions/EditLocationModal.js @@ -0,0 +1,274 @@ +import { useQuery } from '@apollo/react-hooks' +import { makeStyles } from '@material-ui/core/styles' +import { Form, Formik, FastField } from 'formik' +import gql from 'graphql-tag' +import * as R from 'ramda' +import React, { useState } from 'react' +import * as Yup from 'yup' + +import ErrorMessage from 'src/components/ErrorMessage' +import { Button } from 'src/components/buttons' +import { Autocomplete } from 'src/components/inputs' +import { + TextInput, + Autocomplete as FormikAutocomplete +} from 'src/components/inputs/formik' +import { spacer } from 'src/styling/variables' +import { fromNamespace, namespaces } from 'src/utils/config' + +import Modal from '../Modal' + +const GET_COUNTRIES = gql` + { + machineLocations { + id + label + addressLine1 + addressLine2 + zipCode + country + } + config + countries { + code + display + } + } +` + +const styles = { + form: { + display: 'flex', + flexDirection: 'column', + height: '100%', + '& > *': { + marginTop: 20 + }, + '& > *:last-child': { + marginTop: 'auto' + } + }, + submit: { + display: 'flex', + flexDirection: 'row', + margin: [['auto', 0, 0, 'auto']], + '& > *': { + marginRight: 10 + }, + '& > *:last-child': { + marginRight: 0 + } + }, + footer: { + display: 'flex', + flexDirection: 'row', + margin: [['auto', 0, spacer * 3, 0]] + }, + existingLocation: { + marginBottom: 50 + } +} + +const useStyles = makeStyles(styles) + +const EditLocationModal = ({ + machine, + handleClose, + editAction, + deleteAction, + createAction +}) => { + const classes = useStyles() + const { data, loading, refetch } = useQuery(GET_COUNTRIES, { + onCompleted: () => + setPreset( + R.find(it => it.value?.id === machine.location?.id, locationOptions) ?? + locationOptions[0] + ) + }) + + const machineLocations = data?.machineLocations ?? [] + const countries = data?.countries ?? [] + const locationOptions = [ + { label: 'New location' }, + ...R.map(it => ({ label: it.label, value: it }), machineLocations) + ] + const localeCountry = R.find( + it => it.code === fromNamespace(namespaces.LOCALE)(data?.config).country, + countries + ) + const [preset, setPreset] = useState(null) + + const initialValues = { + location: { + id: machine.location?.id ?? '', + label: machine.location?.label ?? '', + addressLine1: machine.location?.addressLine1 ?? '', + addressLine2: machine.location?.addressLine2 ?? '', + zipCode: machine.location?.zipCode ?? '', + country: machine.location?.country ?? localeCountry?.display ?? '' + } + } + + const newLocationValues = { + location: { + id: '', + label: '', + addressLine1: '', + addressLine2: '', + zipCode: '', + country: localeCountry?.display ?? '' + } + } + + const validationSchema = Yup.object().shape({ + location: Yup.object().shape({ + label: Yup.string() + .required('A label is required.') + .max(50), + addressLine1: Yup.string() + .required('An address is required.') + .max(75), + addressLine2: Yup.string().max(75), + zipCode: Yup.string() + .required('A zip code is required.') + .max(20), + country: Yup.string() + .required('A country is required.') + .max(50) + }) + }) + + const newLocationOption = R.find(it => !it.value, locationOptions) + + const isNewLocation = it => R.equals(it, newLocationOption) + + const createLocation = location => { + return createAction(location) + } + + const editLocation = location => { + return editAction(location) + } + + const deleteLocation = (location, onActionSuccess) => { + return deleteAction(location, onActionSuccess) + } + + return ( + !loading && ( + + { + if (R.isEmpty(location.id)) { + return createLocation(location) + } + return editLocation(location) + }}> + {({ values, errors, setFieldValue }) => ( + <> + {!R.isEmpty(machineLocations) && ( +
+ { + setPreset(it) + setFieldValue( + 'location', + isNewLocation(it) + ? newLocationValues.location + : it.value + ) + }} + /> +
+ )} +
+ + + + + +
+ {!R.isEmpty(errors) && ( + + {R.head(R.values(errors.location))} + + )} +
+ {!isNewLocation(preset) && ( + + )} + +
+
+ + + )} +
+
+ ) + ) +} + +export default EditLocationModal diff --git a/new-lamassu-admin/src/components/machineActions/MachineActions.js b/new-lamassu-admin/src/components/machineActions/MachineActions.js index 06c1d6b19..27663d704 100644 --- a/new-lamassu-admin/src/components/machineActions/MachineActions.js +++ b/new-lamassu-admin/src/components/machineActions/MachineActions.js @@ -15,6 +15,7 @@ import { ReactComponent as ShutdownIcon } from 'src/styling/icons/button/shut do import { ReactComponent as UnpairReversedIcon } from 'src/styling/icons/button/unpair/white.svg' import { ReactComponent as UnpairIcon } from 'src/styling/icons/button/unpair/zodiac.svg' +import EditLocationModal from './EditLocationModal' import { machineActionsStyles } from './MachineActions.styles' const useStyles = makeStyles(machineActionsStyles) @@ -24,8 +25,14 @@ const MACHINE_ACTION = gql` $deviceId: ID! $action: MachineAction! $newName: String + $location: JSONObject ) { - machineAction(deviceId: $deviceId, action: $action, newName: $newName) { + machineAction( + deviceId: $deviceId + action: $action + newName: $newName + location: $location + ) { deviceId } } @@ -67,6 +74,7 @@ const MachineActions = memo(({ machine, onActionSuccess }) => { const [action, setAction] = useState({ command: null }) const [preflightOptions, setPreflightOptions] = useState({}) const [errorMessage, setErrorMessage] = useState(null) + const [showLocationModal, setShowLocationModal] = useState(false) const classes = useStyles() const warningMessage = ( @@ -87,7 +95,11 @@ const MachineActions = memo(({ machine, onActionSuccess }) => { setErrorMessage(errorMessage) }, onCompleted: () => { - onActionSuccess && onActionSuccess() + if (action.onActionSuccess) { + action.onActionSuccess() + } else { + onActionSuccess && onActionSuccess() + } setAction({ display: action.display, command: null }) } }) @@ -110,6 +122,38 @@ const MachineActions = memo(({ machine, onActionSuccess }) => { return (
+ {showLocationModal && ( + + setAction({ + command: 'editLocation', + title: "Edit this machine's location", + arguments: { location } + }) + } + deleteAction={(location, onActionSuccess) => + setAction({ + command: 'deleteLocation', + title: `Delete location '${location?.label}'`, + toBeConfirmed: location?.label, + arguments: { location }, + onActionSuccess + }) + } + createAction={location => + setAction({ + command: 'createLocation', + title: 'Create a new location for this machine', + arguments: { location } + }) + } + handleClose={() => { + onActionSuccess && onActionSuccess() + setShowLocationModal(false) + }} + /> + )}

Actions

{ }}> Restart Services + setShowLocationModal(true)}> + Change location +
{ variables: { deviceId: machine.deviceId, action: `${action?.command}`, - ...(action?.command === 'rename' && { newName: value }) + ...(action?.command === 'rename' && { newName: value }), + ...(action?.arguments ?? {}) } }) }} diff --git a/new-lamassu-admin/src/pages/AddMachine/AddMachine.js b/new-lamassu-admin/src/pages/AddMachine/AddMachine.js index f74deff2c..7804cbe17 100644 --- a/new-lamassu-admin/src/pages/AddMachine/AddMachine.js +++ b/new-lamassu-admin/src/pages/AddMachine/AddMachine.js @@ -7,11 +7,16 @@ import gql from 'graphql-tag' import QRCode from 'qrcode.react' import * as R from 'ramda' import React, { memo, useState, useEffect, useRef } from 'react' +import * as uuid from 'uuid' import * as Yup from 'yup' import Title from 'src/components/Title' import { Button } from 'src/components/buttons' -import { TextInput } from 'src/components/inputs/formik' +import { Autocomplete } from 'src/components/inputs' +import { + TextInput, + Autocomplete as FormikAutocomplete +} from 'src/components/inputs/formik' import Sidebar from 'src/components/layout/Sidebar' import { Info2, P } from 'src/components/typography' import { ReactComponent as CameraIcon } from 'src/styling/icons/ID/photo/zodiac.svg' @@ -22,12 +27,13 @@ import { ReactComponent as CurrentStageIconZodiac } from 'src/styling/icons/stag import { ReactComponent as EmptyStageIconZodiac } from 'src/styling/icons/stage/zodiac/empty.svg' import { ReactComponent as WarningIcon } from 'src/styling/icons/warning-icon/comet.svg' import { primaryColor } from 'src/styling/variables' +import { fromNamespace, namespaces } from 'src/utils/config' import styles from './styles' const SAVE_CONFIG = gql` - mutation createPairingTotem($name: String!) { - createPairingTotem(name: $name) + mutation createPairingTotem($name: String!, $location: JSONObject!) { + createPairingTotem(name: $name, location: $location) } ` const GET_MACHINES = gql` @@ -39,6 +45,24 @@ const GET_MACHINES = gql` } ` +const GET_COUNTRIES = gql` + { + machineLocations { + id + label + addressLine1 + addressLine2 + zipCode + country + } + config + countries { + code + display + } + } +` + const useStyles = makeStyles(styles) const getSize = R.compose(R.length, R.pathOr([], ['machines'])) @@ -114,37 +138,7 @@ const QrCodeComponent = ({ classes, qrCode, name, count, onPaired }) => { ) } -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.any( - it => R.equals(R.toLower(it), R.toLower(value)), - context.options.context.machineNames - ) - ) -}) - -const MachineNameComponent = ({ nextStep, classes, 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 MachineNameComponent = ({ nextStep, classes, name, setName }) => { const { data } = useQuery(GET_MACHINES) const machineNames = R.map(R.prop('name'), data?.machines || {}) @@ -158,6 +152,25 @@ const MachineNameComponent = ({ nextStep, classes, setQrCode, setName }) => { } } + const initialValues = { + name: 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.any( + it => R.equals(R.toLower(it), R.toLower(value)), + context.options.context.machineNames + ) + ) + }) + return ( <> @@ -170,7 +183,7 @@ const MachineNameComponent = ({ nextStep, classes, setQrCode, setName }) => { validate={uniqueNameValidator} onSubmit={({ name }) => { setName(name) - register({ variables: { name } }) + nextStep() }}> {({ errors }) => (
@@ -183,7 +196,7 @@ const MachineNameComponent = ({ nextStep, classes, setQrCode, setName }) => {
{errors &&

{errors.message}

}
- +
)} @@ -192,11 +205,195 @@ const MachineNameComponent = ({ nextStep, classes, setQrCode, setName }) => { ) } +const LocationComponent = ({ + nextStep, + previousStep, + classes, + location, + name, + setLocation, + setQrCode +}) => { + const [disabled, setDisabled] = useState(false) + const { data, loading } = useQuery(GET_COUNTRIES) + 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 machineLocations = data?.machineLocations ?? [] + const countries = data?.countries ?? [] + const localeCountry = R.find( + it => it.code === fromNamespace(namespaces.LOCALE)(data?.config).country, + countries + ) + + const initialValues = { + location: { + label: location?.label ?? '', + addressLine1: location?.addressLine1 ?? '', + addressLine2: location?.addressLine2 ?? '', + zipCode: location?.zipCode ?? '', + country: location?.country ?? localeCountry?.display ?? '' + } + } + + const validationSchema = Yup.object().shape({ + location: Yup.object().shape({ + label: Yup.string() + .required('A label is required.') + .max(50), + addressLine1: Yup.string() + .required('An address is required.') + .max(75), + addressLine2: Yup.string().max(75), + zipCode: Yup.string() + .required('A zip code is required.') + .max(20), + country: Yup.string() + .required('A country is required.') + .max(50) + }) + }) + + const locationOptions = [ + { label: 'New location' }, + ...R.map(it => ({ label: it.label, value: it }), machineLocations) + ] + const [preset, setPreset] = useState(locationOptions[0]) + + const newLocationOption = R.find(it => !it.value, locationOptions) + + const isNewLocation = it => R.equals(it, newLocationOption) + + return ( + !loading && ( + <> + + Machine Name (ex: Coffee shop 01) + + { + setLocation(location) + register({ + variables: { + name, + location: { ...location, id: location.id ?? uuid.v4() } + } + }) + }}> + {({ values, errors, setFieldValue, setFieldTouched }) => ( + <> + {!R.isEmpty(machineLocations) && ( +
+ { + setPreset(it) + setFieldValue( + 'location', + isNewLocation(it) ? initialValues.location : it.value + ) + setDisabled(!isNewLocation(it)) + // NOTE: Autocomplete fields have a weird behavior with the disabled prop, when they already have a value in them (see initialValues), + // where they do not disable until being touched, remaining permanently disabled until touched again (if the 'disabled' flag allows it in this case). + // Touching the field makes the behavior work as intended + setFieldTouched('location.country', !!isNewLocation(it)) + }} + /> +
+ )} +
+
+ + + + + +
+ {errors && ( +

+ {R.head(R.values(errors.location))} +

+ )} +
+ + +
+
+ + )} +
+ + ) + ) +} + const steps = [ { label: 'Machine name', component: MachineNameComponent }, + { + label: 'Location', + component: LocationComponent + }, { label: 'Scan QR code', component: QrCodeComponent @@ -237,6 +434,7 @@ const AddMachine = memo(({ close, onPaired }) => { const { data } = useQuery(GET_MACHINES) const [qrCode, setQrCode] = useState('') const [name, setName] = useState('') + const [location, setLocation] = useState({}) const [step, setStep] = useState(0) const count = getSize(data) @@ -266,13 +464,16 @@ const AddMachine = memo(({ close, onPaired }) => {
setStep(1)} + nextStep={() => setStep(step + 1)} + previousStep={() => setStep(step - 1)} count={count} onPaired={onPaired} qrCode={qrCode} setQrCode={setQrCode} name={name} setName={setName} + location={location} + setLocation={setLocation} />
diff --git a/new-lamassu-admin/src/pages/AddMachine/styles.js b/new-lamassu-admin/src/pages/AddMachine/styles.js index 75d3c6089..9d227b9e6 100644 --- a/new-lamassu-admin/src/pages/AddMachine/styles.js +++ b/new-lamassu-admin/src/pages/AddMachine/styles.js @@ -45,7 +45,15 @@ const styles = { marginLeft: 48 }, button: { - marginTop: 64 + display: 'flex', + flexDirection: 'row', + marginTop: 64, + '& > *': { + marginRight: 25 + }, + '& > *:last-child': { + marginRight: 0 + } }, nameTitle: { marginTop: 16, @@ -143,6 +151,13 @@ const styles = { '& > p': { marginLeft: 10 } + }, + locationForm: { + display: 'flex', + flexDirection: 'column' + }, + existingLocation: { + marginBottom: 45 } } diff --git a/new-lamassu-admin/src/pages/Machines/MachineComponents/Overview.js b/new-lamassu-admin/src/pages/Machines/MachineComponents/Overview.js index 86965037f..79bcc69fe 100644 --- a/new-lamassu-admin/src/pages/Machines/MachineComponents/Overview.js +++ b/new-lamassu-admin/src/pages/Machines/MachineComponents/Overview.js @@ -1,6 +1,7 @@ import { makeStyles } from '@material-ui/core/styles' import BigNumber from 'bignumber.js' import { formatDistance } from 'date-fns' +import * as R from 'ramda' import React from 'react' import { Status } from 'src/components/Status' @@ -16,14 +17,31 @@ const Overview = ({ data, onActionSuccess }) => { return (
-
-
-

{data.name}

+
+
+
+
+

{data.name}

+
+ {!R.isNil(data.location) && ( +
+ Address +

+ {`${data.location.addressLine1}${ + data.location.addressLine2 + ? `, ${data.location.addressLine2}` + : `` + }, ${data.location.zipCode}, ${data.location.country}`} +

+
+ )}
- Status + + Status + {data && data.statuses ? : null}
diff --git a/new-lamassu-admin/src/pages/Machines/Machines.js b/new-lamassu-admin/src/pages/Machines/Machines.js index 955d40f44..200b183e5 100644 --- a/new-lamassu-admin/src/pages/Machines/Machines.js +++ b/new-lamassu-admin/src/pages/Machines/Machines.js @@ -45,6 +45,14 @@ const GET_INFO = gql` latestEvent { note } + location { + id + label + addressLine1 + addressLine2 + zipCode + country + } } bills(filters: $billFilters) { id @@ -120,7 +128,7 @@ const Machines = ({ data, refetch, reload }) => { {machineName} - +
diff --git a/new-lamassu-admin/src/pages/Machines/Machines.styles.js b/new-lamassu-admin/src/pages/Machines/Machines.styles.js index 2a5249393..fe04f0cc5 100644 --- a/new-lamassu-admin/src/pages/Machines/Machines.styles.js +++ b/new-lamassu-admin/src/pages/Machines/Machines.styles.js @@ -1,4 +1,4 @@ -import { spacer, comet } from 'src/styling/variables' +import { spacer, comet, zircon } from 'src/styling/variables' const styles = { grid: { @@ -56,10 +56,23 @@ const styles = { contentContainer: { '& > *': { marginTop: 26 + } + }, + overviewRow: { + display: 'flex', + flexDirection: 'row', + '& > *': { + marginRight: 26 }, - '& > *:first-child': { - marginTop: 0 + '& > *:last-child': { + marginRight: 0 } + }, + machineBackground: { + minWidth: 116, + minHeight: 116, + backgroundColor: zircon, + borderRadius: 8 } } diff --git a/new-lamassu-admin/src/pages/Maintenance/MachineStatus.js b/new-lamassu-admin/src/pages/Maintenance/MachineStatus.js index 235129474..1f8000952 100644 --- a/new-lamassu-admin/src/pages/Maintenance/MachineStatus.js +++ b/new-lamassu-admin/src/pages/Maintenance/MachineStatus.js @@ -37,6 +37,14 @@ const GET_MACHINES = gql` downloadSpeed responseTime packetLoss + location { + id + label + addressLine1 + addressLine2 + zipCode + country + } } } ` @@ -115,7 +123,11 @@ const MachineStatus = () => { ) const InnerMachineDetailsRow = ({ it }) => ( - + refetch()} + timezone={timezone} + /> ) const loading = machinesLoading || configLoading