merge product filter changes

This commit is contained in:
JuruSysadmin 2026-01-15 16:40:43 -03:00
commit 2b750ed1a3
27 changed files with 606 additions and 750 deletions

View File

@ -1,64 +0,0 @@
import { Avatar, Box, Typography, Grid, Stack } from '@mui/material';
import * as dropdownData from './data';
import Link from 'next/link';
import React from 'react';
const AppLinks = () => {
return (
<Grid container spacing={3} mb={4}>
{dropdownData.appsLink.map((links) => (
<Grid size={{ lg: 6 }} key={links.title}>
<Link href={links.href} className="hover-text-primary">
<Stack direction="row" spacing={2}>
<Box
minWidth="45px"
height="45px"
bgcolor="grey.100"
display="flex"
alignItems="center"
justifyContent="center"
>
<Avatar
src={links.avatar}
alt={links.avatar}
sx={{
width: 24,
height: 24,
borderRadius: 0,
}}
/>
</Box>
<Box>
<Typography
variant="subtitle2"
fontWeight={600}
color="textPrimary"
noWrap
className="text-hover"
sx={{
width: '240px',
}}
>
{links.title}
</Typography>
<Typography
color="textSecondary"
variant="subtitle2"
fontSize="12px"
sx={{
width: '240px',
}}
noWrap
>
{links.subtext}
</Typography>
</Box>
</Stack>
</Link>
</Grid>
))}
</Grid>
);
};
export default AppLinks;

View File

@ -12,11 +12,8 @@ import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import { useCustomizerStore } from '../store/useCustomizerStore';
import Notifications from './Notification';
import Profile from './Profile';
import Search from './Search';
import Navigation from './Navigation';
import MobileRightSidebar from './MobileRightSidebar';
const AppBarStyled = styled(AppBar)(({ theme }) => ({
boxShadow: 'none',
@ -33,7 +30,6 @@ const ToolbarStyled = styled(Toolbar)(({ theme }) => ({
const Header = () => {
const lgUp = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg'));
const lgDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('lg'));
const {
activeMode,
@ -51,7 +47,7 @@ const Header = () => {
<AppBarStyled position="sticky" color="default">
<ToolbarStyled>
{/* ------------------------------------------- */}
{/* Toggle Button Sidebar (Fluxo 2) */}
{/* Toggle Button Sidebar */}
{/* ------------------------------------------- */}
<IconButton
color="inherit"
@ -62,10 +58,9 @@ const Header = () => {
</IconButton>
{/* ------------------------------------------- */}
{/* Search & Navigation */}
{/* Search */}
{/* ------------------------------------------- */}
<Search />
{lgUp && <Navigation />}
<Box flexGrow={1} />
@ -84,12 +79,9 @@ const Header = () => {
{activeMode === 'light' ? <DarkModeIcon /> : <LightModeIcon />}
</IconButton>
<Notifications />
{/* ------------------------------------------- */}
{/* Mobile Right Sidebar & Profile */}
{/* Profile */}
{/* ------------------------------------------- */}
{lgDown && <MobileRightSidebar />}
<Profile />
</Stack>
</ToolbarStyled>
@ -98,3 +90,4 @@ const Header = () => {
};
export default Header;

View File

@ -1,128 +0,0 @@
import React, { useState } from 'react';
import {
IconApps,
IconChevronDown,
IconChevronUp,
IconGridDots,
IconMail,
IconMessages,
} from '@tabler/icons-react';
import {
Box,
Typography,
Drawer,
IconButton,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Collapse,
} from '@mui/material';
import Link from 'next/link';
import AppLinks from './AppLinks';
const MobileRightSidebar = () => {
const [showDrawer, setShowDrawer] = useState(false);
const [open, setOpen] = React.useState(true);
const handleClick = () => {
setOpen(!open);
};
const cartContent = (
<Box>
{/* ------------------------------------------- */}
{/* Apps Content */}
{/* ------------------------------------------- */}
<Box px={1}>
<List
sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper' }}
component="nav"
aria-labelledby="nested-list-subheader"
>
<ListItemButton component={Link} href="/apps/chats">
<ListItemIcon sx={{ minWidth: 35 }}>
<IconMessages size="21" stroke="1.5" />
</ListItemIcon>
<ListItemText>
<Typography variant="subtitle2" fontWeight={600}>
Chats
</Typography>
</ListItemText>
</ListItemButton>
<ListItemButton component={Link} href="/apps/email">
<ListItemIcon sx={{ minWidth: 35 }}>
<IconMail size="21" stroke="1.5" />
</ListItemIcon>
<ListItemText>
<Typography variant="subtitle2" fontWeight={600}>
Email
</Typography>
</ListItemText>
</ListItemButton>
<ListItemButton onClick={handleClick}>
<ListItemIcon sx={{ minWidth: 35 }}>
<IconApps size="21" stroke="1.5" />
</ListItemIcon>
<ListItemText>
<Typography variant="subtitle2" fontWeight={600}>
Apps
</Typography>
</ListItemText>
{open ? (
<IconChevronDown size="21" stroke="1.5" />
) : (
<IconChevronUp size="21" stroke="1.5" />
)}
</ListItemButton>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box px={4} pt={3} overflow="hidden">
<AppLinks />
</Box>
</Collapse>
</List>
</Box>
<Box px={3} mt={3}></Box>
</Box>
);
return (
<Box>
<IconButton
size="large"
color="inherit"
onClick={() => setShowDrawer(true)}
sx={{
...(showDrawer && {
color: 'primary.main',
}),
}}
>
<IconGridDots size="21" stroke="1.5" />
</IconButton>
{/* ------------------------------------------- */}
{/* Cart Sidebar */}
{/* ------------------------------------------- */}
<Drawer
anchor="right"
open={showDrawer}
onClose={() => setShowDrawer(false)}
PaperProps={{ sx: { width: '300px' } }}
>
<Box p={3} pb={0}>
<Typography variant="h5" fontWeight={600}>
Navigation
</Typography>
</Box>
{/* component */}
{cartContent}
</Drawer>
</Box>
);
};
export default MobileRightSidebar;

View File

@ -1,135 +0,0 @@
import { useState } from 'react';
import { Box, Menu, Typography, Button, Divider, Grid } from '@mui/material';
import Link from 'next/link';
import { IconChevronDown, IconHelp } from '@tabler/icons-react';
import AppLinks from './AppLinks';
const AppDD = () => {
const [anchorEl2, setAnchorEl2] = useState(null);
const handleClick2 = (event: any) => {
setAnchorEl2(event.currentTarget);
};
const handleClose2 = () => {
setAnchorEl2(null);
};
return (
<>
<Box>
<Button
aria-label="show 11 new notifications"
color="inherit"
variant="text"
aria-controls="msgs-menu"
aria-haspopup="true"
sx={{
bgcolor: anchorEl2 ? 'primary.light' : '',
color: anchorEl2
? 'primary.main'
: (theme) => theme.palette.text.secondary,
fontSize: '13px',
}}
onClick={handleClick2}
endIcon={
<IconChevronDown
size="15"
style={{ marginLeft: '-5px', marginTop: '2px' }}
/>
}
>
Apps
</Button>
{/* ------------------------------------------- */}
{/* Message Dropdown */}
{/* ------------------------------------------- */}
<Menu
id="msgs-menu"
anchorEl={anchorEl2}
keepMounted
open={Boolean(anchorEl2)}
onClose={handleClose2}
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
transformOrigin={{ horizontal: 'left', vertical: 'top' }}
sx={{
'& .MuiMenu-paper': {
width: '850px',
},
'& .MuiMenu-paper ul': {
p: 0,
},
}}
>
<Grid container>
<Grid size={{ sm: 8 }} display="flex">
<Box p={4} pr={0} pb={3}>
<AppLinks />
<Divider />
<Box
sx={{
display: {
xs: 'none',
sm: 'flex',
},
}}
alignItems="center"
justifyContent="space-between"
pt={2}
pr={4}
>
<Link href="/faq">
<Typography
variant="subtitle2"
fontWeight="600"
color="textPrimary"
display="flex"
alignItems="center"
gap="4px"
>
<IconHelp width={24} />
Frequently Asked Questions
</Typography>
</Link>
<Button variant="contained" color="primary">
Check
</Button>
</Box>
</Box>
<Divider orientation="vertical" />
</Grid>
<Grid size={{ sm: 4 }}>
<Box p={4}></Box>
</Grid>
</Grid>
</Menu>
</Box>
<Button
color="inherit"
sx={{
color: (theme) => theme.palette.text.secondary,
fontSize: '13px',
}}
variant="text"
href="/apps/chats"
component={Link}
>
Chat
</Button>
<Button
color="inherit"
sx={{
color: (theme) => theme.palette.text.secondary,
fontSize: '13px',
}}
variant="text"
href="/apps/email"
component={Link}
>
Email
</Button>
</>
);
};
export default AppDD;

View File

@ -1,132 +0,0 @@
import React, { useState } from 'react';
import {
IconButton,
Box,
Badge,
Menu,
MenuItem,
Avatar,
Typography,
Button,
Chip,
} from '@mui/material';
import * as dropdownData from './data';
import { Scrollbar } from '@/shared/components';
import { IconBellRinging } from '@tabler/icons-react';
import { Stack } from '@mui/system';
import Link from 'next/link';
const Notifications = () => {
const [anchorEl2, setAnchorEl2] = useState(null);
const handleClick2 = (event: any) => {
setAnchorEl2(event.currentTarget);
};
const handleClose2 = () => {
setAnchorEl2(null);
};
return (
<Box>
<IconButton
size="large"
aria-label="show 11 new notifications"
color="inherit"
aria-controls="msgs-menu"
aria-haspopup="true"
sx={{
color: anchorEl2 ? 'primary.main' : 'text.secondary',
}}
onClick={handleClick2}
>
<Badge variant="dot" color="primary">
<IconBellRinging size="21" stroke="1.5" />
</Badge>
</IconButton>
{/* ------------------------------------------- */}
{/* Message Dropdown */}
{/* ------------------------------------------- */}
<Menu
id="msgs-menu"
anchorEl={anchorEl2}
keepMounted
open={Boolean(anchorEl2)}
onClose={handleClose2}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
sx={{
'& .MuiMenu-paper': {
width: '360px',
},
}}
>
<Stack
direction="row"
py={2}
px={4}
justifyContent="space-between"
alignItems="center"
>
<Typography variant="h6">Notifications</Typography>
<Chip label="5 new" color="primary" size="small" />
</Stack>
<Scrollbar sx={{ height: '385px' }}>
{dropdownData.notifications.map((notification) => (
<Box key={notification.id}>
<MenuItem sx={{ py: 2, px: 4 }}>
<Stack direction="row" spacing={2}>
<Avatar
src={notification.avatar}
alt={notification.avatar}
sx={{
width: 48,
height: 48,
}}
/>
<Box>
<Typography
variant="subtitle2"
color="textPrimary"
fontWeight={600}
noWrap
sx={{
width: '240px',
}}
>
{notification.title}
</Typography>
<Typography
color="textSecondary"
variant="subtitle2"
sx={{
width: '240px',
}}
noWrap
>
{notification.subtitle}
</Typography>
</Box>
</Stack>
</MenuItem>
</Box>
))}
</Scrollbar>
<Box p={3} pb={1}>
<Button
href="/apps/email"
variant="outlined"
component={Link}
color="primary"
fullWidth
>
See all Notifications
</Button>
</Box>
</Menu>
</Box>
);
};
export default Notifications;

View File

@ -5,11 +5,9 @@ import {
Menu,
Avatar,
Typography,
Divider,
Button,
IconButton,
} from '@mui/material';
import { IconMail } from '@tabler/icons-react';
import { Stack } from '@mui/system';
import { useAuthStore } from '../../login/store/useAuthStore';
@ -27,10 +25,18 @@ const Profile = () => {
setAnchorEl2(null);
};
// Gera as iniciais do nome
const getIniciais = () => {
if (!user?.nome) return user?.userName?.[0]?.toUpperCase() || 'U';
const partes = user.nome.trim().split(' ').filter(Boolean);
if (partes.length === 1) return partes[0][0].toUpperCase();
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
};
return (
<Box>
<IconButton
aria-label="show 11 new notifications"
aria-label="menu do perfil"
color="inherit"
aria-controls="msgs-menu"
aria-haspopup="true"
@ -48,11 +54,11 @@ const Profile = () => {
bgcolor: 'primary.main',
}}
>
{user?.nome?.[0] || user?.userName?.[0] || 'U'}
{getIniciais()}
</Avatar>
</IconButton>
{/* ------------------------------------------- */}
{/* Message Dropdown */}
{/* Profile Dropdown */}
{/* ------------------------------------------- */}
<Menu
id="msgs-menu"
@ -64,17 +70,19 @@ const Profile = () => {
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
sx={{
'& .MuiMenu-paper': {
width: '360px',
p: 4,
width: '320px',
p: 3,
},
}}
>
<Typography variant="h5">User Profile</Typography>
<Stack direction="row" py={3} spacing={2} alignItems="center">
<Typography variant="h6" fontWeight={600}>
Meu Perfil
</Typography>
<Stack direction="row" py={2} spacing={2} alignItems="center">
<Avatar
sx={{ width: 95, height: 95, bgcolor: 'primary.main' }}
sx={{ width: 64, height: 64, bgcolor: 'primary.main', fontSize: '1.5rem' }}
>
{user?.nome?.[0] || user?.userName?.[0] || 'U'}
{getIniciais()}
</Avatar>
<Box>
<Typography
@ -84,34 +92,36 @@ const Profile = () => {
>
{user?.nome || user?.userName || 'Usuário'}
</Typography>
<Typography variant="subtitle2" color="textSecondary">
{user?.nomeFilial || 'Sem filial'}
<Typography variant="body2" color="textSecondary">
{user?.nomeFilial || '-'}
</Typography>
<Typography
variant="subtitle2"
color="textSecondary"
display="flex"
alignItems="center"
gap={1}
>
<IconMail width={15} height={15} />
{user?.rca ? `RCA: ${user.rca}` : 'Sem e-mail'}
<Typography variant="body2" color="textSecondary">
Matrícula: {user?.matricula || '-'}
</Typography>
</Box>
</Stack>
<Box mt={2}>
<Button
variant="outlined"
color="primary"
onClick={logout}
fullWidth
>
Sair
</Button>
</Box>
<Button
variant="outlined"
color="primary"
component={Link}
href="/dashboard/profile"
fullWidth
sx={{ mb: 1 }}
>
Ver Perfil Completo
</Button>
<Button
variant="contained"
color="primary"
onClick={logout}
fullWidth
>
Sair
</Button>
</Menu>
</Box>
);
};
export default Profile;

View File

@ -1,185 +0,0 @@
// Notifications dropdown
interface NotificationType {
id: string;
avatar: string;
title: string;
subtitle: string;
}
const notifications: NotificationType[] = [
{
id: '1',
avatar: '/images/profile/user-2.jpg',
title: 'Roman Joined the Team!',
subtitle: 'Congratulate him',
},
{
id: '2',
avatar: '/images/profile/user-3.jpg',
title: 'New message received',
subtitle: 'Salma sent you new message',
},
{
id: '3',
avatar: '/images/profile/user-4.jpg',
title: 'New Payment received',
subtitle: 'Check your earnings',
},
{
id: '4',
avatar: '/images/profile/user-5.jpg',
title: 'Jolly completed tasks',
subtitle: 'Assign her new tasks',
},
{
id: '5',
avatar: '/images/profile/user-6.jpg',
title: 'Roman Joined the Team!',
subtitle: 'Congratulate him',
},
{
id: '6',
avatar: '/images/profile/user-7.jpg',
title: 'New message received',
subtitle: 'Salma sent you new message',
},
{
id: '7',
avatar: '/images/profile/user-8.jpg',
title: 'New Payment received',
subtitle: 'Check your earnings',
},
{
id: '8',
avatar: '/images/profile/user-9.jpg',
title: 'Jolly completed tasks',
subtitle: 'Assign her new tasks',
},
];
//
// Profile dropdown
//
interface ProfileType {
href: string;
title: string;
subtitle: string;
icon: any;
}
const profile: ProfileType[] = [
{
href: '/dashboard/profile',
title: 'My Profile',
subtitle: 'Account Settings',
icon: '/images/svgs/icon-account.svg',
},
{
href: '/apps/email',
title: 'My Inbox',
subtitle: 'Messages & Emails',
icon: '/images/svgs/icon-inbox.svg',
},
{
href: '/apps/notes',
title: 'My Tasks',
subtitle: 'To-do and Daily Tasks',
icon: '/images/svgs/icon-tasks.svg',
},
];
// apps dropdown
interface AppsLinkType {
href: string;
title: string;
subtext: string;
avatar: string;
}
const appsLink: AppsLinkType[] = [
{
href: '/apps/chats',
title: 'Chat Application',
subtext: 'New messages arrived',
avatar: '/images/svgs/icon-dd-chat.svg',
},
{
href: '/apps/ecommerce/shop',
title: 'eCommerce App',
subtext: 'New stock available',
avatar: '/images/svgs/icon-dd-cart.svg',
},
{
href: '/apps/notes',
title: 'Notes App',
subtext: 'To-do and Daily tasks',
avatar: '/images/svgs/icon-dd-invoice.svg',
},
{
href: '/apps/contacts',
title: 'Contact Application',
subtext: '2 Unsaved Contacts',
avatar: '/images/svgs/icon-dd-mobile.svg',
},
{
href: '/apps/tickets',
title: 'Tickets App',
subtext: 'Submit tickets',
avatar: '/images/svgs/icon-dd-lifebuoy.svg',
},
{
href: '/apps/email',
title: 'Email App',
subtext: 'Get new emails',
avatar: '/images/svgs/icon-dd-message-box.svg',
},
{
href: '/apps/blog/post',
title: 'Blog App',
subtext: 'added new blog',
avatar: '/images/svgs/icon-dd-application.svg',
},
];
interface LinkType {
href: string;
title: string;
}
const pageLinks: LinkType[] = [
{
href: '/theme-pages/pricing',
title: 'Pricing Page',
},
{
href: '/auth/auth1/login',
title: 'Authentication Design',
},
{
href: '/auth/auth1/register',
title: 'Register Now',
},
{
href: '/404',
title: '404 Error Page',
},
{
href: '/apps/note',
title: 'Notes App',
},
{
href: '/apps/user-profile/profile',
title: 'User Application',
},
{
href: '/apps/blog/post',
title: 'Blog Design',
},
{
href: '/apps/ecommerce/checkout',
title: 'Shopping Cart',
},
];
export { notifications, profile, pageLinks, appsLink };

View File

@ -20,7 +20,7 @@ export function AuthInitializer({ children }: { children: React.ReactNode }) {
try {
await loginService.refreshToken();
const profile = await profileService.getMe();
const profile = await profileService.obterColaboradorAtual();
setUser(mapToSafeProfile(profile));
} catch (error) {
console.warn('Sessão expirada ou inválida', error);

View File

@ -1,10 +1,11 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '../store/useAuthStore';
import { loginService } from '../services/login.service';
import { profileService } from '../../profile/services/profile.service';
import { clearAuthData } from '../utils/tokenRefresh';
import { mapToSafeProfile } from '../utils/mappers';
import { COLABORADOR_ATUAL_QUERY_KEY } from '../../profile';
export function useAuth() {
const queryClient = useQueryClient();
@ -16,11 +17,11 @@ export function useAuth() {
mutationFn: loginService.login,
onSuccess: async () => {
try {
const profile = await profileService.getMe();
const profile = await profileService.obterColaboradorAtual();
const safeProfile = mapToSafeProfile(profile);
setUser(safeProfile);
queryClient.setQueryData(['auth-me'], safeProfile);
queryClient.setQueryData(COLABORADOR_ATUAL_QUERY_KEY, profile);
router.push('/dashboard');
} catch (error) {
@ -30,19 +31,6 @@ export function useAuth() {
},
});
const useMe = () =>
useQuery({
queryKey: ['auth-me'],
queryFn: async () => {
const data = await profileService.getMe();
const safeData = mapToSafeProfile(data);
setUser(safeData);
return safeData;
},
retry: false,
staleTime: Infinity,
});
const logout = async () => {
try {
await loginService.logout();
@ -54,5 +42,5 @@ export function useAuth() {
}
};
return { loginMutation, useMe, logout };
return { loginMutation, logout };
}

View File

@ -1,6 +1,6 @@
import { z } from 'zod';
import type { ReactNode } from 'react';
import type { UserProfile } from '../../profile/types';
import type { UserProfileDto } from '../../profile/types';
import {
loginSchema,
@ -38,9 +38,9 @@ export interface AuthLoginProps {
}
export interface AuthState {
user: UserProfile | null;
user: UserProfileDto | null;
isAuthenticated: boolean;
setUser: (user: UserProfile | null) => void;
setUser: (user: UserProfileDto | null) => void;
logout: () => void;
hydrate: () => void;
}

View File

@ -1,16 +1,22 @@
import { UserProfile } from '../../profile/types';
import { UserProfileDto } from '../../profile/types';
import { Colaborador } from '../../profile/domain/Colaborador';
export const mapToSafeProfile = (data: any): UserProfile => {
/**
* Converte Colaborador (entidade) ou objeto raw para UserProfileDto.
* Suporta ambos os formatos de propriedade (entidade DDD e DTO da API).
*/
export const mapToSafeProfile = (data: Colaborador | any): UserProfileDto => {
return {
matricula: data.matricula,
userName: data.userName,
nome: data.nome,
codigoFilial: data.codigoFilial,
nomeFilial: data.nomeFilial,
rca: data.rca,
discountPercent: data.discountPercent,
sectorId: data.sectorId,
sectorManagerId: data.sectorManagerId,
supervisorId: data.supervisorId,
rca: data.codigoRCA ?? data.rca,
discountPercent: data.percentualDesconto ?? data._percentualDesconto ?? data.discountPercent,
sectorId: data.codigoSetor ?? data.sectorId,
sectorManagerId: data.sectorManagerId ?? 0,
supervisorId: data.codigoSupervisor ?? data.supervisorId,
};
};

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

@ -20,6 +20,8 @@ import {
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';
@ -39,6 +41,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 +60,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 +83,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 +108,8 @@ export const SearchBar = () => {
const handleReset = useCallback(() => {
setTouchedFields({});
setCustomerSearchTerm('');
setPartnerSearchTerm('');
setProductSearchTerm('');
const resetState = {
status: null,
@ -103,7 +119,6 @@ export const SearchBar = () => {
codusur2: null,
store: null,
orderId: null,
productId: null,
stockId: null,
hasPreBox: false,
includeCheckout: false,
@ -112,6 +127,10 @@ export const SearchBar = () => {
searchTriggered: false,
customerId: null,
customerName: null,
partnerId: null,
partnerName: null,
productId: null,
productName: null,
};
setLocalFilters(getInitialLocalFilters(resetState));
@ -172,6 +191,8 @@ export const SearchBar = () => {
localFilters.store?.length,
localFilters.stockId?.length,
localFilters.sellerId,
localFilters.partnerId,
localFilters.productId,
].filter(Boolean).length;
return (
@ -601,6 +622,139 @@ export const SearchBar = () => {
}}
/>
</Grid>
{/* Autocomplete do MUI para Parceiro */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Autocomplete
size="small"
options={partners.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={
partners.options.find(
(option) =>
localFilters.partnerId ===
option.partner?.id?.toString()
) || null
}
onChange={(_, newValue) => {
if (!newValue) {
updateLocalFilter('partnerName', 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('');
}
}
}}
loading={partners.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => (
<TextField
{...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}
clearOnBlur={false}
selectOnFocus
handleHomeEndKeys
/>
</Grid>
{/* Autocomplete do MUI para Produto */}
<Grid size={{ xs: 12, sm: 12, md: 6 }}>
<Autocomplete
size="small"
options={products.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={
products.options.find(
(option) => localFilters.productId === option.product?.id
) || null
}
onChange={(_, newValue) => {
if (!newValue) {
updateLocalFilter('productName', 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('');
}
}
}}
loading={products.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => (
<TextField
{...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}
clearOnBlur={false}
selectOnFocus
handleHomeEndKeys
/>
</Grid>
</Grid>
</Collapse>
</Grid>

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>;

View File

@ -7,15 +7,12 @@ import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import { useAuthStore } from '../../login/store/useAuthStore';
import { useAuth } from '../../login/hooks/useAuth';
import Avatar from '@mui/material/Avatar';
import Chip from '@mui/material/Chip';
import { useColaboradorAtual } from '../hooks/useColaboradorAtual';
export default function ProfilePage() {
const { useMe } = useAuth();
const { data: profile, isLoading, error } = useMe();
const user = useAuthStore((s) => s.user);
const displayData = profile || user;
const { data: colaborador, isLoading, error } = useColaboradorAtual();
if (isLoading) {
return (
@ -36,12 +33,53 @@ export default function ProfilePage() {
return <Alert severity="error">Erro ao carregar dados do perfil</Alert>;
}
if (!displayData) {
if (!colaborador) {
return <Alert severity="warning">Nenhum dado de perfil disponível</Alert>;
}
return (
<Grid container spacing={3}>
{/* Header com Avatar e Nome */}
<Grid size={{ xs: 12 }}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Avatar
sx={{
width: 80,
height: 80,
bgcolor: 'primary.main',
fontSize: '1.5rem',
}}
>
{colaborador.iniciais}
</Avatar>
<Box>
<Typography variant="h5" fontWeight="600">
{colaborador.nome}
</Typography>
<Typography variant="body2" color="textSecondary">
{colaborador.nomeComFilial}
</Typography>
<Box sx={{ mt: 1, display: 'flex', gap: 1 }}>
{colaborador.ehRepresentanteComercial && (
<Chip label="RCA" color="primary" size="small" />
)}
{colaborador.podeAplicarDesconto && (
<Chip
label={`${colaborador.percentualDesconto}% desconto`}
color="success"
size="small"
/>
)}
</Box>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
{/* Informações Pessoais */}
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
@ -54,7 +92,7 @@ export default function ProfilePage() {
Nome
</Typography>
<Typography variant="body1" fontWeight="500">
{displayData.nome || '-'}
{colaborador.nome || '-'}
</Typography>
</Box>
<Box>
@ -62,7 +100,7 @@ export default function ProfilePage() {
Usuário
</Typography>
<Typography variant="body1" fontWeight="500">
{displayData.userName || '-'}
{colaborador.userName || '-'}
</Typography>
</Box>
<Box>
@ -70,7 +108,7 @@ export default function ProfilePage() {
Matrícula
</Typography>
<Typography variant="body1" fontWeight="500">
{displayData.matricula || '-'}
{colaborador.matricula || '-'}
</Typography>
</Box>
</Box>
@ -78,6 +116,7 @@ export default function ProfilePage() {
</Card>
</Grid>
{/* Informações Profissionais */}
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
@ -90,8 +129,7 @@ export default function ProfilePage() {
Filial
</Typography>
<Typography variant="body1" fontWeight="500">
{displayData.nomeFilial || '-'} (
{displayData.codigoFilial || '-'})
{colaborador.nomeFilial || '-'} ({colaborador.codigoFilial || '-'})
</Typography>
</Box>
<Box>
@ -99,16 +137,16 @@ export default function ProfilePage() {
RCA
</Typography>
<Typography variant="body1" fontWeight="500">
{displayData.rca || '-'}
{colaborador.ehRepresentanteComercial ? colaborador.codigoRCA : 'Não é vendedor'}
</Typography>
</Box>
{displayData.discountPercent !== undefined && (
{colaborador.podeAplicarDesconto && (
<Box>
<Typography variant="body2" color="textSecondary">
Desconto (%)
Desconto Disponível
</Typography>
<Typography variant="body1" fontWeight="500">
{displayData.discountPercent}%
{colaborador.percentualDesconto}%
</Typography>
</Box>
)}
@ -117,9 +155,9 @@ export default function ProfilePage() {
</Card>
</Grid>
{(displayData.sectorId !== undefined ||
displayData.sectorManagerId !== undefined ||
displayData.supervisorId !== undefined) && (
{/* Informações Adicionais */}
{(colaborador.codigoSetor !== undefined ||
colaborador.codigoSupervisor !== undefined) && (
<Grid size={{ xs: 12 }}>
<Card>
<CardContent>
@ -127,23 +165,23 @@ export default function ProfilePage() {
Informações Adicionais
</Typography>
<Grid container spacing={2}>
{displayData.sectorId !== undefined && (
{colaborador.codigoSetor !== undefined && (
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="body2" color="textSecondary">
Setor
</Typography>
<Typography variant="body1" fontWeight="500">
{displayData.sectorId}
{colaborador.codigoSetor}
</Typography>
</Grid>
)}
{displayData.supervisorId !== undefined && (
{colaborador.codigoSupervisor !== undefined && (
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="body2" color="textSecondary">
Supervisor
</Typography>
<Typography variant="body1" fontWeight="500">
{displayData.supervisorId}
{colaborador.codigoSupervisor}
</Typography>
</Grid>
)}
@ -155,3 +193,4 @@ export default function ProfilePage() {
</Grid>
);
}

View File

@ -0,0 +1,71 @@
import { UserProfileDto } from '../types';
/**
* Entidade de Domínio: Colaborador
*/
export class Colaborador {
private constructor(
public readonly matricula: number,
public readonly userName: string,
public readonly nome: string,
public readonly codigoFilial: string,
public readonly nomeFilial: string,
public readonly codigoRCA: number,
private readonly _percentualDesconto: number,
public readonly codigoSetor: number,
public readonly codigoSupervisor: number
) {}
// ========== FACTORY METHODS ==========
static criarAPartirDoDto(dto: UserProfileDto): Colaborador {
return new Colaborador(
dto.matricula,
dto.userName,
dto.nome,
dto.codigoFilial,
dto.nomeFilial,
dto.rca,
dto.discountPercent,
dto.sectorId,
dto.supervisorId
);
}
// ========== COMPUTED PROPERTIES ==========
get iniciais(): string {
if (!this.nome) return 'U';
const partes = this.nome.trim().split(' ').filter(Boolean);
if (partes.length === 1) {
return partes[0][0].toUpperCase();
}
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
}
get ehRepresentanteComercial(): boolean {
return this.codigoRCA > 0;
}
get podeAplicarDesconto(): boolean {
return this._percentualDesconto > 0 && this.ehRepresentanteComercial;
}
get percentualDesconto(): number {
return this._percentualDesconto;
}
get nomeComFilial(): string {
return `${this.nome} - ${this.nomeFilial}`;
}
// ========== BUSINESS METHODS ==========
aplicarDescontoAoValor(valorOriginal: number): number {
if (!this.podeAplicarDesconto) {
return valorOriginal;
}
return valorOriginal * (1 - this._percentualDesconto / 100);
}
}

View File

@ -0,0 +1,19 @@
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { profileService } from '../services/profile.service';
import { Colaborador } from '../domain/Colaborador';
export const COLABORADOR_ATUAL_QUERY_KEY = ['colaborador-atual'] as const;
/**
* Hook para obter o colaborador atualmente autenticado.
* Encapsula a lógica de busca e cache do perfil do usuário,
* desacoplando essa responsabilidade do módulo de login.
*/
export function useColaboradorAtual(): UseQueryResult<Colaborador> {
return useQuery({
queryKey: COLABORADOR_ATUAL_QUERY_KEY,
queryFn: () => profileService.obterColaboradorAtual(),
retry: false,
staleTime: Infinity,
});
}

View File

@ -1,3 +1,5 @@
// Profile feature barrel export
export { default as ProfilePage } from './components/ProfilePage';
export type { UserProfile } from './types';
export type { UserProfileDto } from './types';
export { Colaborador } from './domain/Colaborador';
export { useColaboradorAtual, COLABORADOR_ATUAL_QUERY_KEY } from './hooks/useColaboradorAtual';

View File

@ -1,9 +1,23 @@
import { profileApi } from '../api';
import { UserProfile } from '../types';
import { UserProfileDto } from '../types';
import { Colaborador } from '../domain/Colaborador';
export const profileService = {
getMe: async (): Promise<UserProfile> => {
const response = await profileApi.get<UserProfile>('/auth/me');
/**
* Obtém o colaborador atualmente autenticado como entidade de domínio.
* Segue o padrão de Linguagem Ubíqua do DDD.
*/
obterColaboradorAtual: async (): Promise<Colaborador> => {
const response = await profileApi.get<UserProfileDto>('/auth/me');
return Colaborador.criarAPartirDoDto(response.data);
},
/**
* Obtém os dados brutos (DTO) do colaborador autenticado.
* Usar apenas quando a conversão para entidade de domínio não for necessária.
*/
obterColaboradorAtualDto: async (): Promise<UserProfileDto> => {
const response = await profileApi.get<UserProfileDto>('/auth/me');
return response.data;
},
};

View File

@ -1,7 +1,7 @@
/**
* Tipagem da resposta do User Info (Endpoint 1.3)
*/
export interface UserProfile {
export interface UserProfileDto {
matricula: number;
userName: string;
nome: string;