From 544d723f1b7db70850947eb6d819c456b299c22e Mon Sep 17 00:00:00 2001 From: JuruSysadmin Date: Wed, 14 Jan 2026 15:18:51 -0300 Subject: [PATCH] feat(orders): implement manual search and information panel - Refactor SearchBar to use local state (search only triggers on button click) - Add InformationPanel with DataGrid displaying order details - Create shared dataGridStyles for consistent table styling - Simplify OrderItemsTable using shared styles - Add useOrderDetails hook for fetching order by ID - Translate status codes to readable text (F->Faturado, C->Cancelado, P->Pendente) --- src/features/orders/components/SearchBar.tsx | 153 +++++++++++------- .../components/tabs/InformationPanel.tsx | 58 ++++++- .../tabs/InformationPanelColumns.tsx | 91 +++++++++++ .../components/tabs/OrderItemsTable.tsx | 99 ++++++++++++ src/features/orders/hooks/useOrderDetails.ts | 19 +++ src/features/orders/utils/dataGridStyles.ts | 93 +++++++++++ 6 files changed, 443 insertions(+), 70 deletions(-) create mode 100644 src/features/orders/components/tabs/InformationPanelColumns.tsx create mode 100644 src/features/orders/components/tabs/OrderItemsTable.tsx create mode 100644 src/features/orders/hooks/useOrderDetails.ts create mode 100644 src/features/orders/utils/dataGridStyles.ts diff --git a/src/features/orders/components/SearchBar.tsx b/src/features/orders/components/SearchBar.tsx index ab2357f..626701f 100644 --- a/src/features/orders/components/SearchBar.tsx +++ b/src/features/orders/components/SearchBar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useOrderFilters } from '../hooks/useOrderFilters'; import { Box, @@ -30,8 +30,40 @@ import 'moment/locale/pt-br'; moment.locale('pt-br'); +// Tipo para os filtros locais (não precisam da flag searchTriggered) +interface LocalFilters { + status: string | null; + orderId: number | null; + customerId: number | null; + customerName: string | null; + createDateIni: string | null; + createDateEnd: string | null; + store: string[] | null; + stockId: string[] | null; + sellerId: string | null; + sellerName: string | null; +} + +const getInitialLocalFilters = (urlFilters: any): LocalFilters => ({ + status: urlFilters.status ?? null, + orderId: urlFilters.orderId ?? null, + customerId: urlFilters.customerId ?? null, + customerName: urlFilters.customerName ?? null, + createDateIni: urlFilters.createDateIni ?? null, + createDateEnd: urlFilters.createDateEnd ?? null, + store: urlFilters.store ?? null, + stockId: urlFilters.stockId ?? null, + sellerId: urlFilters.sellerId ?? null, + sellerName: urlFilters.sellerName ?? null, +}); + export const SearchBar = () => { - const [filters, setFilters] = useOrderFilters(); + const [urlFilters, setUrlFilters] = useOrderFilters(); + + // Estado local para inputs (não dispara busca ao mudar) + const [localFilters, setLocalFilters] = useState(() => + getInitialLocalFilters(urlFilters) + ); const stores = useStores(); const sellers = useSellers(); @@ -43,6 +75,18 @@ export const SearchBar = () => { createDateEnd?: boolean; }>({}); + // Sync local state com URL (para navegação Back/Forward) + useEffect(() => { + setLocalFilters(getInitialLocalFilters(urlFilters)); + }, [urlFilters]); + + const updateLocalFilter = useCallback(( + key: K, + value: LocalFilters[K] + ) => { + setLocalFilters(prev => ({ ...prev, [key]: value })); + }, []); + const handleReset = useCallback(() => { setTouchedFields({}); setCustomerSearchTerm(''); @@ -66,23 +110,25 @@ export const SearchBar = () => { customerName: null, }; - setFilters(resetState); - }, [setFilters]); + // Reset local + URL + setLocalFilters(getInitialLocalFilters(resetState)); + setUrlFilters(resetState); + }, [setUrlFilters]); const validateDates = useCallback(() => { - if (!filters.createDateIni || !filters.createDateEnd) { + if (!localFilters.createDateIni || !localFilters.createDateEnd) { return null; } - const dateIni = moment(filters.createDateIni, 'YYYY-MM-DD'); - const dateEnd = moment(filters.createDateEnd, 'YYYY-MM-DD'); + const dateIni = moment(localFilters.createDateIni, 'YYYY-MM-DD'); + const dateEnd = moment(localFilters.createDateEnd, 'YYYY-MM-DD'); if (dateEnd.isBefore(dateIni)) { return 'Data final não pode ser anterior à data inicial'; } return null; - }, [filters.createDateIni, filters.createDateEnd]); + }, [localFilters.createDateIni, localFilters.createDateEnd]); const handleFilter = useCallback(() => { - if (!filters.createDateIni) { + if (!localFilters.createDateIni) { setTouchedFields(prev => ({ ...prev, createDateIni: true })); return; } @@ -93,25 +139,26 @@ export const SearchBar = () => { return; } - setFilters({ - ...filters, + // Commit local filters to URL (triggers search) + setUrlFilters({ + ...localFilters, searchTriggered: true, }); - }, [filters, setFilters, validateDates]); + }, [localFilters, setUrlFilters, validateDates]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { - const isValid = !!filters.createDateIni; + const isValid = !!localFilters.createDateIni; const dateErr = validateDates(); if (isValid && !dateErr) { handleFilter(); } } - }, [filters.createDateIni, validateDates, handleFilter]); + }, [localFilters.createDateIni, validateDates, handleFilter]); - const isDateValid = !!filters.createDateIni; + const isDateValid = !!localFilters.createDateIni; const dateError = validateDates(); - const showDateIniError = touchedFields.createDateIni && !filters.createDateIni; + const showDateIniError = touchedFields.createDateIni && !localFilters.createDateIni; const showDateEndError = touchedFields.createDateEnd && dateError; return ( @@ -138,10 +185,10 @@ export const SearchBar = () => { variant="outlined" size="small" type="number" - value={filters.orderId ?? ''} + value={localFilters.orderId ?? ''} onChange={(e) => { const value = e.target.value ? Number(e.target.value) : null; - setFilters({ orderId: value }); + updateLocalFilter('orderId', value); }} slotProps={{ htmlInput: { min: 0 } }} placeholder="Ex: 12345" @@ -155,10 +202,10 @@ export const SearchBar = () => { fullWidth label="Situação" size="small" - value={filters.status ?? ''} + value={localFilters.status ?? ''} onChange={(e) => { const value = e.target.value || null; - setFilters({ status: value }); + updateLocalFilter('status', value); }} > Todos @@ -176,29 +223,23 @@ export const SearchBar = () => { getOptionLabel={(option) => option.label} isOptionEqualToValue={(option, value) => option.id === value.id} value={customers.options.find(option => - filters.customerId === option.customer?.id + localFilters.customerId === option.customer?.id ) || null} onChange={(_, newValue) => { if (!newValue) { - setFilters({ - customerName: null, - customerId: null, - }); + updateLocalFilter('customerName', null); + updateLocalFilter('customerId', null); setCustomerSearchTerm(''); return; } - setFilters({ - customerId: newValue.customer?.id || null, - customerName: newValue.customer?.name || null, - }); + updateLocalFilter('customerId', newValue.customer?.id || null); + updateLocalFilter('customerName', newValue.customer?.name || null); }} onInputChange={(_, newInputValue, reason) => { if (reason === 'clear') { - setFilters({ - customerName: null, - customerId: null, - }); + updateLocalFilter('customerName', null); + updateLocalFilter('customerId', null); setCustomerSearchTerm(''); return; } @@ -206,10 +247,8 @@ export const SearchBar = () => { if (reason === 'input') { setCustomerSearchTerm(newInputValue); if (!newInputValue) { - setFilters({ - customerName: null, - customerId: null, - }); + updateLocalFilter('customerName', null); + updateLocalFilter('customerId', null); setCustomerSearchTerm(''); } } @@ -238,15 +277,13 @@ export const SearchBar = () => { { setTouchedFields(prev => ({ ...prev, createDateIni: true })); - setFilters({ - createDateIni: date ? date.format('YYYY-MM-DD') : null, - }); + updateLocalFilter('createDateIni', date ? date.format('YYYY-MM-DD') : null); }} format="DD/MM/YYYY" - maxDate={filters.createDateEnd ? moment(filters.createDateEnd, 'YYYY-MM-DD') : undefined} + maxDate={localFilters.createDateEnd ? moment(localFilters.createDateEnd, 'YYYY-MM-DD') : undefined} slotProps={{ textField: { size: 'small', @@ -265,15 +302,13 @@ export const SearchBar = () => { { setTouchedFields(prev => ({ ...prev, createDateEnd: true })); - setFilters({ - createDateEnd: date ? date.format('YYYY-MM-DD') : null, - }); + updateLocalFilter('createDateEnd', date ? date.format('YYYY-MM-DD') : null); }} format="DD/MM/YYYY" - minDate={filters.createDateIni ? moment(filters.createDateIni, 'YYYY-MM-DD') : undefined} + minDate={localFilters.createDateIni ? moment(localFilters.createDateIni, 'YYYY-MM-DD') : undefined} slotProps={{ textField: { size: 'small', @@ -361,19 +396,17 @@ export const SearchBar = () => { getOptionLabel={(option) => option.label} isOptionEqualToValue={(option, value) => option.id === value.id} value={stores.options.filter(option => - filters.store?.includes(option.value) + localFilters.store?.includes(option.value) )} onChange={(_, newValue) => { - setFilters({ - store: newValue.map(option => option.value), - }); + updateLocalFilter('store', newValue.map(option => option.value)); }} loading={stores.isLoading} renderInput={(params: Readonly) => ( )} /> @@ -388,19 +421,17 @@ export const SearchBar = () => { getOptionLabel={(option) => option.label} isOptionEqualToValue={(option, value) => option.id === value.id} value={stores.options.filter(option => - filters.stockId?.includes(option.value) + localFilters.stockId?.includes(option.value) )} onChange={(_, newValue) => { - setFilters({ - stockId: newValue.map(option => option.value), - }); + updateLocalFilter('stockId', newValue.map(option => option.value)); }} loading={stores.isLoading} renderInput={(params: Readonly) => ( )} /> @@ -414,13 +445,11 @@ export const SearchBar = () => { getOptionLabel={(option) => option.label} isOptionEqualToValue={(option, value) => option.id === value.id} value={sellers.options.find(option => - filters.sellerId === option.seller.id.toString() + localFilters.sellerId === option.seller.id.toString() ) || null} onChange={(_, newValue) => { - setFilters({ - sellerId: newValue?.seller.id.toString() || null, - sellerName: newValue?.seller.name || null, - }); + updateLocalFilter('sellerId', newValue?.seller.id.toString() || null); + updateLocalFilter('sellerName', newValue?.seller.name || null); }} loading={sellers.isLoading} renderInput={(params) => ( diff --git a/src/features/orders/components/tabs/InformationPanel.tsx b/src/features/orders/components/tabs/InformationPanel.tsx index 67721a4..113af4a 100644 --- a/src/features/orders/components/tabs/InformationPanel.tsx +++ b/src/features/orders/components/tabs/InformationPanel.tsx @@ -1,21 +1,63 @@ 'use client'; +import { useMemo } from 'react'; import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; -import Typography from '@mui/material/Typography'; +import { DataGridPremium } from '@mui/x-data-grid-premium'; +import { useOrderDetails } from '../../hooks/useOrderDetails'; +import { createInformationPanelColumns } from './InformationPanelColumns'; +import { dataGridStylesSimple } from '../../utils/dataGridStyles'; interface InformationPanelProps { orderId: number; } export const InformationPanel = ({ orderId }: InformationPanelProps) => { + const { data: order, isLoading, error } = useOrderDetails(orderId); + + const columns = useMemo(() => createInformationPanelColumns(), []); + + const rows = useMemo(() => { + if (!order) return []; + return [{ + id: order.orderId || orderId, + ...order + }]; + }, [order, orderId]); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + Erro ao carregar detalhes do pedido. + + ); + } + + if (!order) { + return ( + + Informações do pedido não encontradas. + + ); + } + return ( - - - - Funcionalidade em desenvolvimento para o pedido {orderId} - - - + ); }; diff --git a/src/features/orders/components/tabs/InformationPanelColumns.tsx b/src/features/orders/components/tabs/InformationPanelColumns.tsx new file mode 100644 index 0000000..a79c20f --- /dev/null +++ b/src/features/orders/components/tabs/InformationPanelColumns.tsx @@ -0,0 +1,91 @@ +import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid-premium'; +import Chip from '@mui/material/Chip'; +import { formatCurrency, formatDate, getStatusColor } from '../../utils/orderFormatters'; + +/** + * Mapeia códigos de status para texto legível. + */ +const STATUS_LABELS: Record = { + 'F': 'Faturado', + 'C': 'Cancelado', + 'P': 'Pendente', +}; + +const getStatusLabel = (status: string): string => STATUS_LABELS[status] || status; + +export const createInformationPanelColumns = (): GridColDef[] => [ + { + field: 'customerName', + headerName: 'Cliente', + width: 250, + description: 'Nome do cliente do pedido', + }, + { + field: 'storeId', + headerName: 'Filial', + width: 80, + align: 'center', + headerAlign: 'center', + description: 'Código da filial', + }, + { + field: 'createDate', + headerName: 'Data Criação', + width: 110, + align: 'center', + headerAlign: 'center', + description: 'Data de criação do pedido', + valueFormatter: (value) => formatDate(value as string), + }, + { + field: 'status', + headerName: 'Situação', + width: 120, + align: 'center', + headerAlign: 'center', + description: 'Situação atual do pedido', + renderCell: (params: Readonly) => ( + + ), + }, + { + field: 'paymentName', + headerName: 'Forma Pagamento', + width: 150, + description: 'Forma de pagamento utilizada', + }, + { + field: 'billingName', + headerName: 'Cond. Pagamento', + width: 150, + description: 'Condição de pagamento', + }, + { + field: 'amount', + headerName: 'Valor Total', + width: 120, + align: 'right', + headerAlign: 'right', + description: 'Valor total do pedido', + valueFormatter: (value) => formatCurrency(value as number), + }, + { + field: 'deliveryType', + headerName: 'Tipo Entrega', + width: 150, + description: 'Tipo de entrega selecionado', + }, + { + field: 'deliveryLocal', + headerName: 'Local Entrega', + width: 200, + flex: 1, + description: 'Local de entrega do pedido', + }, +]; diff --git a/src/features/orders/components/tabs/OrderItemsTable.tsx b/src/features/orders/components/tabs/OrderItemsTable.tsx new file mode 100644 index 0000000..a6777e2 --- /dev/null +++ b/src/features/orders/components/tabs/OrderItemsTable.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; +import Alert from '@mui/material/Alert'; +import { DataGridPremium } from '@mui/x-data-grid-premium'; +import { useOrderItems } from '../../hooks/useOrderItems'; +import { createOrderItemsColumns } from '../OrderItemsTableColumns'; +import { OrderItem } from '../../schemas/order.item.schema'; +import { dataGridStyles } from '../../utils/dataGridStyles'; + +interface OrderItemsTableProps { + orderId: number; +} + +export const OrderItemsTable = ({ orderId }: OrderItemsTableProps) => { + const { data: items, isLoading, error } = useOrderItems(orderId); + + const columns = useMemo(() => createOrderItemsColumns(), []); + + const rows = useMemo(() => { + if (!Array.isArray(items) || items.length === 0) return []; + return items.map((item: OrderItem, index: number) => ({ + id: `${orderId}-${item.productId}-${index}`, + ...item, + })); + }, [items, orderId]); + + if (isLoading) { + return ( + + + + Carregando itens do pedido... + + + ); + } + + if (error) { + return ( + + + {error instanceof Error + ? `Erro ao carregar itens: ${error.message}` + : 'Erro ao carregar itens do pedido.'} + + + ); + } + + if (!items || items.length === 0) { + return ( + + Nenhum item encontrado para este pedido. + + ); + } + + return ( + + `${visibleCount.toLocaleString()} de ${totalCount.toLocaleString()}`, + }} + slotProps={{ + pagination: { + labelRowsPerPage: 'Itens por página:', + labelDisplayedRows: ({ from, to, count }: { from: number; to: number; count: number }) => { + const pageSize = to >= from ? to - from + 1 : 10; + const currentPage = Math.floor((from - 1) / pageSize) + 1; + const totalPages = Math.ceil(count / pageSize); + return `${from}–${to} de ${count} | Página ${currentPage} de ${totalPages}`; + }, + }, + }} + /> + ); +}; diff --git a/src/features/orders/hooks/useOrderDetails.ts b/src/features/orders/hooks/useOrderDetails.ts new file mode 100644 index 0000000..02c90f8 --- /dev/null +++ b/src/features/orders/hooks/useOrderDetails.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { orderService } from '../api/order.service'; + +/** + * Hook to fetch details for a specific order. + * Uses the general search endpoint filtering by ID as requested. + */ +export function useOrderDetails(orderId: number) { + return useQuery({ + queryKey: ['orderDetails', orderId], + enabled: !!orderId, + queryFn: async () => { + // The findOrders method returns an array. We search by orderId and take the first result. + const orders = await orderService.findOrders({ orderId: orderId }); + return orders.length > 0 ? orders[0] : null; + }, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +} diff --git a/src/features/orders/utils/dataGridStyles.ts b/src/features/orders/utils/dataGridStyles.ts new file mode 100644 index 0000000..c80899c --- /dev/null +++ b/src/features/orders/utils/dataGridStyles.ts @@ -0,0 +1,93 @@ +import { SxProps, Theme } from '@mui/material/styles'; + +/** + * Estilos compartilhados para DataGridPremium utilizado em tabelas de pedidos. + * Garante consistência visual entre OrderItemsTable, InformationPanel, etc. + */ +export const dataGridStyles: SxProps = { + border: '1px solid', + borderColor: 'divider', + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + backgroundColor: 'background.paper', + + '& .MuiDataGrid-root': { + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + border: 'none', + }, + + '& .MuiDataGrid-columnHeaders': { + backgroundColor: 'grey.50', + fontWeight: 600, + fontSize: '0.75rem', + borderBottom: '2px solid', + borderColor: 'divider', + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + minHeight: '40px !important', + maxHeight: '40px !important', + }, + + '& .MuiDataGrid-columnHeader': { + borderRight: '1px solid', + borderColor: 'divider', + paddingLeft: '12px', + paddingRight: '12px', + '&:focus': { outline: 'none' }, + '&:focus-within': { outline: 'none' }, + '&:last-of-type': { borderRight: 'none' }, + }, + + '& .MuiDataGrid-row': { + borderBottom: '1px solid', + borderColor: 'divider', + backgroundColor: 'background.paper', + minHeight: '36px !important', + maxHeight: '36px !important', + '&:hover': { backgroundColor: 'action.hover' }, + '&:last-child': { borderBottom: 'none' }, + }, + + '& .MuiDataGrid-row:nth-of-type(even)': { + backgroundColor: 'grey.50', + '&:hover': { backgroundColor: 'action.hover' }, + }, + + '& .MuiDataGrid-cell': { + borderRight: '1px solid', + borderColor: 'divider', + borderBottom: 'none', + paddingLeft: '12px', + paddingRight: '12px', + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + fontSize: '0.75rem', + lineHeight: 1.2, + '&:focus': { outline: 'none' }, + '&:focus-within': { outline: 'none' }, + '&:last-of-type': { borderRight: 'none' }, + }, + + '& .MuiDataGrid-footerContainer': { + borderTop: '2px solid', + borderColor: 'divider', + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + minHeight: '48px !important', + fontSize: '0.75rem', + backgroundColor: 'grey.50', + }, +}; + +/** + * Versão simplificada sem zebra-striping (para tabelas de linha única). + */ +export const dataGridStylesSimple: SxProps = { + ...dataGridStyles, + '& .MuiDataGrid-row': { + borderBottom: 'none', + backgroundColor: 'background.paper', + minHeight: '36px !important', + maxHeight: '36px !important', + '&:hover': { backgroundColor: 'action.hover' }, + }, + '& .MuiDataGrid-row:nth-of-type(even)': { + backgroundColor: 'background.paper', + }, +};