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,
customersResponseSchema,
sellersResponseSchema,
partnersResponseSchema,
productsResponseSchema,
unwrapApiData,
} from '../schemas/order.schema';
import {
@ -178,5 +180,42 @@ export const orderService = {
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,
Paper,
Tooltip,
Collapse,
CircularProgress,
Badge,
Drawer,
IconButton,
Typography,
Divider,
Stack,
type AutocompleteRenderInputParams,
} from '@mui/material';
import { useStores } from '../store/useStores';
import { useCustomers } from '../hooks/useCustomers';
import { useSellers } from '../hooks/useSellers';
import { usePartners } from '../hooks/usePartners';
import { useProducts } from '../hooks/useProducts';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment';
import {
Search as SearchIcon,
RestartAlt as ResetIcon,
ExpandLess as ExpandLessIcon,
ExpandMore as ExpandMoreIcon,
Tune as TuneIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import moment from 'moment';
import 'moment/locale/pt-br';
@ -39,6 +45,10 @@ interface LocalFilters {
orderId: number | null;
customerId: number | null;
customerName: string | null;
partnerId: string | null;
partnerName: string | null;
productId: number | null;
productName: string | null;
createDateIni: string | null;
createDateEnd: string | null;
store: string[] | null;
@ -54,6 +64,10 @@ const getInitialLocalFilters = (
orderId: urlFilters.orderId ?? null,
customerId: urlFilters.customerId ?? 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,
createDateEnd: urlFilters.createDateEnd ?? null,
store: urlFilters.store ?? null,
@ -73,6 +87,10 @@ export const SearchBar = () => {
const sellers = useSellers();
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
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 { isFetching } = useOrders();
const [touchedFields, setTouchedFields] = useState<{
@ -94,6 +112,8 @@ export const SearchBar = () => {
const handleReset = useCallback(() => {
setTouchedFields({});
setCustomerSearchTerm('');
setPartnerSearchTerm('');
setProductSearchTerm('');
const resetState = {
status: null,
@ -103,7 +123,6 @@ export const SearchBar = () => {
codusur2: null,
store: null,
orderId: null,
productId: null,
stockId: null,
hasPreBox: false,
includeCheckout: false,
@ -112,6 +131,10 @@ export const SearchBar = () => {
searchTriggered: false,
customerId: null,
customerName: null,
partnerId: null,
partnerName: null,
productId: null,
productName: null,
};
setLocalFilters(getInitialLocalFilters(resetState));
@ -167,17 +190,32 @@ export const SearchBar = () => {
touchedFields.createDateIni && !localFilters.createDateIni;
const showDateEndError = touchedFields.createDateEnd && dateError;
// Contador de filtros avançados ativos
const advancedFiltersCount = [
// Contador de filtros ativos no Drawer
const activeDrawerFiltersCount = [
localFilters.status,
localFilters.customerId,
localFilters.store?.length,
localFilters.stockId?.length,
localFilters.sellerId,
localFilters.partnerId,
localFilters.productId,
].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 (
<Paper
sx={{
p: { xs: 2, md: 3 },
p: { xs: 2, md: 2 },
mb: 2,
bgcolor: 'background.paper',
borderRadius: 2,
@ -186,10 +224,8 @@ export const SearchBar = () => {
elevation={0}
onKeyDown={handleKeyDown}
>
<Grid container spacing={{ xs: 1.5, md: 2 }} alignItems="flex-end">
{/* --- Primary Filters (Always Visible) --- */}
{/* Campo de Texto Simples (Nº Pedido) */}
<Grid container spacing={2} alignItems="center">
{/* Nº do Pedido */}
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<TextField
fullWidth
@ -207,404 +243,227 @@ export const SearchBar = () => {
/>
</Grid>
{/* Select do MUI (Situação) */}
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<TextField
select
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}>
{/* Datas */}
<Grid size={{ xs: 12, sm: 12, md: 5 }}>
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="pt-br">
<Box display="flex" gap={1.5}>
<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
);
value={localFilters.createDateIni ? moment(localFilters.createDateIni, 'YYYY-MM-DD') : null}
onChange={(date) => {
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,
},
},
helperText: showDateIniError ? 'Obrigatório' : '',
}
}}
/>
</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
);
label="Data Final"
value={localFilters.createDateEnd ? moment(localFilters.createDateEnd, 'YYYY-MM-DD') : null}
onChange={(date) => {
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>
</LocalizationProvider>
</Grid>
{/* Botão Mais Filtros - inline com filtros primários */}
<Grid
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>
{/* Ações */}
<Grid size={{ xs: 12, md: 5 }} sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button
variant="outlined"
color="inherit"
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',
},
}}
startIcon={<ResetIcon />}
sx={{ textTransform: 'none', minWidth: 90 }}
>
<ResetIcon sx={{ mr: 0.5, fontSize: 18 }} />
Limpar
</Button>
</span>
</Tooltip>
<Tooltip
title={
isDateValid
? 'Buscar pedidos'
: 'Preencha a data inicial para buscar'
}
arrow
<Badge badgeContent={activeDrawerFiltersCount} color="primary">
<Button
variant="outlined"
color="primary"
size="small"
onClick={toggleDrawer(true)}
startIcon={<TuneIcon />}
sx={{ textTransform: 'none', minWidth: 100 }}
>
<span>
Filtros
</Button>
</Badge>
<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,
},
startIcon={isFetching ? <CircularProgress size={16} color="inherit" /> : <SearchIcon />}
sx={{ textTransform: 'none', minWidth: 100 }}
>
Buscar
</Button>
</Grid>
</Grid>
{/* Sidebar de Filtros */}
<Drawer
anchor="right"
open={showAdvancedFilters}
onClose={toggleDrawer(false)}
PaperProps={{
sx: { width: { xs: '100%', sm: 400 }, p: 0 }
}}
>
{isFetching ? (
<CircularProgress size={16} color="inherit" />
) : (
<>
<SearchIcon sx={{ mr: 0.5, fontSize: 18 }} />
Buscar
</>
)}
</Button>
</span>
</Tooltip>
<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>
</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 }}>
{/* 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((option) =>
localFilters.store?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'store',
newValue.map((option) => option.value)
);
}}
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: AutocompleteRenderInputParams) => (
<TextField
{...params}
label="Filiais"
placeholder={
localFilters.store?.length
? `${localFilters.store.length} selecionadas`
: 'Selecione'
}
renderInput={(params) => <TextField {...params} label="Filiais" placeholder="Selecione filiais" />}
/>
)}
/>
</Grid>
{/* Autocomplete do MUI para Filial de Estoque */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{/* 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((option) =>
localFilters.stockId?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'stockId',
newValue.map((option) => option.value)
);
}}
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: AutocompleteRenderInputParams) => (
<TextField
{...params}
label="Filial de Estoque"
placeholder={
localFilters.stockId?.length
? `${localFilters.stockId.length} selecionadas`
: 'Selecione'
}
renderInput={(params) => <TextField {...params} label="Filial de Estoque" placeholder="Selecione filial" />}
/>
)}
/>
</Grid>
{/* Autocomplete do MUI para Vendedor */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{/* Vendedor */}
<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
}
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
);
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"
renderInput={(params) => <TextField {...params} label="Vendedor" placeholder="Selecione vendedor" />}
/>
)}
renderOption={(props, option) => {
const { key, ...otherProps } = props;
return (
<li key={option.id} {...otherProps}>
{option.label}
</li>
);
{/* 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}
/>
</Grid>
</Grid>
</Collapse>
</Grid>
</Grid>
{/* 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>
);
};

View File

@ -16,12 +16,15 @@ export const useOrderFilters = () => {
sellerId: parseAsString,
customerName: parseAsString,
customerId: parseAsInteger,
partnerName: parseAsString,
partnerId: parseAsString,
codfilial: parseAsArrayOf(parseAsString, ','),
codusur2: parseAsArrayOf(parseAsString, ','),
store: parseAsArrayOf(parseAsString, ','),
orderId: parseAsInteger,
productId: parseAsInteger,
productName: parseAsString,
stockId: parseAsArrayOf(parseAsString, ','),
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 { customerSchema } from './customer.schema';
import { sellerSchema } from './seller.schema';
import { partnerSchema } from './partner.schema';
import { productSchema } from './product.schema';
import { createApiSchema } from './api-response.schema';
/**
@ -39,3 +41,15 @@ export const customersResponseSchema = createApiSchema(z.array(customerSchema));
* Formato: { success: boolean, data: Seller[] }
*/
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(),
partnerId: z.string().optional(),
partnerName: z.string().optional(),
codusur2: z.string().optional(),
customerName: 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(),
invoiceId: z.coerce.number().optional(),
productId: z.coerce.number().optional(),
productName: z.string().optional(),
createDateIni: z.string().optional(),
createDateEnd: z.string().optional(),
@ -98,19 +100,31 @@ const formatValueToString = (val: any): string => {
*/
export const orderApiParamsSchema = findOrdersSchema
.transform((filters) => {
// Remove customerName quando customerId existe (evita redundância)
const { customerName, customerId, ...rest } = filters;
return customerId ? { customerId, ...rest } : filters;
const {
productName,
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) => {
// Mapeamento de chaves que precisam ser renomeadas
const keyMap: Record<string, string> = {
store: 'codfilial',
};
return Object.entries(filters).reduce(
(acc, [key, value]) => {
// Early return: ignora valores vazios
if (isEmptyValue(value)) return acc;
const apiKey = keyMap[key] ?? key;

View File

@ -26,6 +26,16 @@ export { sellerSchema } from './seller.schema';
export type { Seller } from './seller.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
export { orderItemSchema, orderItemsResponseSchema } 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>;