Merge branch 'feature/searchbar-drawer-redesign'

This commit is contained in:
JuruSysadmin 2026-01-15 16:46:07 -03:00
commit 8b0401a6a7
10 changed files with 462 additions and 399 deletions

View File

@ -15,6 +15,8 @@ import {
storesResponseSchema, storesResponseSchema,
customersResponseSchema, customersResponseSchema,
sellersResponseSchema, sellersResponseSchema,
partnersResponseSchema,
productsResponseSchema,
unwrapApiData, unwrapApiData,
} from '../schemas/order.schema'; } from '../schemas/order.schema';
import { import {
@ -178,5 +180,42 @@ export const orderService = {
return unwrapApiData(response, cuttingItemResponseSchema, []); return unwrapApiData(response, cuttingItemResponseSchema, []);
}, },
/**
* Busca parceiros por nome/CPF.
* Retorna array vazio se o termo de busca tiver menos de 2 caracteres.
*
* @param {string} filter - O termo de busca (mínimo 2 caracteres)
* @returns {Promise<Array<{id: number, cpf: string, nome: string}>>} Array de parceiros correspondentes
*/
findPartners: async (
filter: string
): Promise<Array<{ id: number; cpf: string; nome: string }>> => {
if (!filter || filter.trim().length < 2) return [];
const response = await ordersApi.get(
`/api/v1/parceiros/${encodeURIComponent(filter)}`
);
return unwrapApiData(response, partnersResponseSchema, []);
},
/**
* Busca produtos por código ou descrição.
* Retorna array vazio se o termo de busca tiver menos de 2 caracteres.
*
* @param {string} term - O termo de busca (mínimo 2 caracteres)
* @returns {Promise<Array<{id: number, description: string}>>} Array de produtos correspondentes
*/
findProducts: async (
term: string
): Promise<Array<{ id: number; description: string }>> => {
if (!term || term.trim().length < 2) return [];
const response = await ordersApi.get(
`/api/v1/data-consult/products/${encodeURIComponent(term)}`
);
return unwrapApiData(response, productsResponseSchema, []);
},
}; };

View File

@ -12,22 +12,28 @@ 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';
import { useCustomers } from '../hooks/useCustomers'; import { useCustomers } from '../hooks/useCustomers';
import { useSellers } from '../hooks/useSellers'; import { useSellers } from '../hooks/useSellers';
import { usePartners } from '../hooks/usePartners';
import { useProducts } from '../hooks/useProducts';
import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; 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';
@ -39,6 +45,10 @@ interface LocalFilters {
orderId: number | null; orderId: number | null;
customerId: number | null; customerId: number | null;
customerName: string | null; customerName: string | null;
partnerId: string | null;
partnerName: string | null;
productId: number | null;
productName: string | null;
createDateIni: string | null; createDateIni: string | null;
createDateEnd: string | null; createDateEnd: string | null;
store: string[] | null; store: string[] | null;
@ -54,6 +64,10 @@ const getInitialLocalFilters = (
orderId: urlFilters.orderId ?? null, orderId: urlFilters.orderId ?? null,
customerId: urlFilters.customerId ?? null, customerId: urlFilters.customerId ?? null,
customerName: urlFilters.customerName ?? null, customerName: urlFilters.customerName ?? null,
partnerId: urlFilters.partnerId ?? null,
partnerName: urlFilters.partnerName ?? null,
productId: urlFilters.productId ?? null,
productName: urlFilters.productName ?? null,
createDateIni: urlFilters.createDateIni ?? null, createDateIni: urlFilters.createDateIni ?? null,
createDateEnd: urlFilters.createDateEnd ?? null, createDateEnd: urlFilters.createDateEnd ?? null,
store: urlFilters.store ?? null, store: urlFilters.store ?? null,
@ -73,6 +87,10 @@ export const SearchBar = () => {
const sellers = useSellers(); const sellers = useSellers();
const [customerSearchTerm, setCustomerSearchTerm] = useState(''); const [customerSearchTerm, setCustomerSearchTerm] = useState('');
const customers = useCustomers(customerSearchTerm); const customers = useCustomers(customerSearchTerm);
const [partnerSearchTerm, setPartnerSearchTerm] = useState('');
const partners = usePartners(partnerSearchTerm);
const [productSearchTerm, setProductSearchTerm] = useState('');
const products = useProducts(productSearchTerm);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const { isFetching } = useOrders(); const { isFetching } = useOrders();
const [touchedFields, setTouchedFields] = useState<{ const [touchedFields, setTouchedFields] = useState<{
@ -94,6 +112,8 @@ export const SearchBar = () => {
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setTouchedFields({}); setTouchedFields({});
setCustomerSearchTerm(''); setCustomerSearchTerm('');
setPartnerSearchTerm('');
setProductSearchTerm('');
const resetState = { const resetState = {
status: null, status: null,
@ -103,7 +123,6 @@ export const SearchBar = () => {
codusur2: null, codusur2: null,
store: null, store: null,
orderId: null, orderId: null,
productId: null,
stockId: null, stockId: null,
hasPreBox: false, hasPreBox: false,
includeCheckout: false, includeCheckout: false,
@ -112,6 +131,10 @@ export const SearchBar = () => {
searchTriggered: false, searchTriggered: false,
customerId: null, customerId: null,
customerName: null, customerName: null,
partnerId: null,
partnerName: null,
productId: null,
productName: null,
}; };
setLocalFilters(getInitialLocalFilters(resetState)); setLocalFilters(getInitialLocalFilters(resetState));
@ -167,17 +190,32 @@ 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,
localFilters.partnerId,
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,
@ -186,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
@ -207,404 +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 <DatePicker
label="Situação" label="Data Inicial"
size="small" value={localFilters.createDateIni ? moment(localFilters.createDateIni, 'YYYY-MM-DD') : null}
value={localFilters.status ?? ''} onChange={(date) => {
onChange={(e) => { setTouchedFields(prev => ({ ...prev, createDateIni: true }));
const value = e.target.value || null; updateLocalFilter('createDateIni', date ? date.format('YYYY-MM-DD') : null);
updateLocalFilter('status', value); }}
}} format="DD/MM/YYYY"
> slotProps={{
<MenuItem value="">Todos</MenuItem> textField: {
<MenuItem value="P">Pendente</MenuItem> size: 'small',
<MenuItem value="F">Faturado</MenuItem> fullWidth: true,
<MenuItem value="C">Cancelado</MenuItem> required: true,
</TextField> error: showDateIniError,
</Grid> helperText: showDateIniError ? 'Obrigatório' : '',
}
{/* 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..."
/> />
)} <DatePicker
noOptionsText={ label="Data Final"
customerSearchTerm.length < 2 value={localFilters.createDateEnd ? moment(localFilters.createDateEnd, 'YYYY-MM-DD') : null}
? 'Digite pelo menos 2 caracteres' onChange={(date) => {
: 'Nenhum cliente encontrado' setTouchedFields(prev => ({ ...prev, createDateEnd: true }));
} updateLocalFilter('createDateEnd', date ? date.format('YYYY-MM-DD') : null);
loadingText="Buscando clientes..." }}
filterOptions={(x) => x} format="DD/MM/YYYY"
clearOnBlur={false} slotProps={{
selectOnFocus textField: {
handleHomeEndKeys size: 'small',
/> fullWidth: true,
</Grid> error: !!showDateEndError,
helperText: showDateEndError || '',
{/* 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
label="Data Inicial"
value={
localFilters.createDateIni
? moment(localFilters.createDateIni, '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"
maxDate={
localFilters.createDateEnd
? moment(localFilters.createDateEnd, 'YYYY-MM-DD')
: undefined
}
slotProps={{
textField: {
size: 'small',
fullWidth: true,
required: true,
error: showDateIniError,
helperText: showDateIniError
? 'Data inicial é obrigatória'
: '',
onBlur: () =>
setTouchedFields((prev) => ({
...prev,
createDateIni: true,
})),
inputProps: {
'aria-required': true,
},
},
}}
/>
</Box>
<Box flex={1}>
<DatePicker
label="Data Final (opcional)"
value={
localFilters.createDateEnd
? moment(localFilters.createDateEnd, '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"
minDate={
localFilters.createDateIni
? moment(localFilters.createDateIni, 'YYYY-MM-DD')
: undefined
}
slotProps={{
textField: {
size: 'small',
fullWidth: true,
error: !!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 }} <Button
sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: { xs: 'flex-start', md: 'flex-end' } }} variant="outlined"
> color="inherit"
<Badge size="small"
badgeContent={advancedFiltersCount} onClick={handleReset}
color="primary" startIcon={<ResetIcon />}
invisible={advancedFiltersCount === 0} sx={{ textTransform: 'none', minWidth: 90 }}
> >
Limpar
</Button>
<Badge badgeContent={activeDrawerFiltersCount} color="primary">
<Button <Button
variant="outlined"
color="primary"
size="small" size="small"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)} onClick={toggleDrawer(true)}
endIcon={ startIcon={<TuneIcon />}
showAdvancedFilters ? <ExpandLessIcon /> : <ExpandMoreIcon /> sx={{ textTransform: 'none', minWidth: 100 }}
}
aria-label={showAdvancedFilters ? 'Ocultar filtros avançados' : 'Mostrar filtros avançados'}
sx={{ textTransform: 'none', color: 'text.secondary', minHeight: 40 }}
> >
{showAdvancedFilters ? 'Menos filtros' : 'Mais filtros'} Filtros
</Button> </Button>
</Badge> </Badge>
</Grid>
{/* Botões de Ação - nova linha abaixo */} <Button
<Grid variant="contained"
size={{ xs: 12 }} color="primary"
sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }} size="small"
> onClick={handleFilter}
<Box sx={{ display: 'flex', gap: 1, width: { xs: '100%', sm: 'auto' }, flexWrap: 'nowrap' }}> disabled={!isDateValid || !!dateError || isFetching}
<Tooltip title="Limpar filtros" arrow> startIcon={isFetching ? <CircularProgress size={16} color="inherit" /> : <SearchIcon />}
<span> sx={{ textTransform: 'none', minWidth: 100 }}
<Button >
variant="outlined" Buscar
color="inherit" </Button>
size="small"
onClick={handleReset}
aria-label="Limpar filtros"
sx={{
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
</Button>
</span>
</Tooltip>
<Tooltip
title={
isDateValid
? 'Buscar pedidos'
: 'Preencha a data inicial para buscar'
}
arrow
>
<span>
<Button
variant="contained"
color="primary"
size="small"
onClick={handleFilter}
disabled={!isDateValid || !!dateError || isFetching}
aria-label="Buscar pedidos"
sx={{
minWidth: { xs: 'auto', sm: 90 },
minHeight: 32,
px: 1.5,
flexShrink: 0,
flex: { xs: 1, sm: 'none' },
fontSize: '0.8125rem',
'&:disabled': {
opacity: 0.6,
},
}}
>
{isFetching ? (
<CircularProgress size={16} color="inherit" />
) : (
<>
<SearchIcon sx={{ mr: 0.5, fontSize: 18 }} />
Buscar
</>
)}
</Button>
</span>
</Tooltip>
</Box>
</Grid>
{/* --- Advanced Filters (Collapsible) --- */}
<Grid size={{ xs: 12 }}>
<Collapse in={showAdvancedFilters}>
<Grid container spacing={2} sx={{ pt: 2 }}>
{/* Autocomplete do MUI para Múltiplas Filiais (codfilial) */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Autocomplete
multiple
size="small"
options={stores.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={stores.options.filter((option) =>
localFilters.store?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'store',
newValue.map((option) => option.value)
);
}}
loading={stores.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => (
<TextField
{...params}
label="Filiais"
placeholder={
localFilters.store?.length
? `${localFilters.store.length} selecionadas`
: 'Selecione'
}
/>
)}
/>
</Grid>
{/* Autocomplete do MUI para Filial de Estoque */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Autocomplete
multiple
size="small"
options={stores.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={stores.options.filter((option) =>
localFilters.stockId?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'stockId',
newValue.map((option) => option.value)
);
}}
loading={stores.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => (
<TextField
{...params}
label="Filial de Estoque"
placeholder={
localFilters.stockId?.length
? `${localFilters.stockId.length} selecionadas`
: 'Selecione'
}
/>
)}
/>
</Grid>
{/* Autocomplete do MUI para Vendedor */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Autocomplete
size="small"
options={sellers.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={
sellers.options.find(
(option) =>
localFilters.sellerId === option.seller.id.toString()
) || null
}
onChange={(_, newValue) => {
updateLocalFilter(
'sellerId',
newValue?.seller.id.toString() || null
);
updateLocalFilter(
'sellerName',
newValue?.seller.name || null
);
}}
loading={sellers.isLoading}
renderInput={(params) => (
<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>
</Grid>
</Collapse>
</Grid> </Grid>
</Grid> </Grid>
{/* Sidebar de Filtros */}
<Drawer
anchor="right"
open={showAdvancedFilters}
onClose={toggleDrawer(false)}
PaperProps={{
sx: { width: { xs: '100%', sm: 400 }, p: 0 }
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 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>
<IconButton onClick={toggleDrawer(false)} size="small">
<CloseIcon />
</IconButton>
</Box>
{/* Filtros Content */}
<Box sx={{ p: 3, flex: 1, overflowY: 'auto' }}>
<Stack spacing={3}>
{/* Situação */}
<TextField
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
multiple
size="small"
options={stores.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={stores.options.filter((opt) => localFilters.store?.includes(opt.value))}
onChange={(_, newValue) => updateLocalFilter('store', newValue.map((opt) => opt.value))}
loading={stores.isLoading}
renderInput={(params) => <TextField {...params} label="Filiais" placeholder="Selecione filiais" />}
/>
{/* Filial de Estoque */}
<Autocomplete
multiple
size="small"
options={stores.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={stores.options.filter((opt) => localFilters.stockId?.includes(opt.value))}
onChange={(_, newValue) => updateLocalFilter('stockId', newValue.map((opt) => opt.value))}
loading={stores.isLoading}
renderInput={(params) => <TextField {...params} label="Filial de Estoque" placeholder="Selecione filial" />}
/>
{/* Vendedor */}
<Autocomplete
size="small"
options={sellers.options}
getOptionLabel={(option) => option.label}
value={sellers.options.find(opt => localFilters.sellerId === opt.seller.id.toString()) || null}
onChange={(_, newValue) => {
updateLocalFilter('sellerId', newValue?.seller.id.toString() || null);
updateLocalFilter('sellerName', newValue?.seller.name || null);
}}
loading={sellers.isLoading}
renderInput={(params) => <TextField {...params} label="Vendedor" placeholder="Selecione vendedor" />}
/>
{/* Parceiro */}
<Autocomplete
size="small"
options={partners.options}
getOptionLabel={(option) => option.label}
value={partners.options.find(opt => localFilters.partnerId === opt.partner?.id?.toString()) || null}
onChange={(_, newValue) => {
updateLocalFilter('partnerId', newValue?.partner?.id?.toString() || null);
updateLocalFilter('partnerName', newValue?.partner?.nome || null);
}}
onInputChange={(_, val) => setPartnerSearchTerm(val)}
loading={partners.isLoading}
renderInput={(params) => <TextField {...params} label="Parceiro" placeholder="Buscar parceiro..." />}
noOptionsText={partnerSearchTerm.length < 2 ? 'Digite 2+ caracteres' : 'Nenhum parceiro'}
filterOptions={(x) => x}
/>
{/* Produto */}
<Autocomplete
size="small"
options={products.options}
getOptionLabel={(option) => option.label}
value={products.options.find(opt => localFilters.productId === opt.product?.id) || null}
onChange={(_, newValue) => {
updateLocalFilter('productId', newValue?.product?.id || null);
updateLocalFilter('productName', newValue?.product?.description || null);
}}
onInputChange={(_, val) => setProductSearchTerm(val)}
loading={products.isLoading}
renderInput={(params) => <TextField {...params} label="Produto" placeholder="Buscar produto..." />}
noOptionsText={productSearchTerm.length < 2 ? 'Digite 2+ caracteres' : 'Nenhum produto'}
filterOptions={(x) => x}
/>
</Stack>
</Box>
{/* 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>
); );
}; };

View File

@ -16,12 +16,15 @@ export const useOrderFilters = () => {
sellerId: parseAsString, sellerId: parseAsString,
customerName: parseAsString, customerName: parseAsString,
customerId: parseAsInteger, customerId: parseAsInteger,
partnerName: parseAsString,
partnerId: parseAsString,
codfilial: parseAsArrayOf(parseAsString, ','), codfilial: parseAsArrayOf(parseAsString, ','),
codusur2: parseAsArrayOf(parseAsString, ','), codusur2: parseAsArrayOf(parseAsString, ','),
store: parseAsArrayOf(parseAsString, ','), store: parseAsArrayOf(parseAsString, ','),
orderId: parseAsInteger, orderId: parseAsInteger,
productId: parseAsInteger, productId: parseAsInteger,
productName: parseAsString,
stockId: parseAsArrayOf(parseAsString, ','), stockId: parseAsArrayOf(parseAsString, ','),
hasPreBox: parseAsBoolean.withDefault(false), hasPreBox: parseAsBoolean.withDefault(false),

View File

@ -0,0 +1,48 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { orderService } from '../api/order.service';
export interface Partner {
id: number;
cpf: string;
nome: string;
}
export function usePartners(searchTerm: string) {
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
const isEnabled = debouncedSearchTerm.length >= 2;
const query = useQuery({
queryKey: ['partners', debouncedSearchTerm],
queryFn: () => orderService.findPartners(debouncedSearchTerm),
enabled: isEnabled,
staleTime: 1000 * 60 * 5,
retry: 1,
retryOnMount: false,
refetchOnWindowFocus: false,
});
const options =
query.data?.map((partner, index) => ({
value: partner.id.toString(),
label: partner.nome,
id: `partner-${partner.id}-${index}`,
partner: partner,
})) ?? [];
return {
...query,
options,
};
}

View File

@ -0,0 +1,47 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { orderService } from '../api/order.service';
export interface Product {
id: number;
description: string;
}
export function useProducts(searchTerm: string) {
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
const isEnabled = debouncedSearchTerm.length >= 2;
const query = useQuery({
queryKey: ['products', debouncedSearchTerm],
queryFn: () => orderService.findProducts(debouncedSearchTerm),
enabled: isEnabled,
staleTime: 1000 * 60 * 5,
retry: 1,
retryOnMount: false,
refetchOnWindowFocus: false,
});
const options =
query.data?.map((product, index) => ({
value: product.id.toString(),
label: product.description,
id: `product-${product.id}-${index}`,
product: product,
})) ?? [];
return {
...query,
options,
};
}

View File

@ -2,6 +2,8 @@ import { z } from 'zod';
import { storeSchema } from './store.schema'; import { storeSchema } from './store.schema';
import { customerSchema } from './customer.schema'; import { customerSchema } from './customer.schema';
import { sellerSchema } from './seller.schema'; import { sellerSchema } from './seller.schema';
import { partnerSchema } from './partner.schema';
import { productSchema } from './product.schema';
import { createApiSchema } from './api-response.schema'; import { createApiSchema } from './api-response.schema';
/** /**
@ -39,3 +41,15 @@ export const customersResponseSchema = createApiSchema(z.array(customerSchema));
* Formato: { success: boolean, data: Seller[] } * Formato: { success: boolean, data: Seller[] }
*/ */
export const sellersResponseSchema = createApiSchema(z.array(sellerSchema)); export const sellersResponseSchema = createApiSchema(z.array(sellerSchema));
/**
* Schema para validar a resposta da API ao buscar parceiros.
* Formato: { success: boolean, data: Partner[] }
*/
export const partnersResponseSchema = createApiSchema(z.array(partnerSchema));
/**
* Schema para validar a resposta da API ao buscar produtos.
* Formato: { success: boolean, data: Product[] }
*/
export const productsResponseSchema = createApiSchema(z.array(productSchema));

View File

@ -16,6 +16,7 @@ export const findOrdersSchema = z.object({
minute: z.coerce.number().optional(), minute: z.coerce.number().optional(),
partnerId: z.string().optional(), partnerId: z.string().optional(),
partnerName: z.string().optional(),
codusur2: z.string().optional(), codusur2: z.string().optional(),
customerName: z.string().optional(), customerName: z.string().optional(),
stockId: z.union([z.string(), z.array(z.string())]).optional(), stockId: z.union([z.string(), z.array(z.string())]).optional(),
@ -27,6 +28,7 @@ export const findOrdersSchema = z.object({
orderId: z.coerce.number().optional(), orderId: z.coerce.number().optional(),
invoiceId: z.coerce.number().optional(), invoiceId: z.coerce.number().optional(),
productId: z.coerce.number().optional(), productId: z.coerce.number().optional(),
productName: z.string().optional(),
createDateIni: z.string().optional(), createDateIni: z.string().optional(),
createDateEnd: z.string().optional(), createDateEnd: z.string().optional(),
@ -98,19 +100,31 @@ const formatValueToString = (val: any): string => {
*/ */
export const orderApiParamsSchema = findOrdersSchema export const orderApiParamsSchema = findOrdersSchema
.transform((filters) => { .transform((filters) => {
// Remove customerName quando customerId existe (evita redundância) const {
const { customerName, customerId, ...rest } = filters; productName,
return customerId ? { customerId, ...rest } : filters; partnerName,
customerName,
customerId,
...rest
} = filters;
const queryParams: any = { ...rest };
if (customerId) queryParams.customerId = customerId;
else if (customerName) queryParams.customerName = customerName;
if (filters.partnerId) queryParams.partnerId = filters.partnerId;
if (filters.productId) queryParams.productId = filters.productId;
return queryParams;
}) })
.transform((filters) => { .transform((filters) => {
// Mapeamento de chaves que precisam ser renomeadas
const keyMap: Record<string, string> = { const keyMap: Record<string, string> = {
store: 'codfilial', store: 'codfilial',
}; };
return Object.entries(filters).reduce( return Object.entries(filters).reduce(
(acc, [key, value]) => { (acc, [key, value]) => {
// Early return: ignora valores vazios
if (isEmptyValue(value)) return acc; if (isEmptyValue(value)) return acc;
const apiKey = keyMap[key] ?? key; const apiKey = keyMap[key] ?? key;

View File

@ -26,6 +26,16 @@ export { sellerSchema } from './seller.schema';
export type { Seller } from './seller.schema'; export type { Seller } from './seller.schema';
export { sellersResponseSchema } from './api-responses.schema'; export { sellersResponseSchema } from './api-responses.schema';
// Schema de parceiros
export { partnerSchema } from './partner.schema';
export type { Partner } from './partner.schema';
export { partnersResponseSchema } from './api-responses.schema';
// Schema de produtos
export { productSchema } from './product.schema';
export type { Product } from './product.schema';
export { productsResponseSchema } from './api-responses.schema';
// Schema de itens de pedido // Schema de itens de pedido
export { orderItemSchema, orderItemsResponseSchema } from './order.item.schema'; export { orderItemSchema, orderItemsResponseSchema } from './order.item.schema';
export type { OrderItem, OrderItemsResponse } from './order.item.schema'; export type { OrderItem, OrderItemsResponse } from './order.item.schema';

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
/**
* Schema Zod para validar um parceiro.
*/
export const partnerSchema = z.object({
id: z.number(),
cpf: z.string(),
nome: z.string(),
});
/**
* Type para parceiro inferido do schema.
*/
export type Partner = z.infer<typeof partnerSchema>;

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
/**
* Schema Zod para validar um produto na busca.
*/
export const productSchema = z.object({
id: z.number(),
description: z.string(),
});
/**
* Type para produto inferido do schema.
*/
export type Product = z.infer<typeof productSchema>;