Merge pull request #851 from chaotixkilla/feat-customer-creation-modal
Add customer creation via admin
This commit is contained in:
commit
a422054375
8 changed files with 190 additions and 14 deletions
|
|
@ -49,7 +49,8 @@ const resolvers = {
|
||||||
},
|
},
|
||||||
deleteCustomerNote: (...[, { noteId }]) => {
|
deleteCustomerNote: (...[, { noteId }]) => {
|
||||||
return customerNotes.deleteCustomerNote(noteId)
|
return customerNotes.deleteCustomerNote(noteId)
|
||||||
}
|
},
|
||||||
|
createCustomer: (...[, { phoneNumber }]) => customers.add({ phone: phoneNumber })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ const typeDef = gql`
|
||||||
createCustomerNote(customerId: ID!, title: String!, content: String!): Boolean @auth
|
createCustomerNote(customerId: ID!, title: String!, content: String!): Boolean @auth
|
||||||
editCustomerNote(noteId: ID!, newContent: String!): Boolean @auth
|
editCustomerNote(noteId: ID!, newContent: String!): Boolean @auth
|
||||||
deleteCustomerNote(noteId: ID!): Boolean @auth
|
deleteCustomerNote(noteId: ID!): Boolean @auth
|
||||||
|
createCustomer(phoneNumber: String): Customer @auth
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
|
||||||
5
new-lamassu-admin/package-lock.json
generated
5
new-lamassu-admin/package-lock.json
generated
|
|
@ -13870,6 +13870,11 @@
|
||||||
"delegate": "^3.1.2"
|
"delegate": "^3.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"google-libphonenumber": {
|
||||||
|
"version": "3.2.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.22.tgz",
|
||||||
|
"integrity": "sha512-lzEllxWc05n/HEv75SsDrA7zdEVvQzTZimItZm/TZ5XBs7cmx2NJmSlA5I0kZbdKNu8GFETBhSpo+SOhx0JslA=="
|
||||||
|
},
|
||||||
"graceful-fs": {
|
"graceful-fs": {
|
||||||
"version": "4.2.5",
|
"version": "4.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"downshift": "3.3.4",
|
"downshift": "3.3.4",
|
||||||
"file-saver": "2.0.2",
|
"file-saver": "2.0.2",
|
||||||
"formik": "2.2.0",
|
"formik": "2.2.0",
|
||||||
|
"google-libphonenumber": "^3.2.22",
|
||||||
"graphql": "^14.5.8",
|
"graphql": "^14.5.8",
|
||||||
"graphql-tag": "^2.10.3",
|
"graphql-tag": "^2.10.3",
|
||||||
"jss-plugin-extend": "^10.0.0",
|
"jss-plugin-extend": "^10.0.0",
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@ const TitleSection = ({
|
||||||
buttons = [],
|
buttons = [],
|
||||||
children,
|
children,
|
||||||
appendix,
|
appendix,
|
||||||
appendixClassName
|
appendixRight
|
||||||
}) => {
|
}) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
return (
|
return (
|
||||||
<div className={classnames(classes.titleWrapper, className)}>
|
<div className={classnames(classes.titleWrapper, className)}>
|
||||||
<div className={classes.titleAndButtonsContainer}>
|
<div className={classes.titleAndButtonsContainer}>
|
||||||
<Title>{title}</Title>
|
<Title>{title}</Title>
|
||||||
{appendix && <div className={appendixClassName}>{appendix}</div>}
|
{!!appendix && appendix}
|
||||||
{error && (
|
{error && (
|
||||||
<ErrorMessage className={classes.error}>Failed to save</ErrorMessage>
|
<ErrorMessage className={classes.error}>Failed to save</ErrorMessage>
|
||||||
)}
|
)}
|
||||||
|
|
@ -46,13 +46,14 @@ const TitleSection = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Box display="flex" flexDirection="row">
|
<Box display="flex" flexDirection="row" alignItems="center">
|
||||||
{(labels ?? []).map(({ icon, label }, idx) => (
|
{(labels ?? []).map(({ icon, label }, idx) => (
|
||||||
<Box key={idx} display="flex" alignItems="center">
|
<Box key={idx} display="flex" alignItems="center">
|
||||||
<div className={classes.icon}>{icon}</div>
|
<div className={classes.icon}>{icon}</div>
|
||||||
<Label1 className={classes.label}>{label}</Label1>
|
<Label1 className={classes.label}>{label}</Label1>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
{appendixRight}
|
||||||
</Box>
|
</Box>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useQuery } from '@apollo/react-hooks'
|
import { useQuery, useMutation } from '@apollo/react-hooks'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { Box, makeStyles } from '@material-ui/core'
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import * as R from 'ramda'
|
import * as R from 'ramda'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
@ -7,6 +7,7 @@ import { useHistory } from 'react-router-dom'
|
||||||
|
|
||||||
import SearchBox from 'src/components/SearchBox'
|
import SearchBox from 'src/components/SearchBox'
|
||||||
import SearchFilter from 'src/components/SearchFilter'
|
import SearchFilter from 'src/components/SearchFilter'
|
||||||
|
import { Link } from 'src/components/buttons'
|
||||||
import TitleSection from 'src/components/layout/TitleSection'
|
import TitleSection from 'src/components/layout/TitleSection'
|
||||||
import baseStyles from 'src/pages/Logs.styles'
|
import baseStyles from 'src/pages/Logs.styles'
|
||||||
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
|
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
|
||||||
|
|
@ -14,6 +15,7 @@ import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-ou
|
||||||
import { fromNamespace, namespaces } from 'src/utils/config'
|
import { fromNamespace, namespaces } from 'src/utils/config'
|
||||||
|
|
||||||
import CustomersList from './CustomersList'
|
import CustomersList from './CustomersList'
|
||||||
|
import CreateCustomerModal from './components/CreateCustomerModal'
|
||||||
|
|
||||||
const GET_CUSTOMER_FILTERS = gql`
|
const GET_CUSTOMER_FILTERS = gql`
|
||||||
query filters {
|
query filters {
|
||||||
|
|
@ -49,6 +51,14 @@ const GET_CUSTOMERS = gql`
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const CREATE_CUSTOMER = gql`
|
||||||
|
mutation createCustomer($phoneNumber: String) {
|
||||||
|
createCustomer(phoneNumber: $phoneNumber) {
|
||||||
|
phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
const useBaseStyles = makeStyles(baseStyles)
|
const useBaseStyles = makeStyles(baseStyles)
|
||||||
|
|
||||||
const getFiltersObj = filters =>
|
const getFiltersObj = filters =>
|
||||||
|
|
@ -64,6 +74,7 @@ const Customers = () => {
|
||||||
const [filteredCustomers, setFilteredCustomers] = useState([])
|
const [filteredCustomers, setFilteredCustomers] = useState([])
|
||||||
const [variables, setVariables] = useState({})
|
const [variables, setVariables] = useState({})
|
||||||
const [filters, setFilters] = useState([])
|
const [filters, setFilters] = useState([])
|
||||||
|
const [showCreationModal, setShowCreationModal] = useState(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: customersResponse,
|
data: customersResponse,
|
||||||
|
|
@ -78,6 +89,11 @@ const Customers = () => {
|
||||||
GET_CUSTOMER_FILTERS
|
GET_CUSTOMER_FILTERS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [createNewCustomer] = useMutation(CREATE_CUSTOMER, {
|
||||||
|
onCompleted: () => setShowCreationModal(false),
|
||||||
|
refetchQueries: () => ['configAndCustomers']
|
||||||
|
})
|
||||||
|
|
||||||
const configData = R.path(['config'])(customersResponse) ?? []
|
const configData = R.path(['config'])(customersResponse) ?? []
|
||||||
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
|
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
|
||||||
const customersData = R.sortWith([
|
const customersData = R.sortWith([
|
||||||
|
|
@ -139,7 +155,7 @@ const Customers = () => {
|
||||||
<TitleSection
|
<TitleSection
|
||||||
title="Customers"
|
title="Customers"
|
||||||
appendix={
|
appendix={
|
||||||
<div>
|
<div className={baseStyles.buttonsWrapper}>
|
||||||
<SearchBox
|
<SearchBox
|
||||||
loading={loadingFilters}
|
loading={loadingFilters}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
@ -149,7 +165,13 @@ const Customers = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
appendixClassName={baseStyles.buttonsWrapper}
|
appendixRight={
|
||||||
|
<Box display="flex">
|
||||||
|
<Link color="primary" onClick={() => setShowCreationModal(true)}>
|
||||||
|
Add new user
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
labels={[
|
labels={[
|
||||||
{ label: 'Cash-in', icon: <TxInIcon /> },
|
{ label: 'Cash-in', icon: <TxInIcon /> },
|
||||||
{ label: 'Cash-out', icon: <TxOutIcon /> }
|
{ label: 'Cash-out', icon: <TxOutIcon /> }
|
||||||
|
|
@ -169,6 +191,12 @@ const Customers = () => {
|
||||||
onClick={handleCustomerClicked}
|
onClick={handleCustomerClicked}
|
||||||
loading={customerLoading}
|
loading={customerLoading}
|
||||||
/>
|
/>
|
||||||
|
<CreateCustomerModal
|
||||||
|
showModal={showCreationModal}
|
||||||
|
handleClose={() => setShowCreationModal(false)}
|
||||||
|
locale={locale}
|
||||||
|
onSubmit={createNewCustomer}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,36 +19,36 @@ const CustomersList = ({ data, locale, onClick, loading }) => {
|
||||||
const elements = [
|
const elements = [
|
||||||
{
|
{
|
||||||
header: 'Phone',
|
header: 'Phone',
|
||||||
width: 175,
|
width: 199,
|
||||||
view: it => getFormattedPhone(it.phone, locale.country)
|
view: it => getFormattedPhone(it.phone, locale.country)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
width: 247,
|
width: 241,
|
||||||
view: getName
|
view: getName
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Total TXs',
|
header: 'Total TXs',
|
||||||
width: 130,
|
width: 126,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
view: it => `${Number.parseInt(it.totalTxs)}`
|
view: it => `${Number.parseInt(it.totalTxs)}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Total spent',
|
header: 'Total spent',
|
||||||
width: 155,
|
width: 152,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
view: it =>
|
view: it =>
|
||||||
`${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}`
|
`${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Last active',
|
header: 'Last active',
|
||||||
width: 137,
|
width: 133,
|
||||||
view: it =>
|
view: it =>
|
||||||
(it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? ''
|
(it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? ''
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Last transaction',
|
header: 'Last transaction',
|
||||||
width: 165,
|
width: 161,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
view: it => {
|
view: it => {
|
||||||
const hasLastTx = !R.isNil(it.lastTxFiatCode)
|
const hasLastTx = !R.isNil(it.lastTxFiatCode)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import { Field, Form, Formik } from 'formik'
|
||||||
|
import { PhoneNumberFormat, PhoneNumberUtil } from 'google-libphonenumber'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React from 'react'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
|
||||||
|
import ErrorMessage from 'src/components/ErrorMessage'
|
||||||
|
import Modal from 'src/components/Modal'
|
||||||
|
import { Button } from 'src/components/buttons'
|
||||||
|
import { TextInput } from 'src/components/inputs/formik'
|
||||||
|
import { H1 } from 'src/components/typography'
|
||||||
|
import { spacer, primaryColor, fontPrimary } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
modalTitle: {
|
||||||
|
marginTop: -5,
|
||||||
|
color: primaryColor,
|
||||||
|
fontFamily: fontPrimary
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
margin: [['auto', 0, spacer * 3, 0]]
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%'
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
margin: [['auto', 0, 0, 'auto']]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pnUtilInstance = PhoneNumberUtil.getInstance()
|
||||||
|
|
||||||
|
const getValidationSchema = countryCodes =>
|
||||||
|
Yup.object().shape({
|
||||||
|
phoneNumber: Yup.string()
|
||||||
|
.required('A phone number is required')
|
||||||
|
.test('is-valid-number', 'That is not a valid phone number', value => {
|
||||||
|
try {
|
||||||
|
const validMap = R.map(it => {
|
||||||
|
const number = pnUtilInstance.parseAndKeepRawInput(value, it)
|
||||||
|
return pnUtilInstance.isValidNumber(number)
|
||||||
|
}, countryCodes)
|
||||||
|
|
||||||
|
return R.any(it => it === true, validMap)
|
||||||
|
} catch (e) {}
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatPhoneNumber = (countryCodes, numberStr) => {
|
||||||
|
const matchedCountry = R.find(it => {
|
||||||
|
const number = pnUtilInstance.parseAndKeepRawInput(numberStr, it)
|
||||||
|
return pnUtilInstance.isValidNumber(number)
|
||||||
|
}, countryCodes)
|
||||||
|
|
||||||
|
const matchedNumber = pnUtilInstance.parseAndKeepRawInput(
|
||||||
|
numberStr,
|
||||||
|
matchedCountry
|
||||||
|
)
|
||||||
|
|
||||||
|
return pnUtilInstance.format(matchedNumber, PhoneNumberFormat.E164)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
phoneNumber: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const getErrorMsg = (formikErrors, formikTouched) => {
|
||||||
|
if (!formikErrors || !formikTouched) return null
|
||||||
|
if (formikErrors.phoneNumber && formikTouched.phoneNumber)
|
||||||
|
return formikErrors.phoneNumber
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateCustomerModal = ({ showModal, handleClose, onSubmit, locale }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const possibleCountries = R.append(
|
||||||
|
locale?.country,
|
||||||
|
R.map(it => it.country, locale?.overrides ?? [])
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
closeOnBackdropClick={true}
|
||||||
|
width={600}
|
||||||
|
height={300}
|
||||||
|
handleClose={handleClose}
|
||||||
|
open={showModal}>
|
||||||
|
<Formik
|
||||||
|
validationSchema={getValidationSchema(possibleCountries)}
|
||||||
|
initialValues={initialValues}
|
||||||
|
validateOnChange={false}
|
||||||
|
onSubmit={values => {
|
||||||
|
onSubmit({
|
||||||
|
variables: {
|
||||||
|
phoneNumber: formatPhoneNumber(
|
||||||
|
possibleCountries,
|
||||||
|
values.phoneNumber
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
{({ errors, touched }) => (
|
||||||
|
<Form id="customer-registration-form" className={classes.form}>
|
||||||
|
<H1 className={classes.modalTitle}>Create new customer</H1>
|
||||||
|
<Field
|
||||||
|
component={TextInput}
|
||||||
|
name="phoneNumber"
|
||||||
|
width={338}
|
||||||
|
autoFocus
|
||||||
|
label="Phone number"
|
||||||
|
/>
|
||||||
|
<div className={classes.footer}>
|
||||||
|
{getErrorMsg(errors, touched) && (
|
||||||
|
<ErrorMessage>{getErrorMsg(errors, touched)}</ErrorMessage>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="customer-registration-form"
|
||||||
|
className={classes.submit}>
|
||||||
|
Finish
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateCustomerModal
|
||||||
Loading…
Add table
Add a link
Reference in a new issue