feat: redesign searchbar with drawer lateral for better scalability

This commit is contained in:
JuruSysadmin 2026-01-15 16:44:15 -03:00
parent 2b750ed1a3
commit 5558ca6a0d
1 changed files with 231 additions and 526 deletions

View File

@ -12,9 +12,13 @@ import {
Autocomplete, Autocomplete,
Paper, Paper,
Tooltip, Tooltip,
Collapse,
CircularProgress, CircularProgress,
Badge, Badge,
Drawer,
IconButton,
Typography,
Divider,
Stack,
type AutocompleteRenderInputParams, type AutocompleteRenderInputParams,
} from '@mui/material'; } from '@mui/material';
import { useStores } from '../store/useStores'; import { useStores } from '../store/useStores';
@ -28,8 +32,8 @@ import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment';
import { import {
Search as SearchIcon, Search as SearchIcon,
RestartAlt as ResetIcon, RestartAlt as ResetIcon,
ExpandLess as ExpandLessIcon, Tune as TuneIcon,
ExpandMore as ExpandMoreIcon, Close as CloseIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import moment from 'moment'; import moment from 'moment';
import 'moment/locale/pt-br'; import 'moment/locale/pt-br';
@ -186,8 +190,10 @@ export const SearchBar = () => {
touchedFields.createDateIni && !localFilters.createDateIni; touchedFields.createDateIni && !localFilters.createDateIni;
const showDateEndError = touchedFields.createDateEnd && dateError; const showDateEndError = touchedFields.createDateEnd && dateError;
// Contador de filtros avançados ativos // Contador de filtros ativos no Drawer
const advancedFiltersCount = [ const activeDrawerFiltersCount = [
localFilters.status,
localFilters.customerId,
localFilters.store?.length, localFilters.store?.length,
localFilters.stockId?.length, localFilters.stockId?.length,
localFilters.sellerId, localFilters.sellerId,
@ -195,10 +201,21 @@ export const SearchBar = () => {
localFilters.productId, localFilters.productId,
].filter(Boolean).length; ].filter(Boolean).length;
const toggleDrawer = (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if (
event.type === 'keydown' &&
((event as React.KeyboardEvent).key === 'Tab' ||
(event as React.KeyboardEvent).key === 'Shift')
) {
return;
}
setShowAdvancedFilters(open);
};
return ( return (
<Paper <Paper
sx={{ sx={{
p: { xs: 2, md: 3 }, p: { xs: 2, md: 2 },
mb: 2, mb: 2,
bgcolor: 'background.paper', bgcolor: 'background.paper',
borderRadius: 2, borderRadius: 2,
@ -207,10 +224,8 @@ export const SearchBar = () => {
elevation={0} elevation={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<Grid container spacing={{ xs: 1.5, md: 2 }} alignItems="flex-end"> <Grid container spacing={2} alignItems="center">
{/* --- Primary Filters (Always Visible) --- */} {/* Nº do Pedido */}
{/* Campo de Texto Simples (Nº Pedido) */}
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <Grid size={{ xs: 12, sm: 6, md: 2 }}>
<TextField <TextField
fullWidth fullWidth
@ -228,537 +243,227 @@ export const SearchBar = () => {
/> />
</Grid> </Grid>
{/* Select do MUI (Situação) */} {/* Datas */}
<Grid size={{ xs: 12, sm: 6, md: 2 }}> <Grid size={{ xs: 12, sm: 12, md: 5 }}>
<TextField <LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="pt-br">
select <Box display="flex" gap={1.5}>
fullWidth
label="Situação"
size="small"
value={localFilters.status ?? ''}
onChange={(e) => {
const value = e.target.value || null;
updateLocalFilter('status', value);
}}
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value="P">Pendente</MenuItem>
<MenuItem value="F">Faturado</MenuItem>
<MenuItem value="C">Cancelado</MenuItem>
</TextField>
</Grid>
{/* Autocomplete do MUI para Cliente */}
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<Autocomplete
size="small"
options={customers.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={
customers.options.find(
(option) => localFilters.customerId === option.customer?.id
) || null
}
onChange={(_, newValue) => {
if (!newValue) {
updateLocalFilter('customerName', null);
updateLocalFilter('customerId', null);
setCustomerSearchTerm('');
return;
}
updateLocalFilter('customerId', newValue.customer?.id || null);
updateLocalFilter(
'customerName',
newValue.customer?.name || null
);
}}
onInputChange={(_, newInputValue, reason) => {
if (reason === 'clear') {
updateLocalFilter('customerName', null);
updateLocalFilter('customerId', null);
setCustomerSearchTerm('');
return;
}
if (reason === 'input') {
setCustomerSearchTerm(newInputValue);
if (!newInputValue) {
updateLocalFilter('customerName', null);
updateLocalFilter('customerId', null);
setCustomerSearchTerm('');
}
}
}}
loading={customers.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => (
<TextField
{...params}
label="Cliente"
placeholder="Digite para buscar..."
/>
)}
noOptionsText={
customerSearchTerm.length < 2
? 'Digite pelo menos 2 caracteres'
: 'Nenhum cliente encontrado'
}
loadingText="Buscando clientes..."
filterOptions={(x) => x}
clearOnBlur={false}
selectOnFocus
handleHomeEndKeys
/>
</Grid>
{/* Campos de Data */}
<Grid size={{ xs: 12, sm: 12, md: 3.5 }}>
<LocalizationProvider
dateAdapter={AdapterMoment}
adapterLocale="pt-br"
>
<Box display="flex" gap={{ xs: 1.5, md: 2 }} flexDirection={{ xs: 'column', sm: 'row' }}>
<Box flex={1}>
<DatePicker <DatePicker
label="Data Inicial" label="Data Inicial"
value={ value={localFilters.createDateIni ? moment(localFilters.createDateIni, 'YYYY-MM-DD') : null}
localFilters.createDateIni onChange={(date) => {
? moment(localFilters.createDateIni, 'YYYY-MM-DD') setTouchedFields(prev => ({ ...prev, createDateIni: true }));
: null updateLocalFilter('createDateIni', date ? date.format('YYYY-MM-DD') : null);
}
onChange={(date: moment.Moment | null) => {
setTouchedFields((prev) => ({
...prev,
createDateIni: true,
}));
updateLocalFilter(
'createDateIni',
date ? date.format('YYYY-MM-DD') : null
);
}} }}
format="DD/MM/YYYY" format="DD/MM/YYYY"
maxDate={
localFilters.createDateEnd
? moment(localFilters.createDateEnd, 'YYYY-MM-DD')
: undefined
}
slotProps={{ slotProps={{
textField: { textField: {
size: 'small', size: 'small',
fullWidth: true, fullWidth: true,
required: true, required: true,
error: showDateIniError, error: showDateIniError,
helperText: showDateIniError helperText: showDateIniError ? 'Obrigatório' : '',
? 'Data inicial é obrigatória' }
: '',
onBlur: () =>
setTouchedFields((prev) => ({
...prev,
createDateIni: true,
})),
inputProps: {
'aria-required': true,
},
},
}} }}
/> />
</Box>
<Box flex={1}>
<DatePicker <DatePicker
label="Data Final (opcional)" label="Data Final"
value={ value={localFilters.createDateEnd ? moment(localFilters.createDateEnd, 'YYYY-MM-DD') : null}
localFilters.createDateEnd onChange={(date) => {
? moment(localFilters.createDateEnd, 'YYYY-MM-DD') setTouchedFields(prev => ({ ...prev, createDateEnd: true }));
: null updateLocalFilter('createDateEnd', date ? date.format('YYYY-MM-DD') : null);
}
onChange={(date: moment.Moment | null) => {
setTouchedFields((prev) => ({
...prev,
createDateEnd: true,
}));
updateLocalFilter(
'createDateEnd',
date ? date.format('YYYY-MM-DD') : null
);
}} }}
format="DD/MM/YYYY" format="DD/MM/YYYY"
minDate={
localFilters.createDateIni
? moment(localFilters.createDateIni, 'YYYY-MM-DD')
: undefined
}
slotProps={{ slotProps={{
textField: { textField: {
size: 'small', size: 'small',
fullWidth: true, fullWidth: true,
error: !!showDateEndError, error: !!showDateEndError,
helperText: showDateEndError || '', helperText: showDateEndError || '',
onBlur: () => }
setTouchedFields((prev) => ({
...prev,
createDateEnd: true,
})),
inputProps: {
placeholder: 'Opcional',
},
},
}} }}
/> />
</Box> </Box>
</Box>
</LocalizationProvider> </LocalizationProvider>
</Grid> </Grid>
{/* Botão Mais Filtros - inline com filtros primários */} {/* Ações */}
<Grid <Grid size={{ xs: 12, md: 5 }} sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
size={{ xs: 12, sm: 12, md: 2.5 }}
sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: { xs: 'flex-start', md: 'flex-end' } }}
>
<Badge
badgeContent={advancedFiltersCount}
color="primary"
invisible={advancedFiltersCount === 0}
>
<Button
size="small"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
endIcon={
showAdvancedFilters ? <ExpandLessIcon /> : <ExpandMoreIcon />
}
aria-label={showAdvancedFilters ? 'Ocultar filtros avançados' : 'Mostrar filtros avançados'}
sx={{ textTransform: 'none', color: 'text.secondary', minHeight: 40 }}
>
{showAdvancedFilters ? 'Menos filtros' : 'Mais filtros'}
</Button>
</Badge>
</Grid>
{/* Botões de Ação - nova linha abaixo */}
<Grid
size={{ xs: 12 }}
sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}
>
<Box sx={{ display: 'flex', gap: 1, width: { xs: '100%', sm: 'auto' }, flexWrap: 'nowrap' }}>
<Tooltip title="Limpar filtros" arrow>
<span>
<Button <Button
variant="outlined" variant="outlined"
color="inherit" color="inherit"
size="small" size="small"
onClick={handleReset} onClick={handleReset}
aria-label="Limpar filtros" startIcon={<ResetIcon />}
sx={{ sx={{ textTransform: 'none', minWidth: 90 }}
minWidth: { xs: 'auto', sm: 90 },
minHeight: 32,
px: 1.5,
flexShrink: 0,
flex: { xs: 1, sm: 'none' },
fontSize: '0.8125rem',
'&:hover': {
bgcolor: 'action.hover',
},
}}
> >
<ResetIcon sx={{ mr: 0.5, fontSize: 18 }} />
Limpar Limpar
</Button> </Button>
</span>
</Tooltip> <Badge badgeContent={activeDrawerFiltersCount} color="primary">
<Tooltip <Button
title={ variant="outlined"
isDateValid color="primary"
? 'Buscar pedidos' size="small"
: 'Preencha a data inicial para buscar' onClick={toggleDrawer(true)}
} startIcon={<TuneIcon />}
arrow sx={{ textTransform: 'none', minWidth: 100 }}
> >
<span> Filtros
</Button>
</Badge>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
size="small" size="small"
onClick={handleFilter} onClick={handleFilter}
disabled={!isDateValid || !!dateError || isFetching} disabled={!isDateValid || !!dateError || isFetching}
aria-label="Buscar pedidos" startIcon={isFetching ? <CircularProgress size={16} color="inherit" /> : <SearchIcon />}
sx={{ sx={{ textTransform: 'none', minWidth: 100 }}
minWidth: { xs: 'auto', sm: 90 }, >
minHeight: 32, Buscar
px: 1.5, </Button>
flexShrink: 0, </Grid>
flex: { xs: 1, sm: 'none' }, </Grid>
fontSize: '0.8125rem',
'&:disabled': { {/* Sidebar de Filtros */}
opacity: 0.6, <Drawer
}, anchor="right"
open={showAdvancedFilters}
onClose={toggleDrawer(false)}
PaperProps={{
sx: { width: { xs: '100%', sm: 400 }, p: 0 }
}} }}
> >
{isFetching ? ( <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<CircularProgress size={16} color="inherit" /> {/* Header */}
) : ( <Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid', borderColor: 'divider' }}>
<> <Typography variant="h6" sx={{ fontWeight: 600 }}>Todos os Filtros</Typography>
<SearchIcon sx={{ mr: 0.5, fontSize: 18 }} /> <IconButton onClick={toggleDrawer(false)} size="small">
Buscar <CloseIcon />
</> </IconButton>
)}
</Button>
</span>
</Tooltip>
</Box> </Box>
</Grid>
{/* --- Advanced Filters (Collapsible) --- */} {/* Filtros Content */}
<Grid size={{ xs: 12 }}> <Box sx={{ p: 3, flex: 1, overflowY: 'auto' }}>
<Collapse in={showAdvancedFilters}> <Stack spacing={3}>
<Grid container spacing={2} sx={{ pt: 2 }}> {/* Situação */}
{/* Autocomplete do MUI para Múltiplas Filiais (codfilial) */} <TextField
<Grid size={{ xs: 12, sm: 6, md: 3 }}> select
fullWidth
label="Situação"
size="small"
value={localFilters.status ?? ''}
onChange={(e) => updateLocalFilter('status', e.target.value || null)}
>
<MenuItem value="">Todos</MenuItem>
<MenuItem value="P">Pendente</MenuItem>
<MenuItem value="F">Faturado</MenuItem>
<MenuItem value="C">Cancelado</MenuItem>
</TextField>
{/* Cliente */}
<Autocomplete
size="small"
options={customers.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={customers.options.find(opt => localFilters.customerId === opt.customer?.id) || null}
onChange={(_, newValue) => {
updateLocalFilter('customerId', newValue?.customer?.id || null);
updateLocalFilter('customerName', newValue?.customer?.name || null);
}}
onInputChange={(_, val) => setCustomerSearchTerm(val)}
loading={customers.isLoading}
renderInput={(params) => <TextField {...params} label="Cliente" placeholder="Buscar cliente..." />}
noOptionsText={customerSearchTerm.length < 2 ? 'Digite 2+ caracteres' : 'Nenhum cliente'}
filterOptions={(x) => x}
/>
{/* Filiais */}
<Autocomplete <Autocomplete
multiple multiple
size="small" size="small"
options={stores.options} options={stores.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => isOptionEqualToValue={(option, value) => option.id === value.id}
option.id === value.id value={stores.options.filter((opt) => localFilters.store?.includes(opt.value))}
} onChange={(_, newValue) => updateLocalFilter('store', newValue.map((opt) => opt.value))}
value={stores.options.filter((option) =>
localFilters.store?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'store',
newValue.map((option) => option.value)
);
}}
loading={stores.isLoading} loading={stores.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => ( renderInput={(params) => <TextField {...params} label="Filiais" placeholder="Selecione filiais" />}
<TextField
{...params}
label="Filiais"
placeholder={
localFilters.store?.length
? `${localFilters.store.length} selecionadas`
: 'Selecione'
}
/> />
)}
/>
</Grid>
{/* Autocomplete do MUI para Filial de Estoque */} {/* Filial de Estoque */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Autocomplete <Autocomplete
multiple multiple
size="small" size="small"
options={stores.options} options={stores.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => isOptionEqualToValue={(option, value) => option.id === value.id}
option.id === value.id value={stores.options.filter((opt) => localFilters.stockId?.includes(opt.value))}
} onChange={(_, newValue) => updateLocalFilter('stockId', newValue.map((opt) => opt.value))}
value={stores.options.filter((option) =>
localFilters.stockId?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'stockId',
newValue.map((option) => option.value)
);
}}
loading={stores.isLoading} loading={stores.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => ( renderInput={(params) => <TextField {...params} label="Filial de Estoque" placeholder="Selecione filial" />}
<TextField
{...params}
label="Filial de Estoque"
placeholder={
localFilters.stockId?.length
? `${localFilters.stockId.length} selecionadas`
: 'Selecione'
}
/> />
)}
/>
</Grid>
{/* Autocomplete do MUI para Vendedor */} {/* Vendedor */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Autocomplete <Autocomplete
size="small" size="small"
options={sellers.options} options={sellers.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => value={sellers.options.find(opt => localFilters.sellerId === opt.seller.id.toString()) || null}
option.id === value.id
}
value={
sellers.options.find(
(option) =>
localFilters.sellerId === option.seller.id.toString()
) || null
}
onChange={(_, newValue) => { onChange={(_, newValue) => {
updateLocalFilter( updateLocalFilter('sellerId', newValue?.seller.id.toString() || null);
'sellerId', updateLocalFilter('sellerName', newValue?.seller.name || null);
newValue?.seller.id.toString() || null
);
updateLocalFilter(
'sellerName',
newValue?.seller.name || null
);
}} }}
loading={sellers.isLoading} loading={sellers.isLoading}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Vendedor" placeholder="Selecione vendedor" />}
<TextField
{...params}
label="Vendedor"
placeholder="Selecione um vendedor"
/> />
)}
renderOption={(props, option) => {
const { key, ...otherProps } = props;
return (
<li key={option.id} {...otherProps}>
{option.label}
</li>
);
}}
/>
</Grid>
{/* Autocomplete do MUI para Parceiro */} {/* Parceiro */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Autocomplete <Autocomplete
size="small" size="small"
options={partners.options} options={partners.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id} value={partners.options.find(opt => localFilters.partnerId === opt.partner?.id?.toString()) || null}
value={
partners.options.find(
(option) =>
localFilters.partnerId ===
option.partner?.id?.toString()
) || null
}
onChange={(_, newValue) => { onChange={(_, newValue) => {
if (!newValue) { updateLocalFilter('partnerId', newValue?.partner?.id?.toString() || null);
updateLocalFilter('partnerName', null); updateLocalFilter('partnerName', newValue?.partner?.nome || null);
updateLocalFilter('partnerId', null);
setPartnerSearchTerm('');
return;
}
updateLocalFilter(
'partnerId',
newValue.partner?.id?.toString() || null
);
updateLocalFilter(
'partnerName',
newValue.partner?.nome || null
);
}}
onInputChange={(_, newInputValue, reason) => {
if (reason === 'clear') {
updateLocalFilter('partnerName', null);
updateLocalFilter('partnerId', null);
setPartnerSearchTerm('');
return;
}
if (reason === 'input') {
setPartnerSearchTerm(newInputValue);
if (!newInputValue) {
updateLocalFilter('partnerName', null);
updateLocalFilter('partnerId', null);
setPartnerSearchTerm('');
}
}
}} }}
onInputChange={(_, val) => setPartnerSearchTerm(val)}
loading={partners.isLoading} loading={partners.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => ( renderInput={(params) => <TextField {...params} label="Parceiro" placeholder="Buscar parceiro..." />}
<TextField noOptionsText={partnerSearchTerm.length < 2 ? 'Digite 2+ caracteres' : 'Nenhum parceiro'}
{...params}
label="Parceiro"
placeholder="Digite para buscar..."
/>
)}
noOptionsText={
partnerSearchTerm.length < 2
? 'Digite pelo menos 2 caracteres'
: 'Nenhum parceiro encontrado'
}
loadingText="Buscando parceiros..."
filterOptions={(x) => x} filterOptions={(x) => x}
clearOnBlur={false}
selectOnFocus
handleHomeEndKeys
/> />
</Grid>
{/* Autocomplete do MUI para Produto */} {/* Produto */}
<Grid size={{ xs: 12, sm: 12, md: 6 }}>
<Autocomplete <Autocomplete
size="small" size="small"
options={products.options} options={products.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id} value={products.options.find(opt => localFilters.productId === opt.product?.id) || null}
value={
products.options.find(
(option) => localFilters.productId === option.product?.id
) || null
}
onChange={(_, newValue) => { onChange={(_, newValue) => {
if (!newValue) { updateLocalFilter('productId', newValue?.product?.id || null);
updateLocalFilter('productName', null); updateLocalFilter('productName', newValue?.product?.description || null);
updateLocalFilter('productId', null);
setProductSearchTerm('');
return;
}
updateLocalFilter('productId', newValue.product?.id || null);
updateLocalFilter(
'productName',
newValue.product?.description || null
);
}}
onInputChange={(_, newInputValue, reason) => {
if (reason === 'clear') {
updateLocalFilter('productName', null);
updateLocalFilter('productId', null);
setProductSearchTerm('');
return;
}
if (reason === 'input') {
setProductSearchTerm(newInputValue);
if (!newInputValue) {
updateLocalFilter('productName', null);
updateLocalFilter('productId', null);
setProductSearchTerm('');
}
}
}} }}
onInputChange={(_, val) => setProductSearchTerm(val)}
loading={products.isLoading} loading={products.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => ( renderInput={(params) => <TextField {...params} label="Produto" placeholder="Buscar produto..." />}
<TextField noOptionsText={productSearchTerm.length < 2 ? 'Digite 2+ caracteres' : 'Nenhum produto'}
{...params}
label="Produto"
placeholder="Busque por nome ou código..."
/>
)}
noOptionsText={
productSearchTerm.length < 2
? 'Digite pelo menos 2 caracteres'
: 'Nenhum produto encontrado'
}
loadingText="Buscando produtos..."
filterOptions={(x) => x} filterOptions={(x) => x}
clearOnBlur={false}
selectOnFocus
handleHomeEndKeys
/> />
</Grid>
</Grid> </Stack>
</Collapse> </Box>
</Grid>
</Grid> {/* Footer */}
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider', display: 'flex', gap: 2 }}>
<Button fullWidth variant="outlined" color="inherit" onClick={handleReset}>Limpar</Button>
<Button fullWidth variant="contained" color="primary" onClick={() => { handleFilter(); setShowAdvancedFilters(false); }}>Aplicar</Button>
</Box>
</Box>
</Drawer>
</Paper> </Paper>
); );
}; };