Compare commits

..

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

157 changed files with 17464 additions and 22253 deletions

3
.gitignore vendored
View File

@ -39,6 +39,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
*storybook.log
storybook-static

View File

@ -1,7 +0,0 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80
}

View File

@ -1,20 +0,0 @@
import type { StorybookConfig } from '@storybook/nextjs-vite';
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@chromatic-com/storybook",
"@storybook/addon-vitest",
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-onboarding"
],
"framework": "@storybook/nextjs-vite",
"staticDirs": [
"..\\public"
]
};
export default config;

View File

@ -1,14 +0,0 @@
import type { Preview } from '@storybook/nextjs-vite'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@ -23,13 +23,11 @@ The application is designed to provide a robust and scalable frontend interface
## Installation
1. Clone the repository:
```bash
git clone <repository-url>
```
2. Navigate to the project directory:
```bash
cd portal-web-v2
```
@ -44,7 +42,7 @@ The application is designed to provide a robust and scalable frontend interface
The following scripts are available in `package.json` for development and operations:
| Script | Description |
| ----------------------- | --------------------------------------------------- |
|--------|-------------|
| `npm run dev` | Starts the development server with hot-reloading. |
| `npm run build` | Compiles the application for production deployment. |
| `npm start` | Runs the compiled production build locally. |

View File

@ -3,10 +3,7 @@
import createCache from '@emotion/cache';
import { useServerInsertedHTML } from 'next/navigation';
import { CacheProvider as DefaultCacheProvider } from '@emotion/react';
import type {
EmotionCache,
Options as OptionsOfCreateCache,
} from '@emotion/cache';
import type { EmotionCache, Options as OptionsOfCreateCache } from '@emotion/cache';
import type { ReactNode } from 'react';
import { useState } from 'react';
import React from 'react';
@ -23,9 +20,7 @@ export type NextAppDirEmotionCacheProviderProps = {
};
// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx
export default function NextAppDirEmotionCacheProvider(
props: NextAppDirEmotionCacheProviderProps
) {
export default function NextAppDirEmotionCacheProvider(props: NextAppDirEmotionCacheProviderProps) {
const { options, CacheProvider = DefaultCacheProvider, children } = props;
const [registry] = useState(() => {

View File

@ -8,17 +8,15 @@ import { useCustomizerStore } from '@/features/dashboard/store/useCustomizerStor
import { LicenseInfo } from '@mui/x-license';
import { AuthInitializer } from '@/features/login/components/AuthInitializer';
const PERPETUAL_LICENSE_KEY =
'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
const PERPETUAL_LICENSE_KEY = 'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
try {
LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY);
} catch (error) {
console.error('Failed to set MUI license key:', error);
}
export default function Providers({
children,
}: Readonly<{ children: React.ReactNode }>) {
export default function Providers({ children }: Readonly<{ children: React.ReactNode }>) {
const [queryClient] = useState(() => new QueryClient());
const [mounted, setMounted] = useState(false);
const activeMode = useCustomizerStore((state) => state.activeMode);
@ -69,24 +67,15 @@ export default function Providers({
},
typography: {
fontFamily: '"Plus Jakarta Sans", "Helvetica", "Arial", sans-serif',
h1: { fontWeight: 600, fontSize: '2.25rem', lineHeight: 1.2 },
h2: { fontWeight: 600, fontSize: '1.875rem', lineHeight: 1.2 },
h3: { fontWeight: 600, fontSize: '1.5rem', lineHeight: 1.2 },
h4: { fontWeight: 600, fontSize: '1.3125rem', lineHeight: 1.2 },
h5: { fontWeight: 600, fontSize: '1.125rem', lineHeight: 1.2 },
h6: { fontWeight: 600, fontSize: '1rem', lineHeight: 1.2 },
button: { textTransform: 'none', fontWeight: 500 },
body1: { fontSize: '0.8125rem', fontWeight: 400, lineHeight: 1.5 }, // 13px
body2: { fontSize: '0.75rem', fontWeight: 400, lineHeight: 1.5 }, // 12px
subtitle1: { fontSize: '0.875rem', fontWeight: 400 },
subtitle2: { fontSize: '0.75rem', fontWeight: 400 },
h1: { fontWeight: 600 },
h6: { fontWeight: 600 },
body1: { fontSize: '0.875rem', fontWeight: 400 },
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
fontFamily:
"var(--font-plus-jakarta), 'Plus Jakarta Sans', sans-serif",
fontFamily: "var(--font-plus-jakarta), 'Plus Jakarta Sans', sans-serif",
},
},
},

View File

@ -17,21 +17,13 @@ function OrdersContent() {
export default function OrdersPageRoute() {
return (
<Suspense
fallback={
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}
>
<Suspense fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<CircularProgress />
</Box>
}
>
}>
<OrdersContent />
</Suspense>
);
}

View File

@ -1 +1,2 @@
export { default } from '../../src/features/dashboard/pages/DashboardPage';

View File

@ -20,21 +20,13 @@ function ProfileContent() {
export default function ProfilePageRoute() {
return (
<Suspense
fallback={
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}
>
<Suspense fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<CircularProgress />
</Box>
}
>
}>
<ProfileContent />
</Suspense>
);
}

View File

@ -1,4 +1,4 @@
@import 'tailwindcss';
@import "tailwindcss";
:root {
--background: #ffffff;

View File

@ -1,29 +1,29 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono, Plus_Jakarta_Sans } from 'next/font/google';
import './globals.css';
import Providers from './components/Providers';
import EmotionRegistry from './components/EmotionRegistry';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import type { Metadata } from "next";
import { Geist, Geist_Mono, Plus_Jakarta_Sans } from "next/font/google";
import "./globals.css";
import Providers from "./components/Providers";
import EmotionRegistry from "./components/EmotionRegistry";
import { NuqsAdapter } from "nuqs/adapters/next/app";
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
variable: "--font-geist-mono",
subsets: ["latin"],
});
const plusJakartaSans = Plus_Jakarta_Sans({
variable: '--font-plus-jakarta',
subsets: ['latin'],
variable: "--font-plus-jakarta",
subsets: ["latin"],
weight: ['300', '400', '500', '600', '700', '800'],
});
export const metadata: Metadata = {
title: 'Portal Jurunense - Login Page',
description: 'Login page for Modernize dashboard',
title: "Portal Jurunense - Login Page",
description: "Login page for Modernize dashboard",
};
export default function RootLayout({
@ -36,6 +36,7 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} ${plusJakartaSans.variable} antialiased`}
>
<EmotionRegistry options={{ key: 'mui' }}>
<NuqsAdapter>
<Providers>{children}</Providers>

View File

@ -1,5 +1,6 @@
import Login from '../../src/features/login/components/LoginForm';
import Login from "../../src/features/login/components/LoginForm";
export default function LoginPage() {
return <Login />;
}

View File

@ -1,4 +1,4 @@
import Login from '../src/features/login/components/LoginForm';
import Login from "../src/features/login/components/LoginForm";
export default function Home() {
return <Login />;

View File

@ -1,9 +1,6 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
@ -11,10 +8,10 @@ const eslintConfig = defineConfig([
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);

View File

@ -22,7 +22,7 @@ jest.mock('next/navigation', () => ({
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,

View File

@ -1,9 +1,8 @@
import { LicenseInfo } from '@mui/x-license';
const PERPETUAL_LICENSE_KEY =
'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
const ALTERNATIVE_LICENSE_KEY =
'61628ce74db2c1b62783a6d438593bc5Tz1NVUktRG9jLEU9MTY4MzQ0NzgyMTI4NCxTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLEtWPTI=';
const PERPETUAL_LICENSE_KEY = 'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
const ALTERNATIVE_LICENSE_KEY = '61628ce74db2c1b62783a6d438593bc5Tz1NVUktRG9jLEU9MTY4MzQ0NzgyMTI4NCxTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLEtWPTI=';
try {
LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY);

View File

@ -1,18 +1,18 @@
import type { NextConfig } from 'next';
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// ... suas outras configurações (como rewrites)
allowedDevOrigins: ['portalconsulta.jurunense.com'],
allowedDevOrigins: ["portalconsulta.jurunense.com"],
transpilePackages: ['@mui/material', '@emotion/react', '@emotion/styled'],
async rewrites() {
return [
{
source: '/api/auth/:path*',
destination: 'https://api.auth.jurunense.com/api/v1/:path*',
source: "/api/auth/:path*",
destination: "https://api.auth.jurunense.com/api/v1/:path*",
},
{
source: '/api/report-viewer/:path*',
destination: 'http://10.1.1.205:8068/Viewer/:path*',
source: "/api/report-viewer/:path*",
destination: "http://10.1.1.205:8068/Viewer/:path*",
},
];
},

3016
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,9 +9,7 @@
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"test:coverage": "jest --coverage"
},
"dependencies": {
"@emotion/cache": "^11.14.0",
@ -44,7 +42,6 @@
"next": "16.1.1",
"next-auth": "latest",
"nuqs": "^2.8.6",
"prettier": "^3.7.4",
"react": "19.2.3",
"react-big-calendar": "^1.19.4",
"react-dom": "19.2.3",
@ -67,19 +64,6 @@
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "^5",
"storybook": "^10.1.11",
"@storybook/nextjs-vite": "^10.1.11",
"@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/addon-a11y": "^10.1.11",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-onboarding": "^10.1.11",
"vite": "^7.3.1",
"eslint-plugin-storybook": "^10.1.11",
"vitest": "^4.0.17",
"playwright": "^1.57.0",
"@vitest/browser-playwright": "^4.0.17",
"@vitest/coverage-v8": "^4.0.17"
"typescript": "^5"
}
}

View File

@ -1,6 +1,6 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
"@tailwindcss/postcss": {},
},
};

View File

@ -28,6 +28,7 @@ const StyledMain = styled(Box, {
}),
}));
export default function DashboardLayout({ children }: DashboardLayoutProps) {
const theme = useTheme();
const lgUp = useMediaQuery(theme.breakpoints.up('lg'));
@ -36,15 +37,10 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
if (!isHydrated) {
return (
<Box sx={{ display: 'flex', height: '100vh' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden',
}}
>
<StyledMain isMobile={!lgUp}>{children}</StyledMain>
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }}>
<StyledMain isMobile={!lgUp}>
{children}
</StyledMain>
</Box>
</Box>
);
@ -53,17 +49,13 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<Box sx={{ display: 'flex', height: '100vh' }}>
<Sidebar />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }}>
<Header />
<StyledMain isMobile={!lgUp}>{children}</StyledMain>
<StyledMain isMobile={!lgUp}>
{children}
</StyledMain>
</Box>
</Box>
);
}

View File

@ -37,3 +37,4 @@ export default function DashboardOverview() {
</Grid>
);
}

View File

@ -13,3 +13,4 @@ export default function Logo() {
</Link>
);
}

View File

@ -1,12 +1,12 @@
import SimpleBar from 'simplebar-react';
import 'simplebar-react/dist/simplebar.min.css';
import SimpleBar from "simplebar-react";
import "simplebar-react/dist/simplebar.min.css";
import Box from '@mui/material/Box';
import { SxProps } from '@mui/system';
import { styled } from '@mui/material/styles';
import { useMediaQuery } from '@mui/material';
import { useMediaQuery } from "@mui/material";
const SimpleBarStyle = styled(SimpleBar)(() => ({
maxHeight: '100%',
maxHeight: "100%",
}));
interface PropsType {
@ -19,7 +19,7 @@ const Scrollbar = (props: PropsType) => {
const lgDown = useMediaQuery((theme: any) => theme.breakpoints.down('lg'));
if (lgDown) {
return <Box sx={{ overflowX: 'auto' }}>{children}</Box>;
return <Box sx={{ overflowX: "auto" }}>{children}</Box>;
}
return (
@ -30,3 +30,4 @@ const Scrollbar = (props: PropsType) => {
};
export default Scrollbar;

View File

@ -1,46 +1,46 @@
'use client';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Toolbar from '@mui/material/Toolbar';
import useMediaQuery from '@mui/material/useMediaQuery';
import { styled, Theme } from '@mui/material/styles';
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Toolbar from "@mui/material/Toolbar";
import useMediaQuery from "@mui/material/useMediaQuery";
import { styled, Theme } from "@mui/material/styles";
import MenuIcon from '@mui/icons-material/Menu';
import DarkModeIcon from '@mui/icons-material/DarkMode';
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 Search from './Search';
import Navigation from './Navigation';
import MobileRightSidebar from './MobileRightSidebar';
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',
boxShadow: "none",
background: theme.palette.background.paper,
justifyContent: 'center',
backdropFilter: 'blur(4px)',
justifyContent: "center",
backdropFilter: "blur(4px)",
zIndex: theme.zIndex.drawer + 1,
}));
const ToolbarStyled = styled(Toolbar)(({ theme }) => ({
width: '100%',
width: "100%",
color: theme.palette.text.secondary,
}));
const Header = () => {
const lgUp = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg'));
const lgDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('lg'));
const lgUp = useMediaQuery((theme: Theme) => theme.breakpoints.up("lg"));
const lgDown = useMediaQuery((theme: Theme) => theme.breakpoints.down("lg"));
const {
activeMode,
toggleSidebar,
toggleMobileSidebar,
setDarkMode,
isHydrated,
isHydrated
} = useCustomizerStore();
if (!isHydrated) {
@ -70,18 +70,21 @@ const Header = () => {
<Box flexGrow={1} />
<Stack spacing={1} direction="row" alignItems="center">
{/* ------------------------------------------- */}
{/* Theme Toggle (Dark/Light) */}
{/* ------------------------------------------- */}
<IconButton
size="large"
color="inherit"
onClick={() =>
setDarkMode(activeMode === 'light' ? 'dark' : 'light')
}
onClick={() => setDarkMode(activeMode === "light" ? "dark" : "light")}
aria-label="alternar tema"
>
{activeMode === 'light' ? <DarkModeIcon /> : <LightModeIcon />}
{activeMode === "light" ? (
<DarkModeIcon />
) : (
<LightModeIcon />
)}
</IconButton>
<Notifications />

View File

@ -85,7 +85,8 @@ const MobileRightSidebar = () => {
</List>
</Box>
<Box px={3} mt={3}></Box>
<Box px={3} mt={3}>
</Box>
</Box>
);

View File

@ -1,8 +1,8 @@
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';
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);
@ -25,17 +25,17 @@ const AppDD = () => {
aria-controls="msgs-menu"
aria-haspopup="true"
sx={{
bgcolor: anchorEl2 ? 'primary.light' : '',
bgcolor: anchorEl2 ? "primary.light" : "",
color: anchorEl2
? 'primary.main'
? "primary.main"
: (theme) => theme.palette.text.secondary,
fontSize: '13px',
fontSize: "13px",
}}
onClick={handleClick2}
endIcon={
<IconChevronDown
size="15"
style={{ marginLeft: '-5px', marginTop: '2px' }}
style={{ marginLeft: "-5px", marginTop: "2px" }}
/>
}
>
@ -50,13 +50,13 @@ const AppDD = () => {
keepMounted
open={Boolean(anchorEl2)}
onClose={handleClose2}
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
transformOrigin={{ horizontal: 'left', vertical: 'top' }}
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
transformOrigin={{ horizontal: "left", vertical: "top" }}
sx={{
'& .MuiMenu-paper': {
width: '850px',
"& .MuiMenu-paper": {
width: "850px",
},
'& .MuiMenu-paper ul': {
"& .MuiMenu-paper ul": {
p: 0,
},
}}
@ -69,8 +69,8 @@ const AppDD = () => {
<Box
sx={{
display: {
xs: 'none',
sm: 'flex',
xs: "none",
sm: "flex",
},
}}
alignItems="center"
@ -99,17 +99,15 @@ const AppDD = () => {
<Divider orientation="vertical" />
</Grid>
<Grid size={{ sm: 4 }}>
<Box p={4}></Box>
<Box p={4}>
</Box>
</Grid>
</Grid>
</Menu>
</Box>
<Button
color="inherit"
sx={{
color: (theme) => theme.palette.text.secondary,
fontSize: '13px',
}}
sx={{ color: (theme) => theme.palette.text.secondary, fontSize: "13px" }}
variant="text"
href="/apps/chats"
component={Link}
@ -118,10 +116,7 @@ const AppDD = () => {
</Button>
<Button
color="inherit"
sx={{
color: (theme) => theme.palette.text.secondary,
fontSize: '13px',
}}
sx={{ color: (theme) => theme.palette.text.secondary, fontSize: "13px" }}
variant="text"
href="/apps/email"
component={Link}

View File

@ -11,7 +11,7 @@ import {
Chip,
} from '@mui/material';
import * as dropdownData from './data';
import { Scrollbar } from '@/shared/components';
import Scrollbar from '../components/Scrollbar';
import { IconBellRinging } from '@tabler/icons-react';
import { Stack } from '@mui/system';
@ -62,13 +62,7 @@ const Notifications = () => {
},
}}
>
<Stack
direction="row"
py={2}
px={4}
justifyContent="space-between"
alignItems="center"
>
<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>
@ -114,13 +108,7 @@ const Notifications = () => {
))}
</Scrollbar>
<Box p={3} pb={1}>
<Button
href="/apps/email"
variant="outlined"
component={Link}
color="primary"
fullWidth
>
<Button href="/apps/email" variant="outlined" component={Link} color="primary" fullWidth>
See all Notifications
</Button>
</Box>

View File

@ -9,17 +9,15 @@ import {
Button,
IconButton,
} from '@mui/material';
import * as dropdownData from './data';
import { IconMail } from '@tabler/icons-react';
import { Stack } from '@mui/system';
import Image from 'next/image';
import { useAuthStore } from '../../login/store/useAuthStore';
import { useAuth } from '../../login/hooks/useAuth';
const Profile = () => {
const [anchorEl2, setAnchorEl2] = useState(null);
const user = useAuthStore((s) => s.user);
const { logout } = useAuth();
const handleClick2 = (event: any) => {
setAnchorEl2(event.currentTarget);
};
@ -42,14 +40,13 @@ const Profile = () => {
onClick={handleClick2}
>
<Avatar
src={"/images/profile/user-1.jpg"}
alt={'ProfileImg'}
sx={{
width: 35,
height: 35,
bgcolor: 'primary.main',
}}
>
{user?.nome?.[0] || user?.userName?.[0] || 'U'}
</Avatar>
/>
</IconButton>
{/* ------------------------------------------- */}
{/* Message Dropdown */}
@ -71,21 +68,13 @@ const Profile = () => {
>
<Typography variant="h5">User Profile</Typography>
<Stack direction="row" py={3} spacing={2} alignItems="center">
<Avatar
sx={{ width: 95, height: 95, bgcolor: 'primary.main' }}
>
{user?.nome?.[0] || user?.userName?.[0] || 'U'}
</Avatar>
<Avatar src={"/images/profile/user-1.jpg"} alt={"ProfileImg"} sx={{ width: 95, height: 95 }} />
<Box>
<Typography
variant="subtitle2"
color="textPrimary"
fontWeight={600}
>
{user?.nome || user?.userName || 'Usuário'}
<Typography variant="subtitle2" color="textPrimary" fontWeight={600}>
Mathew Anderson
</Typography>
<Typography variant="subtitle2" color="textSecondary">
{user?.nomeFilial || 'Sem filial'}
Designer
</Typography>
<Typography
variant="subtitle2"
@ -95,18 +84,80 @@ const Profile = () => {
gap={1}
>
<IconMail width={15} height={15} />
{user?.rca ? `RCA: ${user.rca}` : 'Sem e-mail'}
info@modernize.com
</Typography>
</Box>
</Stack>
<Box mt={2}>
<Button
variant="outlined"
color="primary"
onClick={logout}
fullWidth
<Divider />
{dropdownData.profile.map((profile) => (
<Box key={profile.title}>
<Box sx={{ py: 2, px: 0 }} className="hover-text-primary">
<Link href={profile.href}>
<Stack direction="row" spacing={2}>
<Box
width="45px"
height="45px"
bgcolor="primary.light"
display="flex"
alignItems="center"
justifyContent="center" flexShrink="0"
>
Sair
<Avatar
src={profile.icon}
alt={profile.icon}
sx={{
width: 24,
height: 24,
borderRadius: 0,
}}
/>
</Box>
<Box>
<Typography
variant="subtitle2"
fontWeight={600}
color="textPrimary"
className="text-hover"
noWrap
sx={{
width: '240px',
}}
>
{profile.title}
</Typography>
<Typography
color="textSecondary"
variant="subtitle2"
sx={{
width: '240px',
}}
noWrap
>
{profile.subtitle}
</Typography>
</Box>
</Stack>
</Link>
</Box>
</Box>
))}
<Box mt={2}>
<Box bgcolor="primary.light" p={3} mb={3} overflow="hidden" position="relative">
<Box display="flex" justifyContent="space-between">
<Box>
<Typography variant="h5" mb={2}>
Unlimited <br />
Access
</Typography>
<Button variant="contained" color="primary">
Upgrade
</Button>
</Box>
<Image src={"/images/backgrounds/unlimited-bg.png"} width={150} height={183} style={{ height: 'auto', width: 'auto' }} alt="unlimited" className="signup-bg" />
</Box>
</Box>
<Button href="/auth/auth1/login" variant="outlined" color="primary" component={Link} fullWidth>
Logout
</Button>
</Box>
</Menu>

View File

@ -36,9 +36,7 @@ const Search = () => {
const filterRoutes = (rotr: any, cSearch: string) => {
if (rotr.length > 1)
return rotr.filter((t: any) =>
t.title
? t.href.toLocaleLowerCase().includes(cSearch.toLocaleLowerCase())
: ''
t.title ? t.href.toLocaleLowerCase().includes(cSearch.toLocaleLowerCase()) : '',
);
return rotr;
@ -91,11 +89,7 @@ const Search = () => {
return (
<Box key={menu.title ? menu.id : menu.subheader}>
{menu.title && !menu.children ? (
<ListItemButton
sx={{ py: 0.5, px: 1 }}
href={menu?.href}
component={Link}
>
<ListItemButton sx={{ py: 0.5, px: 1 }} href={menu?.href} component={Link}>
<ListItemText
primary={menu.title}
secondary={menu?.href}

View File

@ -10,51 +10,51 @@ interface NotificationType {
const notifications: NotificationType[] = [
{
id: '1',
avatar: '/images/profile/user-2.jpg',
title: 'Roman Joined the Team!',
subtitle: 'Congratulate him',
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',
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',
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',
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',
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',
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',
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',
avatar: "/images/profile/user-9.jpg",
title: "Jolly completed tasks",
subtitle: "Assign her new tasks",
},
];
@ -69,22 +69,22 @@ interface ProfileType {
}
const profile: ProfileType[] = [
{
href: '/dashboard/profile',
title: 'My Profile',
subtitle: 'Account Settings',
icon: '/images/svgs/icon-account.svg',
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/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',
href: "/apps/notes",
title: "My Tasks",
subtitle: "To-do and Daily Tasks",
icon: "/images/svgs/icon-tasks.svg",
},
];
@ -99,46 +99,46 @@ interface AppsLinkType {
const appsLink: AppsLinkType[] = [
{
href: '/apps/chats',
title: 'Chat Application',
subtext: 'New messages arrived',
avatar: '/images/svgs/icon-dd-chat.svg',
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/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/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/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/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/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',
href: "/apps/blog/post",
title: "Blog App",
subtext: "added new blog",
avatar: "/images/svgs/icon-dd-application.svg",
},
];
@ -149,36 +149,36 @@ interface LinkType {
const pageLinks: LinkType[] = [
{
href: '/theme-pages/pricing',
title: 'Pricing Page',
href: "/theme-pages/pricing",
title: "Pricing Page",
},
{
href: '/auth/auth1/login',
title: 'Authentication Design',
href: "/auth/auth1/login",
title: "Authentication Design",
},
{
href: '/auth/auth1/register',
title: 'Register Now',
href: "/auth/auth1/register",
title: "Register Now",
},
{
href: '/404',
title: '404 Error Page',
href: "/404",
title: "404 Error Page",
},
{
href: '/apps/note',
title: 'Notes App',
href: "/apps/note",
title: "Notes App",
},
{
href: '/apps/user-profile/profile',
title: 'User Application',
href: "/apps/user-profile/profile",
title: "User Application",
},
{
href: '/apps/blog/post',
title: 'Blog Design',
href: "/apps/blog/post",
title: "Blog Design",
},
{
href: '/apps/ecommerce/checkout',
title: 'Shopping Cart',
href: "/apps/ecommerce/checkout",
title: "Shopping Cart",
},
];

View File

@ -1,2 +1,3 @@
export { default as Dashboard } from './components/Dashboard';
export { default as DashboardPage } from './pages/DashboardPage';

View File

@ -8,3 +8,4 @@ export default function DashboardPage() {
</DashboardLayout>
);
}

View File

@ -36,3 +36,4 @@ const Menuitems: MenuItemType[] = [
];
export default Menuitems;

View File

@ -16,7 +16,7 @@ import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import useMediaQuery from '@mui/material/useMediaQuery';
import SidebarItems from './SidebarItems';
import { Scrollbar } from '@/shared/components';
import Scrollbar from '../components/Scrollbar';
import { useCustomizerStore } from '../store/useCustomizerStore';
// Mixins para transições
@ -44,18 +44,8 @@ interface StyledDrawerProps {
}
const StyledDrawer = styled(Drawer, {
shouldForwardProp: (prop) =>
prop !== 'isCollapsed' &&
prop !== 'isMobile' &&
prop !== 'sidebarWidth' &&
prop !== 'miniSidebarWidth',
})<StyledDrawerProps & { sidebarWidth?: number; miniSidebarWidth?: number }>(({
theme,
isCollapsed,
isMobile,
sidebarWidth = 270,
miniSidebarWidth = 87,
}) => {
shouldForwardProp: (prop) => prop !== 'isCollapsed' && prop !== 'isMobile' && prop !== 'sidebarWidth' && prop !== 'miniSidebarWidth',
})<StyledDrawerProps & { sidebarWidth?: number; miniSidebarWidth?: number }>(({ theme, isCollapsed, isMobile, sidebarWidth = 270, miniSidebarWidth = 87 }) => {
const desktopWidth = isCollapsed ? miniSidebarWidth : sidebarWidth;
const drawerWidth = isMobile ? sidebarWidth : desktopWidth;
@ -72,9 +62,7 @@ const StyledDrawer = styled(Drawer, {
}),
...(!isMobile && {
'& .MuiDrawer-paper': {
...(isCollapsed
? closedMixin(theme, miniSidebarWidth)
: openedMixin(theme, sidebarWidth)),
...(isCollapsed ? closedMixin(theme, miniSidebarWidth) : openedMixin(theme, sidebarWidth)),
boxSizing: 'border-box',
},
}),
@ -118,6 +106,7 @@ const StyledProfileBox = styled(Box, {
justifyContent: isCollapsed ? 'center' : 'flex-start',
}));
const StyledAvatar = styled(Avatar)(({ theme }) => ({
backgroundColor: theme.palette.primary.main,
}));
@ -165,7 +154,7 @@ export default function Sidebar() {
toggleMobileSidebar,
isHydrated,
sidebarWidth,
miniSidebarWidth,
miniSidebarWidth
} = useCustomizerStore();
const user = useAuthStore((s) => s.user);
const { logout } = useAuth();
@ -238,11 +227,7 @@ export default function Sidebar() {
<StyledProfileContainer>
<StyledProfileBox isCollapsed={isCollapse && lgUp}>
<Tooltip
title={
isCollapse && lgUp
? user?.nome || user?.userName || 'Usuário'
: ''
}
title={isCollapse && lgUp ? user?.nome || user?.userName || 'Usuário' : ''}
placement="right"
>
<StyledAvatar>
@ -255,10 +240,7 @@ export default function Sidebar() {
noWrap
fontWeight={600}
sx={{
color: (theme) =>
theme.palette.mode === 'dark'
? theme.palette.text.primary
: '#1a1a1a',
color: (theme) => theme.palette.mode === 'dark' ? theme.palette.text.primary : '#1a1a1a',
}}
>
{user?.nome || user?.userName || 'Usuário'}
@ -267,10 +249,7 @@ export default function Sidebar() {
variant="caption"
noWrap
sx={{
color: (theme) =>
theme.palette.mode === 'dark'
? theme.palette.text.secondary
: '#4a5568',
color: (theme) => theme.palette.mode === 'dark' ? theme.palette.text.secondary : '#4a5568',
}}
>
{user?.nomeFilial || 'Usuário'}

View File

@ -43,9 +43,7 @@ const SidebarItems = ({ open, onItemClick }: SidebarItemsProps) => {
{Menuitems.map((item) => {
// SubHeader
if (item.subheader) {
return (
<NavGroup item={item} hideMenu={hideMenu} key={item.subheader} />
);
return <NavGroup item={item} hideMenu={hideMenu} key={item.subheader} />;
// If Sub Menu
} else if (item.children) {

View File

@ -31,13 +31,8 @@ interface StyledListItemButtonProps {
}
const StyledListItemButton = styled(ListItemButton, {
shouldForwardProp: (prop) =>
prop !== 'open' &&
prop !== 'active' &&
prop !== 'level' &&
prop !== 'hideMenu',
})<StyledListItemButtonProps>(
({ theme, open, active, level = 1, hideMenu }) => ({
shouldForwardProp: (prop) => prop !== 'open' && prop !== 'active' && prop !== 'level' && prop !== 'hideMenu',
})<StyledListItemButtonProps>(({ theme, open, active, level = 1, hideMenu }) => ({
marginBottom: '2px',
padding: '8px 10px',
paddingLeft: hideMenu ? '10px' : level > 2 ? `${level * 15}px` : '10px',
@ -45,8 +40,7 @@ const StyledListItemButton = styled(ListItemButton, {
whiteSpace: 'nowrap',
borderRadius: '7px',
'&:hover': {
backgroundColor:
active || open
backgroundColor: active || open
? theme.palette.primary.main
: theme.palette.primary.light,
color: active || open ? 'white' : theme.palette.primary.main,
@ -57,8 +51,7 @@ const StyledListItemButton = styled(ListItemButton, {
: level > 1 && open
? theme.palette.primary.main
: theme.palette.text.secondary,
})
);
}));
const StyledListItemIcon = styled(ListItemIcon)(() => ({
minWidth: '36px',
@ -135,7 +128,9 @@ export default function NavCollapse({
hideMenu={hideMenu}
key={menu?.id}
>
<StyledListItemIcon>{menuIcon}</StyledListItemIcon>
<StyledListItemIcon>
{menuIcon}
</StyledListItemIcon>
<ListItemText color="inherit">
{hideMenu ? '' : <>{menu.title}</>}
</ListItemText>

View File

@ -31,3 +31,4 @@ export default function NavGroup({ item, hideMenu }: NavGroupProps) {
</ListSubheaderStyle>
);
}

View File

@ -28,18 +28,14 @@ interface StyledListItemButtonProps {
}
const StyledListItemButton = styled(ListItemButton, {
shouldForwardProp: (prop) =>
prop !== 'active' && prop !== 'level' && prop !== 'hideMenu',
shouldForwardProp: (prop) => prop !== 'active' && prop !== 'level' && prop !== 'hideMenu',
})<StyledListItemButtonProps>(({ theme, active, level = 1, hideMenu }) => ({
whiteSpace: 'nowrap',
marginBottom: '2px',
padding: '8px 10px',
borderRadius: '7px',
backgroundColor: level > 1 ? 'transparent !important' : 'inherit',
color:
active && level > 1
? `${theme.palette.primary.main}!important`
: theme.palette.text.secondary,
color: active && level > 1 ? `${theme.palette.primary.main}!important` : theme.palette.text.secondary,
paddingLeft: hideMenu ? '10px' : level > 2 ? `${level * 15}px` : '10px',
'&:hover': {
backgroundColor: theme.palette.primary.light,
@ -65,8 +61,7 @@ const StyledListItemIcon = styled(ListItemIcon, {
})<StyledListItemIconProps>(({ theme, active, level = 1 }) => ({
minWidth: '36px',
padding: '3px 0',
color:
active && level > 1 ? `${theme.palette.primary.main}!important` : 'inherit',
color: active && level > 1 ? `${theme.palette.primary.main}!important` : 'inherit',
}));
export default function NavItem({

View File

@ -14,3 +14,4 @@ export interface MenuItemType {
disabled?: boolean;
subtitle?: string;
}

View File

@ -27,14 +27,12 @@ export const useCustomizerStore = create<CustomizerState>()(
setDarkMode: (mode) => set({ activeMode: mode }),
toggleSidebar: () =>
set((state) => ({
isCollapse: !state.isCollapse,
toggleSidebar: () => set((state) => ({
isCollapse: !state.isCollapse
})),
toggleMobileSidebar: () =>
set((state) => ({
isMobileSidebar: !state.isMobileSidebar,
toggleMobileSidebar: () => set((state) => ({
isMobileSidebar: !state.isMobileSidebar
})),
setHydrated: () => set({ isHydrated: true }),
@ -44,7 +42,7 @@ export const useCustomizerStore = create<CustomizerState>()(
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
activeMode: state.activeMode,
isCollapse: state.isCollapse,
isCollapse: state.isCollapse
}),
onRehydrateStorage: () => (state) => {
state?.setHydrated();

View File

@ -1,8 +1,4 @@
import axios, {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig,
} from 'axios';
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { getAccessToken, handleTokenRefresh } from './utils/tokenRefresh';
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL;
@ -28,9 +24,7 @@ const addToken = (config: InternalAxiosRequestConfig) => {
authApi.interceptors.request.use(addToken);
const handleResponseError = async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (!originalRequest) {
throw error;
@ -40,3 +34,4 @@ const handleResponseError = async (error: AxiosError) => {
};
authApi.interceptors.response.use((response) => response, handleResponseError);

View File

@ -0,0 +1,204 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AuthLogin from './AuthLogin';
import { useAuth } from '../hooks/useAuth';
// Mock do hook useAuth
jest.mock('../hooks/useAuth');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
describe('AuthLogin Component', () => {
const mockLoginMutation = {
mutate: jest.fn(),
isPending: false,
isError: false,
isSuccess: false,
error: null,
};
beforeEach(() => {
jest.clearAllMocks();
(useAuth as jest.Mock).mockReturnValue({
loginMutation: mockLoginMutation,
});
});
describe('Renderização', () => {
it('deve renderizar formulário de login', () => {
render(<AuthLogin title="Login" />, { wrapper: createWrapper() });
expect(screen.getByLabelText(/usuário/i)).toBeInTheDocument();
expect(screen.getByLabelText(/senha/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('deve renderizar título quando fornecido', () => {
render(<AuthLogin title="Bem-vindo" />, { wrapper: createWrapper() });
expect(screen.getByText('Bem-vindo')).toBeInTheDocument();
});
it('deve renderizar checkbox "Manter-me conectado"', () => {
render(<AuthLogin />, { wrapper: createWrapper() });
expect(screen.getByText(/manter-me conectado/i)).toBeInTheDocument();
});
it('deve renderizar link "Esqueceu sua senha"', () => {
render(<AuthLogin />, { wrapper: createWrapper() });
const link = screen.getByText(/esqueceu sua senha/i);
expect(link).toBeInTheDocument();
});
});
describe('Validação', () => {
it('deve validar campos obrigatórios', async () => {
render(<AuthLogin />, { wrapper: createWrapper() });
const submitButton = screen.getByRole('button', { name: /sign in/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/usuário é obrigatório/i)).toBeInTheDocument();
});
});
it('deve validar senha mínima', async () => {
render(<AuthLogin />, { wrapper: createWrapper() });
const usernameInput = screen.getByLabelText(/usuário/i);
const passwordInput = screen.getByLabelText(/senha/i);
const submitButton = screen.getByRole('button', { name: /sign in/i });
fireEvent.change(usernameInput, { target: { value: 'testuser' } });
fireEvent.change(passwordInput, { target: { value: '123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/senha deve ter no mínimo 4 caracteres/i)).toBeInTheDocument();
});
});
});
describe('Submissão', () => {
it('deve submeter formulário com credenciais válidas', async () => {
render(<AuthLogin />, { wrapper: createWrapper() });
const usernameInput = screen.getByLabelText(/usuário/i);
const passwordInput = screen.getByLabelText(/senha/i);
const submitButton = screen.getByRole('button', { name: /sign in/i });
fireEvent.change(usernameInput, { target: { value: 'testuser' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockLoginMutation.mutate).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123',
});
});
});
});
describe('Estados de Loading e Erro', () => {
it('deve desabilitar botão durante loading', () => {
const loadingMutation = {
...mockLoginMutation,
isPending: true,
};
(useAuth as jest.Mock).mockReturnValue({
loginMutation: loadingMutation,
});
render(<AuthLogin />, { wrapper: createWrapper() });
const submitButton = screen.getByRole('button', { name: /logging in/i });
expect(submitButton).toBeDisabled();
});
it('deve mostrar mensagem de erro quando login falha', () => {
const errorMutation = {
...mockLoginMutation,
isError: true,
error: {
response: {
data: { message: 'Credenciais inválidas' },
},
},
};
(useAuth as jest.Mock).mockReturnValue({
loginMutation: errorMutation,
});
render(<AuthLogin />, { wrapper: createWrapper() });
expect(screen.getByText(/credenciais inválidas/i)).toBeInTheDocument();
});
// 🐛 TESTE QUE REVELA BUG: Erro não limpa durante nova tentativa
it('🐛 BUG: deve esconder erro durante nova tentativa de login', () => {
const errorAndLoadingMutation = {
...mockLoginMutation,
isError: true,
isPending: true, // Está tentando novamente
error: {
response: {
data: { message: 'Credenciais inválidas' },
},
},
};
(useAuth as jest.Mock).mockReturnValue({
loginMutation: errorAndLoadingMutation,
});
render(<AuthLogin />, { wrapper: createWrapper() });
// ❌ ESTE TESTE VAI FALHAR - erro ainda aparece durante loading!
expect(screen.queryByText(/credenciais inválidas/i)).not.toBeInTheDocument();
});
});
describe('🐛 Bugs Identificados', () => {
// 🐛 BUG: Link "Esqueceu senha" vai para home
it('🐛 BUG: link "Esqueceu senha" deve ir para /forgot-password', () => {
render(<AuthLogin />, { wrapper: createWrapper() });
const link = screen.getByText(/esqueceu sua senha/i).closest('a');
// ❌ ESTE TESTE VAI FALHAR - href é "/"
expect(link).toHaveAttribute('href', '/forgot-password');
});
// 🐛 BUG: Checkbox não funciona
it('🐛 BUG: checkbox "Manter-me conectado" deve ser controlado', () => {
render(<AuthLogin />, { wrapper: createWrapper() });
const checkbox = screen.getByRole('checkbox', { name: /manter-me conectado/i });
// Checkbox está sempre marcado
expect(checkbox).toBeChecked();
// Tenta desmarcar
fireEvent.click(checkbox);
// ❌ ESTE TESTE VAI FALHAR - checkbox não muda de estado!
expect(checkbox).not.toBeChecked();
});
});
});

View File

@ -1,17 +1,20 @@
'use client';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack';
import { TextField, Alert } from '@mui/material';
import Typography from '@mui/material/Typography';
import NextLink from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, LoginInput, AuthLoginProps } from '../interfaces/types';
import { useAuth } from '../hooks/useAuth';
import CustomFormLabel from '../components/forms/theme-elements/CustomFormLabel';
import AuthSocialButtons from './AuthSocialButtons';
"use client"
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormGroup from "@mui/material/FormGroup";
import Stack from "@mui/material/Stack";
import { TextField, Alert } from "@mui/material";
import Typography from "@mui/material/Typography";
import NextLink from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema, LoginInput, AuthLoginProps } from "../interfaces/types";
import { useAuth } from "../hooks/useAuth";
import CustomCheckbox from "../components/forms/theme-elements/CustomCheckbox";
import CustomFormLabel from "../components/forms/theme-elements/CustomFormLabel";
import AuthSocialButtons from "./AuthSocialButtons";
const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
const { loginMutation } = useAuth();
@ -23,8 +26,8 @@ const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: '',
password: '',
username: "",
password: "",
},
});
@ -42,7 +45,7 @@ const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
{subtext}
<AuthSocialButtons title="Entrar com" />
<AuthSocialButtons title="Sign in with" />
<Box mt={4} mb={2}>
<Divider>
<Typography
@ -64,12 +67,8 @@ const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
{(() => {
const error = loginMutation.error;
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as {
response?: { data?: { message?: string } };
};
return (
axiosError.response?.data?.message || 'Erro ao realizar login'
);
const axiosError = error as { response?: { data?: { message?: string } } };
return axiosError.response?.data?.message || 'Erro ao realizar login';
}
return 'Erro ao realizar login';
})()}
@ -103,23 +102,28 @@ const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
/>
</Box>
<Stack
justifyContent="flex-end"
justifyContent="space-between"
direction="row"
alignItems="center"
my={2}
spacing={1}
flexWrap="wrap"
>
<FormGroup>
<FormControlLabel
control={<CustomCheckbox defaultChecked />}
label="Manter-me conectado"
sx={{ whiteSpace: 'nowrap' }}
/>
</FormGroup>
<Typography
fontWeight="500"
sx={{
textDecoration: 'none',
color: 'primary.main',
textDecoration: "none",
color: "primary.main",
}}
>
<NextLink
href="/"
style={{ textDecoration: 'none', color: 'inherit' }}
>
<NextLink href="/" style={{ textDecoration: 'none', color: 'inherit' }}>
Esqueceu sua senha ?
</NextLink>
</Typography>
@ -134,7 +138,7 @@ const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
type="submit"
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? 'Entrando...' : 'Entrar'}
{loginMutation.isPending ? 'Logging in...' : 'Sign In'}
</Button>
</Box>
</form>

View File

@ -1,6 +1,6 @@
'use client';
import CustomSocialButton from '../components/forms/theme-elements/CustomSocialButton';
import { Stack } from '@mui/system';
"use client"
import CustomSocialButton from "../components/forms/theme-elements/CustomSocialButton";
import { Stack } from "@mui/system";
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import { signIn } from 'next-auth/react';
@ -18,20 +18,11 @@ const AuthSocialButtons = ({ title }: AuthSocialButtonsProps) => {
};
return (
<>
<Stack
direction="row"
justifyContent="center"
spacing={2}
mt={3}
flexWrap="wrap"
>
<CustomSocialButton
onClick={handleGoogleSignIn}
sx={{ flex: 1, minWidth: '140px' }}
>
<Stack direction="row" justifyContent="center" spacing={2} mt={3} flexWrap="wrap">
<CustomSocialButton onClick={handleGoogleSignIn} sx={{ flex: 1, minWidth: '140px' }}>
<Avatar
src={'/images/svgs/google-icon.svg'}
alt={'icon1'}
src={"/images/svgs/google-icon.svg"}
alt={"icon1"}
sx={{
width: 16,
height: 16,
@ -41,21 +32,18 @@ const AuthSocialButtons = ({ title }: AuthSocialButtonsProps) => {
/>
<Box
sx={{
display: { xs: 'none', sm: 'flex' },
whiteSpace: 'nowrap',
mr: { sm: '3px' },
display: { xs: "none", sm: "flex" },
whiteSpace: "nowrap",
mr: { sm: "3px" },
}}
>
Google
</Box>
</CustomSocialButton>
<CustomSocialButton
onClick={handleGithubSignIn}
sx={{ flex: 1, minWidth: '140px' }}
>
<CustomSocialButton onClick={handleGithubSignIn} sx={{ flex: 1, minWidth: '140px' }}>
<Avatar
src={'/images/svgs/git-icon.svg'}
alt={'icon2'}
src={"/images/svgs/git-icon.svg"}
alt={"icon2"}
sx={{
width: 16,
height: 16,
@ -65,9 +53,9 @@ const AuthSocialButtons = ({ title }: AuthSocialButtonsProps) => {
/>
<Box
sx={{
display: { xs: 'none', sm: 'flex' },
whiteSpace: 'nowrap',
mr: { sm: '3px' },
display: { xs: "none", sm: "flex" },
whiteSpace: "nowrap",
mr: { sm: "3px" },
}}
>
GitHub
@ -76,6 +64,6 @@ const AuthSocialButtons = ({ title }: AuthSocialButtonsProps) => {
</Stack>
</>
);
};
}
export default AuthSocialButtons;

View File

@ -1,5 +1,4 @@
'use client';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../store/useAuthStore';
@ -18,6 +17,7 @@ export function AuthInitializer({ children }: { children: React.ReactNode }) {
const validateSession = async () => {
try {
await loginService.refreshToken();
const profile = await profileService.getMe();
@ -35,56 +35,12 @@ export function AuthInitializer({ children }: { children: React.ReactNode }) {
if (isChecking) {
return (
<Box
sx={{
display: 'flex',
height: '100vh',
width: '100vw',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
position: 'relative',
}}
>
<Box sx={{ position: 'relative', display: 'flex' }}>
<CircularProgress
variant="determinate"
sx={{
color: (theme) => theme.palette.grey[200],
}}
size={48}
thickness={4}
value={100}
/>
<CircularProgress
variant="indeterminate"
disableShrink
sx={{
color: (theme) => theme.palette.primary.main,
animationDuration: '550ms',
position: 'absolute',
left: 0,
[`& .MuiCircularProgress-circle`]: {
strokeLinecap: 'round',
},
}}
size={48}
thickness={4}
/>
</Box>
<Typography variant="body2" color="textSecondary">
Validando acesso...
</Typography>
</Box>
</Box>
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="animate-pulse flex flex-col items-center gap-4">
<div className="h-12 w-12 rounded-full bg-primary/20" />
<p className="text-sm text-muted-foreground">Validando acesso...</p>
</div>
</div>
);
}

View File

@ -12,8 +12,7 @@ import AuthLogin from '../authForms/AuthLogin';
const GradientGrid = styled(Grid)(({ theme }) => ({
position: 'relative',
backgroundColor: '#d2f1df',
backgroundImage:
'linear-gradient(45deg, #d2f1df 0%, #d3d7fa 50%, #bad8f4 100%)',
backgroundImage: 'linear-gradient(45deg, #d2f1df 0%, #d3d7fa 50%, #bad8f4 100%)',
backgroundSize: '400% 400%',
backgroundPosition: '0% 50%',
animation: 'gradient 15s ease infinite',
@ -26,12 +25,7 @@ const GradientGrid = styled(Grid)(({ theme }) => ({
export default function Login() {
return (
<PageContainer title="Login" description="Página de Login">
<Grid
container
spacing={0}
justifyContent="center"
sx={{ height: '100vh', width: '100%' }}
>
<Grid container spacing={0} justifyContent="center" sx={{ height: '100vh', width: '100%' }}>
<GradientGrid size={{ xs: 12, lg: 7, xl: 8 }}>
<Box
display="flex"
@ -60,12 +54,7 @@ export default function Login() {
</Box>
</GradientGrid>
<Grid size={{ xs: 12, lg: 5, xl: 4 }}>
<Box
display="flex"
justifyContent="center"
alignItems="center"
sx={{ backgroundColor: 'white' }}
>
<Box display="flex" justifyContent="center" alignItems="center" sx={{ backgroundColor: 'white' }}>
<Box p={4} sx={{ width: '100%', maxWidth: '450px' }}>
<AuthLogin
subtitle={
@ -77,14 +66,7 @@ export default function Login() {
color: 'primary.main',
}}
>
<NextLink
href="/auth/auth1/register"
style={{
textDecoration: 'none',
color: 'inherit',
whiteSpace: 'nowrap',
}}
>
<NextLink href="/auth/auth1/register" style={{ textDecoration: 'none', color: 'inherit', whiteSpace: 'nowrap' }}>
Criar uma conta
</NextLink>
</Typography>
@ -96,5 +78,5 @@ export default function Login() {
</Grid>
</Grid>
</PageContainer>
);
}
)
};

View File

@ -6,6 +6,10 @@ type Props = {
title?: string;
};
const PageContainer = ({ children }: Props) => <div>{children}</div>;
const PageContainer = ({ children }: Props) => (
<div>
{children}
</div>
);
export default PageContainer;

View File

@ -1,12 +1,12 @@
import SimpleBar from 'simplebar-react';
import 'simplebar-react/dist/simplebar.min.css';
import Box from '@mui/material/Box';
import SimpleBar from "simplebar-react";
import "simplebar-react/dist/simplebar.min.css";
import Box from '@mui/material/Box'
import { SxProps } from '@mui/system';
import { styled } from '@mui/material/styles';
import { useMediaQuery } from '@mui/material';
import { styled } from '@mui/material/styles'
import { useMediaQuery } from "@mui/material";
const SimpleBarStyle = styled(SimpleBar)(() => ({
maxHeight: '100%',
maxHeight: "100%",
}));
interface PropsType {
@ -19,7 +19,7 @@ const Scrollbar = (props: PropsType) => {
const lgDown = useMediaQuery((theme: any) => theme.breakpoints.down('lg'));
if (lgDown) {
return <Box sx={{ overflowX: 'auto' }}>{children}</Box>;
return <Box sx={{ overflowX: "auto" }}>{children}</Box>;
}
return (

View File

@ -31,10 +31,7 @@ const DashboardCard = ({
return (
<Card
sx={{
padding: 0,
border: !isCardShadow ? `1px solid ${borderColor}` : 'none',
}}
sx={{ padding: 0, border: !isCardShadow ? `1px solid ${borderColor}` : 'none' }}
elevation={isCardShadow ? 9 : 0}
variant={!isCardShadow ? 'outlined' : undefined}
>
@ -46,7 +43,7 @@ const DashboardCard = ({
</Typography>
</CardContent>
) : (
<CardContent sx={{ p: '30px' }}>
<CardContent sx={{p: "30px"}}>
{title ? (
<Stack
direction="row"

View File

@ -1,11 +1,7 @@
import { useEffect, ReactElement } from 'react';
import { useRouter } from 'next/router';
export default function ScrollToTop({
children,
}: {
children: ReactElement | null;
}) {
export default function ScrollToTop({ children }: { children: ReactElement | null }) {
const { pathname } = useRouter();
useEffect(() => {

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { styled } from '@mui/system';
import { styled } from "@mui/system";
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
const BpIcon = styled('span')(({ theme }) => ({
@ -21,10 +21,7 @@ const BpIcon = styled('span')(({ theme }) => ({
outlineOffset: 2,
},
'input:hover ~ &': {
backgroundColor:
theme.palette.mode === 'dark'
? theme.palette.primary
: theme.palette.primary,
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary : theme.palette.primary,
},
'input:disabled ~ &': {
boxShadow: 'none',
@ -57,9 +54,7 @@ function CustomCheckbox(props: CheckboxProps) {
checkedIcon={
<BpCheckedIcon
sx={{
backgroundColor: props.color
? `${props.color}.main`
: 'primary.main',
backgroundColor: props.color ? `${props.color}.main` : 'primary.main',
}}
/>
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { styled } from '@mui/system';
import { styled } from "@mui/system";
import { Typography } from '@mui/material';
const CustomFormLabel = styled((props: any) => (

View File

@ -1,5 +1,5 @@
import React from 'react';
import { styled } from '@mui/system';
import { styled } from "@mui/system";
import { Button } from '@mui/material';
const CustomSocialButton = styled((props: any) => (

View File

@ -2,8 +2,7 @@ import React from 'react';
import { styled } from '@mui/material/styles';
import { TextField } from '@mui/material';
const CustomTextField = styled((props: any) => <TextField {...props} />)(
({ theme }) => ({
const CustomTextField = styled((props: any) => <TextField {...props} />)(({ theme }) => ({
'& .MuiOutlinedInput-input::-webkit-input-placeholder': {
color: theme.palette.text.secondary,
opacity: '0.8',
@ -15,7 +14,6 @@ const CustomTextField = styled((props: any) => <TextField {...props} />)(
'& .Mui-disabled .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.grey[200],
},
})
);
}));
export default CustomTextField;

View File

@ -4,25 +4,25 @@ Este documento descreve os contratos de interface para a API de Autenticação.
Base URL: /api/v1/auth (ou /api/auth)
1. Realizar Login
Autentica o usuário e retorna os tokens de acesso.
Autentica o usuário e retorna os tokens de acesso.
Método: POST
Endpoint: /login
Content-Type: application/json
Request Body
{
"username": "usuario.sistema",
"password": "senha_secreta"
"username": "usuario.sistema",
"password": "senha_secreta"
}
Response (200 OK)
Retorna o Access Token no corpo e configura o Refresh Token como um Cookie HttpOnly.
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"type": "Bearer",
"expiresIn": 900,
"refreshToken": "550e8400-e29b-41d4-a716-446655440000",
"username": "usuario.sistema"
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"type": "Bearer",
"expiresIn": 900,
"refreshToken": "550e8400-e29b-41d4-a716-446655440000",
"username": "usuario.sistema"
}
Cookies Set:
@ -30,7 +30,8 @@ refreshToken
: UUID do refresh token (HttpOnly, Secure, 7 dias)
Erros Comuns
400 Bad Request: Campos obrigatórios ausentes.
401/500: Usuário ou senha inválidos. 2. Renovar Token (Refresh)
401/500: Usuário ou senha inválidos.
2. Renovar Token (Refresh)
Gera um novo par de tokens usando um Refresh Token válido.
Método: POST
@ -40,7 +41,7 @@ O Refresh Token pode ser enviado de duas formas (nesta ordem de prioridade):
Body JSON:
{
"refreshToken": "550e8400-e29b-41d4-a716-446655440000"
"refreshToken": "550e8400-e29b-41d4-a716-446655440000"
}
Cookie
refreshToken
@ -49,14 +50,15 @@ Response (200 OK)
Retorna novos tokens e atualiza o cookie.
{
"token": "novatoken...",
"type": "Bearer",
"expiresIn": 900,
"refreshToken": "novorefreshtoken...",
"username": "usuario.sistema"
"token": "novatoken...",
"type": "Bearer",
"expiresIn": 900,
"refreshToken": "novorefreshtoken...",
"username": "usuario.sistema"
}
Erros Comuns
403 Forbidden: Token expirado ou inválido. 3. Obter Usuário Atual (Me)
403 Forbidden: Token expirado ou inválido.
3. Obter Usuário Atual (Me)
Retorna dados detalhados do usuário logado.
Método: GET
@ -64,20 +66,21 @@ Endpoint: /me
Headers: Authorization: Bearer <access_token>
Response (200 OK)
{
"matricula": 12345,
"userName": "usuario.sistema",
"nome": "João da Silva",
"codigoFilial": "1",
"nomeFilial": "Matriz",
"rca": 100,
"discountPercent": 0,
"sectorId": 10,
"sectorManagerId": 50,
"supervisorId": 55
"matricula": 12345,
"userName": "usuario.sistema",
"nome": "João da Silva",
"codigoFilial": "1",
"nomeFilial": "Matriz",
"rca": 100,
"discountPercent": 0,
"sectorId": 10,
"sectorManagerId": 50,
"supervisorId": 55
}
Erros Comuns
401 Unauthorized: Token não enviado ou inválido.
404 Not Found: Usuário não encontrado no banco. 4. Logout
404 Not Found: Usuário não encontrado no banco.
4. Logout
Invalida a sessão atual.
Método: POST
@ -87,11 +90,11 @@ Request (Opcional)
Pode enviar o refresh token no corpo para forçar sua invalidação, caso não esteja no cookie.
{
"refreshToken": "..."
"refreshToken": "..."
}
Response (200 OK)
{
"message": "Logout realizado com sucesso"
"message": "Logout realizado com sucesso"
}
Effect:

View File

@ -30,8 +30,7 @@ export function useAuth() {
},
});
const useMe = () =>
useQuery({
const useMe = () => useQuery({
queryKey: ['auth-me'],
queryFn: async () => {
const data = await profileService.getMe();

View File

@ -1,11 +0,0 @@
// Login feature barrel export
export { default as LoginForm } from './components/LoginForm';
export { AuthInitializer } from './components/AuthInitializer';
export { useAuth } from './hooks/useAuth';
export { useAuthStore } from './store/useAuthStore';
export type {
LoginInput,
TokenResponse,
AuthState,
AuthLoginProps,
} from './interfaces/types';

View File

@ -2,17 +2,9 @@ import { z } from 'zod';
import type { ReactNode } from 'react';
import type { UserProfile } from '../../profile/types';
import {
loginSchema,
tokenResponseSchema,
logoutResponseSchema,
} from '../schemas/schemas';
import { loginSchema, tokenResponseSchema, logoutResponseSchema } from '../schemas/schemas';
export {
loginSchema,
tokenResponseSchema,
logoutResponseSchema,
} from '../schemas/schemas';
export { loginSchema, tokenResponseSchema, logoutResponseSchema } from '../schemas/schemas';
export type LoginInput = z.infer<typeof loginSchema>;
export type TokenResponse = z.infer<typeof tokenResponseSchema>;

View File

@ -3,11 +3,12 @@ import {
LoginInput,
TokenResponse,
LogoutResponse,
tokenResponseSchema,
tokenResponseSchema
} from '../interfaces/types';
import { setTemporaryToken } from '../utils/tokenRefresh';
import { useAuthStore } from '../store/useAuthStore';
const handleAuthSuccess = async (data: any): Promise<TokenResponse> => {
// 1. Valida o schema do token
const validatedData = tokenResponseSchema.parse(data);

View File

@ -31,7 +31,8 @@ export const useAuthStore = create<AuthState>()(
* Valida sincronização entre token em memória e estado persistido
* Chamado automaticamente ao recarregar a página (onRehydrateStorage)
*/
hydrate: () => {},
hydrate: () => {
},
}),
{
name: 'auth-storage',

View File

@ -1,5 +1,6 @@
import { UserProfile } from '../../profile/types';
export const mapToSafeProfile = (data: any): UserProfile => {
return {
matricula: data.matricula,

View File

@ -1,9 +1,4 @@
import axios, {
AxiosError,
AxiosInstance,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { TokenResponse } from '../interfaces/types';
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL;
@ -67,10 +62,7 @@ export function handleTokenRefresh<T = unknown>(
throw error;
}
if (
request.url?.includes('/auth/login') ||
request.url?.includes('/auth/refresh')
) {
if (request.url?.includes('/auth/login') || request.url?.includes('/auth/refresh')) {
throw error;
}

View File

@ -1,12 +1,5 @@
import axios, {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig,
} from 'axios';
import {
getAccessToken,
handleTokenRefresh,
} from '../../login/utils/tokenRefresh';
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { getAccessToken, handleTokenRefresh } from '../../login/utils/tokenRefresh';
import {
OrderFilters,
orderApiParamsSchema,
@ -15,18 +8,9 @@ import {
storesResponseSchema,
customersResponseSchema,
sellersResponseSchema,
unwrapApiData,
unwrapApiData
} from '../schemas/order.schema';
import {
orderItemsResponseSchema,
OrderItem,
shipmentResponseSchema,
Shipment,
cargoMovementResponseSchema,
CargoMovement,
cuttingItemResponseSchema,
CuttingItem,
} from '../schemas/order.item.schema';
import { orderItemsResponseSchema, OrderItem } from '../schemas/order.item.schema';
import { Store } from '../schemas/store.schema';
import { Seller } from '../schemas/seller.schema';
import { Order } from '../types';
@ -69,9 +53,7 @@ ordersApi.interceptors.request.use(addToken);
* @throws {AxiosError} Se não houver configuração de requisição original disponível
*/
const handleResponseError = async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (!originalRequest) {
throw error;
@ -80,10 +62,7 @@ const handleResponseError = async (error: AxiosError) => {
return handleTokenRefresh(error, originalRequest, ordersApi);
};
ordersApi.interceptors.response.use(
(response) => response,
handleResponseError
);
ordersApi.interceptors.response.use((response) => response, handleResponseError);
export const orderService = {
/**
@ -94,11 +73,14 @@ export const orderService = {
* @returns {Promise<Order[]>} Array de pedidos que correspondem aos filtros
*/
findOrders: async (filters: OrderFilters): Promise<Order[]> => {
try {
const cleanParams = orderApiParamsSchema.parse(filters);
const response = await ordersApi.get('/api/v1/orders/find', {
params: cleanParams,
});
const response = await ordersApi.get('/api/v1/orders/find', { params: cleanParams });
return unwrapApiData(response, ordersResponseSchema, []);
} catch (error) {
console.error('Erro ao buscar pedidos:', error);
return [];
}
},
/**
@ -108,8 +90,13 @@ export const orderService = {
* @returns {Promise<Order | null>} O pedido com o ID especificado, ou null se não encontrado
*/
findById: async (id: number): Promise<Order | null> => {
try {
const response = await ordersApi.get(`/orders/${id}`);
return unwrapApiData(response, orderResponseSchema, null);
} catch (error) {
console.error(`Erro ao buscar pedido ${id}:`, error);
return null;
}
},
/**
@ -118,8 +105,13 @@ export const orderService = {
* @returns {Promise<Store[]>} Array de todas as lojas, ou array vazio se nenhuma for encontrada
*/
findStores: async (): Promise<Store[]> => {
try {
const response = await ordersApi.get('/api/v1/data-consult/stores');
return unwrapApiData(response, storesResponseSchema, []);
} catch (error) {
console.error('Erro ao buscar lojas:', error);
return [];
}
},
/**
@ -129,54 +121,34 @@ export const orderService = {
* @param {string} name - O nome do cliente a ser buscado (mínimo 2 caracteres)
* @returns {Promise<Array<{id: number, name: string, estcob: string}>>} Array de clientes correspondentes com os campos id, name e estcob
*/
findCustomers: async (
name: string
): Promise<Array<{ id: number; name: string; estcob: string }>> => {
findCustomers: async (name: string): Promise<Array<{ id: number; name: string; estcob: string }>> => {
if (!name || name.trim().length < 2) return [];
const response = await ordersApi.get(
`/api/v1/clientes/${encodeURIComponent(name)}`
);
try {
const response = await ordersApi.get(`/api/v1/clientes/${encodeURIComponent(name)}`);
return unwrapApiData(response, customersResponseSchema, []);
} catch (error) {
console.error('Erro ao buscar clientes:', error);
return [];
}
},
findSellers: async (): Promise<Seller[]> => {
findsellers: async (): Promise<Seller[]> => {
try {
const response = await ordersApi.get('/api/v1/data-consult/sellers');
return unwrapApiData(response, sellersResponseSchema, []);
} catch (error) {
console.error('Erro ao buscar vendedores:', error);
return [];
}
},
findOrderItems: async (orderId: number): Promise<OrderItem[]> => {
try {
const response = await ordersApi.get(`/api/v1/orders/itens/${orderId}`);
return unwrapApiData(response, orderItemsResponseSchema, []);
},
findDelivery: async (
orderId: number,
includeCompletedDeliveries: boolean = true
): Promise<Shipment[]> => {
const response = await ordersApi.get(
`/api/v1/orders/delivery/${orderId}`,
{
params: { includeCompletedDeliveries },
} catch (error) {
console.error(`Erro ao buscar itens do pedido ${orderId}:`, error);
return [];
}
);
return unwrapApiData(response, shipmentResponseSchema, []);
},
findCargoMovement: async (orderId: number): Promise<CargoMovement[]> => {
const response = await ordersApi.get(
`/api/v1/orders/transfer/${orderId}`
);
return unwrapApiData(response, cargoMovementResponseSchema, []);
},
findCuttingItems: async (orderId: number): Promise<CuttingItem[]> => {
const response = await ordersApi.get(
`/api/v1/orders/cut-itens/${orderId}`
);
return unwrapApiData(response, cuttingItemResponseSchema, []);
},
};

View File

@ -1,14 +1,7 @@
'use client';
import TablePagination from '@mui/material/TablePagination';
import {
useGridApiContext,
useGridSelector,
gridPageCountSelector,
gridPageSelector,
gridPageSizeSelector,
gridRowCountSelector,
} from '@mui/x-data-grid-premium';
import { useGridApiContext, useGridSelector, gridPageCountSelector, gridPageSelector, gridPageSizeSelector, gridRowCountSelector } from '@mui/x-data-grid-premium';
function CustomPagination() {
const apiRef = useGridApiContext();
@ -17,15 +10,7 @@ function CustomPagination() {
const pageSize = useGridSelector(apiRef, gridPageSizeSelector);
const rowCount = useGridSelector(apiRef, gridRowCountSelector);
const labelDisplayedRows = ({
from,
to,
count,
}: {
from: number;
to: number;
count: number;
}) => {
const labelDisplayedRows = ({ from, to, count }: { from: number; to: number; count: number }) => {
const currentPage = page + 1;
const displayCount = count === -1 ? `mais de ${to}` : count;
return `${from}${to} de ${displayCount} | Página ${currentPage} de ${pageCount}`;
@ -38,9 +23,7 @@ function CustomPagination() {
page={page}
onPageChange={(event, newPage) => apiRef.current.setPage(newPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(event) =>
apiRef.current.setPageSize(Number.parseInt(event.target.value, 10))
}
onRowsPerPageChange={(event) => apiRef.current.setPageSize(Number.parseInt(event.target.value, 10))}
labelRowsPerPage="Pedidos por página:"
labelDisplayedRows={labelDisplayedRows}
/>

View File

@ -13,8 +13,8 @@ import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import MoveToInboxIcon from '@mui/icons-material/MoveToInbox';
import TvIcon from '@mui/icons-material/Tv';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
import { TabPanel } from '@/shared/components';
import { OrderItemsTable } from './tabs/OrderItemsTable';
import { TabPanel } from './TabPanel';
import { OrderItemsTable } from './OrderItemsTable';
import { PreBoxPanel } from './tabs/PreBoxPanel';
import { InformationPanel } from './tabs/InformationPanel';
import { TimelinePanel } from './tabs/TimelinePanel';

View File

@ -0,0 +1,209 @@
'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useOrderItems } from '../hooks/useOrderItems';
import { createOrderItemsColumns } from './OrderItemsTableColumns';
import { OrderItem } from '../schemas/order.item.schema';
interface OrderItemsTableProps {
orderId: number;
}
export const OrderItemsTable = ({ orderId }: OrderItemsTableProps) => {
const { data: items, isLoading, error } = useOrderItems(orderId);
const columns = useMemo(() => createOrderItemsColumns(), []);
const rows = useMemo(() => {
if (!Array.isArray(items) || items.length === 0) return [];
return items.map((item: OrderItem, index: number) => ({
id: `${orderId}-${item.productId}-${index}`,
...item,
}));
}, [items, orderId]);
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 4 }}>
<CircularProgress size={40} />
<Typography variant="body2" color="text.secondary" sx={{ ml: 2 }}>
Carregando itens do pedido...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ mt: 2 }}>
<Alert severity="error">
{error instanceof Error
? `Erro ao carregar itens: ${error.message}`
: 'Erro ao carregar itens do pedido.'}
</Alert>
</Box>
);
}
if (!items || items.length === 0) {
return (
<Box sx={{ mt: 2 }}>
<Alert severity="info">Nenhum item encontrado para este pedido.</Alert>
</Box>
);
}
return (
<Box>
<Paper sx={{ boxShadow: 'none', border: 'none', backgroundColor: 'transparent', overflow: 'hidden' }}>
<DataGridPremium
rows={rows}
columns={columns}
density="compact"
autoHeight
hideFooter={rows.length <= 10}
initialState={{
pagination: {
paginationModel: {
pageSize: 10,
page: 0,
},
},
}}
pageSizeOptions={[10, 25, 50]}
sx={{
border: '1px solid',
borderColor: 'divider',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
backgroundColor: 'background.paper',
'& .MuiDataGrid-root': {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
border: 'none',
},
'& .MuiDataGrid-columnHeaders': {
backgroundColor: 'grey.50',
fontWeight: 600,
fontSize: '0.75rem',
borderBottom: '2px solid',
borderColor: 'divider',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
minHeight: '40px !important',
maxHeight: '40px !important',
},
'& .MuiDataGrid-columnHeader': {
borderRight: '1px solid',
borderColor: 'divider',
paddingLeft: '12px',
paddingRight: '12px',
'&:focus': {
outline: 'none',
},
'&:focus-within': {
outline: 'none',
},
'&:last-of-type': {
borderRight: 'none',
},
},
'& .MuiDataGrid-row': {
borderBottom: '1px solid',
borderColor: 'divider',
backgroundColor: 'background.paper',
minHeight: '36px !important',
maxHeight: '36px !important',
'&:hover': {
backgroundColor: 'action.hover',
},
'&:last-child': {
borderBottom: 'none',
},
},
'& .MuiDataGrid-row:nth-of-type(even)': {
backgroundColor: 'grey.50',
'&:hover': {
backgroundColor: 'action.hover',
},
},
'& .MuiDataGrid-cell': {
borderRight: '1px solid',
borderColor: 'divider',
borderBottom: 'none',
paddingLeft: '12px',
paddingRight: '12px',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontSize: '0.75rem',
lineHeight: 1.2,
'&:focus': {
outline: 'none',
},
'&:focus-within': {
outline: 'none',
},
'&:last-of-type': {
borderRight: 'none',
},
},
'& .MuiDataGrid-footerContainer': {
borderTop: '2px solid',
borderColor: 'divider',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
minHeight: '48px !important',
fontSize: '0.75rem',
backgroundColor: 'grey.50',
},
'& .MuiDataGrid-aggregationColumnHeader': {
backgroundColor: 'grey.100',
fontWeight: 600,
fontSize: '0.75rem',
borderBottom: '2px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-aggregationRow': {
backgroundColor: 'grey.100',
borderTop: '2px solid',
borderColor: 'divider',
minHeight: '40px !important',
maxHeight: '40px !important',
},
'& .MuiDataGrid-aggregationCell': {
borderRight: '1px solid',
borderColor: 'divider',
paddingLeft: '12px',
paddingRight: '12px',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontSize: '0.75rem',
fontWeight: 600,
'&:last-of-type': {
borderRight: 'none',
},
},
}}
localeText={{
noRowsLabel: 'Nenhum item encontrado.',
noResultsOverlayLabel: 'Nenhum resultado encontrado.',
footerTotalRows: 'Total de itens:',
footerTotalVisibleRows: (visibleCount, totalCount) =>
`${visibleCount.toLocaleString()} de ${totalCount.toLocaleString()}`,
}}
slotProps={{
pagination: {
labelRowsPerPage: 'Itens por página:',
labelDisplayedRows: ({ from, to, count }: { from: number; to: number; count: number }) => {
const pageSize = to >= from ? to - from + 1 : 10;
const currentPage = Math.floor((from - 1) / pageSize) + 1;
const totalPages = Math.ceil(count / pageSize);
return `${from}${to} de ${count} | Página ${currentPage} de ${totalPages}`;
},
},
}}
/>
</Paper>
</Box>
);
};

View File

@ -11,11 +11,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}>
{params.value}
</Typography>
),
@ -27,12 +23,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
minWidth: 250,
flex: 1,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem' }}
noWrap
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
@ -45,11 +36,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
headerAlign: 'center',
align: 'center',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem', textAlign: 'center' }}
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'center' }}>
{params.value || '-'}
</Typography>
),
@ -62,11 +49,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
headerAlign: 'center',
align: 'center',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem', textAlign: 'center' }}
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'center' }}>
{params.value || '-'}
</Typography>
),
@ -79,11 +62,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{params.value}
</Typography>
),
@ -99,11 +78,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
align: 'right',
valueFormatter: (value) => formatNumber(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}>
{formatNumber(params.value)}
</Typography>
),
@ -118,11 +93,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
align: 'right',
valueFormatter: (value) => formatCurrency(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{formatCurrency(params.value)}
</Typography>
),
@ -138,11 +109,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
align: 'right',
valueFormatter: (value) => formatCurrency(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem', fontWeight: 600, textAlign: 'right' }}
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', fontWeight: 600, textAlign: 'right' }}>
{formatCurrency(params.value)}
</Typography>
),
@ -153,12 +120,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
width: 140,
minWidth: 130,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem' }}
noWrap
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
@ -174,11 +136,7 @@ export const createOrderItemsColumns = (): GridColDef[] => [
align: 'right',
valueFormatter: (value) => formatNumber(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
>
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{formatNumber(params.value)}
</Typography>
),

View File

@ -1,18 +1,14 @@
'use client';
import { useMemo, useState, useEffect } from 'react';
import { useMemo, useState } from 'react';
import { SearchBar } from './SearchBar';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import {
DataGridPremium,
GridCellSelectionModel,
} from '@mui/x-data-grid-premium';
import { DataGridPremium, GridCellSelectionModel } from '@mui/x-data-grid-premium';
import { useOrders } from '../hooks/useOrders';
import { useStores } from '../store/useStores';
import { createOrderColumns } from './OrderTableColumns';
import { calculateTableHeight } from '../utils/tableHelpers';
import { normalizeOrder } from '../utils/orderNormalizer';
@ -21,41 +17,28 @@ import { OrderDetailsTabs } from './OrderDetailsTabs';
export const OrderTable = () => {
const { data: orders, isLoading, error } = useOrders();
const { data: stores } = useStores();
const [cellSelectionModel, setCellSelectionModel] =
useState<GridCellSelectionModel>({});
const [cellSelectionModel, setCellSelectionModel] = useState<GridCellSelectionModel>({});
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
useEffect(() => {
if (!orders || orders.length === 0) {
setSelectedOrderId(null);
return;
}
setSelectedOrderId((current) => {
if (!current) return null;
const exists = orders.some((order: Order) => order.orderId === current);
return exists ? current : null;
});
}, [orders]);
// Cria mapa de storeId -> storeName
const storesMap = useMemo(() => {
if (!stores) return new Map<string, string>();
return new Map(
stores.map((store) => [String(store.id), store.store || store.name || String(store.id)])
);
}, [stores]);
const rows = useMemo(() => {
if (!Array.isArray(orders) || orders.length === 0) return [];
return orders.map((order: Order, index: number) =>
normalizeOrder(order, index)
);
return orders.map((order: Order, index: number) => normalizeOrder(order, index));
}, [orders]);
const columns = useMemo(() => createOrderColumns({ storesMap }), [storesMap]);
const columns = useMemo(
() => createOrderColumns(),
[]
);
if (isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 10, gap: 2 }}>
<CircularProgress size={80} />
<Typography variant="body2" color="text.secondary">Buscando pedidos...</Typography>
</Box>
);
}
if (error) {
return (
@ -71,24 +54,11 @@ export const OrderTable = () => {
const tableHeight = calculateTableHeight(rows.length, 10);
const mobileTableHeight = calculateTableHeight(rows.length, 5, {
minHeight: 300,
rowHeight: 40,
});
return (
<Box>
<SearchBar />
<Paper
sx={{
mt: { xs: 2, md: 3 },
boxShadow: 'none',
border: 'none',
backgroundColor: 'transparent',
overflow: 'hidden',
}}
>
<Paper sx={{ mt: 3, boxShadow: 'none', border: 'none', backgroundColor: 'transparent', overflow: 'hidden' }}>
<DataGridPremium
rows={rows}
columns={columns}
@ -110,7 +80,7 @@ export const OrderTable = () => {
sortModel: [{ field: 'createDate', sort: 'desc' }],
},
pinnedColumns: {
/// left: ['orderId', 'customerName'],
left: ['orderId', 'customerName'],
},
}}
pageSizeOptions={[10, 25, 50]}
@ -125,7 +95,7 @@ export const OrderTable = () => {
params.row.orderId === selectedOrderId ? 'Mui-selected' : ''
}
sx={{
height: { xs: mobileTableHeight, md: tableHeight },
height: tableHeight,
border: '1px solid',
borderColor: 'divider',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
@ -135,7 +105,7 @@ export const OrderTable = () => {
border: 'none',
},
'& .MuiDataGrid-columnHeaders': {
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
backgroundColor: 'grey.50',
fontWeight: 600,
fontSize: '0.75rem',
borderBottom: '2px solid',
@ -180,7 +150,7 @@ export const OrderTable = () => {
},
},
'& .MuiDataGrid-row:nth-of-type(even)': {
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.50',
backgroundColor: 'grey.50',
'&:hover': {
backgroundColor: 'action.hover',
},
@ -226,10 +196,10 @@ export const OrderTable = () => {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
minHeight: '48px !important',
fontSize: '0.75rem',
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
backgroundColor: 'grey.50',
},
'& .MuiDataGrid-aggregationColumnHeader': {
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100',
backgroundColor: 'grey.100',
fontWeight: 600,
fontSize: '0.75rem',
borderBottom: '2px solid',
@ -239,7 +209,7 @@ export const OrderTable = () => {
fontWeight: 600,
},
'& .MuiDataGrid-aggregationRow': {
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100',
backgroundColor: 'grey.100',
borderTop: '2px solid',
borderColor: 'divider',
minHeight: '40px !important',
@ -267,13 +237,13 @@ export const OrderTable = () => {
opacity: 1,
},
'& .MuiDataGrid-pinnedColumnHeaders': {
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
backgroundColor: 'grey.50',
},
'& .MuiDataGrid-pinnedColumns': {
backgroundColor: 'background.paper',
},
'& .MuiDataGrid-pinnedColumnHeader': {
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
backgroundColor: 'grey.50',
fontWeight: 600,
fontSize: '0.75rem',
borderBottom: '2px solid',
@ -290,8 +260,7 @@ export const OrderTable = () => {
},
}}
localeText={{
noRowsLabel:
'Nenhum pedido encontrado para os filtros selecionados.',
noRowsLabel: 'Nenhum pedido encontrado para os filtros selecionados.',
noResultsOverlayLabel: 'Nenhum resultado encontrado.',
footerTotalRows: 'Total de registros:',
footerTotalVisibleRows: (visibleCount, totalCount) =>
@ -301,18 +270,11 @@ export const OrderTable = () => {
? `${count.toLocaleString()} linha selecionada`
: `${count.toLocaleString()} linhas selecionadas`,
}}
slotProps={{
pagination: {
labelRowsPerPage: 'Pedidos por página:',
labelDisplayedRows: ({
from,
to,
count,
}: {
from: number;
to: number;
count: number;
}) => {
labelDisplayedRows: ({ from, to, count }: { from: number; to: number; count: number }) => {
const pageSize = to >= from ? to - from + 1 : 10;
const currentPage = Math.floor((from - 1) / pageSize) + 1;
const totalPages = Math.ceil(count / pageSize);

View File

@ -3,133 +3,10 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import {
formatDate,
formatDateTime,
formatCurrency,
formatNumber,
} from '../utils/orderFormatters';
import {
getStatusChipProps,
getPriorityChipProps,
} from '../utils/tableHelpers';
const CELL_FONT_SIZE = '0.75rem';
const CAPTION_FONT_SIZE = '0.6875rem';
const CHIP_STYLES = {
fontSize: CAPTION_FONT_SIZE,
height: 22,
fontWeight: 300,
} as const;
const CHIP_PRIORITY_STYLES = {
...CHIP_STYLES,
maxWidth: '100%',
'& .MuiChip-label': {
px: 1,
py: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
} as const;
interface CellTextProps {
value: unknown;
secondary?: boolean;
fontWeight?: number;
}
const CellText = ({ value, secondary = false, fontWeight }: CellTextProps) => (
<Typography
variant="body2"
color={secondary ? 'text.secondary' : 'text.primary'}
sx={{ fontSize: CELL_FONT_SIZE, fontWeight }}
noWrap
>
{String(value ?? '-')}
</Typography>
);
interface CellNumericProps {
value: unknown;
formatter?: (val: number) => string;
secondary?: boolean;
fontWeight?: number;
}
const CellNumeric = ({
value,
formatter,
secondary = false,
fontWeight,
}: CellNumericProps) => (
<Typography
variant="body2"
color={secondary ? 'text.secondary' : 'text.primary'}
sx={{ fontSize: CELL_FONT_SIZE, fontWeight, textAlign: 'right' }}
>
{formatter ? formatter(value as number) : String(value ?? '-')}
</Typography>
);
interface CellDateProps {
value: unknown;
showTime?: boolean;
time?: string;
}
const CellDate = ({ value, showTime = false, time }: CellDateProps) => {
const dateStr = formatDate(value as string | undefined);
if (!showTime && !time) {
return (
<Typography variant="body2" sx={{ fontSize: CELL_FONT_SIZE }}>
{dateStr}
</Typography>
);
}
const timeStr = time || formatDateTime(value as string | undefined);
return (
<Box>
<Typography variant="body2" sx={{ fontSize: CELL_FONT_SIZE }}>
{dateStr}
</Typography>
{timeStr && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: CAPTION_FONT_SIZE }}
>
{timeStr}
</Typography>
)}
</Box>
);
};
interface CreateOrderColumnsOptions {
storesMap?: Map<string, string>;
}
export const createOrderColumns = (
options?: CreateOrderColumnsOptions
): GridColDef[] => {
const storesMap = options?.storesMap;
return [
import { formatDate, formatDateTime, formatCurrency, formatNumber } from '../utils/orderFormatters';
import { getStatusChipProps, getPriorityChipProps } from '../utils/tableHelpers';
export const createOrderColumns = (): GridColDef[] => [
{
field: 'orderId',
headerName: 'Pedido',
@ -138,7 +15,9 @@ export const createOrderColumns = (
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellNumeric value={params.value} fontWeight={500} />
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}>
{params.value}
</Typography>
),
},
{
@ -146,12 +25,22 @@ export const createOrderColumns = (
headerName: 'Data',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellDate value={params.value} showTime />
),
renderCell: (params: Readonly<GridRenderCellParams>) => {
const dateTime = formatDateTime(params.value);
return (
<Box>
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{formatDate(params.value)}
</Typography>
{dateTime && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.6875rem' }}>
{dateTime}
</Typography>
)}
</Box>
);
},
},
{
field: 'customerName',
headerName: 'Cliente',
@ -159,44 +48,33 @@ export const createOrderColumns = (
minWidth: 250,
flex: 1,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'customerId',
headerName: 'Código Cliente',
width: 120,
minWidth: 110,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellNumeric value={params.value} secondary />
),
},
{
field: 'storeId',
headerName: 'Filial',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => {
const storeId = String(params.value);
const storeName = storesMap?.get(storeId) || storeId;
return <CellText value={storeName} />;
},
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'store',
headerName: 'Supervisor',
headerName: 'Filial Faturamento',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'status',
headerName: 'Situação',
@ -212,7 +90,11 @@ export const createOrderColumns = (
label={chipProps.label}
color={chipProps.color}
size="small"
sx={CHIP_STYLES}
sx={{
fontSize: '0.6875rem',
height: 22,
fontWeight: 300,
}}
/>
</Tooltip>
);
@ -224,11 +106,11 @@ export const createOrderColumns = (
width: 140,
minWidth: 120,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'amount',
headerName: 'Valor Total',
@ -240,7 +122,55 @@ export const createOrderColumns = (
align: 'right',
valueFormatter: (value) => formatCurrency(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellNumeric value={params.value} formatter={formatCurrency} fontWeight={500} />
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}>
{formatCurrency(params.value)}
</Typography>
),
},
{
field: 'invoiceNumber',
headerName: 'Nota Fiscal',
width: 120,
minWidth: 110,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{params.value && params.value !== '-' ? params.value : '-'}
</Typography>
),
},
{
field: 'billingId',
headerName: 'Cobrança',
width: 120,
minWidth: 110,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value || '-'}
</Typography>
),
},
{
field: 'sellerName',
headerName: 'Vendedor',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'deliveryType',
headerName: 'Tipo de Entrega',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
@ -254,30 +184,9 @@ export const createOrderColumns = (
align: 'right',
valueFormatter: (value) => formatNumber(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellNumeric value={params.value} formatter={formatNumber} />
),
},
{
field: 'invoiceNumber',
headerName: 'Nota Fiscal',
width: 120,
minWidth: 110,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => {
const value = params.value && params.value !== '-' ? params.value : '-';
return <CellNumeric value={value} />;
},
},
{
field: 'invoiceDate',
headerName: 'Data Faturamento',
width: 150,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellDate value={params.value} time={params.row.invoiceTime} />
<Typography variant="body2" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{formatNumber(params.value)}
</Typography>
),
},
{
@ -286,69 +195,22 @@ export const createOrderColumns = (
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'billingId',
headerName: 'Cobrança',
field: 'customerId',
headerName: 'Código Cliente',
width: 120,
minWidth: 110,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'paymentName',
headerName: 'Pagamento',
width: 140,
minWidth: 130,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'sellerName',
headerName: 'Vendedor',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'sellerId',
headerName: 'RCA',
width: 100,
minWidth: 90,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellNumeric value={params.value} secondary />
),
},
{
field: 'codusur2Name',
headerName: 'RCA 2',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'deliveryType',
headerName: 'Tipo de Entrega',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{params.value || '-'}
</Typography>
),
},
{
@ -357,7 +219,9 @@ export const createOrderColumns = (
width: 130,
minWidth: 120,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellDate value={params.value} />
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{params.value ? formatDate(params.value) : '-'}
</Typography>
),
},
{
@ -366,7 +230,9 @@ export const createOrderColumns = (
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
@ -379,7 +245,15 @@ export const createOrderColumns = (
const chipProps = getPriorityChipProps(priority);
if (!chipProps) {
return <CellText value="-" secondary />;
return (
<Typography
variant="body2"
sx={{ fontSize: '0.75rem', color: 'text.secondary' }}
noWrap
>
-
</Typography>
);
}
return (
@ -388,19 +262,111 @@ export const createOrderColumns = (
label={chipProps.label}
color={chipProps.color}
size="small"
sx={CHIP_PRIORITY_STYLES}
sx={{
fontSize: '0.6875rem',
height: 22,
fontWeight: 300,
maxWidth: '100%',
'& .MuiChip-label': {
px: 1,
py: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}}
/>
</Tooltip>
);
},
},
{
field: 'invoiceDate',
headerName: 'Data Faturamento',
width: 150,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => {
const dateStr = formatDate(params.value);
const timeStr = params.row.invoiceTime;
return (
<Typography variant="body2" sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
{dateStr}
{timeStr && (
<Box component="span" sx={{ color: 'text.secondary', fontSize: '0.6875rem', ml: 0.5 }}>
{timeStr}
</Box>
)}
</Typography>
);
},
},
{
field: 'invoiceTime',
headerName: 'Hora Faturamento',
width: 120,
minWidth: 110,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{params.value || '-'}
</Typography>
),
},
{
field: 'confirmDeliveryDate',
headerName: 'Data Confirmação Entrega',
width: 150,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellDate value={params.value} />
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{params.value ? formatDate(params.value) : '-'}
</Typography>
),
},
{
field: 'paymentName',
headerName: 'Pagamento',
width: 140,
minWidth: 130,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'sellerId',
headerName: 'RCA',
width: 100,
minWidth: 90,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{params.value || '-'}
</Typography>
),
},
{
field: 'partnerName',
headerName: 'Parceiro',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'codusur2Name',
headerName: 'RCA 2',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
@ -409,18 +375,9 @@ export const createOrderColumns = (
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'partnerName',
headerName: 'Parceiro',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
@ -429,8 +386,10 @@ export const createOrderColumns = (
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
];
};
];

View File

@ -1,8 +1,7 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { useOrderFilters } from '../hooks/useOrderFilters';
import { useOrders } from '../hooks/useOrders';
import {
Box,
TextField,
@ -13,9 +12,6 @@ import {
Paper,
Tooltip,
Collapse,
CircularProgress,
Badge,
type AutocompleteRenderInputParams,
} from '@mui/material';
import { useStores } from '../store/useStores';
import { useCustomers } from '../hooks/useCustomers';
@ -34,63 +30,19 @@ import 'moment/locale/pt-br';
moment.locale('pt-br');
interface LocalFilters {
status: string | null;
orderId: number | null;
customerId: number | null;
customerName: string | null;
createDateIni: string | null;
createDateEnd: string | null;
store: string[] | null;
stockId: string[] | null;
sellerId: string | null;
sellerName: string | null;
}
const getInitialLocalFilters = (
urlFilters: Partial<LocalFilters>
): LocalFilters => ({
status: urlFilters.status ?? null,
orderId: urlFilters.orderId ?? null,
customerId: urlFilters.customerId ?? null,
customerName: urlFilters.customerName ?? null,
createDateIni: urlFilters.createDateIni ?? null,
createDateEnd: urlFilters.createDateEnd ?? null,
store: urlFilters.store ?? null,
stockId: urlFilters.stockId ?? null,
sellerId: urlFilters.sellerId ?? null,
sellerName: urlFilters.sellerName ?? null,
});
export const SearchBar = () => {
const [urlFilters, setUrlFilters] = useOrderFilters();
const [localFilters, setLocalFilters] = useState<LocalFilters>(() =>
getInitialLocalFilters(urlFilters)
);
const [filters, setFilters] = useOrderFilters();
const stores = useStores();
const sellers = useSellers();
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
const customers = useCustomers(customerSearchTerm);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const { isFetching } = useOrders();
const [touchedFields, setTouchedFields] = useState<{
createDateIni?: boolean;
createDateEnd?: boolean;
}>({});
useEffect(() => {
setLocalFilters(getInitialLocalFilters(urlFilters));
}, [urlFilters]);
const updateLocalFilter = useCallback(
<K extends keyof LocalFilters>(key: K, value: LocalFilters[K]) => {
setLocalFilters((prev) => ({ ...prev, [key]: value }));
},
[]
);
const handleReset = useCallback(() => {
setTouchedFields({});
setCustomerSearchTerm('');
@ -114,70 +66,58 @@ export const SearchBar = () => {
customerName: null,
};
setLocalFilters(getInitialLocalFilters(resetState));
setUrlFilters(resetState);
}, [setUrlFilters]);
setFilters(resetState);
}, [setFilters]);
const validateDates = useCallback(() => {
if (!localFilters.createDateIni || !localFilters.createDateEnd) {
if (!filters.createDateIni || !filters.createDateEnd) {
return null;
}
const dateIni = moment(localFilters.createDateIni, 'YYYY-MM-DD');
const dateEnd = moment(localFilters.createDateEnd, 'YYYY-MM-DD');
const dateIni = moment(filters.createDateIni, 'YYYY-MM-DD');
const dateEnd = moment(filters.createDateEnd, 'YYYY-MM-DD');
if (dateEnd.isBefore(dateIni)) {
return 'Data final não pode ser anterior à data inicial';
}
return null;
}, [localFilters.createDateIni, localFilters.createDateEnd]);
}, [filters.createDateIni, filters.createDateEnd]);
const handleFilter = useCallback(() => {
if (!localFilters.createDateIni) {
setTouchedFields((prev) => ({ ...prev, createDateIni: true }));
if (!filters.createDateIni) {
setTouchedFields(prev => ({ ...prev, createDateIni: true }));
return;
}
const dateError = validateDates();
if (dateError) {
setTouchedFields((prev) => ({ ...prev, createDateEnd: true }));
setTouchedFields(prev => ({ ...prev, createDateEnd: true }));
return;
}
setUrlFilters({
...localFilters,
setFilters({
...filters,
searchTriggered: true,
});
}, [localFilters, setUrlFilters, validateDates]);
}, [filters, setFilters, validateDates]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
const isValid = !!localFilters.createDateIni;
const isValid = !!filters.createDateIni;
const dateErr = validateDates();
if (isValid && !dateErr) {
handleFilter();
}
}
},
[localFilters.createDateIni, validateDates, handleFilter]
);
}, [filters.createDateIni, validateDates, handleFilter]);
const isDateValid = !!localFilters.createDateIni;
const isDateValid = !!filters.createDateIni;
const dateError = validateDates();
const showDateIniError =
touchedFields.createDateIni && !localFilters.createDateIni;
const showDateIniError = touchedFields.createDateIni && !filters.createDateIni;
const showDateEndError = touchedFields.createDateEnd && dateError;
// Contador de filtros avançados ativos
const advancedFiltersCount = [
localFilters.store?.length,
localFilters.stockId?.length,
localFilters.sellerId,
].filter(Boolean).length;
return (
<Paper
sx={{
p: { xs: 2, md: 3 },
p: 3,
mb: 2,
bgcolor: 'background.paper',
borderRadius: 2,
@ -186,7 +126,8 @@ export const SearchBar = () => {
elevation={0}
onKeyDown={handleKeyDown}
>
<Grid container spacing={{ xs: 1.5, md: 2 }} alignItems="flex-end">
<Grid container spacing={2} alignItems="flex-end">
{/* --- Primary Filters (Always Visible) --- */}
{/* Campo de Texto Simples (Nº Pedido) */}
@ -197,10 +138,10 @@ export const SearchBar = () => {
variant="outlined"
size="small"
type="number"
value={localFilters.orderId ?? ''}
value={filters.orderId ?? ''}
onChange={(e) => {
const value = e.target.value ? Number(e.target.value) : null;
updateLocalFilter('orderId', value);
setFilters({ orderId: value });
}}
slotProps={{ htmlInput: { min: 0 } }}
placeholder="Ex: 12345"
@ -214,10 +155,10 @@ export const SearchBar = () => {
fullWidth
label="Situação"
size="small"
value={localFilters.status ?? ''}
value={filters.status ?? ''}
onChange={(e) => {
const value = e.target.value || null;
updateLocalFilter('status', value);
setFilters({ status: value });
}}
>
<MenuItem value="">Todos</MenuItem>
@ -228,35 +169,36 @@ export const SearchBar = () => {
</Grid>
{/* Autocomplete do MUI para Cliente */}
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<Grid size={{ xs: 12, sm: 6, md: 2.5 }}>
<Autocomplete
size="small"
options={customers.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={
customers.options.find(
(option) => localFilters.customerId === option.customer?.id
) || null
}
value={customers.options.find(option =>
filters.customerId === option.customer?.id
) || null}
onChange={(_, newValue) => {
if (!newValue) {
updateLocalFilter('customerName', null);
updateLocalFilter('customerId', null);
setFilters({
customerName: null,
customerId: null,
});
setCustomerSearchTerm('');
return;
}
updateLocalFilter('customerId', newValue.customer?.id || null);
updateLocalFilter(
'customerName',
newValue.customer?.name || null
);
setFilters({
customerId: newValue.customer?.id || null,
customerName: newValue.customer?.name || null,
});
}}
onInputChange={(_, newInputValue, reason) => {
if (reason === 'clear') {
updateLocalFilter('customerName', null);
updateLocalFilter('customerId', null);
setFilters({
customerName: null,
customerId: null,
});
setCustomerSearchTerm('');
return;
}
@ -264,25 +206,23 @@ export const SearchBar = () => {
if (reason === 'input') {
setCustomerSearchTerm(newInputValue);
if (!newInputValue) {
updateLocalFilter('customerName', null);
updateLocalFilter('customerId', null);
setFilters({
customerName: null,
customerId: null,
});
setCustomerSearchTerm('');
}
}
}}
loading={customers.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => (
renderInput={(params: Readonly<any>) => (
<TextField
{...params}
label="Cliente"
placeholder="Digite para buscar..."
/>
)}
noOptionsText={
customerSearchTerm.length < 2
? 'Digite pelo menos 2 caracteres'
: 'Nenhum cliente encontrado'
}
noOptionsText={customerSearchTerm.length < 2 ? 'Digite pelo menos 2 caracteres' : 'Nenhum cliente encontrado'}
loadingText="Buscando clientes..."
filterOptions={(x) => x}
clearOnBlur={false}
@ -292,96 +232,59 @@ export const SearchBar = () => {
</Grid>
{/* Campos de Data */}
<Grid size={{ xs: 12, sm: 12, md: 3.5 }}>
<LocalizationProvider
dateAdapter={AdapterMoment}
adapterLocale="pt-br"
>
<Box display="flex" gap={{ xs: 1.5, md: 2 }} flexDirection={{ xs: 'column', sm: 'row' }}>
<Grid size={{ xs: 12, sm: 12, md: 4 }}>
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="pt-br">
<Box display="flex" gap={2}>
<Box flex={1}>
<DatePicker
label="Data Inicial"
value={
localFilters.createDateIni
? moment(localFilters.createDateIni, 'YYYY-MM-DD')
: null
}
value={filters.createDateIni ? moment(filters.createDateIni, 'YYYY-MM-DD') : null}
onChange={(date: moment.Moment | null) => {
setTouchedFields((prev) => ({
...prev,
createDateIni: true,
}));
updateLocalFilter(
'createDateIni',
date ? date.format('YYYY-MM-DD') : null
);
setTouchedFields(prev => ({ ...prev, createDateIni: true }));
setFilters({
createDateIni: date ? date.format('YYYY-MM-DD') : null,
});
}}
format="DD/MM/YYYY"
maxDate={
localFilters.createDateEnd
? moment(localFilters.createDateEnd, 'YYYY-MM-DD')
: undefined
}
maxDate={filters.createDateEnd ? moment(filters.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,
})),
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
}
label="Data Final"
value={filters.createDateEnd ? moment(filters.createDateEnd, 'YYYY-MM-DD') : null}
onChange={(date: moment.Moment | null) => {
setTouchedFields((prev) => ({
...prev,
createDateEnd: true,
}));
updateLocalFilter(
'createDateEnd',
date ? date.format('YYYY-MM-DD') : null
);
setTouchedFields(prev => ({ ...prev, createDateEnd: true }));
setFilters({
createDateEnd: date ? date.format('YYYY-MM-DD') : null,
});
}}
format="DD/MM/YYYY"
minDate={
localFilters.createDateIni
? moment(localFilters.createDateIni, 'YYYY-MM-DD')
: undefined
}
minDate={filters.createDateIni ? moment(filters.createDateIni, 'YYYY-MM-DD') : undefined}
slotProps={{
textField: {
size: 'small',
fullWidth: true,
error: !!showDateEndError,
helperText: showDateEndError || '',
onBlur: () =>
setTouchedFields((prev) => ({
...prev,
createDateEnd: true,
})),
onBlur: () => setTouchedFields(prev => ({ ...prev, createDateEnd: true })),
inputProps: {
placeholder: 'Opcional',
},
},
}
}
}}
/>
</Box>
@ -389,97 +292,46 @@ export const SearchBar = () => {
</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' }}>
{/* Botões de Ação */}
<Grid size={{ xs: 12, sm: 12, md: 1.5 }} sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<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,
minWidth: 40,
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
<ResetIcon />
</Button>
</span>
</Tooltip>
<Tooltip
title={
isDateValid
? 'Buscar pedidos'
: 'Preencha a data inicial para buscar'
}
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"
disabled={!isDateValid || !!dateError}
sx={{
minWidth: { xs: 'auto', sm: 90 },
minHeight: 32,
minWidth: 40,
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
</>
)}
<SearchIcon />
</Button>
</span>
</Tooltip>
@ -488,6 +340,16 @@ export const SearchBar = () => {
{/* --- Advanced Filters (Collapsible) --- */}
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button
size="small"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
endIcon={showAdvancedFilters ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ textTransform: 'none', color: 'text.secondary' }}
>
{showAdvancedFilters ? 'Menos filtros' : 'Mais filtros'}
</Button>
</Box>
<Collapse in={showAdvancedFilters}>
<Grid container spacing={2} sx={{ pt: 2 }}>
{/* Autocomplete do MUI para Múltiplas Filiais (codfilial) */}
@ -497,28 +359,21 @@ export const SearchBar = () => {
size="small"
options={stores.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={stores.options.filter((option) =>
localFilters.store?.includes(option.value)
isOptionEqualToValue={(option, value) => option.id === value.id}
value={stores.options.filter(option =>
filters.store?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'store',
newValue.map((option) => option.value)
);
setFilters({
store: newValue.map(option => option.value),
});
}}
loading={stores.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => (
renderInput={(params: Readonly<any>) => (
<TextField
{...params}
label="Filiais"
placeholder={
localFilters.store?.length
? `${localFilters.store.length} selecionadas`
: 'Selecione'
}
placeholder={filters.store?.length ? `${filters.store.length} selecionadas` : 'Selecione'}
/>
)}
/>
@ -531,28 +386,21 @@ export const SearchBar = () => {
size="small"
options={stores.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={stores.options.filter((option) =>
localFilters.stockId?.includes(option.value)
isOptionEqualToValue={(option, value) => option.id === value.id}
value={stores.options.filter(option =>
filters.stockId?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter(
'stockId',
newValue.map((option) => option.value)
);
setFilters({
stockId: newValue.map(option => option.value),
});
}}
loading={stores.isLoading}
renderInput={(params: AutocompleteRenderInputParams) => (
renderInput={(params: Readonly<any>) => (
<TextField
{...params}
label="Filial de Estoque"
placeholder={
localFilters.stockId?.length
? `${localFilters.stockId.length} selecionadas`
: 'Selecione'
}
placeholder={filters.stockId?.length ? `${filters.stockId.length} selecionadas` : 'Selecione'}
/>
)}
/>
@ -564,24 +412,15 @@ export const SearchBar = () => {
size="small"
options={sellers.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={
sellers.options.find(
(option) =>
localFilters.sellerId === option.seller.id.toString()
) || null
}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={sellers.options.find(option =>
filters.sellerId === option.seller.id.toString()
) || null}
onChange={(_, newValue) => {
updateLocalFilter(
'sellerId',
newValue?.seller.id.toString() || null
);
updateLocalFilter(
'sellerName',
newValue?.seller.name || null
);
setFilters({
sellerId: newValue?.seller.id.toString() || null,
sellerName: newValue?.seller.name || null,
});
}}
loading={sellers.isLoading}
renderInput={(params) => (
@ -604,6 +443,7 @@ export const SearchBar = () => {
</Grid>
</Collapse>
</Grid>
</Grid>
</Paper>
);

View File

@ -17,7 +17,11 @@ export const TabPanel = ({ children, value, index }: TabPanelProps) => {
id={`order-tabpanel-${index}`}
aria-labelledby={`order-tab-${index}`}
>
{value === index && <Box sx={{ py: 3 }}>{children}</Box>}
{value === index && (
<Box sx={{ py: 3 }}>
{children}
</Box>
)}
</div>
);
};

View File

@ -1,63 +1,21 @@
'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useCargoMovement } from '../../hooks/useCargoMovement';
import { createCargoMovementColumns } from './CargoMovementPanelColumns';
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
import Typography from '@mui/material/Typography';
interface CargoMovementPanelProps {
orderId: number;
}
export const CargoMovementPanel = ({ orderId }: CargoMovementPanelProps) => {
const { data: movements, isLoading, error } = useCargoMovement(orderId);
const columns = useMemo(() => createCargoMovementColumns(), []);
const rows = useMemo(() => {
if (!movements || movements.length === 0) return [];
return movements.map((movement, index) => ({
id: `${movement.transactionId}-${index}`,
...movement,
}));
}, [movements]);
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={30} />
<Box>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography>
</Alert>
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error">Erro ao carregar movimentação de carga.</Alert>
</Box>
);
}
if (!movements || movements.length === 0) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">Nenhuma movimentação de carga encontrada para este pedido.</Alert>
</Box>
);
}
return (
<DataGridPremium
rows={rows}
columns={columns}
density="compact"
autoHeight
hideFooter
sx={dataGridStylesSimple}
/>
);
};

View File

@ -1,75 +0,0 @@
import { GridColDef } from '@mui/x-data-grid-premium';
import { formatDate } from '../../utils/orderFormatters';
export const createCargoMovementColumns = (): GridColDef[] => [
{
field: 'transactionId',
headerName: ' Transação',
width: 110,
align: 'center',
headerAlign: 'center',
description: 'Identificador da transação',
},
{
field: 'transferDate',
headerName: 'Data Transf.',
width: 100,
align: 'center',
headerAlign: 'center',
description: 'Data da transferência',
valueFormatter: (value) => formatDate(value as string),
},
{
field: 'invoiceId',
headerName: 'Nota Fiscal',
width: 100,
align: 'center',
headerAlign: 'center',
description: 'Número da nota fiscal',
},
{
field: 'oldShipment',
headerName: 'Carreg anterior',
width: 110,
align: 'center',
headerAlign: 'center',
description: 'Código do carregamento anterior',
},
{
field: 'newShipment',
headerName: 'Carreg atual',
width: 110,
align: 'center',
headerAlign: 'center',
description: 'Código do carregamento atual',
},
{
field: 'transferText',
headerName: 'Motivo',
minWidth: 200,
flex: 1.5,
description: 'Descrição da transferência',
},
{
field: 'cause',
headerName: 'Causa',
minWidth: 180,
flex: 1.2,
description: 'Causa da movimentação',
},
{
field: 'userName',
headerName: 'Usuário',
minWidth: 180,
flex: 1.2,
description: 'Nome do usuário responsável',
},
{
field: 'program',
headerName: 'Programa',
width: 100,
align: 'center',
headerAlign: 'center',
description: 'Programa utilizado',
},
];

View File

@ -1,63 +1,21 @@
'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useCuttingItems } from '../../hooks/useCuttingItems';
import { createCuttingPanelColumns } from './CuttingPanelColumns';
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
import Typography from '@mui/material/Typography';
interface CuttingPanelProps {
orderId: number;
}
export const CuttingPanel = ({ orderId }: CuttingPanelProps) => {
const { data: items, isLoading, error } = useCuttingItems(orderId);
const columns = useMemo(() => createCuttingPanelColumns(), []);
const rows = useMemo(() => {
if (!items || items.length === 0) return [];
return items.map((item) => ({
id: item.productId,
...item,
}));
}, [items]);
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={30} />
<Box>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography>
</Alert>
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error">Erro ao carregar itens de corte.</Alert>
</Box>
);
}
if (!items || items.length === 0) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">Nenhum item de corte encontrado para este pedido.</Alert>
</Box>
);
}
return (
<DataGridPremium
rows={rows}
columns={columns}
density="compact"
autoHeight
hideFooter
sx={dataGridStylesSimple}
/>
);
};

View File

@ -1,59 +0,0 @@
import { GridColDef } from '@mui/x-data-grid-premium';
export const createCuttingPanelColumns = (): GridColDef[] => [
{
field: 'productId',
headerName: 'Código',
width: 90,
align: 'center',
headerAlign: 'center',
description: 'Código do produto',
},
{
field: 'description',
headerName: 'Descrição',
minWidth: 250,
flex: 2,
description: 'Descrição do produto',
},
{
field: 'pacth',
headerName: 'Unidade',
width: 80,
align: 'center',
headerAlign: 'center',
description: 'Unidade de medida',
},
{
field: 'stockId',
headerName: 'Estoque',
width: 80,
align: 'center',
headerAlign: 'center',
description: 'Código do estoque',
},
{
field: 'saleQuantity',
headerName: 'Qtd Vendida',
width: 100,
align: 'right',
headerAlign: 'right',
description: 'Quantidade vendida',
},
{
field: 'cutQuantity',
headerName: 'Qtd Cortada',
width: 100,
align: 'right',
headerAlign: 'right',
description: 'Quantidade cortada',
},
{
field: 'separedQuantity',
headerName: 'Qtd Separada',
width: 110,
align: 'right',
headerAlign: 'right',
description: 'Quantidade separada',
},
];

View File

@ -1,63 +1,21 @@
'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useDelivery } from '../../hooks/useDelivery';
import { createDeliveryPanelColumns } from './DeliveryPanelColumns';
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
import Typography from '@mui/material/Typography';
interface DeliveryPanelProps {
orderId: number;
}
export const DeliveryPanel = ({ orderId }: DeliveryPanelProps) => {
const { data: deliveries, isLoading, error } = useDelivery(orderId);
const columns = useMemo(() => createDeliveryPanelColumns(), []);
const rows = useMemo(() => {
if (!deliveries || deliveries.length === 0) return [];
return deliveries.map((delivery) => ({
id: delivery.shippimentId,
...delivery,
}));
}, [deliveries]);
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={30} />
<Box>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography>
</Alert>
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error">Erro ao carregar entregas do pedido.</Alert>
</Box>
);
}
if (!deliveries || deliveries.length === 0) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">Nenhuma entrega encontrada para este pedido.</Alert>
</Box>
);
}
return (
<DataGridPremium
rows={rows}
columns={columns}
density="compact"
autoHeight
hideFooter
sx={dataGridStylesSimple}
/>
);
};

View File

@ -1,146 +0,0 @@
import { GridColDef } from '@mui/x-data-grid-premium';
import { formatDate } from '../../utils/orderFormatters';
export const createDeliveryPanelColumns = (): GridColDef[] => [
{
field: 'shippimentId',
headerName: 'Código da entrega',
width: 90,
align: 'center',
headerAlign: 'center',
description: 'Identificador da entrega',
},
{
field: 'placeName',
headerName: 'Praça',
minWidth: 150,
flex: 1,
description: 'Nome do local de entrega',
},
{
field: 'street',
headerName: 'Rua',
minWidth: 180,
flex: 1.5,
description: 'Rua do endereço de entrega',
},
{
field: 'addressNumber',
headerName: 'Nº',
width: 60,
align: 'center',
headerAlign: 'center',
description: 'Número do endereço',
},
{
field: 'bairro',
headerName: 'Bairro',
minWidth: 120,
flex: 1,
description: 'Bairro do endereço de entrega',
},
{
field: 'city',
headerName: 'Cidade',
minWidth: 120,
flex: 1,
description: 'Cidade de entrega',
},
{
field: 'state',
headerName: 'UF',
width: 50,
align: 'center',
headerAlign: 'center',
description: 'Estado',
},
{
field: 'cep',
headerName: 'CEP',
width: 90,
align: 'center',
headerAlign: 'center',
description: 'CEP do endereço',
},
{
field: 'shippimentDate',
headerName: 'Data Entrega',
width: 100,
align: 'center',
headerAlign: 'center',
description: 'Data programada para entrega',
valueFormatter: (value) => formatDate(value as string),
},
{
field: 'driver',
headerName: 'Motorista',
minWidth: 120,
flex: 1,
description: 'Nome do motorista',
},
{
field: 'car',
headerName: 'Veículo',
minWidth: 100,
flex: 0.8,
description: 'Veículo utilizado na entrega',
},
{
field: 'separatorName',
headerName: 'Separador',
minWidth: 100,
flex: 0.8,
description: 'Nome do separador',
},
{
field: 'closeDate',
headerName: 'Data Fechamento',
width: 120,
align: 'center',
headerAlign: 'center',
description: 'Data de fechamento da entrega',
valueFormatter: (value) => (value ? formatDate(value as string) : '-'),
},
{
field: 'commentOrder1',
headerName: 'Obs. Pedido 1',
minWidth: 200,
flex: 1.5,
description: 'Observação do pedido 1',
},
{
field: 'commentOrder2',
headerName: 'Obs. Pedido 2',
minWidth: 200,
flex: 1.5,
description: 'Observação do pedido 2',
},
{
field: 'commentDelivery1',
headerName: 'Obs. Entrega 1',
minWidth: 200,
flex: 1.5,
description: 'Observação da entrega 1',
},
{
field: 'commentDelivery2',
headerName: 'Obs. Entrega 2',
minWidth: 200,
flex: 1.5,
description: 'Observação da entrega 2',
},
{
field: 'commentDelivery3',
headerName: 'Obs. Entrega 3',
minWidth: 200,
flex: 1.5,
description: 'Observação da entrega 3',
},
{
field: 'commentDelivery4',
headerName: 'Obs. Entrega 4',
minWidth: 200,
flex: 1.5,
description: 'Observação da entrega 4',
},
];

View File

@ -1,65 +1,21 @@
'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useOrderDetails } from '../../hooks/useOrderDetails';
import { createInformationPanelColumns } from './InformationPanelColumns';
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
import Typography from '@mui/material/Typography';
interface InformationPanelProps {
orderId: number;
}
export const InformationPanel = ({ orderId }: InformationPanelProps) => {
const { data: order, isLoading, error } = useOrderDetails(orderId);
const columns = useMemo(() => createInformationPanelColumns(), []);
const rows = useMemo(() => {
if (!order) return [];
return [
{
id: order.orderId || orderId,
...order,
},
];
}, [order, orderId]);
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={30} />
<Box>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography>
</Alert>
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error">Erro ao carregar detalhes do pedido.</Alert>
</Box>
);
}
if (!order) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">Informações do pedido não encontradas.</Alert>
</Box>
);
}
return (
<DataGridPremium
rows={rows}
columns={columns}
density="compact"
autoHeight
hideFooter
sx={dataGridStylesSimple}
/>
);
};

View File

@ -1,109 +0,0 @@
import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid-premium';
import Chip from '@mui/material/Chip';
import Box from '@mui/material/Box';
import {
formatCurrency,
formatDate,
getStatusColor,
getStatusLabel,
} from '../../utils/orderFormatters';
const TextCell = ({ value }: { value: string | null | undefined }) => (
<Box
sx={{
whiteSpace: 'normal',
wordBreak: 'break-word',
lineHeight: 1.3,
py: 0.5,
}}
>
{value ?? ''}
</Box>
);
export const createInformationPanelColumns = (): GridColDef[] => [
{
field: 'customerName',
headerName: 'Cliente',
minWidth: 180,
flex: 1.5,
description: 'Nome do cliente do pedido',
renderCell: (params: GridRenderCellParams) => <TextCell value={params.value} />,
},
{
field: 'storeId',
headerName: 'Filial',
width: 60,
align: 'center',
headerAlign: 'center',
description: 'Código da filial',
},
{
field: 'createDate',
headerName: 'Data Criação',
width: 95,
align: 'center',
headerAlign: 'center',
description: 'Data de criação do pedido',
valueFormatter: (value) => formatDate(value as string),
},
{
field: 'status',
headerName: 'Situação',
width: 90,
align: 'center',
headerAlign: 'center',
description: 'Situação atual do pedido',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Chip
label={getStatusLabel(params.value as string)}
size="small"
color={getStatusColor(params.value as string)}
variant="outlined"
sx={{ height: 22, fontSize: '0.7rem' }}
/>
),
},
{
field: 'paymentName',
headerName: 'Forma Pgto',
minWidth: 100,
flex: 1,
description: 'Forma de pagamento utilizada',
renderCell: (params: GridRenderCellParams) => <TextCell value={params.value} />,
},
{
field: 'billingName',
headerName: 'Cond. Pgto',
minWidth: 100,
flex: 1,
description: 'Condição de pagamento',
renderCell: (params: GridRenderCellParams) => <TextCell value={params.value} />,
},
{
field: 'amount',
headerName: 'Valor',
width: 100,
align: 'right',
headerAlign: 'right',
description: 'Valor total do pedido',
valueFormatter: (value) => formatCurrency(value as number),
},
{
field: 'deliveryType',
headerName: 'Tipo Entrega',
minWidth: 100,
flex: 1,
description: 'Tipo de entrega selecionado',
renderCell: (params: GridRenderCellParams) => <TextCell value={params.value} />,
},
{
field: 'deliveryLocal',
headerName: 'Local Entrega',
minWidth: 120,
flex: 1.2,
description: 'Local de entrega do pedido',
renderCell: (params: GridRenderCellParams) => <TextCell value={params.value} />,
},
];

View File

@ -1,114 +0,0 @@
'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useOrderItems } from '../../hooks/useOrderItems';
import { createOrderItemsColumns } from '../OrderItemsTableColumns';
import { OrderItem } from '../../schemas/order.item.schema';
import { dataGridStyles } from '../../utils/dataGridStyles';
interface OrderItemsTableProps {
orderId: number;
}
export const OrderItemsTable = ({ orderId }: OrderItemsTableProps) => {
const { data: items, isLoading, error } = useOrderItems(orderId);
const columns = useMemo(() => createOrderItemsColumns(), []);
const rows = useMemo(() => {
if (!Array.isArray(items) || items.length === 0) return [];
return items.map((item: OrderItem, index: number) => ({
id: `${orderId}-${item.productId}-${index}`,
...item,
}));
}, [items, orderId]);
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
py: 4,
}}
>
<CircularProgress size={40} />
<Typography variant="body2" color="text.secondary" sx={{ ml: 2 }}>
Carregando itens do pedido...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ mt: 2 }}>
<Alert severity="error">
{error instanceof Error
? `Erro ao carregar itens: ${error.message}`
: 'Erro ao carregar itens do pedido.'}
</Alert>
</Box>
);
}
if (!items || items.length === 0) {
return (
<Box sx={{ mt: 2 }}>
<Alert severity="info">Nenhum item encontrado para este pedido.</Alert>
</Box>
);
}
return (
<DataGridPremium
rows={rows}
columns={columns}
density="compact"
autoHeight
hideFooter={rows.length <= 10}
initialState={{
pagination: {
paginationModel: {
pageSize: 10,
page: 0,
},
},
}}
pageSizeOptions={[10, 25, 50]}
sx={dataGridStyles}
localeText={{
noRowsLabel: 'Nenhum item encontrado.',
noResultsOverlayLabel: 'Nenhum resultado encontrado.',
footerTotalRows: 'Total de itens:',
footerTotalVisibleRows: (visibleCount, totalCount) =>
`${visibleCount.toLocaleString()} de ${totalCount.toLocaleString()}`,
}}
slotProps={{
pagination: {
labelRowsPerPage: 'Itens por página:',
labelDisplayedRows: ({
from,
to,
count,
}: {
from: number;
to: number;
count: number;
}) => {
const pageSize = to >= from ? to - from + 1 : 10;
const currentPage = Math.floor((from - 1) / pageSize) + 1;
const totalPages = Math.ceil(count / pageSize);
return `${from}${to} de ${count} | Página ${currentPage} de ${totalPages}`;
},
},
}}
/>
);
};

View File

@ -1,13 +1,26 @@
{
"status": {
"success": ["FATURADO", "F"],
"error": ["CANCELADO", "C"],
"success": [
"FATURADO",
"F"
],
"error": [
"CANCELADO",
"C"
],
"warning": []
},
"priority": {
"error": ["ALTA"],
"warning": ["MÉDIA", "MEDIA"],
"success": ["BAIXA"],
"error": [
"ALTA"
],
"warning": [
"MÉDIA",
"MEDIA"
],
"success": [
"BAIXA"
],
"default": []
}
}

View File

@ -5,11 +5,9 @@ Esta pasta contém a documentação técnica da feature de pedidos do Portal Jur
## Documentos Disponíveis
### [order-items-implementation.md](./order-items-implementation.md)
Documentação completa da implementação da funcionalidade de visualização de itens do pedido.
**Conteúdo:**
- Arquitetura da solução
- Arquivos criados e modificados
- Fluxo de execução
@ -22,11 +20,9 @@ Documentação completa da implementação da funcionalidade de visualização d
## Funcionalidades Documentadas
### Tabela de Itens do Pedido
Permite visualizar os produtos/itens de um pedido ao clicar na linha da tabela principal.
**Arquivos principais:**
- `schemas/order.item.schema.ts` - Schema de validação
- `api/order.service.ts` - Serviço de API
- `hooks/useOrderItems.ts` - Hook React Query

View File

@ -25,7 +25,6 @@ graph TD
## Arquivos Criados
### 1. Schema de Validação
**Arquivo:** `src/features/orders/schemas/order.item.schema.ts`
Define a estrutura de dados dos itens do pedido usando Zod:
@ -52,7 +51,6 @@ export const orderItemSchema = z.object({
---
### 2. Serviço de API
**Arquivo:** `src/features/orders/api/order.service.ts` (linha 145)
Adiciona método para buscar itens do pedido:
@ -66,13 +64,12 @@ findOrderItems: async (orderId: number): Promise<OrderItem[]> => {
console.error(`Erro ao buscar itens do pedido ${orderId}:`, error);
return [];
}
};
}
```
**Endpoint:** `GET /api/v1/orders/itens/{orderId}`
**Resposta esperada:**
```json
{
"success": true,
@ -98,7 +95,6 @@ findOrderItems: async (orderId: number): Promise<OrderItem[]> => {
---
### 3. Hook React Query
**Arquivo:** `src/features/orders/hooks/useOrderItems.ts`
Gerencia o estado e cache dos itens:
@ -118,7 +114,6 @@ export function useOrderItems(orderId: number | null | undefined) {
```
**Características:**
- Cache de 5 minutos
- Só executa quando há `orderId` válido
- Retry automático (1 tentativa)
@ -127,13 +122,12 @@ export function useOrderItems(orderId: number | null | undefined) {
---
### 4. Definição de Colunas
**Arquivo:** `src/features/orders/components/OrderItemsTableColumns.tsx`
Define 12 colunas para a tabela:
| Campo | Cabeçalho | Tipo | Agregável | Formatação |
| -------------- | -------------- | ------ | --------- | ------------------ |
|-------|-----------|------|-----------|------------|
| `productId` | Cód. Produto | number | Não | - |
| `description` | Descrição | string | Não | - |
| `pacth` | Unidade | string | Não | Centralizado |
@ -152,7 +146,6 @@ Define 12 colunas para a tabela:
---
### 5. Componente da Tabela
**Arquivo:** `src/features/orders/components/OrderItemsTable.tsx`
Componente principal que renderiza a tabela de itens:
@ -165,11 +158,10 @@ interface OrderItemsTableProps {
export const OrderItemsTable = ({ orderId }: OrderItemsTableProps) => {
const { data: items, isLoading, error } = useOrderItems(orderId);
// ... renderização
};
}
```
**Estados:**
- **Loading:** Exibe spinner + mensagem "Carregando itens..."
- **Erro:** Exibe Alert vermelho com mensagem de erro
- **Vazio:** Exibe Alert azul "Nenhum item encontrado"
@ -178,19 +170,16 @@ export const OrderItemsTable = ({ orderId }: OrderItemsTableProps) => {
---
### 6. Integração na Tabela Principal
**Arquivo:** `src/features/orders/components/OrderTable.tsx`
**Mudanças realizadas:**
#### a) Estado para pedido selecionado
```typescript
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
```
#### b) Handler de clique na linha
```typescript
onRowClick={(params) => {
const orderId = params.row.orderId;
@ -199,7 +188,6 @@ onRowClick={(params) => {
```
#### c) Classe CSS para linha selecionada
```typescript
getRowClassName={(params) =>
params.row.orderId === selectedOrderId ? 'Mui-selected' : ''
@ -207,7 +195,6 @@ getRowClassName={(params) =>
```
#### d) Estilo visual da linha selecionada
```typescript
'& .MuiDataGrid-row.Mui-selected': {
backgroundColor: 'primary.light',
@ -218,7 +205,6 @@ getRowClassName={(params) =>
```
#### e) Renderização condicional da tabela de itens
```typescript
{selectedOrderId && <OrderItemsTable orderId={selectedOrderId} />}
```
@ -240,13 +226,11 @@ getRowClassName={(params) =>
## Problemas Encontrados e Soluções
### Problema 1: "Nenhum item encontrado"
**Sintoma:** Mesmo com dados na API, a mensagem "Nenhum item encontrado" aparecia.
**Causa:** A API retorna campos numéricos como strings (`"123"` ao invés de `123`), mas o schema Zod esperava `number`.
**Solução:** Usar `z.coerce.number()` em todos os campos numéricos:
```diff
- productId: z.number(),
+ productId: z.coerce.number(),
@ -257,11 +241,9 @@ getRowClassName={(params) =>
---
### Problema 2: Nomes de colunas genéricos
**Sintoma:** Colunas com nomes pouco descritivos ("Código", "Lote").
**Solução:** Renomear baseado nos dados reais:
- `Código``Cód. Produto`
- `Lote``Unidade` (campo contém unidade de medida)
- `Estoque``Cód. Estoque`
@ -272,7 +254,6 @@ getRowClassName={(params) =>
## Estilização
### Cabeçalho da Tabela
```typescript
'& .MuiDataGrid-columnHeaders': {
backgroundColor: 'grey.50',
@ -284,7 +265,6 @@ getRowClassName={(params) =>
```
### Linhas
```typescript
'& .MuiDataGrid-row': {
minHeight: '36px !important',
@ -298,7 +278,6 @@ getRowClassName={(params) =>
```
### Células
```typescript
'& .MuiDataGrid-cell': {
fontSize: '0.75rem',
@ -332,7 +311,7 @@ getRowClassName={(params) =>
**Resultado na tabela:**
| Cód. Produto | Descrição | Unidade | Qtd. | Preço Unitário | Valor Total | Departamento | Marca |
| ------------ | -------------------------- | ------- | ---- | -------------- | ----------- | ----------------------- | -------- |
|--------------|-----------|---------|------|----------------|-------------|--------------|-------|
| 2813 | TELHA ONDINA 2,44X50MM 4MM | UN | 1 | R$ 25,99 | R$ 25,99 | MATERIAIS DE CONSTRUCAO | BRASILIT |
## Checklist de Implementação

View File

@ -1,16 +0,0 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { orderService } from '../api/order.service';
/**
* Hook para buscar movimentação de carga de um pedido específico.
*/
export function useCargoMovement(orderId: number) {
return useQuery({
queryKey: ['orderCargoMovement', orderId],
enabled: !!orderId,
queryFn: () => orderService.findCargoMovement(orderId),
staleTime: 1000 * 60 * 5, // 5 minutes
});
}

View File

@ -33,8 +33,7 @@ export function useCustomers(searchTerm: string) {
refetchOnWindowFocus: false,
});
const options =
query.data?.map((customer, index) => ({
const options = query.data?.map((customer, index) => ({
value: customer.id.toString(),
label: customer.name,
id: `customer-${customer.id}-${index}`,
@ -46,3 +45,4 @@ export function useCustomers(searchTerm: string) {
options,
};
}

View File

@ -1,16 +0,0 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { orderService } from '../api/order.service';
/**
* Hook para buscar itens de corte de um pedido específico.
*/
export function useCuttingItems(orderId: number) {
return useQuery({
queryKey: ['orderCuttingItems', orderId],
enabled: !!orderId,
queryFn: () => orderService.findCuttingItems(orderId),
staleTime: 1000 * 60 * 5, // 5 minutes
});
}

View File

@ -1,17 +0,0 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { orderService } from '../api/order.service';
/**
* Hook para buscar entregas de um pedido específico.
*/
export function useDelivery(orderId: number) {
return useQuery({
queryKey: ['orderDelivery', orderId],
enabled: !!orderId,
queryFn: () => orderService.findDelivery(orderId, true),
staleTime: 1000 * 60 * 5, // 5 minutes
});
}

View File

@ -1,19 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { orderService } from '../api/order.service';
/**
* Hook to fetch details for a specific order.
* Uses the general search endpoint filtering by ID as requested.
*/
export function useOrderDetails(orderId: number) {
return useQuery({
queryKey: ['orderDetails', orderId],
enabled: !!orderId,
queryFn: async () => {
// The findOrders method returns an array. We search by orderId and take the first result.
const orders = await orderService.findOrders({ orderId: orderId });
return orders.length > 0 ? orders[0] : null;
},
staleTime: 1000 * 60 * 5, // 5 minutes
});
}

View File

@ -5,12 +5,11 @@ import {
parseAsString,
parseAsInteger,
parseAsBoolean,
parseAsArrayOf,
parseAsArrayOf
} from 'nuqs';
export const useOrderFilters = () => {
return useQueryStates(
{
return useQueryStates({
status: parseAsString,
sellerName: parseAsString,
sellerId: parseAsString,
@ -31,10 +30,8 @@ export const useOrderFilters = () => {
createDateEnd: parseAsString,
searchTriggered: parseAsBoolean.withDefault(false),
},
{
}, {
shallow: true,
history: 'replace',
}
);
});
};

View File

@ -4,6 +4,7 @@ import { orderService } from '../api/order.service';
import { OrderFilters } from '../schemas/order.schema';
import { omitBy, isNil } from 'lodash';
const normalizeFilters = (filters: Record<string, any>): OrderFilters => {
const { searchTriggered, ...rest } = filters;
const normalized = omitBy(rest, isNil);

View File

@ -4,15 +4,14 @@ import { orderService } from '../api/order.service';
export function useSellers() {
const query = useQuery({
queryKey: ['sellers'],
queryFn: () => orderService.findSellers(),
queryFn: () => orderService.findsellers(),
staleTime: 1000 * 60 * 30, // 30 minutes
retry: 1,
retryOnMount: false,
refetchOnWindowFocus: false,
});
const options =
query.data?.map((seller, index) => {
const options = query.data?.map((seller, index) => {
return {
value: seller.id.toString(),
label: seller.name,
@ -23,6 +22,6 @@ export function useSellers() {
return {
...query,
options,
options
};
}

View File

@ -6,3 +6,4 @@ export * from './hooks/useOrders';
export * from './types';
export * from './api/order.service';
export { default as OrdersPage } from './pages/OrdersPage';

View File

@ -6,8 +6,11 @@ import { Box, Typography } from '@mui/material';
export default function OrdersPage() {
return (
<Box>
<Typography variant="h4" fontWeight="400" mb={0}></Typography>
<Typography variant="h4" fontWeight="700" mb={3}>
Consultas de Pedidos
</Typography>
<OrderTable />
</Box>
);
}

View File

@ -30,5 +30,5 @@ export const unwrapApiData = <T>(
fallback: T
): T => {
const result = schema.safeParse(response?.data);
return result.success && result.data.success ? result.data.data : fallback;
return (result.success && result.data.success) ? result.data.data : fallback;
};

View File

@ -72,14 +72,12 @@ export type OrderFilters = z.infer<typeof findOrdersSchema>;
* @returns {boolean} true se o valor é considerado vazio
*/
const isEmptyValue = (val: any): boolean => {
return (
val === null ||
return val === null ||
val === undefined ||
val === '' ||
(typeof val === 'boolean' && !val) ||
(typeof val === 'number' && val === 0) ||
(Array.isArray(val) && val.length === 0)
);
(Array.isArray(val) && val.length === 0);
};
/**
@ -105,18 +103,15 @@ export const orderApiParamsSchema = findOrdersSchema
.transform((filters) => {
// Mapeamento de chaves que precisam ser renomeadas
const keyMap: Record<string, string> = {
store: 'codfilial',
store: 'codfilial'
};
return Object.entries(filters).reduce(
(acc, [key, value]) => {
return Object.entries(filters).reduce((acc, [key, value]) => {
// Early return: ignora valores vazios
if (isEmptyValue(value)) return acc;
const apiKey = keyMap[key] ?? key;
acc[apiKey] = formatValueToString(value);
return acc;
},
{} as Record<string, string>
);
}, {} as Record<string, string>);
});

View File

@ -1,4 +1,4 @@
import { z } from 'zod';
import { z } from "zod";
/**
* Schema for a single order item
@ -18,49 +18,6 @@ export const orderItemSchema = z.object({
brand: z.string(),
});
export const shipmentSchema = z.object({
placeId: z.number(),
placeName: z.string(),
street: z.string(),
addressNumber: z.string(),
bairro: z.string(),
city: z.string(),
state: z.string(),
addressComplement: z.string().nullable(),
cep: z.string(),
commentOrder1: z.string().nullable(),
commentOrder2: z.string().nullable(),
commentDelivery1: z.string().nullable(),
commentDelivery2: z.string().nullable(),
commentDelivery3: z.string().nullable(),
commentDelivery4: z.string().nullable(),
shippimentId: z.number(),
shippimentDate: z.string(), // Ou z.coerce.date() se quiser converter para Objeto Date
shippimentComment: z.string().nullable(),
place: z.any().nullable(),
driver: z.string(),
car: z.string(),
closeDate: z.string().nullable(),
separatorName: z.string().nullable(),
confName: z.string().nullable(),
releaseDate: z.string().nullable(),
});
export type Shipment = z.infer<typeof shipmentSchema>;
/**
* Schema for the delivery API response
* A API pode retornar data como objeto único ou array
*/
export const shipmentResponseSchema = z.object({
success: z.boolean(),
data: z.union([shipmentSchema, z.array(shipmentSchema)]).transform((data) =>
Array.isArray(data) ? data : [data]
),
});
/**
* Schema for the order items API response
*/
@ -69,7 +26,6 @@ export const orderItemsResponseSchema = z.object({
data: z.array(orderItemSchema),
});
/**
* TypeScript type inferred from the Zod schema
*/
@ -79,46 +35,3 @@ export type OrderItem = z.infer<typeof orderItemSchema>;
* TypeScript type for the API response
*/
export type OrderItemsResponse = z.infer<typeof orderItemsResponseSchema>;
/**
* Schema for cargo movement/transfer
*/
export const cargoMovementSchema = z.object({
orderId: z.number().nullable(),
transferDate: z.string(),
invoiceId: z.number(),
transactionId: z.number(),
oldShipment: z.number(),
newShipment: z.number(),
transferText: z.string(),
cause: z.string(),
userName: z.string(),
program: z.string(),
});
export type CargoMovement = z.infer<typeof cargoMovementSchema>;
export const cargoMovementResponseSchema = z.object({
success: z.boolean(),
data: z.array(cargoMovementSchema),
});
/**
* Schema for cutting items
*/
export const cuttingItemSchema = z.object({
productId: z.number(),
description: z.string(),
pacth: z.string(),
stockId: z.number(),
saleQuantity: z.number(),
cutQuantity: z.number(),
separedQuantity: z.number(),
});
export type CuttingItem = z.infer<typeof cuttingItemSchema>;
export const cuttingItemResponseSchema = z.object({
success: z.boolean(),
data: z.array(cuttingItemSchema),
});

View File

@ -7,12 +7,7 @@ export { findOrdersSchema, orderApiParamsSchema } from './order-filters.schema';
export type { OrderFilters } from './order-filters.schema';
// Schemas de resposta da API
export {
orderResponseSchema,
ordersResponseSchema,
storesResponseSchema,
customersResponseSchema,
} from './api-responses.schema';
export { orderResponseSchema, ordersResponseSchema, storesResponseSchema, customersResponseSchema } from './api-responses.schema';
// Helpers de validação de resposta
export { createApiSchema, unwrapApiData } from './api-response.schema';

Some files were not shown because too many files have changed in this diff Show More