Compare commits

..

No commits in common. "5558ca6a0d118c659cf6bc1669a852ec3db8332a" and "2e940404818d14a6d28acb4c5e1ecd972ae0b9ba" have entirely different histories.

27 changed files with 1143 additions and 704 deletions

View File

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

View File

@ -0,0 +1,128 @@
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

@ -0,0 +1,135 @@
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

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

View File

@ -0,0 +1,185 @@
// 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 { try {
await loginService.refreshToken(); await loginService.refreshToken();
const profile = await profileService.obterColaboradorAtual(); const profile = await profileService.getMe();
setUser(mapToSafeProfile(profile)); setUser(mapToSafeProfile(profile));
} catch (error) { } catch (error) {
console.warn('Sessão expirada ou inválida', error); console.warn('Sessão expirada ou inválida', error);

View File

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

View File

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

View File

@ -1,22 +1,16 @@
import { UserProfileDto } from '../../profile/types'; import { UserProfile } 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 { return {
matricula: data.matricula, matricula: data.matricula,
userName: data.userName, userName: data.userName,
nome: data.nome, nome: data.nome,
codigoFilial: data.codigoFilial, codigoFilial: data.codigoFilial,
nomeFilial: data.nomeFilial, nomeFilial: data.nomeFilial,
rca: data.codigoRCA ?? data.rca, rca: data.rca,
discountPercent: data.percentualDesconto ?? data._percentualDesconto ?? data.discountPercent, discountPercent: data.discountPercent,
sectorId: data.codigoSetor ?? data.sectorId, sectorId: data.sectorId,
sectorManagerId: data.sectorManagerId ?? 0, sectorManagerId: data.sectorManagerId,
supervisorId: data.codigoSupervisor ?? data.supervisorId, supervisorId: data.supervisorId,
}; };
}; };

View File

@ -15,8 +15,6 @@ import {
storesResponseSchema, storesResponseSchema,
customersResponseSchema, customersResponseSchema,
sellersResponseSchema, sellersResponseSchema,
partnersResponseSchema,
productsResponseSchema,
unwrapApiData, unwrapApiData,
} from '../schemas/order.schema'; } from '../schemas/order.schema';
import { import {
@ -180,42 +178,5 @@ 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,28 +12,22 @@ 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,
Tune as TuneIcon, ExpandLess as ExpandLessIcon,
Close as CloseIcon, ExpandMore as ExpandMoreIcon,
} 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';
@ -45,10 +39,6 @@ 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;
@ -64,10 +54,6 @@ 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,
@ -87,10 +73,6 @@ 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<{
@ -112,8 +94,6 @@ export const SearchBar = () => {
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setTouchedFields({}); setTouchedFields({});
setCustomerSearchTerm(''); setCustomerSearchTerm('');
setPartnerSearchTerm('');
setProductSearchTerm('');
const resetState = { const resetState = {
status: null, status: null,
@ -123,6 +103,7 @@ 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,
@ -131,10 +112,6 @@ 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));
@ -190,32 +167,17 @@ export const SearchBar = () => {
touchedFields.createDateIni && !localFilters.createDateIni; touchedFields.createDateIni && !localFilters.createDateIni;
const showDateEndError = touchedFields.createDateEnd && dateError; const showDateEndError = touchedFields.createDateEnd && dateError;
// Contador de filtros ativos no Drawer // Contador de filtros avançados ativos
const activeDrawerFiltersCount = [ const advancedFiltersCount = [
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: 2 }, p: { xs: 2, md: 3 },
mb: 2, mb: 2,
bgcolor: 'background.paper', bgcolor: 'background.paper',
borderRadius: 2, borderRadius: 2,
@ -224,8 +186,10 @@ export const SearchBar = () => {
elevation={0} elevation={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<Grid container spacing={2} alignItems="center"> <Grid container spacing={{ xs: 1.5, md: 2 }} alignItems="flex-end">
{/* Nº do Pedido */} {/* --- Primary Filters (Always Visible) --- */}
{/* 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
@ -243,227 +207,404 @@ export const SearchBar = () => {
/> />
</Grid> </Grid>
{/* Datas */} {/* Select do MUI (Situação) */}
<Grid size={{ xs: 12, sm: 12, md: 5 }}> <Grid size={{ xs: 12, sm: 6, md: 2 }}>
<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) => {
setTouchedFields(prev => ({ ...prev, createDateIni: true }));
updateLocalFilter('createDateIni', date ? date.format('YYYY-MM-DD') : null);
}}
format="DD/MM/YYYY"
slotProps={{
textField: {
size: 'small',
fullWidth: true,
required: true,
error: showDateIniError,
helperText: showDateIniError ? 'Obrigatório' : '',
}
}}
/>
<DatePicker
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"
slotProps={{
textField: {
size: 'small',
fullWidth: true,
error: !!showDateEndError,
helperText: showDateEndError || '',
}
}}
/>
</Box>
</LocalizationProvider>
</Grid>
{/* 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}
startIcon={<ResetIcon />}
sx={{ textTransform: 'none', minWidth: 90 }}
>
Limpar
</Button>
<Badge badgeContent={activeDrawerFiltersCount} color="primary">
<Button
variant="outlined"
color="primary"
size="small"
onClick={toggleDrawer(true)}
startIcon={<TuneIcon />}
sx={{ textTransform: 'none', minWidth: 100 }}
>
Filtros
</Button>
</Badge>
<Button
variant="contained"
color="primary"
size="small"
onClick={handleFilter}
disabled={!isDateValid || !!dateError || isFetching}
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 }
}}
>
<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 <TextField
select select
fullWidth fullWidth
label="Situação" label="Situação"
size="small" size="small"
value={localFilters.status ?? ''} value={localFilters.status ?? ''}
onChange={(e) => updateLocalFilter('status', e.target.value || null)} onChange={(e) => {
const value = e.target.value || null;
updateLocalFilter('status', value);
}}
> >
<MenuItem value="">Todos</MenuItem> <MenuItem value="">Todos</MenuItem>
<MenuItem value="P">Pendente</MenuItem> <MenuItem value="P">Pendente</MenuItem>
<MenuItem value="F">Faturado</MenuItem> <MenuItem value="F">Faturado</MenuItem>
<MenuItem value="C">Cancelado</MenuItem> <MenuItem value="C">Cancelado</MenuItem>
</TextField> </TextField>
</Grid>
{/* Cliente */} {/* Autocomplete do MUI para Cliente */}
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<Autocomplete <Autocomplete
size="small" size="small"
options={customers.options} options={customers.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id} isOptionEqualToValue={(option, value) => option.id === value.id}
value={customers.options.find(opt => localFilters.customerId === opt.customer?.id) || null} value={
customers.options.find(
(option) => localFilters.customerId === option.customer?.id
) || null
}
onChange={(_, newValue) => { onChange={(_, newValue) => {
updateLocalFilter('customerId', newValue?.customer?.id || null); if (!newValue) {
updateLocalFilter('customerName', newValue?.customer?.name || null); 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('');
}
}
}} }}
onInputChange={(_, val) => setCustomerSearchTerm(val)}
loading={customers.isLoading} loading={customers.isLoading}
renderInput={(params) => <TextField {...params} label="Cliente" placeholder="Buscar cliente..." />} renderInput={(params: AutocompleteRenderInputParams) => (
noOptionsText={customerSearchTerm.length < 2 ? 'Digite 2+ caracteres' : 'Nenhum cliente'} <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} filterOptions={(x) => x}
clearOnBlur={false}
selectOnFocus
handleHomeEndKeys
/> />
</Grid>
{/* Filiais */} {/* 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>
</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>
<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',
},
}}
>
<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 <Autocomplete
multiple multiple
size="small" size="small"
options={stores.options} options={stores.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id} isOptionEqualToValue={(option, value) =>
value={stores.options.filter((opt) => localFilters.store?.includes(opt.value))} option.id === value.id
onChange={(_, newValue) => updateLocalFilter('store', newValue.map((opt) => opt.value))} }
value={stores.options.filter((option) =>
localFilters.store?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'store',
newValue.map((option) => option.value)
);
}}
loading={stores.isLoading} loading={stores.isLoading}
renderInput={(params) => <TextField {...params} label="Filiais" placeholder="Selecione filiais" />} renderInput={(params: AutocompleteRenderInputParams) => (
<TextField
{...params}
label="Filiais"
placeholder={
localFilters.store?.length
? `${localFilters.store.length} selecionadas`
: 'Selecione'
}
/> />
)}
/>
</Grid>
{/* Filial de Estoque */} {/* Autocomplete do MUI para Filial de Estoque */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Autocomplete <Autocomplete
multiple multiple
size="small" size="small"
options={stores.options} options={stores.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id} isOptionEqualToValue={(option, value) =>
value={stores.options.filter((opt) => localFilters.stockId?.includes(opt.value))} option.id === value.id
onChange={(_, newValue) => updateLocalFilter('stockId', newValue.map((opt) => opt.value))} }
value={stores.options.filter((option) =>
localFilters.stockId?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'stockId',
newValue.map((option) => option.value)
);
}}
loading={stores.isLoading} loading={stores.isLoading}
renderInput={(params) => <TextField {...params} label="Filial de Estoque" placeholder="Selecione filial" />} renderInput={(params: AutocompleteRenderInputParams) => (
<TextField
{...params}
label="Filial de Estoque"
placeholder={
localFilters.stockId?.length
? `${localFilters.stockId.length} selecionadas`
: 'Selecione'
}
/> />
)}
/>
</Grid>
{/* Vendedor */} {/* Autocomplete do MUI para Vendedor */}
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Autocomplete <Autocomplete
size="small" size="small"
options={sellers.options} options={sellers.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
value={sellers.options.find(opt => localFilters.sellerId === opt.seller.id.toString()) || null} isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={
sellers.options.find(
(option) =>
localFilters.sellerId === option.seller.id.toString()
) || null
}
onChange={(_, newValue) => { onChange={(_, newValue) => {
updateLocalFilter('sellerId', newValue?.seller.id.toString() || null); updateLocalFilter(
updateLocalFilter('sellerName', newValue?.seller.name || null); 'sellerId',
newValue?.seller.id.toString() || null
);
updateLocalFilter(
'sellerName',
newValue?.seller.name || null
);
}} }}
loading={sellers.isLoading} loading={sellers.isLoading}
renderInput={(params) => <TextField {...params} label="Vendedor" placeholder="Selecione vendedor" />} renderInput={(params) => (
<TextField
{...params}
label="Vendedor"
placeholder="Selecione um vendedor"
/> />
)}
{/* Parceiro */} renderOption={(props, option) => {
<Autocomplete const { key, ...otherProps } = props;
size="small" return (
options={partners.options} <li key={option.id} {...otherProps}>
getOptionLabel={(option) => option.label} {option.label}
value={partners.options.find(opt => localFilters.partnerId === opt.partner?.id?.toString()) || null} </li>
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>
{/* Produto */} </Grid>
<Autocomplete </Collapse>
size="small" </Grid>
options={products.options} </Grid>
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,15 +16,12 @@ 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

@ -1,48 +0,0 @@
'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

@ -1,47 +0,0 @@
'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,8 +2,6 @@ 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';
/** /**
@ -41,15 +39,3 @@ 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,7 +16,6 @@ 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(),
@ -28,7 +27,6 @@ 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(),
@ -100,31 +98,19 @@ const formatValueToString = (val: any): string => {
*/ */
export const orderApiParamsSchema = findOrdersSchema export const orderApiParamsSchema = findOrdersSchema
.transform((filters) => { .transform((filters) => {
const { // Remove customerName quando customerId existe (evita redundância)
productName, const { customerName, customerId, ...rest } = filters;
partnerName, return customerId ? { customerId, ...rest } : filters;
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,16 +26,6 @@ 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

@ -1,15 +0,0 @@
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

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

View File

@ -1,71 +0,0 @@
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

@ -1,19 +0,0 @@
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,5 +1,3 @@
// Profile feature barrel export // Profile feature barrel export
export { default as ProfilePage } from './components/ProfilePage'; export { default as ProfilePage } from './components/ProfilePage';
export type { UserProfileDto } from './types'; export type { UserProfile } from './types';
export { Colaborador } from './domain/Colaborador';
export { useColaboradorAtual, COLABORADOR_ATUAL_QUERY_KEY } from './hooks/useColaboradorAtual';

View File

@ -1,23 +1,9 @@
import { profileApi } from '../api'; import { profileApi } from '../api';
import { UserProfileDto } from '../types'; import { UserProfile } from '../types';
import { Colaborador } from '../domain/Colaborador';
export const profileService = { export const profileService = {
/** getMe: async (): Promise<UserProfile> => {
* Obtém o colaborador atualmente autenticado como entidade de domínio. const response = await profileApi.get<UserProfile>('/auth/me');
* 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; return response.data;
}, },
}; };

View File

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