Merge pull request #1890 from RafaelTaranto/feat/async-autocomplete-on-loyalty-page

LAM-1453 feat: async autocomplete on loyalty page
This commit is contained in:
Rafael Taranto 2025-06-25 11:21:10 +01:00 committed by GitHub
commit 9125b26e88
8 changed files with 153 additions and 105 deletions

View file

@ -0,0 +1,29 @@
import React, { useState } from 'react'
import { AsyncAutocomplete as BaseAsyncAutocomplete } from '../base/AsyncAutocomplete'
const AsyncAutocompleteFormik = ({ field, form, ...props }) => {
const { name } = field
const { touched, errors, setFieldValue } = form
const [selectedOption, setSelectedOption] = useState(null)
const error = touched[name] && errors[name]
const getOptionId = props.getOptionId || (opt => opt.id)
const handleChange = (event, newValue) => {
setSelectedOption(newValue)
setFieldValue(name, newValue ? getOptionId(newValue) : '')
}
return (
<BaseAsyncAutocomplete
{...props}
name={name}
value={selectedOption}
onChange={handleChange}
error={!!error}
helperText={error || ''}
/>
)
}
export const AsyncAutocomplete = AsyncAutocompleteFormik

View file

@ -1,4 +1,5 @@
import Autocomplete from './Autocomplete'
import { AsyncAutocomplete } from './AsyncAutocomplete'
import CashCassetteInput from './CashCassetteInput'
import Checkbox from './Checkbox'
import Dropdown from './Dropdown'
@ -9,6 +10,7 @@ import TextInput from './TextInput'
export {
Autocomplete,
AsyncAutocomplete,
Checkbox,
TextInput,
NumberInput,

View file

@ -296,6 +296,7 @@ const CustomerProfile = memo(() => {
refetch: getCustomer,
loading: customerLoading,
} = useQuery(GET_CUSTOMER, {
notifyOnNetworkStatusChange: true,
variables: { customerId },
skip: !customerId,
})

View file

@ -1,6 +1,6 @@
import { Form, Formik, Field } from 'formik'
import * as R from 'ramda'
import React from 'react'
import { useLazyQuery, gql } from '@apollo/client'
import ErrorMessage from '../../components/ErrorMessage'
import Modal from '../../components/Modal'
import { HelpTooltip } from '../../components/Tooltip'
@ -8,7 +8,18 @@ import { H1, H3, P } from '../../components/typography'
import * as Yup from 'yup'
import { Button } from '../../components/buttons'
import { NumberInput, Autocomplete } from '../../components/inputs/formik'
import { NumberInput, AsyncAutocomplete } from '../../components/inputs/formik'
const SEARCH_CUSTOMERS = gql`
query searchCustomers($searchTerm: String!, $limit: Int) {
searchCustomers(searchTerm: $searchTerm, limit: $limit) {
id
name
phone
email
}
}
`
const initialValues = {
customer: '',
@ -39,8 +50,15 @@ const IndividualDiscountModal = ({
onClose,
creationError,
addDiscount,
customers,
}) => {
const [searchCustomersQuery] = useLazyQuery(SEARCH_CUSTOMERS)
const searchCustomers = async searchTerm => {
const { data } = await searchCustomersQuery({
variables: { searchTerm, limit: 20 },
})
return data?.searchCustomers || []
}
const handleAddDiscount = (customer, discount) => {
addDiscount({
variables: {
@ -77,18 +95,18 @@ const IndividualDiscountModal = ({
<Field
name="customer"
label="Select a customer"
component={Autocomplete}
component={AsyncAutocomplete}
fullWidth
options={R.map(it => ({
code: it.id,
display: `${it?.idCardData?.firstName ?? ``}${
it?.idCardData?.firstName && it?.idCardData?.lastName
? ` `
: ``
}${it?.idCardData?.lastName ?? ``} (${it.phone})`,
}))(customers)}
labelProp="display"
valueProp="code"
onSearch={searchCustomers}
getOptionLabel={option => {
const name = option.name
const contact = option.phone || option.email
return contact ? `${name} (${contact})` : name
}}
getOptionId={option => option.id}
placeholder="Type to search customers..."
noOptionsText="Type at least 3 characters to search"
minSearchLength={2}
/>
</div>
<div>

View file

@ -1,18 +1,20 @@
import IconButton from '@mui/material/IconButton'
import SvgIcon from '@mui/material/SvgIcon'
import { useQuery, useMutation, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import React, { useState, useMemo } from 'react'
import {
MaterialReactTable,
MRT_ActionMenuItem,
useMaterialReactTable,
} from 'material-react-table'
import Delete from '@mui/icons-material/Delete'
import { Link, Button } from '../../components/buttons'
import { DeleteDialog } from '../../components/DeleteDialog'
import DataTable from '../../components/tables/DataTable'
import { Label3, TL1 } from '../../components/typography'
import PhoneIdIcon from '../../styling/icons/ID/phone/zodiac.svg?react'
import DeleteIcon from '../../styling/icons/action/delete/enabled.svg?react'
import { defaultMaterialTableOpts } from '../../utils/materialReactTableOpts'
import IndividualDiscountModal from './IndividualDiscountModal'
import classnames from 'classnames'
const GET_INDIVIDUAL_DISCOUNTS = gql`
query individualDiscounts {
@ -44,16 +46,6 @@ const CREATE_DISCOUNT = gql`
}
`
const GET_CUSTOMERS = gql`
{
customers {
id
phone
idCardData
}
}
`
const IndividualDiscounts = () => {
const [deleteDialog, setDeleteDialog] = useState(false)
const [toBeDeleted, setToBeDeleted] = useState()
@ -62,9 +54,11 @@ const IndividualDiscounts = () => {
const [showModal, setShowModal] = useState(false)
const toggleModal = () => setShowModal(!showModal)
const { data: discountResponse, loading } = useQuery(GET_INDIVIDUAL_DISCOUNTS)
const { data: customerData, loading: customerLoading } =
useQuery(GET_CUSTOMERS)
const { data: discountResponse, loading } = useQuery(
GET_INDIVIDUAL_DISCOUNTS,
{ notifyOnNetworkStatusChange: true },
)
const discounts = discountResponse?.individualDiscounts || []
const [createDiscount, { error: creationError }] = useMutation(
CREATE_DISCOUNT,
@ -82,88 +76,86 @@ const IndividualDiscounts = () => {
refetchQueries: () => ['individualDiscounts'],
})
const elements = [
const columns = useMemo(
() => [
{
id: 'identification',
header: 'Identification',
width: 312,
textAlign: 'left',
size: 'sm',
view: t => {
return (
size: 312,
accessorFn: row => row.customer.phone,
Cell: ({ row }) => (
<div className="flex items-center gap-2">
<PhoneIdIcon />
<span>{t.customer.phone}</span>
<span>{row.original.customer.phone}</span>
</div>
)
},
),
},
{
id: 'name',
header: 'Name',
width: 300,
textAlign: 'left',
size: 'sm',
view: t => {
const customer = t.customer
size: 300,
accessorFn: row => {
const customer = row.customer
if (R.isNil(customer.idCardData)) {
return <>{'-'}</>
return '-'
}
return (
<>{`${customer.idCardData.firstName ?? ``}${
return `${customer.idCardData.firstName ?? ''}${
customer.idCardData.firstName && customer.idCardData.lastName
? ` `
: ``
}${customer.idCardData.lastName ?? ``}`}</>
)
? ' '
: ''
}${customer.idCardData.lastName ?? ''}`
},
},
{
id: 'discount',
header: 'Discount rate',
width: 220,
textAlign: 'left',
size: 'sm',
view: t => (
size: 220,
accessorKey: 'discount',
Cell: ({ cell }) => (
<>
<TL1 inline>{t.discount}</TL1> %
<TL1 inline>{cell.getValue()}</TL1> %
</>
),
},
{
header: 'Revoke',
width: 100,
textAlign: 'center',
size: 'sm',
view: t => (
<IconButton
],
[],
)
const table = useMaterialReactTable({
...defaultMaterialTableOpts,
columns,
data: discounts,
state: { isLoading: loading },
getRowId: row => row.id,
enableRowActions: true,
renderRowActionMenuItems: ({ row }) => [
<MRT_ActionMenuItem
icon={<Delete />}
key="delete"
label="Revoke"
onClick={() => {
setDeleteDialog(true)
setToBeDeleted({ variables: { discountId: t.id } })
}}>
<SvgIcon>
<DeleteIcon />
</SvgIcon>
</IconButton>
),
setToBeDeleted({ variables: { discountId: row.original.id } })
}}
table={table}
/>,
],
initialState: {
...defaultMaterialTableOpts.initialState,
columnPinning: { right: ['mrt-row-actions'] },
},
]
})
return (
<>
{!loading && !R.isEmpty(discountResponse.individualDiscounts) && (
{!loading && !R.isEmpty(discounts) && (
<>
<div className="flex justify-end mb-8 -mt-14">
<Link
color="primary"
onClick={toggleModal}
className={classnames({ 'cursor-wait': customerLoading })}
disabled={customerLoading}>
<Link color="primary" onClick={toggleModal}>
Add new code
</Link>
</div>
<DataTable
elements={elements}
data={R.path(['individualDiscounts'])(discountResponse)}
/>
<MaterialReactTable table={table} />
<DeleteDialog
open={deleteDialog}
onDismissed={() => {
@ -178,7 +170,7 @@ const IndividualDiscounts = () => {
/>
</>
)}
{!loading && R.isEmpty(discountResponse.individualDiscounts) && (
{!loading && R.isEmpty(discounts) && (
<div className="flex items-start flex-col">
<Label3>
It seems there are no active individual customer discounts on your
@ -195,7 +187,6 @@ const IndividualDiscounts = () => {
}}
creationError={creationError}
addDiscount={createDiscount}
customers={R.path(['customers'])(customerData)}
/>
</>
)

View file

@ -61,7 +61,7 @@ const MachineStatus = () => {
data: machinesResponse,
refetch,
loading: machinesLoading,
} = useQuery(GET_MACHINES)
} = useQuery(GET_MACHINES, { notifyOnNetworkStatusChange: true })
const { data: configResponse, configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)

View file

@ -59,7 +59,11 @@ const GET_CUSTOM_REQUESTS = gql`
const Triggers = () => {
const [wizardType, setWizard] = useState(false)
const { data, loading: configLoading, refetch } = useQuery(GET_CONFIG)
const {
data,
loading: configLoading,
refetch,
} = useQuery(GET_CONFIG, { notifyOnNetworkStatusChange: true })
const { data: customInfoReqData, loading: customInfoLoading } =
useQuery(GET_CUSTOM_REQUESTS)
const [error, setError] = useState(null)

View file

@ -488,7 +488,10 @@ function getSlimCustomerByIdBatch(ids) {
const sql = `SELECT id, phone, id_card_data
FROM customers
WHERE id = ANY($1::uuid[])`
return db.any(sql, [ids]).then(customers => _.map(camelize, customers))
return db.any(sql, [ids]).then(customers => {
const customersById = _.keyBy('id', _.map(camelize, customers))
return ids.map(id => customersById[id] || null)
})
}
function getCustomersList() {