feat: Implement core application structure including Dashboard, Orders, Login, and Profile modules.

This commit is contained in:
JuruSysadmin 2026-01-15 10:42:56 -03:00
parent 5d469f08a7
commit 02aaae0cd3
156 changed files with 22253 additions and 16957 deletions

3
.gitignore vendored
View File

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

7
.prettierrc Normal file
View File

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

20
.storybook/main.ts Normal file
View File

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

14
.storybook/preview.ts Normal file
View File

@ -0,0 +1,14 @@
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,11 +23,13 @@ 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
```
@ -42,7 +44,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,7 +3,10 @@
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';
@ -20,7 +23,9 @@ 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,15 +8,17 @@ 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);
@ -75,7 +77,8 @@ export default function Providers({ children }: Readonly<{ children: React.React
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,13 +17,21 @@ 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,2 +1 @@
export { default } from '../../src/features/dashboard/pages/DashboardPage';

View File

@ -20,13 +20,21 @@ 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,7 +36,6 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} ${plusJakartaSans.variable} antialiased`}
>
<EmotionRegistry options={{ key: 'mui' }}>
<NuqsAdapter>
<Providers>{children}</Providers>

View File

@ -1,6 +1,5 @@
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,6 +1,9 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
// 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';
const eslintConfig = defineConfig([
...nextVitals,
@ -8,10 +11,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,8 +1,9 @@
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,7 +9,9 @@
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
"test:coverage": "jest --coverage",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@emotion/cache": "^11.14.0",
@ -42,6 +44,7 @@
"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",
@ -64,6 +67,19 @@
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "^5"
"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"
}
}

View File

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

View File

@ -28,7 +28,6 @@ const StyledMain = styled(Box, {
}),
}));
export default function DashboardLayout({ children }: DashboardLayoutProps) {
const theme = useTheme();
const lgUp = useMediaQuery(theme.breakpoints.up('lg'));
@ -37,10 +36,15 @@ 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>
);
@ -49,13 +53,17 @@ 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,4 +37,3 @@ export default function DashboardOverview() {
</Grid>
);
}

View File

@ -13,4 +13,3 @@ 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,4 +30,3 @@ 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,21 +70,18 @@ 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,8 +85,7 @@ 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,15 +99,17 @@ 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}
@ -116,7 +118,10 @@ 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 '../components/Scrollbar';
import { Scrollbar } from '@/shared/components';
import { IconBellRinging } from '@tabler/icons-react';
import { Stack } from '@mui/system';
@ -62,7 +62,13 @@ 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>
@ -108,7 +114,13 @@ 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

@ -15,7 +15,6 @@ import { IconMail } from '@tabler/icons-react';
import { Stack } from '@mui/system';
import Image from 'next/image';
const Profile = () => {
const [anchorEl2, setAnchorEl2] = useState(null);
const handleClick2 = (event: any) => {
@ -40,7 +39,7 @@ const Profile = () => {
onClick={handleClick2}
>
<Avatar
src={"/images/profile/user-1.jpg"}
src={'/images/profile/user-1.jpg'}
alt={'ProfileImg'}
sx={{
width: 35,
@ -68,9 +67,17 @@ const Profile = () => {
>
<Typography variant="h5">User Profile</Typography>
<Stack direction="row" py={3} spacing={2} alignItems="center">
<Avatar src={"/images/profile/user-1.jpg"} alt={"ProfileImg"} sx={{ width: 95, height: 95 }} />
<Avatar
src={'/images/profile/user-1.jpg'}
alt={'ProfileImg'}
sx={{ width: 95, height: 95 }}
/>
<Box>
<Typography variant="subtitle2" color="textPrimary" fontWeight={600}>
<Typography
variant="subtitle2"
color="textPrimary"
fontWeight={600}
>
Mathew Anderson
</Typography>
<Typography variant="subtitle2" color="textSecondary">
@ -100,7 +107,8 @@ const Profile = () => {
bgcolor="primary.light"
display="flex"
alignItems="center"
justifyContent="center" flexShrink="0"
justifyContent="center"
flexShrink="0"
>
<Avatar
src={profile.icon}
@ -142,7 +150,13 @@ const Profile = () => {
</Box>
))}
<Box mt={2}>
<Box bgcolor="primary.light" p={3} mb={3} overflow="hidden" position="relative">
<Box
bgcolor="primary.light"
p={3}
mb={3}
overflow="hidden"
position="relative"
>
<Box display="flex" justifyContent="space-between">
<Box>
<Typography variant="h5" mb={2}>
@ -153,10 +167,23 @@ const Profile = () => {
Upgrade
</Button>
</Box>
<Image src={"/images/backgrounds/unlimited-bg.png"} width={150} height={183} style={{ height: 'auto', width: 'auto' }} alt="unlimited" className="signup-bg" />
<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>
<Button
href="/auth/auth1/login"
variant="outlined"
color="primary"
component={Link}
fullWidth
>
Logout
</Button>
</Box>

View File

@ -36,7 +36,9 @@ 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;
@ -89,7 +91,11 @@ 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,3 +1,2 @@
export { default as Dashboard } from './components/Dashboard';
export { default as DashboardPage } from './pages/DashboardPage';

View File

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

View File

@ -36,4 +36,3 @@ 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 '../components/Scrollbar';
import { Scrollbar } from '@/shared/components';
import { useCustomizerStore } from '../store/useCustomizerStore';
// Mixins para transições
@ -44,8 +44,18 @@ 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;
@ -62,7 +72,9 @@ const StyledDrawer = styled(Drawer, {
}),
...(!isMobile && {
'& .MuiDrawer-paper': {
...(isCollapsed ? closedMixin(theme, miniSidebarWidth) : openedMixin(theme, sidebarWidth)),
...(isCollapsed
? closedMixin(theme, miniSidebarWidth)
: openedMixin(theme, sidebarWidth)),
boxSizing: 'border-box',
},
}),
@ -106,7 +118,6 @@ const StyledProfileBox = styled(Box, {
justifyContent: isCollapsed ? 'center' : 'flex-start',
}));
const StyledAvatar = styled(Avatar)(({ theme }) => ({
backgroundColor: theme.palette.primary.main,
}));
@ -154,7 +165,7 @@ export default function Sidebar() {
toggleMobileSidebar,
isHydrated,
sidebarWidth,
miniSidebarWidth
miniSidebarWidth,
} = useCustomizerStore();
const user = useAuthStore((s) => s.user);
const { logout } = useAuth();
@ -227,7 +238,11 @@ 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>
@ -240,7 +255,10 @@ 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'}
@ -249,7 +267,10 @@ 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,7 +43,9 @@ 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,8 +31,13 @@ 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',
@ -40,7 +45,8 @@ 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,
@ -51,7 +57,8 @@ const StyledListItemButton = styled(ListItemButton, {
: level > 1 && open
? theme.palette.primary.main
: theme.palette.text.secondary,
}));
})
);
const StyledListItemIcon = styled(ListItemIcon)(() => ({
minWidth: '36px',
@ -128,9 +135,7 @@ 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,4 +31,3 @@ export default function NavGroup({ item, hideMenu }: NavGroupProps) {
</ListSubheaderStyle>
);
}

View File

@ -28,14 +28,18 @@ 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,
@ -61,7 +65,8 @@ 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,4 +14,3 @@ export interface MenuItemType {
disabled?: boolean;
subtitle?: string;
}

View File

@ -27,12 +27,14 @@ 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 }),
@ -42,7 +44,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,4 +1,8 @@
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;
@ -24,7 +28,9 @@ 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;
@ -34,4 +40,3 @@ const handleResponseError = async (error: AxiosError) => {
};
authApi.interceptors.response.use((response) => response, handleResponseError);

View File

@ -15,9 +15,7 @@ const createWrapper = () => {
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
@ -43,7 +41,9 @@ describe('AuthLogin Component', () => {
expect(screen.getByLabelText(/usuário/i)).toBeInTheDocument();
expect(screen.getByLabelText(/senha/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /sign in/i })
).toBeInTheDocument();
});
it('deve renderizar título quando fornecido', () => {
@ -87,7 +87,9 @@ describe('AuthLogin Component', () => {
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/senha deve ter no mínimo 4 caracteres/i)).toBeInTheDocument();
expect(
screen.getByText(/senha deve ter no mínimo 4 caracteres/i)
).toBeInTheDocument();
});
});
});
@ -170,7 +172,9 @@ describe('AuthLogin Component', () => {
render(<AuthLogin />, { wrapper: createWrapper() });
// ❌ ESTE TESTE VAI FALHAR - erro ainda aparece durante loading!
expect(screen.queryByText(/credenciais inválidas/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/credenciais inválidas/i)
).not.toBeInTheDocument();
});
});
@ -189,7 +193,9 @@ describe('AuthLogin Component', () => {
it('🐛 BUG: checkbox "Manter-me conectado" deve ser controlado', () => {
render(<AuthLogin />, { wrapper: createWrapper() });
const checkbox = screen.getByRole('checkbox', { name: /manter-me conectado/i });
const checkbox = screen.getByRole('checkbox', {
name: /manter-me conectado/i,
});
// Checkbox está sempre marcado
expect(checkbox).toBeChecked();

View File

@ -1,20 +1,20 @@
"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";
'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();
@ -26,8 +26,8 @@ const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
username: '',
password: '',
},
});
@ -67,8 +67,12 @@ 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';
})()}
@ -119,11 +123,14 @@ const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
<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>

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,11 +18,20 @@ 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,
@ -32,18 +41,21 @@ 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,
@ -53,9 +65,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
@ -64,6 +76,6 @@ const AuthSocialButtons = ({ title }: AuthSocialButtonsProps) => {
</Stack>
</>
);
}
};
export default AuthSocialButtons;

View File

@ -17,7 +17,6 @@ export function AuthInitializer({ children }: { children: React.ReactNode }) {
const validateSession = async () => {
try {
await loginService.refreshToken();
const profile = await profileService.getMe();

View File

@ -12,7 +12,8 @@ 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',
@ -25,7 +26,12 @@ 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"
@ -54,7 +60,12 @@ 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={
@ -66,7 +77,14 @@ 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>
@ -78,5 +96,5 @@ export default function Login() {
</Grid>
</Grid>
</PageContainer>
)
};
);
}

View File

@ -6,10 +6,6 @@ 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,7 +31,10 @@ 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}
>
@ -43,7 +46,7 @@ const DashboardCard = ({
</Typography>
</CardContent>
) : (
<CardContent sx={{p: "30px"}}>
<CardContent sx={{ p: '30px' }}>
{title ? (
<Stack
direction="row"

View File

@ -1,7 +1,11 @@
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,7 +21,10 @@ 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',
@ -54,7 +57,9 @@ 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,7 +2,8 @@ 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',
@ -14,6 +15,7 @@ const CustomTextField = styled((props: any) => <TextField {...props} />)(({ them
'& .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,8 +30,7 @@ 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
@ -41,7 +40,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
@ -50,15 +49,14 @@ 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
@ -66,21 +64,20 @@ 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
@ -90,11 +87,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,7 +30,8 @@ export function useAuth() {
},
});
const useMe = () => useQuery({
const useMe = () =>
useQuery({
queryKey: ['auth-me'],
queryFn: async () => {
const data = await profileService.getMe();

View File

@ -0,0 +1,11 @@
// 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,9 +2,17 @@ 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,12 +3,11 @@ 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,8 +31,7 @@ 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,6 +1,5 @@
import { UserProfile } from '../../profile/types';
export const mapToSafeProfile = (data: any): UserProfile => {
return {
matricula: data.matricula,

View File

@ -1,4 +1,9 @@
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;
@ -62,7 +67,10 @@ 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,5 +1,12 @@
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,
@ -8,9 +15,18 @@ import {
storesResponseSchema,
customersResponseSchema,
sellersResponseSchema,
unwrapApiData
unwrapApiData,
} from '../schemas/order.schema';
import { orderItemsResponseSchema, OrderItem } from '../schemas/order.item.schema';
import {
orderItemsResponseSchema,
OrderItem,
shipmentResponseSchema,
Shipment,
cargoMovementResponseSchema,
CargoMovement,
cuttingItemResponseSchema,
CuttingItem,
} from '../schemas/order.item.schema';
import { Store } from '../schemas/store.schema';
import { Seller } from '../schemas/seller.schema';
import { Order } from '../types';
@ -53,7 +69,9 @@ 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;
@ -62,7 +80,10 @@ 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 = {
/**
@ -75,7 +96,9 @@ export const orderService = {
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);
@ -121,10 +144,14 @@ 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 [];
try {
const response = await ordersApi.get(`/api/v1/clientes/${encodeURIComponent(name)}`);
const response = await ordersApi.get(
`/api/v1/clientes/${encodeURIComponent(name)}`
);
return unwrapApiData(response, customersResponseSchema, []);
} catch (error) {
console.error('Erro ao buscar clientes:', error);
@ -151,4 +178,49 @@ export const orderService = {
return [];
}
},
findDelivery: async (
orderId: number,
includeCompletedDeliveries: boolean = true
): Promise<Shipment[]> => {
try {
const response = await ordersApi.get(
`/api/v1/orders/delivery/${orderId}`,
{
params: { includeCompletedDeliveries },
}
);
return unwrapApiData(response, shipmentResponseSchema, []);
} catch (error) {
console.error(`Erro ao buscar entregas do pedido ${orderId}:`, error);
return [];
}
},
findCargoMovement: async (orderId: number): Promise<CargoMovement[]> => {
try {
const response = await ordersApi.get(
`/api/v1/orders/transfer/${orderId}`
);
return unwrapApiData(response, cargoMovementResponseSchema, []);
} catch (error) {
console.error(`Erro ao buscar movimentação de carga do pedido ${orderId}:`, error);
return [];
}
},
findCuttingItems: async (orderId: number): Promise<CuttingItem[]> => {
try {
const response = await ordersApi.get(
`/api/v1/orders/cut-itens/${orderId}`
);
return unwrapApiData(response, cuttingItemResponseSchema, []);
} catch (error) {
console.error(`Erro ao buscar itens de corte do pedido ${orderId}:`, error);
return [];
}
},
};

View File

@ -1,7 +1,14 @@
'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();
@ -10,7 +17,15 @@ 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}`;
@ -23,7 +38,9 @@ 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,7 +13,7 @@ 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 './TabPanel';
import { TabPanel } from '@/shared/components';
import { OrderItemsTable } from './tabs/OrderItemsTable';
import { PreBoxPanel } from './tabs/PreBoxPanel';
import { InformationPanel } from './tabs/InformationPanel';

View File

@ -11,7 +11,11 @@ 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>
),
@ -23,7 +27,12 @@ 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>
),
@ -36,7 +45,11 @@ 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>
),
@ -49,7 +62,11 @@ 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,7 +79,11 @@ 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>
),
@ -78,7 +99,11 @@ 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>
),
@ -93,7 +118,11 @@ 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>
),
@ -109,7 +138,11 @@ 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>
),
@ -120,7 +153,12 @@ 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>
),
@ -136,7 +174,11 @@ 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

@ -0,0 +1,381 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { LicenseInfo } from '@mui/x-license';
// Mock license for DataGrid Premium (for Storybook demo only)
LicenseInfo.setLicenseKey(
'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y'
);
// Mock data que simula os dados normalizados
const mockOrders = [
{
id: '1',
orderId: 123456,
createDate: '2026-01-15T10:00:00',
customerName: 'Maria Silva',
status: 'Faturado',
orderType: 'Venda',
amount: 1250.5,
invoiceNumber: 'NF-001234',
sellerName: 'João Vendedor',
deliveryType: 'Entrega',
totalWeight: 15.5,
fatUserName: 'Admin',
deliveryLocal: 'São Paulo - SP',
masterDeliveryLocal: 'Região Sul',
deliveryPriority: 'Alta',
paymentName: 'Boleto',
partnerName: 'Parceiro ABC',
codusur2Name: 'Rep. Regional',
releaseUserName: 'Supervisor',
driver: 'Carlos Motorista',
carDescription: 'Sprinter',
carrier: 'Transportadora XYZ',
schedulerDelivery: '15/01/2026',
fatUserDescription: 'Faturamento',
emitenteNome: 'Empresa LTDA',
},
{
id: '2',
orderId: 123457,
createDate: '2026-01-14T14:30:00',
customerName: 'João Santos',
status: 'Pendente',
orderType: 'Venda',
amount: 3450.0,
invoiceNumber: '',
sellerName: 'Ana Vendedora',
deliveryType: 'Retirada',
totalWeight: 8.2,
fatUserName: '',
deliveryLocal: 'Rio de Janeiro - RJ',
masterDeliveryLocal: 'Região Sudeste',
deliveryPriority: 'Normal',
paymentName: 'Cartão de Crédito',
partnerName: '',
codusur2Name: '',
releaseUserName: '',
driver: '',
carDescription: '',
carrier: '',
schedulerDelivery: '',
fatUserDescription: '',
emitenteNome: 'Empresa LTDA',
},
{
id: '3',
orderId: 123458,
createDate: '2026-01-13T09:15:00',
customerName: 'Pedro Oliveira',
status: 'Entregue',
orderType: 'Venda',
amount: 890.75,
invoiceNumber: 'NF-001235',
sellerName: 'João Vendedor',
deliveryType: 'Entrega',
totalWeight: 5.0,
fatUserName: 'Admin',
deliveryLocal: 'Belo Horizonte - MG',
masterDeliveryLocal: 'Região Sudeste',
deliveryPriority: 'Baixa',
paymentName: 'PIX',
partnerName: 'Parceiro DEF',
codusur2Name: 'Rep. Nacional',
releaseUserName: 'Gerente',
driver: 'Paulo Motorista',
carDescription: 'Fiorino',
carrier: 'Transportadora ABC',
schedulerDelivery: '13/01/2026',
fatUserDescription: 'Faturamento Automático',
emitenteNome: 'Outra Empresa LTDA',
},
];
// Colunas simplificadas para a demo
const demoColumns = [
{ field: 'orderId', headerName: 'Pedido', width: 100 },
{ field: 'createDate', headerName: 'Data Criação', width: 150 },
{ field: 'customerName', headerName: 'Cliente', width: 180 },
{ field: 'status', headerName: 'Status', width: 120 },
{ field: 'orderType', headerName: 'Tipo', width: 100 },
{
field: 'amount',
headerName: 'Valor',
width: 120,
valueFormatter: (value: number) =>
new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(value),
},
{ field: 'invoiceNumber', headerName: 'Nota Fiscal', width: 130 },
{ field: 'sellerName', headerName: 'Vendedor', width: 150 },
{ field: 'deliveryType', headerName: 'Entrega', width: 100 },
{ field: 'deliveryLocal', headerName: 'Local Entrega', width: 180 },
];
const theme = createTheme({
palette: {
mode: 'light',
},
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
// Componente wrapper para o Storybook
const OrderTableDemo = ({
rows = mockOrders,
loading = false,
emptyState = false,
}: {
rows?: typeof mockOrders;
loading?: boolean;
emptyState?: boolean;
}) => {
const displayRows = emptyState ? [] : rows;
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<QueryClientProvider client={queryClient}>
<Box sx={{ width: '100%', p: 2 }}>
<Paper
sx={{
boxShadow: 'none',
border: 'none',
backgroundColor: 'transparent',
overflow: 'hidden',
}}
>
<DataGridPremium
rows={displayRows}
columns={demoColumns}
loading={loading}
density="compact"
autoHeight={false}
initialState={{
pagination: {
paginationModel: {
pageSize: 10,
page: 0,
},
},
sorting: {
sortModel: [{ field: 'createDate', sort: 'desc' }],
},
}}
pageSizeOptions={[10, 25, 50]}
paginationMode="client"
pagination
sx={{
height: 400,
border: '1px solid',
borderColor: 'divider',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
backgroundColor: 'background.paper',
'& .MuiDataGrid-columnHeaders': {
backgroundColor: 'grey.50',
fontWeight: 600,
fontSize: '0.75rem',
borderBottom: '2px solid',
borderColor: 'divider',
},
'& .MuiDataGrid-row': {
borderBottom: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'action.hover',
},
},
'& .MuiDataGrid-cell': {
fontSize: '0.75rem',
},
'& .MuiDataGrid-footerContainer': {
borderTop: '2px solid',
borderColor: 'divider',
backgroundColor: 'grey.50',
},
}}
localeText={{
noRowsLabel: 'Nenhum pedido encontrado.',
noResultsOverlayLabel: 'Nenhum resultado encontrado.',
}}
/>
</Paper>
</Box>
</QueryClientProvider>
</ThemeProvider>
);
};
const meta: Meta<typeof OrderTableDemo> = {
title: 'Features/Orders/OrderTable',
component: OrderTableDemo,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: `
## OrderTable
Tabela de pedidos com as seguintes funcionalidades:
- **Paginação**: Suporta 10, 25 ou 50 itens por página
- **Ordenação**: Por qualquer coluna clicando no header
- **Seleção**: Clique em uma linha para ver detalhes
- **Responsividade**: Adapta altura para mobile/desktop
`,
},
},
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof OrderTableDemo>;
/**
* Estado padrão da tabela com dados de pedidos.
*/
export const Default: Story = {
args: {
rows: [
{
id: '1',
orderId: 1232236,
createDate: '2026-01-15T10:00:00',
customerName: 'Maria Silva',
status: 'Faturado',
orderType: 'Venda',
amount: 1250.5,
invoiceNumber: 'NF-001234',
sellerName: 'João Vendedor',
deliveryType: 'Entrega',
totalWeight: 15.5,
fatUserName: 'Admin',
deliveryLocal: 'São Paulo - SP',
masterDeliveryLocal: 'Região Sul',
deliveryPriority: 'Alta',
paymentName: 'Boleto',
partnerName: 'Parceiro ABC',
codusur2Name: 'Rep. Regional',
releaseUserName: 'Supervisor',
driver: 'Carlos Motorista',
carDescription: 'Sprinter',
carrier: 'Transportadora XYZ',
schedulerDelivery: '15/01/2026',
fatUserDescription: 'Faturamento',
emitenteNome: 'Empresa LTDA',
},
{
id: '2',
orderId: 123457,
createDate: '2026-01-14T14:30:00',
customerName: 'João Santos',
status: 'Pendente',
orderType: 'Venda',
amount: 3450,
invoiceNumber: '',
sellerName: 'Ana Vendedora',
deliveryType: 'Retirada',
totalWeight: 8.2,
fatUserName: '',
deliveryLocal: 'Rio de Janeiro - RJ',
masterDeliveryLocal: 'Região Sudeste',
deliveryPriority: 'Normal',
paymentName: 'Cartão de Crédito',
partnerName: '',
codusur2Name: '',
releaseUserName: '',
driver: '',
carDescription: '',
carrier: '',
schedulerDelivery: '',
fatUserDescription: '',
emitenteNome: 'Empresa LTDA',
},
{
id: '3',
orderId: 123458,
createDate: '2026-01-13T09:15:00',
customerName: 'Pedro Oliveira',
status: 'Entregue',
orderType: 'Venda',
amount: 890.75,
invoiceNumber: 'NF-001235',
sellerName: 'João Vendedor',
deliveryType: 'Entrega',
totalWeight: 5,
fatUserName: 'Admin',
deliveryLocal: 'Belo Horizonte - MG',
masterDeliveryLocal: 'Região Sudeste',
deliveryPriority: 'Baixa',
paymentName: 'PIX',
partnerName: 'Parceiro DEF',
codusur2Name: 'Rep. Nacional',
releaseUserName: 'Gerente',
driver: 'Paulo Motorista',
carDescription: 'Fiorino',
carrier: 'Transportadora ABC',
schedulerDelivery: '13/01/2026',
fatUserDescription: 'Faturamento Automático',
emitenteNome: 'Outra Empresa LTDA',
},
],
loading: false,
emptyState: false,
},
};
/**
* Estado de carregamento enquanto busca pedidos da API.
*/
export const Loading: Story = {
args: {
rows: [],
loading: true,
emptyState: false,
},
};
/**
* Estado vazio quando não pedidos para exibir.
*/
export const Empty: Story = {
args: {
rows: [],
loading: false,
emptyState: true,
},
};
/**
* Tabela com muitos pedidos para demonstrar a paginação.
*/
export const ManyOrders: Story = {
args: {
rows: Array.from({ length: 50 }, (_, i) => ({
...mockOrders[i % 3],
id: String(i + 1),
orderId: 123456 + i,
customerName: `Cliente ${i + 1}`,
amount: Math.random() * 10000,
})),
loading: false,
emptyState: false,
},
};

View File

@ -1,14 +1,18 @@
'use client';
import { useMemo, useState } from 'react';
import { useMemo, useState, useEffect } from 'react';
import { SearchBar } from './SearchBar';
import Box from '@mui/material/Box';
import Alert from '@mui/material/Alert';
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';
@ -17,21 +21,41 @@ import { OrderDetailsTabs } from './OrderDetailsTabs';
export const OrderTable = () => {
const { data: orders, isLoading, error } = useOrders();
const [cellSelectionModel, setCellSelectionModel] = useState<GridCellSelectionModel>({});
const { data: stores } = useStores();
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(),
[]
);
const columns = useMemo(() => createOrderColumns({ storesMap }), [storesMap]);
if (error) {
return (
@ -47,11 +71,24 @@ 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: 3, boxShadow: 'none', border: 'none', backgroundColor: 'transparent', overflow: 'hidden' }}>
<Paper
sx={{
mt: { xs: 2, md: 3 },
boxShadow: 'none',
border: 'none',
backgroundColor: 'transparent',
overflow: 'hidden',
}}
>
<DataGridPremium
rows={rows}
columns={columns}
@ -73,7 +110,7 @@ export const OrderTable = () => {
sortModel: [{ field: 'createDate', sort: 'desc' }],
},
pinnedColumns: {
left: ['orderId', 'customerName'],
/// left: ['orderId', 'customerName'],
},
}}
pageSizeOptions={[10, 25, 50]}
@ -88,7 +125,7 @@ export const OrderTable = () => {
params.row.orderId === selectedOrderId ? 'Mui-selected' : ''
}
sx={{
height: tableHeight,
height: { xs: mobileTableHeight, md: tableHeight },
border: '1px solid',
borderColor: 'divider',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
@ -253,7 +290,8 @@ 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) =>
@ -263,11 +301,18 @@ 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,10 +3,25 @@ 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';
import {
formatDate,
formatDateTime,
formatCurrency,
formatNumber,
} from '../utils/orderFormatters';
import {
getStatusChipProps,
getPriorityChipProps,
} from '../utils/tableHelpers';
export const createOrderColumns = (): GridColDef[] => [
interface CreateOrderColumnsOptions {
storesMap?: Map<string, string>;
}
export const createOrderColumns = (options?: CreateOrderColumnsOptions): GridColDef[] => {
const storesMap = options?.storesMap;
return [
{
field: 'orderId',
headerName: 'Pedido',
@ -15,7 +30,10 @@ export const createOrderColumns = (): GridColDef[] => [
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}>
<Typography
variant="body2"
sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}
>
{params.value}
</Typography>
),
@ -33,7 +51,11 @@ export const createOrderColumns = (): GridColDef[] => [
{formatDate(params.value)}
</Typography>
{dateTime && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.6875rem' }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.6875rem' }}
>
{dateTime}
</Typography>
)}
@ -58,15 +80,19 @@ export const createOrderColumns = (): GridColDef[] => [
headerName: 'Filial',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
renderCell: (params: Readonly<GridRenderCellParams>) => {
const storeId = String(params.value);
const storeName = storesMap?.get(storeId) || storeId;
return (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
{storeName}
</Typography>
),
);
},
},
{
field: 'store',
headerName: 'Filial Faturamento',
headerName: 'Supervisor',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
@ -122,7 +148,10 @@ export const createOrderColumns = (): GridColDef[] => [
align: 'right',
valueFormatter: (value) => formatCurrency(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}>
<Typography
variant="body2"
sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}
>
{formatCurrency(params.value)}
</Typography>
),
@ -135,7 +164,10 @@ export const createOrderColumns = (): GridColDef[] => [
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
<Typography
variant="body2"
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
>
{params.value && params.value !== '-' ? params.value : '-'}
</Typography>
),
@ -184,7 +216,10 @@ export const createOrderColumns = (): GridColDef[] => [
align: 'right',
valueFormatter: (value) => formatNumber(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
<Typography
variant="body2"
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
>
{formatNumber(params.value)}
</Typography>
),
@ -208,7 +243,11 @@ export const createOrderColumns = (): GridColDef[] => [
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
>
{params.value || '-'}
</Typography>
),
@ -290,10 +329,16 @@ export const createOrderColumns = (): GridColDef[] => [
const timeStr = params.row.invoiceTime;
return (
<Typography variant="body2" sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
<Typography
variant="body2"
sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}
>
{dateStr}
{timeStr && (
<Box component="span" sx={{ color: 'text.secondary', fontSize: '0.6875rem', ml: 0.5 }}>
<Box
component="span"
sx={{ color: 'text.secondary', fontSize: '0.6875rem', ml: 0.5 }}
>
{timeStr}
</Box>
)}
@ -342,7 +387,11 @@ export const createOrderColumns = (): GridColDef[] => [
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
>
{params.value || '-'}
</Typography>
),
@ -392,4 +441,4 @@ export const createOrderColumns = (): GridColDef[] => [
),
},
];
};

View File

@ -12,6 +12,9 @@ import {
Paper,
Tooltip,
Collapse,
CircularProgress,
Badge,
type AutocompleteRenderInputParams,
} from '@mui/material';
import { useStores } from '../store/useStores';
import { useCustomers } from '../hooks/useCustomers';
@ -30,7 +33,6 @@ import 'moment/locale/pt-br';
moment.locale('pt-br');
// Tipo para os filtros locais (não precisam da flag searchTriggered)
interface LocalFilters {
status: string | null;
orderId: number | null;
@ -44,7 +46,9 @@ interface LocalFilters {
sellerName: string | null;
}
const getInitialLocalFilters = (urlFilters: any): LocalFilters => ({
const getInitialLocalFilters = (
urlFilters: Partial<LocalFilters>
): LocalFilters => ({
status: urlFilters.status ?? null,
orderId: urlFilters.orderId ?? null,
customerId: urlFilters.customerId ?? null,
@ -60,7 +64,6 @@ const getInitialLocalFilters = (urlFilters: any): LocalFilters => ({
export const SearchBar = () => {
const [urlFilters, setUrlFilters] = useOrderFilters();
// Estado local para inputs (não dispara busca ao mudar)
const [localFilters, setLocalFilters] = useState<LocalFilters>(() =>
getInitialLocalFilters(urlFilters)
);
@ -70,22 +73,22 @@ export const SearchBar = () => {
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
const customers = useCustomers(customerSearchTerm);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [touchedFields, setTouchedFields] = useState<{
createDateIni?: boolean;
createDateEnd?: boolean;
}>({});
// Sync local state com URL (para navegação Back/Forward)
useEffect(() => {
setLocalFilters(getInitialLocalFilters(urlFilters));
}, [urlFilters]);
const updateLocalFilter = useCallback(<K extends keyof LocalFilters>(
key: K,
value: LocalFilters[K]
) => {
setLocalFilters(prev => ({ ...prev, [key]: value }));
}, []);
const updateLocalFilter = useCallback(
<K extends keyof LocalFilters>(key: K, value: LocalFilters[K]) => {
setLocalFilters((prev) => ({ ...prev, [key]: value }));
},
[]
);
const handleReset = useCallback(() => {
setTouchedFields({});
@ -110,7 +113,6 @@ export const SearchBar = () => {
customerName: null,
};
// Reset local + URL
setLocalFilters(getInitialLocalFilters(resetState));
setUrlFilters(resetState);
}, [setUrlFilters]);
@ -129,24 +131,27 @@ export const SearchBar = () => {
const handleFilter = useCallback(() => {
if (!localFilters.createDateIni) {
setTouchedFields(prev => ({ ...prev, createDateIni: true }));
setTouchedFields((prev) => ({ ...prev, createDateIni: true }));
return;
}
const dateError = validateDates();
if (dateError) {
setTouchedFields(prev => ({ ...prev, createDateEnd: true }));
setTouchedFields((prev) => ({ ...prev, createDateEnd: true }));
return;
}
// Commit local filters to URL (triggers search)
setIsSearching(true);
setUrlFilters({
...localFilters,
searchTriggered: true,
});
// Reset loading state after a short delay
setTimeout(() => setIsSearching(false), 500);
}, [localFilters, setUrlFilters, validateDates]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
const isValid = !!localFilters.createDateIni;
const dateErr = validateDates();
@ -154,17 +159,27 @@ export const SearchBar = () => {
handleFilter();
}
}
}, [localFilters.createDateIni, validateDates, handleFilter]);
},
[localFilters.createDateIni, validateDates, handleFilter]
);
const isDateValid = !!localFilters.createDateIni;
const dateError = validateDates();
const showDateIniError = touchedFields.createDateIni && !localFilters.createDateIni;
const showDateIniError =
touchedFields.createDateIni && !localFilters.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: 3,
p: { xs: 2, md: 3 },
mb: 2,
bgcolor: 'background.paper',
borderRadius: 2,
@ -173,8 +188,7 @@ export const SearchBar = () => {
elevation={0}
onKeyDown={handleKeyDown}
>
<Grid container spacing={2} alignItems="flex-end">
<Grid container spacing={{ xs: 1.5, md: 2 }} alignItems="flex-end">
{/* --- Primary Filters (Always Visible) --- */}
{/* Campo de Texto Simples (Nº Pedido) */}
@ -222,9 +236,11 @@ export const SearchBar = () => {
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) => localFilters.customerId === option.customer?.id
) || null
}
onChange={(_, newValue) => {
if (!newValue) {
updateLocalFilter('customerName', null);
@ -234,7 +250,10 @@ export const SearchBar = () => {
}
updateLocalFilter('customerId', newValue.customer?.id || null);
updateLocalFilter('customerName', newValue.customer?.name || null);
updateLocalFilter(
'customerName',
newValue.customer?.name || null
);
}}
onInputChange={(_, newInputValue, reason) => {
if (reason === 'clear') {
@ -254,14 +273,18 @@ export const SearchBar = () => {
}
}}
loading={customers.isLoading}
renderInput={(params: Readonly<any>) => (
renderInput={(params: AutocompleteRenderInputParams) => (
<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}
@ -272,54 +295,95 @@ export const SearchBar = () => {
{/* Campos de Data */}
<Grid size={{ xs: 12, sm: 12, md: 4 }}>
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="pt-br">
<Box display="flex" gap={2}>
<LocalizationProvider
dateAdapter={AdapterMoment}
adapterLocale="pt-br"
>
<Box display="flex" gap={{ xs: 1.5, md: 2 }} flexDirection={{ xs: 'column', sm: 'row' }}>
<Box flex={1}>
<DatePicker
label="Data Inicial"
value={localFilters.createDateIni ? moment(localFilters.createDateIni, 'YYYY-MM-DD') : null}
value={
localFilters.createDateIni
? moment(localFilters.createDateIni, 'YYYY-MM-DD')
: null
}
onChange={(date: moment.Moment | null) => {
setTouchedFields(prev => ({ ...prev, createDateIni: true }));
updateLocalFilter('createDateIni', date ? date.format('YYYY-MM-DD') : null);
setTouchedFields((prev) => ({
...prev,
createDateIni: true,
}));
updateLocalFilter(
'createDateIni',
date ? date.format('YYYY-MM-DD') : null
);
}}
format="DD/MM/YYYY"
maxDate={localFilters.createDateEnd ? moment(localFilters.createDateEnd, 'YYYY-MM-DD') : undefined}
maxDate={
localFilters.createDateEnd
? moment(localFilters.createDateEnd, 'YYYY-MM-DD')
: undefined
}
slotProps={{
textField: {
size: 'small',
fullWidth: true,
required: true,
error: showDateIniError,
helperText: showDateIniError ? 'Data inicial é obrigatória' : '',
onBlur: () => setTouchedFields(prev => ({ ...prev, createDateIni: true })),
helperText: showDateIniError
? 'Data inicial é obrigatória'
: '',
onBlur: () =>
setTouchedFields((prev) => ({
...prev,
createDateIni: true,
})),
inputProps: {
'aria-required': true,
}
}
},
},
}}
/>
</Box>
<Box flex={1}>
<DatePicker
label="Data Final"
value={localFilters.createDateEnd ? moment(localFilters.createDateEnd, 'YYYY-MM-DD') : null}
label="Data Final (opcional)"
value={
localFilters.createDateEnd
? moment(localFilters.createDateEnd, 'YYYY-MM-DD')
: null
}
onChange={(date: moment.Moment | null) => {
setTouchedFields(prev => ({ ...prev, createDateEnd: true }));
updateLocalFilter('createDateEnd', date ? date.format('YYYY-MM-DD') : null);
setTouchedFields((prev) => ({
...prev,
createDateEnd: true,
}));
updateLocalFilter(
'createDateEnd',
date ? date.format('YYYY-MM-DD') : null
);
}}
format="DD/MM/YYYY"
minDate={localFilters.createDateIni ? moment(localFilters.createDateIni, 'YYYY-MM-DD') : undefined}
minDate={
localFilters.createDateIni
? moment(localFilters.createDateIni, 'YYYY-MM-DD')
: undefined
}
slotProps={{
textField: {
size: 'small',
fullWidth: true,
error: !!showDateEndError,
helperText: showDateEndError || '',
onBlur: () => setTouchedFields(prev => ({ ...prev, createDateEnd: true })),
onBlur: () =>
setTouchedFields((prev) => ({
...prev,
createDateEnd: true,
})),
inputProps: {
placeholder: 'Opcional',
}
}
},
},
}}
/>
</Box>
@ -328,28 +392,41 @@ export const SearchBar = () => {
</Grid>
{/* 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 }}>
<Grid
size={{ xs: 12, sm: 12, md: 1.5 }}
sx={{ display: 'flex', justifyContent: { xs: 'stretch', sm: 'flex-end' } }}
>
<Box sx={{ display: 'flex', gap: 1, width: { xs: '100%', sm: 'auto' } }}>
<Tooltip title="Limpar filtros" arrow>
<span>
<Button
variant="outlined"
color="inherit"
onClick={handleReset}
aria-label="Limpar filtros"
sx={{
minWidth: 40,
minWidth: { xs: 48, md: 40 },
minHeight: 44,
px: 1.5,
flex: { xs: 1, sm: 'none' },
'&:hover': {
bgcolor: 'action.hover',
}
},
}}
>
<ResetIcon />
<Box component="span" sx={{ display: { xs: 'none', md: 'inline' }, ml: 0.5 }}>
Limpar
</Box>
</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>
@ -357,16 +434,28 @@ export const SearchBar = () => {
variant="contained"
color="primary"
onClick={handleFilter}
disabled={!isDateValid || !!dateError}
disabled={!isDateValid || !!dateError || isSearching}
aria-label="Buscar pedidos"
sx={{
minWidth: 40,
minWidth: { xs: 48, md: 40 },
minHeight: 44,
px: 1.5,
flex: { xs: 2, sm: 'none' },
'&:disabled': {
opacity: 0.6,
}
},
}}
>
{isSearching ? (
<CircularProgress size={20} color="inherit" />
) : (
<>
<SearchIcon />
<Box component="span" sx={{ display: { xs: 'none', md: 'inline' }, ml: 0.5 }}>
Buscar
</Box>
</>
)}
</Button>
</span>
</Tooltip>
@ -376,14 +465,23 @@ export const SearchBar = () => {
{/* --- Advanced Filters (Collapsible) --- */}
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Badge
badgeContent={advancedFiltersCount}
color="primary"
invisible={advancedFiltersCount === 0}
>
<Button
size="small"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
endIcon={showAdvancedFilters ? <ExpandLessIcon /> : <ExpandMoreIcon />}
endIcon={
showAdvancedFilters ? <ExpandLessIcon /> : <ExpandMoreIcon />
}
aria-label={showAdvancedFilters ? 'Ocultar filtros avançados' : 'Mostrar filtros avançados'}
sx={{ textTransform: 'none', color: 'text.secondary' }}
>
{showAdvancedFilters ? 'Menos filtros' : 'Mais filtros'}
</Button>
</Badge>
</Box>
<Collapse in={showAdvancedFilters}>
<Grid container spacing={2} sx={{ pt: 2 }}>
@ -394,19 +492,28 @@ export const SearchBar = () => {
size="small"
options={stores.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={stores.options.filter(option =>
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={stores.options.filter((option) =>
localFilters.store?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter('store', newValue.map(option => option.value));
updateLocalFilter(
'store',
newValue.map((option) => option.value)
);
}}
loading={stores.isLoading}
renderInput={(params: Readonly<any>) => (
renderInput={(params: AutocompleteRenderInputParams) => (
<TextField
{...params}
label="Filiais"
placeholder={localFilters.store?.length ? `${localFilters.store.length} selecionadas` : 'Selecione'}
placeholder={
localFilters.store?.length
? `${localFilters.store.length} selecionadas`
: 'Selecione'
}
/>
)}
/>
@ -419,19 +526,28 @@ export const SearchBar = () => {
size="small"
options={stores.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={stores.options.filter(option =>
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={stores.options.filter((option) =>
localFilters.stockId?.includes(option.value)
)}
onChange={(_, newValue) => {
updateLocalFilter('stockId', newValue.map(option => option.value));
updateLocalFilter(
'stockId',
newValue.map((option) => option.value)
);
}}
loading={stores.isLoading}
renderInput={(params: Readonly<any>) => (
renderInput={(params: AutocompleteRenderInputParams) => (
<TextField
{...params}
label="Filial de Estoque"
placeholder={localFilters.stockId?.length ? `${localFilters.stockId.length} selecionadas` : 'Selecione'}
placeholder={
localFilters.stockId?.length
? `${localFilters.stockId.length} selecionadas`
: 'Selecione'
}
/>
)}
/>
@ -443,13 +559,24 @@ export const SearchBar = () => {
size="small"
options={sellers.options}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={sellers.options.find(option =>
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
value={
sellers.options.find(
(option) =>
localFilters.sellerId === option.seller.id.toString()
) || null}
) || null
}
onChange={(_, newValue) => {
updateLocalFilter('sellerId', newValue?.seller.id.toString() || null);
updateLocalFilter('sellerName', newValue?.seller.name || null);
updateLocalFilter(
'sellerId',
newValue?.seller.id.toString() || null
);
updateLocalFilter(
'sellerName',
newValue?.seller.name || null
);
}}
loading={sellers.isLoading}
renderInput={(params) => (
@ -472,7 +599,6 @@ export const SearchBar = () => {
</Grid>
</Collapse>
</Grid>
</Grid>
</Paper>
);

View File

@ -17,11 +17,7 @@ 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,21 +1,63 @@
'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useCargoMovement } from '../../hooks/useCargoMovement';
import { createCargoMovementColumns } from './CargoMovementPanelColumns';
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
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>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography>
</Alert>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={30} />
</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

@ -0,0 +1,75 @@
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,21 +1,63 @@
'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useCuttingItems } from '../../hooks/useCuttingItems';
import { createCuttingPanelColumns } from './CuttingPanelColumns';
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
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>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography>
</Alert>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={30} />
</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

@ -0,0 +1,59 @@
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,21 +1,63 @@
'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography';
import { DataGridPremium } from '@mui/x-data-grid-premium';
import { useDelivery } from '../../hooks/useDelivery';
import { createDeliveryPanelColumns } from './DeliveryPanelColumns';
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
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>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography>
</Alert>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={30} />
</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

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

@ -20,10 +20,12 @@ export const InformationPanel = ({ orderId }: InformationPanelProps) => {
const rows = useMemo(() => {
if (!order) return [];
return [{
return [
{
id: order.orderId || orderId,
...order
}];
...order,
},
];
}, [order, orderId]);
if (isLoading) {

View File

@ -1,29 +1,24 @@
import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid-premium';
import Chip from '@mui/material/Chip';
import { formatCurrency, formatDate, getStatusColor } from '../../utils/orderFormatters';
/**
* Mapeia códigos de status para texto legível.
*/
const STATUS_LABELS: Record<string, string> = {
'F': 'Faturado',
'C': 'Cancelado',
'P': 'Pendente',
};
const getStatusLabel = (status: string): string => STATUS_LABELS[status] || status;
import {
formatCurrency,
formatDate,
getStatusColor,
getStatusLabel,
} from '../../utils/orderFormatters';
export const createInformationPanelColumns = (): GridColDef[] => [
{
field: 'customerName',
headerName: 'Cliente',
width: 250,
minWidth: 180,
flex: 1.5,
description: 'Nome do cliente do pedido',
},
{
field: 'storeId',
headerName: 'Filial',
width: 80,
width: 60,
align: 'center',
headerAlign: 'center',
description: 'Código da filial',
@ -31,7 +26,7 @@ export const createInformationPanelColumns = (): GridColDef[] => [
{
field: 'createDate',
headerName: 'Data Criação',
width: 110,
width: 95,
align: 'center',
headerAlign: 'center',
description: 'Data de criação do pedido',
@ -40,7 +35,7 @@ export const createInformationPanelColumns = (): GridColDef[] => [
{
field: 'status',
headerName: 'Situação',
width: 120,
width: 90,
align: 'center',
headerAlign: 'center',
description: 'Situação atual do pedido',
@ -50,26 +45,28 @@ export const createInformationPanelColumns = (): GridColDef[] => [
size="small"
color={getStatusColor(params.value as string)}
variant="outlined"
sx={{ height: 24 }}
sx={{ height: 22, fontSize: '0.7rem' }}
/>
),
},
{
field: 'paymentName',
headerName: 'Forma Pagamento',
width: 150,
headerName: 'Forma Pgto',
minWidth: 100,
flex: 1,
description: 'Forma de pagamento utilizada',
},
{
field: 'billingName',
headerName: 'Cond. Pagamento',
width: 150,
headerName: 'Cond. Pgto',
minWidth: 100,
flex: 1,
description: 'Condição de pagamento',
},
{
field: 'amount',
headerName: 'Valor Total',
width: 120,
headerName: 'Valor',
width: 100,
align: 'right',
headerAlign: 'right',
description: 'Valor total do pedido',
@ -78,14 +75,15 @@ export const createInformationPanelColumns = (): GridColDef[] => [
{
field: 'deliveryType',
headerName: 'Tipo Entrega',
width: 150,
minWidth: 100,
flex: 1,
description: 'Tipo de entrega selecionado',
},
{
field: 'deliveryLocal',
headerName: 'Local Entrega',
width: 200,
flex: 1,
minWidth: 120,
flex: 1.2,
description: 'Local de entrega do pedido',
},
];

View File

@ -30,7 +30,14 @@ export const OrderItemsTable = ({ orderId }: OrderItemsTableProps) => {
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 4 }}>
<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...
@ -86,7 +93,15 @@ export const OrderItemsTable = ({ orderId }: OrderItemsTableProps) => {
slotProps={{
pagination: {
labelRowsPerPage: 'Itens 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

@ -1,26 +1,13 @@
{
"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,9 +5,11 @@ 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
@ -20,9 +22,11 @@ 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,6 +25,7 @@ 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:
@ -51,6 +52,7 @@ 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:
@ -64,12 +66,13 @@ 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,
@ -95,6 +98,7 @@ findOrderItems: async (orderId: number): Promise<OrderItem[]> => {
---
### 3. Hook React Query
**Arquivo:** `src/features/orders/hooks/useOrderItems.ts`
Gerencia o estado e cache dos itens:
@ -114,6 +118,7 @@ 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)
@ -122,12 +127,13 @@ 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 |
@ -146,6 +152,7 @@ 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:
@ -158,10 +165,11 @@ 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"
@ -170,16 +178,19 @@ 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;
@ -188,6 +199,7 @@ onRowClick={(params) => {
```
#### c) Classe CSS para linha selecionada
```typescript
getRowClassName={(params) =>
params.row.orderId === selectedOrderId ? 'Mui-selected' : ''
@ -195,6 +207,7 @@ getRowClassName={(params) =>
```
#### d) Estilo visual da linha selecionada
```typescript
'& .MuiDataGrid-row.Mui-selected': {
backgroundColor: 'primary.light',
@ -205,6 +218,7 @@ getRowClassName={(params) =>
```
#### e) Renderização condicional da tabela de itens
```typescript
{selectedOrderId && <OrderItemsTable orderId={selectedOrderId} />}
```
@ -226,11 +240,13 @@ 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(),
@ -241,9 +257,11 @@ 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`
@ -254,6 +272,7 @@ getRowClassName={(params) =>
## Estilização
### Cabeçalho da Tabela
```typescript
'& .MuiDataGrid-columnHeaders': {
backgroundColor: 'grey.50',
@ -265,6 +284,7 @@ getRowClassName={(params) =>
```
### Linhas
```typescript
'& .MuiDataGrid-row': {
minHeight: '36px !important',
@ -278,6 +298,7 @@ getRowClassName={(params) =>
```
### Células
```typescript
'& .MuiDataGrid-cell': {
fontSize: '0.75rem',
@ -311,7 +332,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

@ -0,0 +1,16 @@
'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,7 +33,8 @@ 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}`,
@ -45,4 +46,3 @@ export function useCustomers(searchTerm: string) {
options,
};
}

View File

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

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

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

View File

@ -4,7 +4,6 @@ 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

@ -11,7 +11,8 @@ export function useSellers() {
refetchOnWindowFocus: false,
});
const options = query.data?.map((seller, index) => {
const options =
query.data?.map((seller, index) => {
return {
value: seller.id.toString(),
label: seller.name,
@ -22,6 +23,6 @@ export function useSellers() {
return {
...query,
options
options,
};
}

View File

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

View File

@ -6,11 +6,8 @@ import { Box, Typography } from '@mui/material';
export default function OrdersPage() {
return (
<Box>
<Typography variant="h4" fontWeight="400" mb={3}>
Consultas de Pedidos
</Typography>
<Typography variant="h4" fontWeight="400" mb={0}></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,12 +72,14 @@ 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)
);
};
/**
@ -103,15 +105,18 @@ 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,6 +18,49 @@ 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
*/
@ -26,6 +69,7 @@ export const orderItemsResponseSchema = z.object({
data: z.array(orderItemSchema),
});
/**
* TypeScript type inferred from the Zod schema
*/
@ -35,3 +79,46 @@ 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,7 +7,12 @@ 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';

View File

@ -11,4 +11,3 @@ export const storeSchema = z.object({
});
export type Store = z.infer<typeof storeSchema>;

View File

@ -11,9 +11,12 @@ export function useStores() {
refetchOnWindowFocus: false,
});
const options = query.data?.map((store, index) => {
const uniqueId = store.id || store.storeId || store.code || `store-${index}`;
const label = store.store || store.label || store.name || `Loja ${index + 1}`;
const options =
query.data?.map((store, index) => {
const uniqueId =
store.id || store.storeId || store.code || `store-${index}`;
const label =
store.store || store.label || store.name || `Loja ${index + 1}`;
return {
value: uniqueId,
@ -24,6 +27,6 @@ export function useStores() {
return {
...query,
options
options,
};
}

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