1183 lines
44 KiB
JavaScript
1183 lines
44 KiB
JavaScript
import { React, useState, useEffect, useRef, useMemo } from 'react';
|
||
import axios from 'axios';
|
||
import { FaRegEdit } from 'react-icons/fa';
|
||
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
|
||
|
||
// material-ui
|
||
import {
|
||
Box,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableContainer,
|
||
TableHead,
|
||
TableRow,
|
||
Grid,
|
||
Typography,
|
||
IconButton,
|
||
Stack,
|
||
Tooltip,
|
||
Dialog,
|
||
DialogActions,
|
||
DialogTitle,
|
||
DialogContent,
|
||
Button,
|
||
TextField,
|
||
Autocomplete,
|
||
Avatar,
|
||
Paper,
|
||
useMediaQuery,
|
||
useTheme
|
||
} from '@mui/material';
|
||
import {
|
||
MdPeopleAlt,
|
||
MdMyLocation,
|
||
MdPersonPin,
|
||
MdPhone,
|
||
MdLocationOn,
|
||
MdEdit,
|
||
MdGroups,
|
||
MdHowToReg,
|
||
MdPlace
|
||
} from 'react-icons/md';
|
||
import Geocode from 'react-geocode';
|
||
import LocationOnIcon from '@mui/icons-material/LocationOn';
|
||
import parse from 'autosuggest-highlight/parse';
|
||
import { debounce } from '@mui/material/utils';
|
||
// project imports
|
||
import Loader from 'components/Loader';
|
||
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
||
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
|
||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||
import { getallcustomers, getcustomersummary } from 'pages/api/api';
|
||
import { OpenToast } from 'components/third-party/OpenToast';
|
||
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
||
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
||
|
||
// ============================================================================
|
||
// Design tokens — shared with the deliveries / tenants / pricing pages so every
|
||
// surface (header, KPI tiles, table, badges, dialog) speaks the same visual
|
||
// language. Keep this block in sync with deliveries.js:109–162.
|
||
// ============================================================================
|
||
const DT = {
|
||
radiusPill: 999,
|
||
radiusCard: 16,
|
||
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
|
||
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
|
||
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
|
||
textPrimary: '#0f172a',
|
||
textSecondary: '#64748b',
|
||
textMuted: '#94a3b8',
|
||
borderSubtle: '#e2e8f0',
|
||
divider: '#f1f5f9',
|
||
surface: '#ffffff',
|
||
surfaceAlt: '#f8fafc'
|
||
};
|
||
const a = (c, suffix) => `${c}${suffix}`;
|
||
const tint = (c) => a(c, '08');
|
||
const soft = (c) => a(c, '18');
|
||
const ring = (c) => a(c, '26');
|
||
const edge = (c) => a(c, '55');
|
||
|
||
const SoftPaper = (props) => (
|
||
<Paper
|
||
{...props}
|
||
sx={{
|
||
mt: 0.75,
|
||
borderRadius: 2,
|
||
boxShadow: DT.shadowPop,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
overflow: 'hidden'
|
||
}}
|
||
/>
|
||
);
|
||
|
||
const AccentAvatar = ({ color, selected, size = 24, children }) => (
|
||
<Avatar
|
||
sx={{
|
||
width: size,
|
||
height: size,
|
||
bgcolor: selected ? color : soft(color),
|
||
color: selected ? '#fff' : color,
|
||
transition: 'background-color 0.15s, color 0.15s'
|
||
}}
|
||
>
|
||
{children}
|
||
</Avatar>
|
||
);
|
||
|
||
// ==============================|| google address ||============================== //
|
||
const GOOGLE_MAPS_API_KEY = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
|
||
|
||
function loadScript(src, position, id) {
|
||
if (!position) {
|
||
return;
|
||
}
|
||
|
||
const script = document.createElement('script');
|
||
script.setAttribute('async', '');
|
||
script.setAttribute('id', id);
|
||
script.src = src;
|
||
position.appendChild(script);
|
||
}
|
||
|
||
const autocompleteService = { current: null };
|
||
|
||
// ==============================|| MUI TABLE - ENHANCED ||============================== //
|
||
|
||
export default function Customers() {
|
||
const theme = useTheme();
|
||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||
const containerRef = useRef();
|
||
const loadMoreRef = useRef();
|
||
const [rowsPerPage] = useState(50);
|
||
const [page] = useState(0);
|
||
const [appId, setAppId] = useState(0);
|
||
const [locaName, setLocoName] = useState('All');
|
||
const [selectedCustomer, setSelectedCustomer] = useState({}); // to edit
|
||
const [open, setOpen] = useState(false);
|
||
const [address, setAddress] = useState('');
|
||
const [latlong, setLatlong] = useState({});
|
||
const [city, setCity] = useState('');
|
||
const [postcode, setPostcode] = useState('');
|
||
const [state, setState] = useState('');
|
||
const [suburb, setSuburb] = useState('');
|
||
const [searchword, setSearchword] = useState('');
|
||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||
|
||
// ==============================|| for google address ||============================== //
|
||
const [value, setValue] = useState(null);
|
||
const [inputValue, setInputValue] = useState('');
|
||
const [options, setOptions] = useState([]);
|
||
const loaded = useRef(false);
|
||
if (typeof window !== 'undefined' && !loaded.current) {
|
||
if (!document.querySelector('#google-maps')) {
|
||
loadScript(
|
||
`https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&libraries=places`,
|
||
document.querySelector('head'),
|
||
'google-maps'
|
||
);
|
||
}
|
||
|
||
loaded.current = true;
|
||
}
|
||
const fetch = useMemo(
|
||
() =>
|
||
debounce((request, callback) => {
|
||
autocompleteService.current.getPlacePredictions(request, callback);
|
||
}, 400),
|
||
[]
|
||
);
|
||
useEffect(() => {
|
||
let active = true;
|
||
|
||
if (!autocompleteService.current && window.google) {
|
||
autocompleteService.current = new window.google.maps.places.AutocompleteService();
|
||
}
|
||
if (!autocompleteService.current) {
|
||
return undefined;
|
||
}
|
||
|
||
if (inputValue === '') {
|
||
setOptions(value ? [value] : []);
|
||
return undefined;
|
||
}
|
||
|
||
fetch({ input: inputValue }, (results) => {
|
||
if (active) {
|
||
let newOptions = [];
|
||
|
||
if (value) {
|
||
newOptions = [value];
|
||
}
|
||
|
||
if (results) {
|
||
newOptions = [...newOptions, ...results];
|
||
}
|
||
|
||
setOptions(newOptions);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [value, inputValue, fetch]);
|
||
|
||
Geocode.setApiKey(process.env.REACT_APP_GOOGLE_MAPS_API_KEY);
|
||
useEffect(() => {
|
||
try {
|
||
console.log('selected address =>', address);
|
||
Geocode.fromAddress(address).then(
|
||
(response) => {
|
||
console.log('lat long response =>', response.results[0]);
|
||
if (response.status == 'OK') {
|
||
const { lat, lng } = response.results[0].geometry.location;
|
||
setLatlong({
|
||
lat,
|
||
lng
|
||
});
|
||
// setSelectedCustomer({
|
||
// ...selectedCustomer,
|
||
// latitude: lat,
|
||
// longitude: lng
|
||
// });
|
||
if (response.results[0].address_components) {
|
||
let place = response.results[0];
|
||
let city1, zipcode1, state1, suburb1;
|
||
for (let i = 0; i < place.address_components.length; i++) {
|
||
for (let j = 0; j < place.address_components[i].types.length; j++) {
|
||
switch (place.address_components[i].types[j]) {
|
||
case 'locality':
|
||
city1 = place.address_components[i].long_name;
|
||
break;
|
||
case 'administrative_area_level_1':
|
||
state1 = place.address_components[i].long_name;
|
||
break;
|
||
case 'postal_code':
|
||
zipcode1 = place.address_components[i].long_name;
|
||
break;
|
||
case 'sublocality':
|
||
suburb1 = place.address_components[i].long_name;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
setCity(city1 || '');
|
||
setState(state1 || '');
|
||
setPostcode(zipcode1 || '');
|
||
setSuburb(suburb1 || '');
|
||
setSelectedCustomer((prev) => ({
|
||
...prev,
|
||
city: city1 || '',
|
||
state: state1 || '',
|
||
postcode: zipcode1 || '',
|
||
suburb: suburb1 || '',
|
||
latitude: lat || '',
|
||
longitude: lng || ''
|
||
}));
|
||
}
|
||
}
|
||
},
|
||
(error) => {
|
||
console.log(error);
|
||
}
|
||
);
|
||
} catch (err) {
|
||
console.log(err);
|
||
}
|
||
}, [address]);
|
||
|
||
// useEffect(() => {
|
||
// selectedCustomer &&
|
||
// setLatlong({
|
||
// lat: selectedCustomer.latitude,
|
||
// lng: selectedCustomer.longitude
|
||
// });
|
||
// }, [selectedCustomer]);
|
||
|
||
// ==============================|| getallcustomers (customers) ||============================== //
|
||
|
||
const {
|
||
data,
|
||
isLoading: customersIsLoading,
|
||
isFetchingNextPage,
|
||
fetchNextPage,
|
||
hasNextPage,
|
||
refetch: getallcustomersRefetch
|
||
} = useInfiniteQuery({
|
||
queryKey: ['getAllCustomers', appId, debouncedSearch, rowsPerPage],
|
||
queryFn: getallcustomers,
|
||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||
keepPreviousData: true
|
||
});
|
||
const rows = data?.pages.flatMap((page) => page.data) || [];
|
||
|
||
useEffect(() => {
|
||
if (!hasNextPage) return;
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries[0].isIntersecting) {
|
||
fetchNextPage();
|
||
}
|
||
},
|
||
{
|
||
root: document.querySelector('.MuiTableContainer-root'), // 👈 or explicitly TableContainer
|
||
rootMargin: '0px',
|
||
threshold: 1.0
|
||
}
|
||
);
|
||
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
|
||
return () => {
|
||
if (loadMoreRef.current) observer.unobserve(loadMoreRef.current);
|
||
};
|
||
}, [hasNextPage, fetchNextPage]);
|
||
|
||
const handleScroll = (event) => {
|
||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||
if (scrollTop + clientHeight >= scrollHeight - 50) {
|
||
if (hasNextPage && !isFetchingNextPage) {
|
||
fetchNextPage();
|
||
}
|
||
}
|
||
};
|
||
|
||
// ==============================|| getcustomersummary (customers) ||============================== //
|
||
|
||
const { data: pageCount, isLoading: customerSummaryIsLoading } = useQuery({
|
||
queryKey: ['customersummary', appId],
|
||
queryFn: getcustomersummary
|
||
});
|
||
useEffect(() => {
|
||
console.log('pageCount', pageCount);
|
||
}, [pageCount]);
|
||
// ==============================|| updateCustomer (post)||============================== //
|
||
const updateCustomer = async () => {
|
||
console.log('selectedCustomer', selectedCustomer);
|
||
|
||
if (!selectedCustomer.firstname) {
|
||
OpenToast('Enter Door NO', 'warning', 1500);
|
||
} else if (!selectedCustomer.contactno) {
|
||
OpenToast('Enter Contact Number ', 'warning', 1500);
|
||
} else if (!selectedCustomer.address) {
|
||
OpenToast('Enter Valid Address', 'warning', 1500);
|
||
} else if (!selectedCustomer.suburb) {
|
||
OpenToast('Enter Suburb', 'warning', 1500);
|
||
} else if (!selectedCustomer.city) {
|
||
OpenToast('Enter City ', 'warning', 1500);
|
||
} else if (!selectedCustomer.state) {
|
||
OpenToast('Enter State', 'warning', 1500);
|
||
} else if (!selectedCustomer.postcode) {
|
||
OpenToast('Enter PostCode', 'warning', 1500);
|
||
} else if (!selectedCustomer.landmark) {
|
||
OpenToast('Enter Landmark', 'warning', 1500);
|
||
} else if (!selectedCustomer.latitude) {
|
||
OpenToast('Enter Latitude', 'warning', 1500);
|
||
} else if (!selectedCustomer.longitude) {
|
||
OpenToast('Enter Longitude', 'warning', 1500);
|
||
} else {
|
||
try {
|
||
const postUpdateResponse = await axios.put(`${process.env.REACT_APP_URL}/customers/update`, {
|
||
customerid: selectedCustomer.customerid,
|
||
configid: 1,
|
||
firstname: selectedCustomer.firstname,
|
||
applocationid: selectedCustomer.applocationid,
|
||
profileimage: '',
|
||
dialcode: '+91',
|
||
contactno: selectedCustomer.contactno,
|
||
devicetype: '',
|
||
deviceid: '',
|
||
customertoken: '123',
|
||
address: selectedCustomer.address,
|
||
suburb: suburb,
|
||
city: city,
|
||
state: state,
|
||
postcode: postcode,
|
||
landmark: selectedCustomer.landmark,
|
||
doorno: selectedCustomer.doorno,
|
||
latitude: selectedCustomer.latitude.toString(),
|
||
longitude: selectedCustomer.longitude.toString()
|
||
});
|
||
|
||
console.log('postUpdateResponse', postUpdateResponse);
|
||
if (postUpdateResponse.data.status) {
|
||
OpenToast(postUpdateResponse.data.message, 'success', 1500);
|
||
setOpen(false);
|
||
getallcustomersRefetch();
|
||
}
|
||
} catch (error) {
|
||
console.log('postUpdate error', error);
|
||
}
|
||
}
|
||
};
|
||
const KPI_META = [
|
||
{ key: 'total', label: 'Total Customers', color: '#662582', icon: MdGroups, value: pageCount?.Total ?? 0 },
|
||
{ key: 'loaded', label: 'Loaded in View', color: '#0ea5e9', icon: MdHowToReg, value: rows.length },
|
||
{ key: 'zone', label: 'Active Zone', color: '#10b981', icon: MdPlace, value: locaName || 'All Zones' }
|
||
];
|
||
|
||
return (
|
||
<>
|
||
{(customerSummaryIsLoading || customersIsLoading) && <Loader />}
|
||
|
||
{/* ============================================= || Header | ============================================= */}
|
||
<Paper
|
||
elevation={0}
|
||
sx={{
|
||
mb: { xs: 1.5, md: 2 },
|
||
p: { xs: 1.5, sm: 2, md: 2.5 },
|
||
borderRadius: DT.radiusCard / 8,
|
||
border: '1px solid',
|
||
borderColor: DT.borderSubtle,
|
||
background: `linear-gradient(135deg, ${tint('#662582')} 0%, ${tint('#9255AB')} 100%)`,
|
||
boxShadow: DT.shadowMd
|
||
}}
|
||
>
|
||
<Stack
|
||
direction={{ xs: 'column', sm: 'row' }}
|
||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||
justifyContent="space-between"
|
||
spacing={{ xs: 1.5, sm: 2 }}
|
||
>
|
||
<Stack direction="row" alignItems="center" spacing={{ xs: 1.25, sm: 1.75 }}>
|
||
<Avatar
|
||
sx={{
|
||
width: { xs: 40, sm: 48 },
|
||
height: { xs: 40, sm: 48 },
|
||
bgcolor: '#662582',
|
||
color: '#fff',
|
||
boxShadow: '0 6px 18px rgba(99,102,241,0.32)'
|
||
}}
|
||
>
|
||
<MdPeopleAlt size={22} />
|
||
</Avatar>
|
||
<Stack>
|
||
<Typography
|
||
variant="h3"
|
||
sx={{
|
||
fontWeight: 800,
|
||
color: DT.textPrimary,
|
||
lineHeight: 1.1,
|
||
fontSize: { xs: '1.25rem', sm: '1.5rem', md: '1.75rem' }
|
||
}}
|
||
>
|
||
Customers
|
||
</Typography>
|
||
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ mt: 0.5 }}>
|
||
<Box
|
||
sx={{
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: '50%',
|
||
bgcolor: '#10b981',
|
||
boxShadow: '0 0 0 4px rgba(16,185,129,0.18)'
|
||
}}
|
||
/>
|
||
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
|
||
Live · {locaName || 'All Zones'}
|
||
</Typography>
|
||
</Stack>
|
||
</Stack>
|
||
</Stack>
|
||
<LocationAutocomplete
|
||
locaName={locaName}
|
||
setAppId={setAppId}
|
||
setLocoName={setLocoName}
|
||
pill
|
||
accentColor="#662582"
|
||
icon={<MdMyLocation size={14} />}
|
||
placeholder="Select Zone"
|
||
paperComponent={SoftPaper}
|
||
sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }}
|
||
/>
|
||
</Stack>
|
||
</Paper>
|
||
|
||
{/* ============================================= || KPI Cards | ============================================= */}
|
||
<Grid container spacing={{ xs: 1.25, sm: 1.5, md: 2 }} sx={{ mt: '1px' }}>
|
||
{KPI_META.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<Grid item key={item.key} xs={6} sm={4}>
|
||
<Paper
|
||
elevation={0}
|
||
sx={{
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
p: { xs: 1.25, sm: 1.75, md: 2.25 },
|
||
borderRadius: DT.radiusCard / 8,
|
||
border: '1px solid',
|
||
borderColor: DT.borderSubtle,
|
||
background: '#fff',
|
||
transition: 'transform 0.2s, box-shadow 0.2s, border-color 0.2s',
|
||
'&:hover': {
|
||
transform: 'translateY(-3px)',
|
||
boxShadow: DT.shadowMd,
|
||
borderColor: edge(item.color)
|
||
}
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: 3,
|
||
background: `linear-gradient(90deg, ${item.color} 0%, ${soft(item.color)} 100%)`
|
||
}}
|
||
/>
|
||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
|
||
<Stack spacing={0.5} sx={{ minWidth: 0, flex: 1 }}>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
color: DT.textSecondary,
|
||
fontWeight: 700,
|
||
letterSpacing: 0.4,
|
||
textTransform: 'uppercase',
|
||
fontSize: { xs: 10, sm: 11 },
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis'
|
||
}}
|
||
>
|
||
{item.label}
|
||
</Typography>
|
||
<Typography
|
||
sx={{
|
||
fontWeight: 800,
|
||
color: DT.textPrimary,
|
||
lineHeight: 1.1,
|
||
fontSize: { xs: '1.25rem', sm: '1.5rem', md: '1.75rem' },
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis'
|
||
}}
|
||
>
|
||
{item.value}
|
||
</Typography>
|
||
</Stack>
|
||
<Avatar
|
||
sx={{
|
||
width: { xs: 36, sm: 42, md: 48 },
|
||
height: { xs: 36, sm: 42, md: 48 },
|
||
bgcolor: soft(item.color),
|
||
color: item.color,
|
||
boxShadow: `inset 0 0 0 1px ${edge(item.color)}`,
|
||
flexShrink: 0
|
||
}}
|
||
>
|
||
<Icon size={20} />
|
||
</Avatar>
|
||
</Stack>
|
||
</Paper>
|
||
</Grid>
|
||
);
|
||
})}
|
||
</Grid>
|
||
|
||
{/* ============================================= || Search header | ============================================= */}
|
||
<Paper
|
||
elevation={0}
|
||
sx={{
|
||
mt: { xs: 1.5, md: 2 },
|
||
p: { xs: 1, md: 1.5 },
|
||
borderTopLeftRadius: DT.radiusCard / 8,
|
||
borderTopRightRadius: DT.radiusCard / 8,
|
||
borderBottomLeftRadius: 0,
|
||
borderBottomRightRadius: 0,
|
||
border: '1px solid',
|
||
borderColor: DT.borderSubtle,
|
||
borderBottom: 0,
|
||
background: '#fff'
|
||
}}
|
||
>
|
||
<Stack
|
||
direction={{ xs: 'column', sm: 'row' }}
|
||
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||
justifyContent="space-between"
|
||
spacing={1.25}
|
||
>
|
||
<Stack direction="row" alignItems="center" spacing={1.25}>
|
||
<AccentAvatar color="#662582" size={32}>
|
||
<MdGroups size={18} />
|
||
</AccentAvatar>
|
||
<Stack>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}
|
||
>
|
||
Directory
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
|
||
{pageCount?.Total ?? 0} total · {rows.length} loaded
|
||
</Typography>
|
||
</Stack>
|
||
</Stack>
|
||
<Box sx={{ width: { xs: '100%', sm: 280, lg: 340 }, flex: { xs: '1 1 100%', sm: '0 0 auto' } }}>
|
||
<DebounceSearchBar
|
||
value={searchword}
|
||
onChange={setSearchword}
|
||
onDebouncedChange={setDebouncedSearch}
|
||
placeholder={`Search customers (ctrl+k)`}
|
||
sx={{
|
||
m: 0,
|
||
width: '100%',
|
||
borderRadius: 999,
|
||
bgcolor: tint('#662582'),
|
||
'& fieldset': { borderColor: edge('#662582'), borderWidth: 1.5 },
|
||
'&:hover fieldset': { borderColor: '#662582' },
|
||
'&.Mui-focused fieldset': { borderColor: '#662582', borderWidth: 2 },
|
||
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring('#662582')}` }
|
||
}}
|
||
/>
|
||
</Box>
|
||
</Stack>
|
||
</Paper>
|
||
|
||
{/* ============================================= || Table || ============================================= */}
|
||
<Paper
|
||
elevation={0}
|
||
sx={{
|
||
borderTopLeftRadius: 0,
|
||
borderTopRightRadius: 0,
|
||
borderBottomLeftRadius: DT.radiusCard / 8,
|
||
borderBottomRightRadius: DT.radiusCard / 8,
|
||
border: '1px solid',
|
||
borderColor: DT.borderSubtle,
|
||
overflow: 'hidden',
|
||
background: '#fff'
|
||
}}
|
||
>
|
||
{isMobile ? (
|
||
<MobileCardList scroll onScroll={handleScroll}>
|
||
{customersIsLoading && <LoaderWithImage />}
|
||
{rows?.length === 0 && !customersIsLoading ? (
|
||
<Stack alignItems="center" spacing={1.5} sx={{ py: 6 }}>
|
||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||
<MdGroups size={28} />
|
||
</Avatar>
|
||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
||
No customers to show
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ color: DT.textSecondary, textAlign: 'center' }}>
|
||
{searchword ? 'Try a different keyword.' : 'Pick a zone above to load the directory.'}
|
||
</Typography>
|
||
</Stack>
|
||
) : (
|
||
rows?.map((row, index) => (
|
||
<MobileCard
|
||
key={row.customerid || `${row.firstname}-${index}`}
|
||
accent="#662582"
|
||
header={
|
||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||
<Stack direction="row" alignItems="center" spacing={1} sx={{ minWidth: 0 }}>
|
||
<AccentAvatar color="#662582" size={36}>
|
||
<MdPersonPin size={18} />
|
||
</AccentAvatar>
|
||
<Stack sx={{ minWidth: 0 }}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
|
||
{row.firstname || '—'}
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||
ID #{row.customerid}
|
||
</Typography>
|
||
</Stack>
|
||
</Stack>
|
||
<Tooltip title="Edit customer" placement="top">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => {
|
||
setSelectedCustomer(row);
|
||
setTimeout(() => setOpen(true), 0);
|
||
}}
|
||
sx={{
|
||
bgcolor: soft('#8b5cf6'),
|
||
color: '#8b5cf6',
|
||
border: `1px solid ${edge('#8b5cf6')}`,
|
||
flexShrink: 0,
|
||
'&:hover': { bgcolor: '#8b5cf6', color: '#fff' }
|
||
}}
|
||
>
|
||
<MdEdit size={16} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Stack>
|
||
}
|
||
>
|
||
<MobileFieldGrid>
|
||
<MobileField label="Contact" value={row.contactno || '—'} />
|
||
<MobileField label="Location">
|
||
{row.suburb ? (
|
||
<Box
|
||
sx={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 0.5,
|
||
px: 1,
|
||
py: 0.375,
|
||
borderRadius: 999,
|
||
bgcolor: tint('#10b981'),
|
||
border: `1px solid ${edge('#10b981')}`,
|
||
color: '#10b981',
|
||
fontSize: 11,
|
||
fontWeight: 800
|
||
}}
|
||
>
|
||
<MdLocationOn size={12} /> {row.suburb}
|
||
</Box>
|
||
) : (
|
||
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textMuted }}>—</Typography>
|
||
)}
|
||
</MobileField>
|
||
<MobileField label="Address" full>
|
||
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }}>
|
||
{row.address || '—'}
|
||
</Typography>
|
||
</MobileField>
|
||
</MobileFieldGrid>
|
||
</MobileCard>
|
||
))
|
||
)}
|
||
{rows?.length !== 0 && (
|
||
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
|
||
{isFetchingNextPage || hasNextPage ? (
|
||
<LoaderWithImage />
|
||
) : (
|
||
<Typography variant="caption" sx={{ color: DT.textMuted, fontWeight: 600 }}>
|
||
No more customers
|
||
</Typography>
|
||
)}
|
||
</div>
|
||
)}
|
||
</MobileCardList>
|
||
) : (
|
||
<TableContainer
|
||
ref={containerRef}
|
||
onScroll={handleScroll}
|
||
sx={{
|
||
maxHeight: { xs: 'calc(100vh - 220px)', md: 'calc(100vh - 190px)' },
|
||
'&::-webkit-scrollbar': { width: 10, height: 10 },
|
||
'&::-webkit-scrollbar-thumb': {
|
||
backgroundColor: edge('#662582'),
|
||
borderRadius: 8,
|
||
'&:hover': { backgroundColor: '#662582' }
|
||
},
|
||
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
|
||
}}
|
||
>
|
||
<Table stickyHeader sx={{ minWidth: { xs: 720, md: 960 } }}>
|
||
<TableHead>
|
||
<TableRow
|
||
sx={{
|
||
'& th': {
|
||
backgroundColor: DT.surfaceAlt,
|
||
color: DT.textSecondary,
|
||
fontSize: { xs: 10, md: 11 },
|
||
fontWeight: 800,
|
||
letterSpacing: 0.6,
|
||
textTransform: 'uppercase',
|
||
whiteSpace: 'nowrap',
|
||
borderBottom: `1px solid ${DT.borderSubtle}`,
|
||
py: { xs: 1, md: 1.25 },
|
||
px: { xs: 1, md: 2 }
|
||
}
|
||
}}
|
||
>
|
||
<TableCell>#</TableCell>
|
||
<TableCell>Customer</TableCell>
|
||
<TableCell>Contact</TableCell>
|
||
<TableCell>Address</TableCell>
|
||
<TableCell>Location</TableCell>
|
||
<TableCell align="right">Action</TableCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
|
||
<TableBody>
|
||
{customersIsLoading && <OrdersTableSkeleton />}
|
||
{rows?.length === 0 && !customersIsLoading ? (
|
||
<TableRow>
|
||
<TableCell colSpan={6} sx={{ py: 6 }}>
|
||
<Stack alignItems="center" spacing={1.5}>
|
||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||
<MdGroups size={28} />
|
||
</Avatar>
|
||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
||
No customers to show
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||
{searchword ? 'Try a different keyword.' : 'Pick a zone above to load the directory.'}
|
||
</Typography>
|
||
</Stack>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
rows?.map((row, index) => (
|
||
<TableRow
|
||
key={row.customerid || `${row.firstname}-${index}`}
|
||
sx={{
|
||
cursor: 'pointer',
|
||
transition: 'background-color 0.15s',
|
||
'& td': {
|
||
borderBottom: `1px solid ${DT.divider}`,
|
||
py: { xs: 1, md: 1.5 },
|
||
px: { xs: 1, md: 2 }
|
||
},
|
||
'&:hover': { backgroundColor: DT.surfaceAlt }
|
||
}}
|
||
>
|
||
<TableCell>
|
||
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
|
||
{String(index + 1 + page * rowsPerPage).padStart(2, '0')}
|
||
</Typography>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Stack direction="row" alignItems="center" spacing={1}>
|
||
<AccentAvatar color="#662582" size={36}>
|
||
<MdPersonPin size={18} />
|
||
</AccentAvatar>
|
||
<Stack>
|
||
<Typography
|
||
variant="subtitle2"
|
||
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
|
||
>
|
||
{row.firstname || '—'}
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||
ID #{row.customerid}
|
||
</Typography>
|
||
</Stack>
|
||
</Stack>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Stack>
|
||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||
<MdPhone size={12} color={DT.textMuted} />
|
||
<Typography
|
||
variant="subtitle2"
|
||
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
|
||
>
|
||
{row.contactno || '—'}
|
||
</Typography>
|
||
</Stack>
|
||
{row.email && (
|
||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||
{row.email}
|
||
</Typography>
|
||
)}
|
||
</Stack>
|
||
</TableCell>
|
||
<TableCell sx={{ maxWidth: 280 }}>
|
||
<Tooltip title={row.address || ''} placement="top">
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
color: DT.textSecondary,
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: 'vertical',
|
||
overflow: 'hidden'
|
||
}}
|
||
>
|
||
{row.address || '—'}
|
||
</Typography>
|
||
</Tooltip>
|
||
</TableCell>
|
||
<TableCell>
|
||
{row.suburb ? (
|
||
<Box
|
||
sx={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 0.5,
|
||
px: 1,
|
||
py: 0.375,
|
||
borderRadius: 999,
|
||
bgcolor: tint('#10b981'),
|
||
border: `1px solid ${edge('#10b981')}`,
|
||
color: '#10b981',
|
||
fontSize: 11,
|
||
fontWeight: 800
|
||
}}
|
||
>
|
||
<MdLocationOn size={12} /> {row.suburb}
|
||
</Box>
|
||
) : (
|
||
<Typography variant="caption" sx={{ color: DT.textMuted }}>—</Typography>
|
||
)}
|
||
</TableCell>
|
||
<TableCell align="right">
|
||
<Tooltip title="Edit customer" placement="top">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => {
|
||
setSelectedCustomer(row);
|
||
setTimeout(() => setOpen(true), 0);
|
||
}}
|
||
sx={{
|
||
bgcolor: soft('#8b5cf6'),
|
||
color: '#8b5cf6',
|
||
border: `1px solid ${edge('#8b5cf6')}`,
|
||
'&:hover': { bgcolor: '#8b5cf6', color: '#fff' }
|
||
}}
|
||
>
|
||
<MdEdit size={16} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
{rows?.length !== 0 && (
|
||
<TableRow>
|
||
<TableCell colSpan={6} sx={{ borderBottom: 'none' }}>
|
||
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
|
||
{isFetchingNextPage || hasNextPage ? (
|
||
<LoaderWithImage />
|
||
) : (
|
||
<Typography variant="caption" sx={{ color: DT.textMuted, fontWeight: 600 }}>
|
||
No more customers
|
||
</Typography>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
)}
|
||
</Paper>
|
||
{/* ======================================== || Edit Dialog || ======================================== */}
|
||
<Dialog
|
||
open={open}
|
||
onClose={() => setOpen(false)}
|
||
aria-labelledby="alert-dialog-title"
|
||
aria-describedby="alert-dialog-description"
|
||
maxWidth="lg"
|
||
fullWidth
|
||
fullScreen={isMobile}
|
||
PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }}
|
||
>
|
||
<DialogTitle
|
||
id="alert-dialog-title"
|
||
sx={{
|
||
background: `linear-gradient(135deg, #662582 0%, #9255AB 100%)`,
|
||
color: '#fff',
|
||
py: 2
|
||
}}
|
||
>
|
||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||
<Avatar sx={{ width: 36, height: 36, bgcolor: 'rgba(255,255,255,0.2)', color: '#fff' }}>
|
||
<FaRegEdit style={{ fontSize: 18 }} />
|
||
</Avatar>
|
||
<Stack>
|
||
<Typography sx={{ fontSize: 11, fontWeight: 700, opacity: 0.85, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1 }}>
|
||
Customer
|
||
</Typography>
|
||
<Typography sx={{ fontWeight: 800, fontSize: { xs: '1.05rem', sm: '1.2rem' }, lineHeight: 1.2, mt: 0.25 }}>
|
||
Edit {selectedCustomer?.firstname || 'Customer'}
|
||
</Typography>
|
||
</Stack>
|
||
</Stack>
|
||
</DialogTitle>
|
||
<DialogContent>
|
||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||
<Grid item xs={6}>
|
||
<Typography sx={{ mb: 1 }}>Customer Name</Typography>
|
||
<TextField
|
||
variant="outlined"
|
||
fullWidth
|
||
defaultValue={selectedCustomer?.firstname}
|
||
onChange={(e) => {
|
||
setSelectedCustomer({
|
||
...selectedCustomer,
|
||
firstname: e.target.value
|
||
});
|
||
}}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={6}>
|
||
<Typography sx={{ mb: 1 }}>Contact Number</Typography>
|
||
<Stack direction={'row'} spacing={1}>
|
||
<TextField readonly variant="outlined" value={'+91'} sx={{ width: 60 }} />
|
||
<TextField
|
||
variant="outlined"
|
||
fullWidth
|
||
type="text"
|
||
value={selectedCustomer?.contactno || ''}
|
||
inputProps={{
|
||
maxLength: 10,
|
||
inputMode: 'numeric', // mobile numeric keypad
|
||
pattern: '[0-9]*'
|
||
}}
|
||
onChange={(e) => {
|
||
const value = e.target.value.replace(/\D/g, ''); // allow only digits
|
||
|
||
setSelectedCustomer((prev) => ({
|
||
...prev,
|
||
contactno: value
|
||
}));
|
||
}}
|
||
/>
|
||
</Stack>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<Typography sx={{ mb: 1 }}>Address</Typography>
|
||
<Autocomplete
|
||
id="google-map-demo"
|
||
sx={{}}
|
||
fullWidth
|
||
getOptionLabel={(option) => (typeof option === 'string' ? option : option?.description || '')}
|
||
filterOptions={(x) => x}
|
||
options={options}
|
||
autoComplete
|
||
includeInputInList
|
||
filterSelectedOptions
|
||
value={selectedCustomer?.address}
|
||
noOptionsText="No locations"
|
||
onChange={(event, newValue) => {
|
||
setOptions(newValue ? [newValue, ...options] : options);
|
||
setValue(newValue);
|
||
console.log('newValue', newValue || '');
|
||
setAddress(newValue?.description);
|
||
setSelectedCustomer({
|
||
...selectedCustomer,
|
||
address: newValue?.description
|
||
});
|
||
}}
|
||
onInputChange={(event, newInputValue) => {
|
||
setInputValue(newInputValue);
|
||
}}
|
||
renderInput={(params) => <TextField {...params} fullWidth />}
|
||
renderOption={(props, option) => {
|
||
const matches = option.structured_formatting.main_text_matched_substrings || [];
|
||
|
||
const parts = parse(
|
||
option.structured_formatting.main_text,
|
||
matches.map((match) => [match.offset, match.offset + match.length])
|
||
);
|
||
|
||
return (
|
||
<li {...props}>
|
||
<Grid container alignItems="center">
|
||
<Grid item sx={{ display: 'flex', width: 44 }}>
|
||
<LocationOnIcon sx={{ color: 'text.secondary' }} />
|
||
</Grid>
|
||
<Grid
|
||
item
|
||
sx={{
|
||
width: 'calc(100% - 44px)',
|
||
wordWrap: 'break-word'
|
||
}}
|
||
>
|
||
{parts?.map((part, index) => (
|
||
<Box
|
||
key={index}
|
||
component="span"
|
||
sx={{
|
||
fontWeight: part.highlight ? 'bold' : 'regular'
|
||
}}
|
||
>
|
||
{part.text}
|
||
</Box>
|
||
))}
|
||
<Typography variant="body2" color="text.secondary">
|
||
{option?.structured_formatting.secondary_text}
|
||
</Typography>
|
||
</Grid>
|
||
</Grid>
|
||
</li>
|
||
);
|
||
}}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={6}>
|
||
<Typography sx={{ mb: 1 }}>Location</Typography>
|
||
<TextField
|
||
variant="outlined"
|
||
fullWidth
|
||
value={selectedCustomer?.suburb}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setSelectedCustomer((prev) => ({
|
||
...prev,
|
||
suburb: value
|
||
}));
|
||
// setSuburb(e.target.value);
|
||
}}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={6}>
|
||
<Typography sx={{ mb: 1 }}>City</Typography>
|
||
<TextField
|
||
variant="outlined"
|
||
fullWidth
|
||
value={selectedCustomer.city || city || ''}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setSelectedCustomer((prev) => ({
|
||
...prev,
|
||
city: value
|
||
}));
|
||
// setCity(e.target.value);
|
||
}}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={6}>
|
||
<Typography sx={{ mb: 1 }}>State</Typography>
|
||
<TextField
|
||
variant="outlined"
|
||
fullWidth
|
||
value={selectedCustomer.state || state || ''}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setSelectedCustomer((prev) => ({
|
||
...prev,
|
||
state: value
|
||
}));
|
||
// setState(e.target.value);
|
||
}}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={6}>
|
||
<Typography sx={{ mb: 1 }}>Postcode</Typography>
|
||
<TextField
|
||
variant="outlined"
|
||
fullWidth
|
||
value={postcode == '' ? selectedCustomer.postcode : postcode}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setSelectedCustomer((prev) => ({
|
||
...prev,
|
||
postcode: value
|
||
}));
|
||
// setPostcode(e.target.value);
|
||
}}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<Typography sx={{ mb: 1 }}>Landmark</Typography>
|
||
<TextField
|
||
variant="outlined"
|
||
fullWidth
|
||
defaultValue={selectedCustomer.landmark}
|
||
onChange={(e) => {
|
||
setSelectedCustomer({
|
||
...selectedCustomer,
|
||
landmark: e.target.value
|
||
});
|
||
}}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={6} sx={{ cursor: 'not-allowed' }}>
|
||
<Typography sx={{ mb: 1 }}>Latitude</Typography>
|
||
<TextField variant="outlined" fullWidth value={latlong.lat} sx={{ cursor: 'not-allowed' }} />
|
||
</Grid>
|
||
<Grid item xs={6} sx={{ cursor: 'not-allowed' }}>
|
||
<Typography sx={{ mb: 1 }}>Longitude</Typography>
|
||
<TextField readonly variant="outlined" fullWidth value={latlong.lng} />
|
||
</Grid>
|
||
</Grid>
|
||
</DialogContent>
|
||
<DialogActions sx={{ mr: 2, mb: 2 }}>
|
||
<Button onClick={() => setOpen(false)} variant="contained" color="error" sx={{ mr: 2 }}>
|
||
Close
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
updateCustomer();
|
||
}}
|
||
variant="contained"
|
||
autoFocus
|
||
>
|
||
Update
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|