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 # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts 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;

10
.vscode/settings.json vendored
View File

@ -1,6 +1,6 @@
{ {
"sonarlint.connectedMode.project": { "sonarlint.connectedMode.project": {
"connectionId": "http-localhost-9000", "connectionId": "http-localhost-9000",
"projectKey": "Portal-web" "projectKey": "Portal-web"
} }
} }

View File

@ -23,11 +23,13 @@ The application is designed to provide a robust and scalable frontend interface
## Installation ## Installation
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone <repository-url> git clone <repository-url>
``` ```
2. Navigate to the project directory: 2. Navigate to the project directory:
```bash ```bash
cd portal-web-v2 cd portal-web-v2
``` ```
@ -41,14 +43,14 @@ The application is designed to provide a robust and scalable frontend interface
The following scripts are available in `package.json` for development and operations: The following scripts are available in `package.json` for development and operations:
| Script | Description | | Script | Description |
|--------|-------------| | ----------------------- | --------------------------------------------------- |
| `npm run dev` | Starts the development server with hot-reloading. | | `npm run dev` | Starts the development server with hot-reloading. |
| `npm run build` | Compiles the application for production deployment. | | `npm run build` | Compiles the application for production deployment. |
| `npm start` | Runs the compiled production build locally. | | `npm start` | Runs the compiled production build locally. |
| `npm run lint` | Runs ESLint to analyze code quality and fix issues. | | `npm run lint` | Runs ESLint to analyze code quality and fix issues. |
| `npm test` | Executes the test suite using Jest. | | `npm test` | Executes the test suite using Jest. |
| `npm run test:coverage` | Runs tests and generates a code coverage report. | | `npm run test:coverage` | Runs tests and generates a code coverage report. |
## Project Structure ## Project Structure

View File

@ -3,93 +3,98 @@
import createCache from '@emotion/cache'; import createCache from '@emotion/cache';
import { useServerInsertedHTML } from 'next/navigation'; import { useServerInsertedHTML } from 'next/navigation';
import { CacheProvider as DefaultCacheProvider } from '@emotion/react'; 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 type { ReactNode } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import React from 'react'; import React from 'react';
export type NextAppDirEmotionCacheProviderProps = { export type NextAppDirEmotionCacheProviderProps = {
/** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */ /** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */
options: Omit<OptionsOfCreateCache, 'insertionPoint'>; options: Omit<OptionsOfCreateCache, 'insertionPoint'>;
/** By default <CacheProvider /> from 'import { CacheProvider } from "@emotion/react"' */ /** By default <CacheProvider /> from 'import { CacheProvider } from "@emotion/react"' */
CacheProvider?: (props: { CacheProvider?: (props: {
value: EmotionCache; value: EmotionCache;
children: ReactNode;
}) => React.JSX.Element | null;
children: ReactNode; children: ReactNode;
}) => React.JSX.Element | null;
children: ReactNode;
}; };
// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx // Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx
export default function NextAppDirEmotionCacheProvider(props: NextAppDirEmotionCacheProviderProps) { export default function NextAppDirEmotionCacheProvider(
const { options, CacheProvider = DefaultCacheProvider, children } = props; props: NextAppDirEmotionCacheProviderProps
) {
const { options, CacheProvider = DefaultCacheProvider, children } = props;
const [registry] = useState(() => { const [registry] = useState(() => {
const cache = createCache(options); const cache = createCache(options);
cache.compat = true; cache.compat = true;
const prevInsert = cache.insert; const prevInsert = cache.insert;
let inserted: { name: string; isGlobal: boolean }[] = []; let inserted: { name: string; isGlobal: boolean }[] = [];
cache.insert = (...args) => { cache.insert = (...args) => {
const [selector, serialized] = args; const [selector, serialized] = args;
if (cache.inserted[serialized.name] === undefined) { if (cache.inserted[serialized.name] === undefined) {
inserted.push({ inserted.push({
name: serialized.name, name: serialized.name,
isGlobal: !selector, isGlobal: !selector,
});
}
return prevInsert(...args);
};
const flush = () => {
const prevInserted = inserted;
inserted = [];
return prevInserted;
};
return { cache, flush };
});
useServerInsertedHTML(() => {
const inserted = registry.flush();
if (inserted.length === 0) {
return null;
}
let styles = '';
let dataEmotionAttribute = registry.cache.key;
const globals: {
name: string;
style: string;
}[] = [];
inserted.forEach(({ name, isGlobal }) => {
const style = registry.cache.inserted[name];
if (style && typeof style !== 'boolean') {
if (isGlobal) {
globals.push({ name, style });
} else {
styles += style;
dataEmotionAttribute += ` ${name}`;
}
}
}); });
}
return prevInsert(...args);
};
const flush = () => {
const prevInserted = inserted;
inserted = [];
return prevInserted;
};
return { cache, flush };
});
return ( useServerInsertedHTML(() => {
<> const inserted = registry.flush();
{globals.map(({ name, style }) => ( if (inserted.length === 0) {
<style return null;
key={name} }
data-emotion={`${registry.cache.key}-global ${name}`} let styles = '';
dangerouslySetInnerHTML={{ __html: style }} let dataEmotionAttribute = registry.cache.key;
/>
))} const globals: {
{styles && ( name: string;
<style style: string;
data-emotion={dataEmotionAttribute} }[] = [];
dangerouslySetInnerHTML={{ __html: styles }}
/> inserted.forEach(({ name, isGlobal }) => {
)} const style = registry.cache.inserted[name];
</>
); if (style && typeof style !== 'boolean') {
if (isGlobal) {
globals.push({ name, style });
} else {
styles += style;
dataEmotionAttribute += ` ${name}`;
}
}
}); });
return <CacheProvider value={registry.cache}>{children}</CacheProvider>; return (
<>
{globals.map(({ name, style }) => (
<style
key={name}
data-emotion={`${registry.cache.key}-global ${name}`}
dangerouslySetInnerHTML={{ __html: style }}
/>
))}
{styles && (
<style
data-emotion={dataEmotionAttribute}
dangerouslySetInnerHTML={{ __html: styles }}
/>
)}
</>
);
});
return <CacheProvider value={registry.cache}>{children}</CacheProvider>;
} }

View File

@ -8,99 +8,102 @@ import { useCustomizerStore } from '@/features/dashboard/store/useCustomizerStor
import { LicenseInfo } from '@mui/x-license'; import { LicenseInfo } from '@mui/x-license';
import { AuthInitializer } from '@/features/login/components/AuthInitializer'; import { AuthInitializer } from '@/features/login/components/AuthInitializer';
const PERPETUAL_LICENSE_KEY = 'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y'; const PERPETUAL_LICENSE_KEY =
'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
try { try {
LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY); LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY);
} catch (error) { } catch (error) {
console.error('Failed to set MUI license key:', error); console.error('Failed to set MUI license key:', error);
} }
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);
const isHydrated = useCustomizerStore((state) => state.isHydrated);
export default function Providers({ children }: Readonly<{ children: React.ReactNode }>) { useEffect(() => {
const [queryClient] = useState(() => new QueryClient()); setMounted(true);
const [mounted, setMounted] = useState(false); }, []);
const activeMode = useCustomizerStore((state) => state.activeMode);
const isHydrated = useCustomizerStore((state) => state.isHydrated);
useEffect(() => { const safeMode = mounted && isHydrated ? activeMode : 'light';
setMounted(true);
}, []);
const safeMode = mounted && isHydrated ? activeMode : 'light'; const theme = useMemo(
() =>
const theme = useMemo( createTheme({
() => palette: {
createTheme({ mode: safeMode,
palette: { primary: {
mode: safeMode, main: '#5d87ff',
primary: { light: '#ecf2ff',
main: '#5d87ff', dark: '#4570ea',
light: '#ecf2ff', },
dark: '#4570ea', secondary: {
}, main: '#49beff',
secondary: { light: '#e8f7ff',
main: '#49beff', dark: '#23afdb',
light: '#e8f7ff', },
dark: '#23afdb', ...(safeMode === 'dark'
}, ? {
...(safeMode === 'dark' text: {
? { primary: '#ffffff',
text: { secondary: '#b0bcc8',
primary: '#ffffff',
secondary: '#b0bcc8',
},
background: {
default: '#0b1426',
paper: '#111c2e',
},
}
: {
text: {
primary: '#1a1a1a',
secondary: '#4a5568',
},
background: {
default: '#ffffff',
paper: '#ffffff',
},
}),
}, },
typography: { background: {
fontFamily: '"Plus Jakarta Sans", "Helvetica", "Arial", sans-serif', default: '#0b1426',
h1: { fontWeight: 600 }, paper: '#111c2e',
h6: { fontWeight: 600 },
body1: { fontSize: '0.875rem', fontWeight: 400 },
}, },
components: { }
MuiCssBaseline: { : {
styleOverrides: { text: {
body: { primary: '#1a1a1a',
fontFamily: "var(--font-plus-jakarta), 'Plus Jakarta Sans', sans-serif", secondary: '#4a5568',
},
},
},
MuiTypography: {
styleOverrides: {
root: ({ theme }) => ({
...(theme.palette.mode === 'light' && {
color: theme.palette.text.primary,
}),
}),
},
},
}, },
}), background: {
[safeMode] default: '#ffffff',
); paper: '#ffffff',
},
}),
},
typography: {
fontFamily: '"Plus Jakarta Sans", "Helvetica", "Arial", sans-serif',
h1: { fontWeight: 600 },
h6: { fontWeight: 600 },
body1: { fontSize: '0.875rem', fontWeight: 400 },
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
fontFamily:
"var(--font-plus-jakarta), 'Plus Jakarta Sans', sans-serif",
},
},
},
MuiTypography: {
styleOverrides: {
root: ({ theme }) => ({
...(theme.palette.mode === 'light' && {
color: theme.palette.text.primary,
}),
}),
},
},
},
}),
[safeMode]
);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthInitializer> <AuthInitializer>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
{children} {children}
</ThemeProvider> </ThemeProvider>
</AuthInitializer> </AuthInitializer>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
@import "tailwindcss"; @import 'tailwindcss';
:root { :root {
--background: #ffffff; --background: #ffffff;
@ -28,4 +28,4 @@ body {
100% { 100% {
background-position: 0% 50%; background-position: 0% 50%;
} }
} }

View File

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

View File

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

View File

@ -3,33 +3,33 @@ import '@testing-library/jest-dom';
// Mock Next.js router // Mock Next.js router
jest.mock('next/navigation', () => ({ jest.mock('next/navigation', () => ({
useRouter() { useRouter() {
return { return {
push: jest.fn(), push: jest.fn(),
replace: jest.fn(), replace: jest.fn(),
prefetch: jest.fn(), prefetch: jest.fn(),
back: jest.fn(), back: jest.fn(),
}; };
}, },
usePathname() { usePathname() {
return ''; return '';
}, },
useSearchParams() { useSearchParams() {
return new URLSearchParams(); return new URLSearchParams();
}, },
})); }));
// Mock window.matchMedia // Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: jest.fn().mockImplementation(query => ({ value: jest.fn().mockImplementation((query) => ({
matches: false, matches: false,
media: query, media: query,
onchange: null, onchange: null,
addListener: jest.fn(), addListener: jest.fn(),
removeListener: jest.fn(), removeListener: jest.fn(),
addEventListener: jest.fn(), addEventListener: jest.fn(),
removeEventListener: jest.fn(), removeEventListener: jest.fn(),
dispatchEvent: jest.fn(), dispatchEvent: jest.fn(),
})), })),
}); });

View File

@ -1,16 +1,17 @@
import { LicenseInfo } from '@mui/x-license'; import { LicenseInfo } from '@mui/x-license';
const PERPETUAL_LICENSE_KEY = 'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y'; const PERPETUAL_LICENSE_KEY =
const ALTERNATIVE_LICENSE_KEY = '61628ce74db2c1b62783a6d438593bc5Tz1NVUktRG9jLEU9MTY4MzQ0NzgyMTI4NCxTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLEtWPTI='; 'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
const ALTERNATIVE_LICENSE_KEY =
'61628ce74db2c1b62783a6d438593bc5Tz1NVUktRG9jLEU9MTY4MzQ0NzgyMTI4NCxTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLEtWPTI=';
try { try {
LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY); LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY);
} catch (error) { } catch (error) {
console.warn('Failed to set perpetual license key', error); console.warn('Failed to set perpetual license key', error);
try { try {
LicenseInfo.setLicenseKey(ALTERNATIVE_LICENSE_KEY); LicenseInfo.setLicenseKey(ALTERNATIVE_LICENSE_KEY);
} catch (fallbackError) { } catch (fallbackError) {
console.error('Failed to set fallback license key', fallbackError); console.error('Failed to set fallback license key', fallbackError);
} }
} }

View File

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

31718
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,69 +1,85 @@
{ {
"name": "portal-web-v2", "name": "portal-web-v2",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage" "test:coverage": "jest --coverage",
}, "storybook": "storybook dev -p 6006",
"dependencies": { "build-storybook": "storybook build"
"@emotion/cache": "^11.14.0", },
"@emotion/react": "^11.14.0", "dependencies": {
"@emotion/server": "^11.11.0", "@emotion/cache": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/react": "^11.14.0",
"@hookform/resolvers": "^5.2.2", "@emotion/server": "^11.11.0",
"@mui/icons-material": "^7.3.6", "@emotion/styled": "^11.14.1",
"@mui/lab": "^7.0.1-beta.20", "@hookform/resolvers": "^5.2.2",
"@mui/material": "^7.3.6", "@mui/icons-material": "^7.3.6",
"@mui/material-nextjs": "^7.3.6", "@mui/lab": "^7.0.1-beta.20",
"@mui/styled-engine-sc": "^6.0.0-alpha.1", "@mui/material": "^7.3.6",
"@mui/x-data-grid": "^8.23.0", "@mui/material-nextjs": "^7.3.6",
"@mui/x-data-grid-generator": "^8.23.0", "@mui/styled-engine-sc": "^6.0.0-alpha.1",
"@mui/x-data-grid-premium": "^8.23.0", "@mui/x-data-grid": "^8.23.0",
"@mui/x-data-grid-pro": "^8.23.0", "@mui/x-data-grid-generator": "^8.23.0",
"@mui/x-date-pickers": "^8.23.0", "@mui/x-data-grid-premium": "^8.23.0",
"@mui/x-license": "^8.23.0", "@mui/x-data-grid-pro": "^8.23.0",
"@mui/x-tree-view": "^8.23.0", "@mui/x-date-pickers": "^8.23.0",
"@popperjs/core": "^2.11.8", "@mui/x-license": "^8.23.0",
"@reduxjs/toolkit": "^1.9.7", "@mui/x-tree-view": "^8.23.0",
"@tabler/icons-react": "^2.47.0", "@popperjs/core": "^2.11.8",
"@tanstack/react-query": "^5.90.12", "@reduxjs/toolkit": "^1.9.7",
"@xstate/react": "^6.0.0", "@tabler/icons-react": "^2.47.0",
"axios": "^1.13.2", "@tanstack/react-query": "^5.90.12",
"date-fns": "^4.1.0", "@xstate/react": "^6.0.0",
"jest": "^30.2.0", "axios": "^1.13.2",
"lodash": "^4.17.21", "date-fns": "^4.1.0",
"moment": "^2.29.4", "jest": "^30.2.0",
"next": "16.1.1", "lodash": "^4.17.21",
"next-auth": "latest", "moment": "^2.29.4",
"nuqs": "^2.8.6", "next": "16.1.1",
"react": "19.2.3", "next-auth": "latest",
"react-big-calendar": "^1.19.4", "nuqs": "^2.8.6",
"react-dom": "19.2.3", "prettier": "^3.7.4",
"react-hook-form": "^7.69.0", "react": "19.2.3",
"react-number-format": "^5.4.4", "react-big-calendar": "^1.19.4",
"simplebar": "^6.3.3", "react-dom": "19.2.3",
"simplebar-react": "^3.3.2", "react-hook-form": "^7.69.0",
"stimulsoft-reports-js": "^2026.1.1", "react-number-format": "^5.4.4",
"xstate": "^5.25.0", "simplebar": "^6.3.3",
"zod": "^4.2.1", "simplebar-react": "^3.3.2",
"zustand": "^5.0.9" "stimulsoft-reports-js": "^2026.1.1",
}, "xstate": "^5.25.0",
"devDependencies": { "zod": "^4.2.1",
"@tailwindcss/postcss": "^4", "zustand": "^5.0.9"
"@types/lodash": "^4.17.21", },
"@types/node": "^20", "devDependencies": {
"@types/react": "^19", "@tailwindcss/postcss": "^4",
"@types/react-big-calendar": "^1.16.3", "@types/lodash": "^4.17.21",
"@types/react-dom": "^19", "@types/node": "^20",
"eslint": "^9", "@types/react": "^19",
"eslint-config-next": "16.1.1", "@types/react-big-calendar": "^1.16.3",
"tailwindcss": "^4", "@types/react-dom": "^19",
"typescript": "^5" "eslint": "^9",
} "eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "^5",
"storybook": "^10.1.11",
"@storybook/nextjs-vite": "^10.1.11",
"@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/addon-a11y": "^10.1.11",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-onboarding": "^10.1.11",
"vite": "^7.3.1",
"eslint-plugin-storybook": "^10.1.11",
"vitest": "^4.0.17",
"playwright": "^1.57.0",
"@vitest/browser-playwright": "^4.0.17",
"@vitest/coverage-v8": "^4.0.17"
}
} }

View File

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

View File

@ -5,12 +5,12 @@ import Typography from '@mui/material/Typography';
import DashboardOverview from './DashboardOverview'; import DashboardOverview from './DashboardOverview';
export default function Dashboard() { export default function Dashboard() {
return ( return (
<Box sx={{ p: 4 }}> <Box sx={{ p: 4 }}>
<Typography variant="h4" fontWeight="700" mb={3}> <Typography variant="h4" fontWeight="700" mb={3}>
Dashboard Dashboard
</Typography> </Typography>
<DashboardOverview /> <DashboardOverview />
</Box> </Box>
); );
} }

View File

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

View File

@ -6,35 +6,34 @@ import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent'; import CardContent from '@mui/material/CardContent';
export default function DashboardOverview() { export default function DashboardOverview() {
const handlePortalTorreClick = () => { const handlePortalTorreClick = () => {
window.open('https://portaltorre.jurunense.com/', '_blank'); window.open('https://portaltorre.jurunense.com/', '_blank');
}; };
return ( return (
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 4 }}> <Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card <Card
onClick={handlePortalTorreClick} onClick={handlePortalTorreClick}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.3s ease', transition: 'all 0.3s ease',
'&:hover': { '&:hover': {
transform: 'translateY(-4px)', transform: 'translateY(-4px)',
boxShadow: 6, boxShadow: 6,
}, },
}} }}
> >
<CardContent> <CardContent>
<Typography variant="h6" fontWeight="600" mb={1}> <Typography variant="h6" fontWeight="600" mb={1}>
Portal Torre Portal Torre
</Typography> </Typography>
<Typography variant="body2" color="textSecondary"> <Typography variant="body2" color="textSecondary">
Acesse o Portal Torre de Controle Acesse o Portal Torre de Controle
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
</Grid> </Grid>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import {
Chip, Chip,
} from '@mui/material'; } from '@mui/material';
import * as dropdownData from './data'; import * as dropdownData from './data';
import Scrollbar from '../components/Scrollbar'; import { Scrollbar } from '@/shared/components';
import { IconBellRinging } from '@tabler/icons-react'; import { IconBellRinging } from '@tabler/icons-react';
import { Stack } from '@mui/system'; 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> <Typography variant="h6">Notifications</Typography>
<Chip label="5 new" color="primary" size="small" /> <Chip label="5 new" color="primary" size="small" />
</Stack> </Stack>
@ -108,7 +114,13 @@ const Notifications = () => {
))} ))}
</Scrollbar> </Scrollbar>
<Box p={3} pb={1}> <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 See all Notifications
</Button> </Button>
</Box> </Box>

View File

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

View File

@ -20,7 +20,7 @@ interface MenuType {
title: string; title: string;
id: string; id: string;
subheader: string; subheader: string;
children: MenuType[]; children: MenuType[];
href: string; href: string;
} }
@ -36,7 +36,9 @@ const Search = () => {
const filterRoutes = (rotr: any, cSearch: string) => { const filterRoutes = (rotr: any, cSearch: string) => {
if (rotr.length > 1) if (rotr.length > 1)
return rotr.filter((t: any) => return rotr.filter((t: any) =>
t.title ? t.href.toLocaleLowerCase().includes(cSearch.toLocaleLowerCase()) : '', t.title
? t.href.toLocaleLowerCase().includes(cSearch.toLocaleLowerCase())
: ''
); );
return rotr; return rotr;
@ -89,7 +91,11 @@ const Search = () => {
return ( return (
<Box key={menu.title ? menu.id : menu.subheader}> <Box key={menu.title ? menu.id : menu.subheader}>
{menu.title && !menu.children ? ( {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 <ListItemText
primary={menu.title} primary={menu.title}
secondary={menu?.href} secondary={menu?.href}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,7 +43,9 @@ const SidebarItems = ({ open, onItemClick }: SidebarItemsProps) => {
{Menuitems.map((item) => { {Menuitems.map((item) => {
// SubHeader // SubHeader
if (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 // If Sub Menu
} else if (item.children) { } else if (item.children) {

View File

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

View File

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

View File

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

View File

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

View File

@ -26,27 +26,29 @@ export const useCustomizerStore = create<CustomizerState>()(
isHydrated: false, isHydrated: false,
setDarkMode: (mode) => set({ activeMode: mode }), setDarkMode: (mode) => set({ activeMode: mode }),
toggleSidebar: () => set((state) => ({ toggleSidebar: () =>
isCollapse: !state.isCollapse set((state) => ({
})), isCollapse: !state.isCollapse,
})),
toggleMobileSidebar: () => set((state) => ({
isMobileSidebar: !state.isMobileSidebar toggleMobileSidebar: () =>
})), set((state) => ({
isMobileSidebar: !state.isMobileSidebar,
})),
setHydrated: () => set({ isHydrated: true }), setHydrated: () => set({ isHydrated: true }),
}), }),
{ {
name: 'modernize-layout-settings', name: 'modernize-layout-settings',
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ partialize: (state) => ({
activeMode: state.activeMode, activeMode: state.activeMode,
isCollapse: state.isCollapse isCollapse: state.isCollapse,
}), }),
onRehydrateStorage: () => (state) => { onRehydrateStorage: () => (state) => {
state?.setHydrated(); state?.setHydrated();
}, },
} }
) )
); );

View File

@ -1,37 +1,42 @@
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; import axios, {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig,
} from 'axios';
import { getAccessToken, handleTokenRefresh } from './utils/tokenRefresh'; import { getAccessToken, handleTokenRefresh } from './utils/tokenRefresh';
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL; const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL;
export const authApi: AxiosInstance = axios.create({ export const authApi: AxiosInstance = axios.create({
baseURL: AUTH_API_URL, baseURL: AUTH_API_URL,
withCredentials: true, withCredentials: true,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
const addToken = (config: InternalAxiosRequestConfig) => { const addToken = (config: InternalAxiosRequestConfig) => {
if (globalThis.window !== undefined) { if (globalThis.window !== undefined) {
const token = getAccessToken(); const token = getAccessToken();
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
}
} }
return config; }
return config;
}; };
authApi.interceptors.request.use(addToken); authApi.interceptors.request.use(addToken);
const handleResponseError = async (error: AxiosError) => { const handleResponseError = async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (!originalRequest) { if (!originalRequest) {
throw error; throw error;
} }
return handleTokenRefresh(error, originalRequest, authApi); return handleTokenRefresh(error, originalRequest, authApi);
}; };
authApi.interceptors.response.use((response) => response, handleResponseError); authApi.interceptors.response.use((response) => response, handleResponseError);

View File

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

View File

@ -1,150 +1,157 @@
"use client" 'use client';
import Box from "@mui/material/Box"; import Box from '@mui/material/Box';
import Button from "@mui/material/Button"; import Button from '@mui/material/Button';
import Divider from "@mui/material/Divider"; import Divider from '@mui/material/Divider';
import FormControlLabel from "@mui/material/FormControlLabel"; import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from "@mui/material/FormGroup"; import FormGroup from '@mui/material/FormGroup';
import Stack from "@mui/material/Stack"; import Stack from '@mui/material/Stack';
import { TextField, Alert } from "@mui/material"; import { TextField, Alert } from '@mui/material';
import Typography from "@mui/material/Typography"; import Typography from '@mui/material/Typography';
import NextLink from "next/link"; import NextLink from 'next/link';
import { useForm } from "react-hook-form"; import { useForm } from 'react-hook-form';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, LoginInput, AuthLoginProps } from "../interfaces/types"; import { loginSchema, LoginInput, AuthLoginProps } from '../interfaces/types';
import { useAuth } from "../hooks/useAuth"; import { useAuth } from '../hooks/useAuth';
import CustomCheckbox from "../components/forms/theme-elements/CustomCheckbox"; import CustomCheckbox from '../components/forms/theme-elements/CustomCheckbox';
import CustomFormLabel from "../components/forms/theme-elements/CustomFormLabel"; import CustomFormLabel from '../components/forms/theme-elements/CustomFormLabel';
import AuthSocialButtons from "./AuthSocialButtons"; import AuthSocialButtons from './AuthSocialButtons';
const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => { const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
const { loginMutation } = useAuth(); const { loginMutation } = useAuth();
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<LoginInput>({ } = useForm<LoginInput>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
defaultValues: { defaultValues: {
username: "", username: '',
password: "", password: '',
}, },
}); });
const onSubmit = (data: LoginInput) => { const onSubmit = (data: LoginInput) => {
loginMutation.mutate(data); loginMutation.mutate(data);
}; };
return ( return (
<> <>
{title ? ( {title ? (
<Typography fontWeight="700" variant="h4" mb={1}> <Typography fontWeight="700" variant="h4" mb={1}>
{title} {title}
</Typography> </Typography>
) : null} ) : null}
{subtext} {subtext}
<AuthSocialButtons title="Sign in with" /> <AuthSocialButtons title="Sign in with" />
<Box mt={4} mb={2}> <Box mt={4} mb={2}>
<Divider> <Divider>
<Typography <Typography
component="span" component="span"
color="textSecondary" color="textSecondary"
variant="h6" variant="h6"
fontWeight="400" fontWeight="400"
position="relative" position="relative"
px={2} px={2}
> >
ou faça login com ou faça login com
</Typography> </Typography>
</Divider> </Divider>
</Box> </Box>
{loginMutation.isError && ( {loginMutation.isError && (
<Box mt={3}> <Box mt={3}>
<Alert severity="error"> <Alert severity="error">
{(() => { {(() => {
const error = loginMutation.error; const error = loginMutation.error;
if (error && typeof error === 'object' && 'response' in error) { if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
return axiosError.response?.data?.message || 'Erro ao realizar login'; response?: { data?: { message?: string } };
} };
return 'Erro ao realizar login'; return (
})()} axiosError.response?.data?.message || 'Erro ao realizar login'
</Alert> );
</Box> }
)} return 'Erro ao realizar login';
})()}
</Alert>
</Box>
)}
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Stack> <Stack>
<Box> <Box>
<CustomFormLabel htmlFor="username">Usuário</CustomFormLabel> <CustomFormLabel htmlFor="username">Usuário</CustomFormLabel>
<TextField <TextField
id="username" id="username"
variant="outlined" variant="outlined"
fullWidth fullWidth
{...register('username')} {...register('username')}
error={!!errors.username} error={!!errors.username}
helperText={errors.username?.message} helperText={errors.username?.message}
/> />
</Box> </Box>
<Box> <Box>
<CustomFormLabel htmlFor="password">Senha</CustomFormLabel> <CustomFormLabel htmlFor="password">Senha</CustomFormLabel>
<TextField <TextField
id="password" id="password"
type="password" type="password"
variant="outlined" variant="outlined"
fullWidth fullWidth
{...register('password')} {...register('password')}
error={!!errors.password} error={!!errors.password}
helperText={errors.password?.message} helperText={errors.password?.message}
/> />
</Box> </Box>
<Stack <Stack
justifyContent="space-between" justifyContent="space-between"
direction="row" direction="row"
alignItems="center" alignItems="center"
my={2} my={2}
spacing={1} spacing={1}
flexWrap="wrap" flexWrap="wrap"
> >
<FormGroup> <FormGroup>
<FormControlLabel <FormControlLabel
control={<CustomCheckbox defaultChecked />} control={<CustomCheckbox defaultChecked />}
label="Manter-me conectado" label="Manter-me conectado"
sx={{ whiteSpace: 'nowrap' }} sx={{ whiteSpace: 'nowrap' }}
/> />
</FormGroup> </FormGroup>
<Typography <Typography
fontWeight="500" fontWeight="500"
sx={{ sx={{
textDecoration: "none", textDecoration: 'none',
color: "primary.main", color: 'primary.main',
}} }}
> >
<NextLink href="/" style={{ textDecoration: 'none', color: 'inherit' }}> <NextLink
Esqueceu sua senha ? href="/"
</NextLink> style={{ textDecoration: 'none', color: 'inherit' }}
</Typography> >
</Stack> Esqueceu sua senha ?
</Stack> </NextLink>
<Box> </Typography>
<Button </Stack>
color="primary" </Stack>
variant="contained" <Box>
size="large" <Button
fullWidth color="primary"
type="submit" variant="contained"
disabled={loginMutation.isPending} size="large"
> fullWidth
{loginMutation.isPending ? 'Logging in...' : 'Sign In'} type="submit"
</Button> disabled={loginMutation.isPending}
</Box> >
</form> {loginMutation.isPending ? 'Logging in...' : 'Sign In'}
{subtitle} </Button>
</> </Box>
); </form>
{subtitle}
</>
);
}; };
export default AuthLogin; export default AuthLogin;

View File

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

View File

@ -7,42 +7,41 @@ import { profileService } from '../../profile/services/profile.service';
import { mapToSafeProfile } from '../utils/mappers'; import { mapToSafeProfile } from '../utils/mappers';
export function AuthInitializer({ children }: { children: React.ReactNode }) { export function AuthInitializer({ children }: { children: React.ReactNode }) {
const { setUser, logout } = useAuthStore(); const { setUser, logout } = useAuthStore();
const initialized = useRef(false); const initialized = useRef(false);
const [isChecking, setIsChecking] = useState(true); const [isChecking, setIsChecking] = useState(true);
useEffect(() => { useEffect(() => {
if (initialized.current) return; if (initialized.current) return;
initialized.current = true; initialized.current = true;
const validateSession = async () => { const validateSession = async () => {
try { try {
await loginService.refreshToken();
await loginService.refreshToken(); const profile = await profileService.getMe();
setUser(mapToSafeProfile(profile));
} catch (error) {
console.warn('Sessão expirada ou inválida', error);
logout();
} finally {
setIsChecking(false);
}
};
const profile = await profileService.getMe(); validateSession();
setUser(mapToSafeProfile(profile)); }, [setUser, logout]);
} catch (error) {
console.warn('Sessão expirada ou inválida', error);
logout();
} finally {
setIsChecking(false);
}
};
validateSession(); if (isChecking) {
}, [setUser, logout]); return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="animate-pulse flex flex-col items-center gap-4">
<div className="h-12 w-12 rounded-full bg-primary/20" />
<p className="text-sm text-muted-foreground">Validando acesso...</p>
</div>
</div>
);
}
if (isChecking) { return <>{children}</>;
return ( }
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="animate-pulse flex flex-col items-center gap-4">
<div className="h-12 w-12 rounded-full bg-primary/20" />
<p className="text-sm text-muted-foreground">Validando acesso...</p>
</div>
</div>
);
}
return <>{children}</>;
}

View File

@ -12,7 +12,8 @@ import AuthLogin from '../authForms/AuthLogin';
const GradientGrid = styled(Grid)(({ theme }) => ({ const GradientGrid = styled(Grid)(({ theme }) => ({
position: 'relative', position: 'relative',
backgroundColor: '#d2f1df', 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%', backgroundSize: '400% 400%',
backgroundPosition: '0% 50%', backgroundPosition: '0% 50%',
animation: 'gradient 15s ease infinite', animation: 'gradient 15s ease infinite',
@ -25,7 +26,12 @@ const GradientGrid = styled(Grid)(({ theme }) => ({
export default function Login() { export default function Login() {
return ( return (
<PageContainer title="Login" description="Página de Login"> <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 }}> <GradientGrid size={{ xs: 12, lg: 7, xl: 8 }}>
<Box <Box
display="flex" display="flex"
@ -54,7 +60,12 @@ export default function Login() {
</Box> </Box>
</GradientGrid> </GradientGrid>
<Grid size={{ xs: 12, lg: 5, xl: 4 }}> <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' }}> <Box p={4} sx={{ width: '100%', maxWidth: '450px' }}>
<AuthLogin <AuthLogin
subtitle={ subtitle={
@ -66,7 +77,14 @@ export default function Login() {
color: 'primary.main', 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 Criar uma conta
</NextLink> </NextLink>
</Typography> </Typography>
@ -78,5 +96,5 @@ export default function Login() {
</Grid> </Grid>
</Grid> </Grid>
</PageContainer> </PageContainer>
) );
}; }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { styled } from "@mui/system"; import { styled } from '@mui/system';
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox'; import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
const BpIcon = styled('span')(({ theme }) => ({ const BpIcon = styled('span')(({ theme }) => ({
@ -21,7 +21,10 @@ const BpIcon = styled('span')(({ theme }) => ({
outlineOffset: 2, outlineOffset: 2,
}, },
'input:hover ~ &': { '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 ~ &': { 'input:disabled ~ &': {
boxShadow: 'none', boxShadow: 'none',
@ -54,7 +57,9 @@ function CustomCheckbox(props: CheckboxProps) {
checkedIcon={ checkedIcon={
<BpCheckedIcon <BpCheckedIcon
sx={{ 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 React from 'react';
import { styled } from "@mui/system"; import { styled } from '@mui/system';
import { Typography } from '@mui/material'; import { Typography } from '@mui/material';
const CustomFormLabel = styled((props: any) => ( const CustomFormLabel = styled((props: any) => (

View File

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

View File

@ -2,18 +2,20 @@ import React from 'react';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import { TextField } from '@mui/material'; import { TextField } from '@mui/material';
const CustomTextField = styled((props: any) => <TextField {...props} />)(({ theme }) => ({ const CustomTextField = styled((props: any) => <TextField {...props} />)(
'& .MuiOutlinedInput-input::-webkit-input-placeholder': { ({ theme }) => ({
color: theme.palette.text.secondary, '& .MuiOutlinedInput-input::-webkit-input-placeholder': {
opacity: '0.8', color: theme.palette.text.secondary,
}, opacity: '0.8',
'& .MuiOutlinedInput-input.Mui-disabled::-webkit-input-placeholder': { },
color: theme.palette.text.secondary, '& .MuiOutlinedInput-input.Mui-disabled::-webkit-input-placeholder': {
opacity: '1', color: theme.palette.text.secondary,
}, opacity: '1',
'& .Mui-disabled .MuiOutlinedInput-notchedOutline': { },
borderColor: theme.palette.grey[200], '& .Mui-disabled .MuiOutlinedInput-notchedOutline': {
}, borderColor: theme.palette.grey[200],
})); },
})
);
export default CustomTextField; 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) Base URL: /api/v1/auth (ou /api/auth)
1. Realizar Login 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 Método: POST
Endpoint: /login Endpoint: /login
Content-Type: application/json Content-Type: application/json
Request Body Request Body
{ {
"username": "usuario.sistema", "username": "usuario.sistema",
"password": "senha_secreta" "password": "senha_secreta"
} }
Response (200 OK) Response (200 OK)
Retorna o Access Token no corpo e configura o Refresh Token como um Cookie HttpOnly. Retorna o Access Token no corpo e configura o Refresh Token como um Cookie HttpOnly.
{ {
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"type": "Bearer", "type": "Bearer",
"expiresIn": 900, "expiresIn": 900,
"refreshToken": "550e8400-e29b-41d4-a716-446655440000", "refreshToken": "550e8400-e29b-41d4-a716-446655440000",
"username": "usuario.sistema" "username": "usuario.sistema"
} }
Cookies Set: Cookies Set:
@ -30,8 +30,7 @@ refreshToken
: UUID do refresh token (HttpOnly, Secure, 7 dias) : UUID do refresh token (HttpOnly, Secure, 7 dias)
Erros Comuns Erros Comuns
400 Bad Request: Campos obrigatórios ausentes. 400 Bad Request: Campos obrigatórios ausentes.
401/500: Usuário ou senha inválidos. 401/500: Usuário ou senha inválidos. 2. Renovar Token (Refresh)
2. Renovar Token (Refresh)
Gera um novo par de tokens usando um Refresh Token válido. Gera um novo par de tokens usando um Refresh Token válido.
Método: POST Método: POST
@ -41,24 +40,23 @@ O Refresh Token pode ser enviado de duas formas (nesta ordem de prioridade):
Body JSON: Body JSON:
{ {
"refreshToken": "550e8400-e29b-41d4-a716-446655440000" "refreshToken": "550e8400-e29b-41d4-a716-446655440000"
} }
Cookie Cookie
refreshToken refreshToken
: Enviado automaticamente pelo navegador. : Enviado automaticamente pelo navegador.
Response (200 OK) Response (200 OK)
Retorna novos tokens e atualiza o cookie. Retorna novos tokens e atualiza o cookie.
{ {
"token": "novatoken...", "token": "novatoken...",
"type": "Bearer", "type": "Bearer",
"expiresIn": 900, "expiresIn": 900,
"refreshToken": "novorefreshtoken...", "refreshToken": "novorefreshtoken...",
"username": "usuario.sistema" "username": "usuario.sistema"
} }
Erros Comuns Erros Comuns
403 Forbidden: Token expirado ou inválido. 403 Forbidden: Token expirado ou inválido. 3. Obter Usuário Atual (Me)
3. Obter Usuário Atual (Me)
Retorna dados detalhados do usuário logado. Retorna dados detalhados do usuário logado.
Método: GET Método: GET
@ -66,21 +64,20 @@ Endpoint: /me
Headers: Authorization: Bearer <access_token> Headers: Authorization: Bearer <access_token>
Response (200 OK) Response (200 OK)
{ {
"matricula": 12345, "matricula": 12345,
"userName": "usuario.sistema", "userName": "usuario.sistema",
"nome": "João da Silva", "nome": "João da Silva",
"codigoFilial": "1", "codigoFilial": "1",
"nomeFilial": "Matriz", "nomeFilial": "Matriz",
"rca": 100, "rca": 100,
"discountPercent": 0, "discountPercent": 0,
"sectorId": 10, "sectorId": 10,
"sectorManagerId": 50, "sectorManagerId": 50,
"supervisorId": 55 "supervisorId": 55
} }
Erros Comuns Erros Comuns
401 Unauthorized: Token não enviado ou inválido. 401 Unauthorized: Token não enviado ou inválido.
404 Not Found: Usuário não encontrado no banco. 404 Not Found: Usuário não encontrado no banco. 4. Logout
4. Logout
Invalida a sessão atual. Invalida a sessão atual.
Método: POST Método: POST
@ -90,16 +87,16 @@ Request (Opcional)
Pode enviar o refresh token no corpo para forçar sua invalidação, caso não esteja no cookie. Pode enviar o refresh token no corpo para forçar sua invalidação, caso não esteja no cookie.
{ {
"refreshToken": "..." "refreshToken": "..."
} }
Response (200 OK) Response (200 OK)
{ {
"message": "Logout realizado com sucesso" "message": "Logout realizado com sucesso"
} }
Effect: Effect:
Adiciona o Access Token à Blacklist (Redis). Adiciona o Access Token à Blacklist (Redis).
Remove o Refresh Token do banco. Remove o Refresh Token do banco.
Remove o Cookie Remove o Cookie
refreshToken refreshToken
. .

View File

@ -18,29 +18,30 @@ export function useAuth() {
try { try {
const profile = await profileService.getMe(); const profile = await profileService.getMe();
const safeProfile = mapToSafeProfile(profile); const safeProfile = mapToSafeProfile(profile);
setUser(safeProfile); setUser(safeProfile);
queryClient.setQueryData(['auth-me'], safeProfile); queryClient.setQueryData(['auth-me'], safeProfile);
router.push('/dashboard'); router.push('/dashboard');
} catch (error) { } catch (error) {
console.error('Falha ao carregar perfil:', error); console.error('Falha ao carregar perfil:', error);
logout(); logout();
} }
}, },
}); });
const useMe = () => useQuery({ const useMe = () =>
queryKey: ['auth-me'], useQuery({
queryFn: async () => { queryKey: ['auth-me'],
const data = await profileService.getMe(); queryFn: async () => {
const safeData = mapToSafeProfile(data); const data = await profileService.getMe();
setUser(safeData); const safeData = mapToSafeProfile(data);
return safeData; setUser(safeData);
}, return safeData;
retry: false, },
staleTime: Infinity, retry: false,
}); staleTime: Infinity,
});
const logout = async () => { const logout = async () => {
try { try {
@ -48,10 +49,10 @@ export function useAuth() {
} finally { } finally {
clearAuthData(); clearAuthData();
logoutStore(); logoutStore();
queryClient.clear(); queryClient.clear();
globalThis.location.href = '/'; globalThis.location.href = '/';
} }
}; };
return { loginMutation, useMe, logout }; return { loginMutation, useMe, logout };
} }

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 { ReactNode } from 'react';
import type { UserProfile } from '../../profile/types'; 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 LoginInput = z.infer<typeof loginSchema>;
export type TokenResponse = z.infer<typeof tokenResponseSchema>; export type TokenResponse = z.infer<typeof tokenResponseSchema>;
@ -35,4 +43,4 @@ export interface AuthState {
setUser: (user: UserProfile | null) => void; setUser: (user: UserProfile | null) => void;
logout: () => void; logout: () => void;
hydrate: () => void; hydrate: () => void;
} }

View File

@ -1,115 +1,115 @@
import { loginSchema, tokenResponseSchema } from './schemas'; import { loginSchema, tokenResponseSchema } from './schemas';
describe('Login Schemas', () => { describe('Login Schemas', () => {
describe('loginSchema', () => { describe('loginSchema', () => {
it('deve validar credenciais válidas', () => { it('deve validar credenciais válidas', () => {
const result = loginSchema.safeParse({ const result = loginSchema.safeParse({
username: 'user123', username: 'user123',
password: 'pass1234', password: 'pass1234',
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) { if (result.success) {
expect(result.data.username).toBe('user123'); expect(result.data.username).toBe('user123');
expect(result.data.password).toBe('pass1234'); expect(result.data.password).toBe('pass1234');
} }
});
it('deve rejeitar username muito curto', () => {
const result = loginSchema.safeParse({
username: 'ab',
password: 'pass1234',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('obrigatório');
}
});
it('deve rejeitar senha muito curta', () => {
const result = loginSchema.safeParse({
username: 'user123',
password: '123',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('mínimo 4 caracteres');
}
});
// 🐛 TESTE QUE REVELA BUG: Senha muito fraca é aceita
it('🐛 BUG: deve rejeitar senhas fracas (menos de 8 caracteres)', () => {
const result = loginSchema.safeParse({
username: 'user123',
password: '1234', // Apenas 4 caracteres - muito fraco!
});
// ❌ ESTE TESTE VAI FALHAR - senha fraca é aceita!
expect(result.success).toBe(false);
});
it('deve rejeitar username vazio', () => {
const result = loginSchema.safeParse({
username: '',
password: 'pass1234',
});
expect(result.success).toBe(false);
});
it('deve rejeitar password vazio', () => {
const result = loginSchema.safeParse({
username: 'user123',
password: '',
});
expect(result.success).toBe(false);
});
}); });
describe('tokenResponseSchema', () => { it('deve rejeitar username muito curto', () => {
it('deve validar resposta de token válida', () => { const result = loginSchema.safeParse({
const result = tokenResponseSchema.safeParse({ username: 'ab',
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', password: 'pass1234',
type: 'Bearer', });
expiresIn: 3600,
username: 'user123',
});
expect(result.success).toBe(true); expect(result.success).toBe(false);
}); if (!result.success) {
expect(result.error.issues[0].message).toContain('obrigatório');
it('deve rejeitar token sem campos obrigatórios', () => { }
const result = tokenResponseSchema.safeParse({
token: 'some-token',
// faltando type, expiresIn, username
});
expect(result.success).toBe(false);
});
it('deve rejeitar expiresIn negativo', () => {
const result = tokenResponseSchema.safeParse({
token: 'some-token',
type: 'Bearer',
expiresIn: -100,
username: 'user123',
});
expect(result.success).toBe(false);
});
it('deve rejeitar expiresIn zero', () => {
const result = tokenResponseSchema.safeParse({
token: 'some-token',
type: 'Bearer',
expiresIn: 0,
username: 'user123',
});
expect(result.success).toBe(false);
});
}); });
it('deve rejeitar senha muito curta', () => {
const result = loginSchema.safeParse({
username: 'user123',
password: '123',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('mínimo 4 caracteres');
}
});
// 🐛 TESTE QUE REVELA BUG: Senha muito fraca é aceita
it('🐛 BUG: deve rejeitar senhas fracas (menos de 8 caracteres)', () => {
const result = loginSchema.safeParse({
username: 'user123',
password: '1234', // Apenas 4 caracteres - muito fraco!
});
// ❌ ESTE TESTE VAI FALHAR - senha fraca é aceita!
expect(result.success).toBe(false);
});
it('deve rejeitar username vazio', () => {
const result = loginSchema.safeParse({
username: '',
password: 'pass1234',
});
expect(result.success).toBe(false);
});
it('deve rejeitar password vazio', () => {
const result = loginSchema.safeParse({
username: 'user123',
password: '',
});
expect(result.success).toBe(false);
});
});
describe('tokenResponseSchema', () => {
it('deve validar resposta de token válida', () => {
const result = tokenResponseSchema.safeParse({
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
type: 'Bearer',
expiresIn: 3600,
username: 'user123',
});
expect(result.success).toBe(true);
});
it('deve rejeitar token sem campos obrigatórios', () => {
const result = tokenResponseSchema.safeParse({
token: 'some-token',
// faltando type, expiresIn, username
});
expect(result.success).toBe(false);
});
it('deve rejeitar expiresIn negativo', () => {
const result = tokenResponseSchema.safeParse({
token: 'some-token',
type: 'Bearer',
expiresIn: -100,
username: 'user123',
});
expect(result.success).toBe(false);
});
it('deve rejeitar expiresIn zero', () => {
const result = tokenResponseSchema.safeParse({
token: 'some-token',
type: 'Bearer',
expiresIn: 0,
username: 'user123',
});
expect(result.success).toBe(false);
});
});
}); });

View File

@ -1,25 +1,25 @@
import { z } from 'zod'; import { z } from 'zod';
export const loginSchema = z.object({ export const loginSchema = z.object({
username: z.string().min(3, 'Usuário é obrigatório'), username: z.string().min(3, 'Usuário é obrigatório'),
password: z.string().min(4, 'Senha deve ter no mínimo 4 caracteres'), password: z.string().min(4, 'Senha deve ter no mínimo 4 caracteres'),
}); });
/** /**
* Schema de resposta de autenticação * Schema de resposta de autenticação
* *
* Arquitetura Híbrida: * Arquitetura Híbrida:
* - accessToken: Retornado no body, armazenado em memória * - accessToken: Retornado no body, armazenado em memória
* - refreshToken: Enviado via cookie HTTP-only (não acessível ao JS) * - refreshToken: Enviado via cookie HTTP-only (não acessível ao JS)
*/ */
export const tokenResponseSchema = z.object({ export const tokenResponseSchema = z.object({
token: z.string(), token: z.string(),
type: z.string(), type: z.string(),
expiresIn: z.number().positive(), expiresIn: z.number().positive(),
username: z.string(), username: z.string(),
// refreshToken removido - agora apenas em cookie HTTP-only // refreshToken removido - agora apenas em cookie HTTP-only
}); });
export const logoutResponseSchema = z.object({ export const logoutResponseSchema = z.object({
message: z.string(), message: z.string(),
}); });

View File

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

View File

@ -6,12 +6,12 @@ import { clearAuthData } from '../utils/tokenRefresh';
/** /**
* Store de autenticação usando apenas dados não sensíveis * Store de autenticação usando apenas dados não sensíveis
* *
* Arquitetura Híbrida: * Arquitetura Híbrida:
* - accessToken: Armazenado em memória (tokenRefresh.ts) * - accessToken: Armazenado em memória (tokenRefresh.ts)
* - refreshToken: Gerenciado via cookies HTTP-only pelo backend * - refreshToken: Gerenciado via cookies HTTP-only pelo backend
* - user profile: Persistido no localStorage (dados não sensíveis) * - user profile: Persistido no localStorage (dados não sensíveis)
* *
* O método hydrate() valida a sincronização entre token e estado ao recarregar * O método hydrate() valida a sincronização entre token e estado ao recarregar
*/ */
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@ -31,8 +31,7 @@ export const useAuthStore = create<AuthState>()(
* Valida sincronização entre token em memória e estado persistido * Valida sincronização entre token em memória e estado persistido
* Chamado automaticamente ao recarregar a página (onRehydrateStorage) * Chamado automaticamente ao recarregar a página (onRehydrateStorage)
*/ */
hydrate: () => { hydrate: () => {},
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',
@ -45,4 +44,4 @@ export const useAuthStore = create<AuthState>()(
}, },
} }
) )
); );

View File

@ -1,17 +1,16 @@
import { UserProfile } from '../../profile/types'; import { UserProfile } from '../../profile/types';
export const mapToSafeProfile = (data: any): UserProfile => { export const mapToSafeProfile = (data: any): UserProfile => {
return { return {
matricula: data.matricula, matricula: data.matricula,
userName: data.userName, userName: data.userName,
nome: data.nome, nome: data.nome,
codigoFilial: data.codigoFilial, codigoFilial: data.codigoFilial,
nomeFilial: data.nomeFilial, nomeFilial: data.nomeFilial,
rca: data.rca, rca: data.rca,
discountPercent: data.discountPercent, discountPercent: data.discountPercent,
sectorId: data.sectorId, sectorId: data.sectorId,
sectorManagerId: data.sectorManagerId, sectorManagerId: data.sectorManagerId,
supervisorId: data.supervisorId, supervisorId: data.supervisorId,
}; };
}; };

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'; import { TokenResponse } from '../interfaces/types';
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL; const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL;
@ -62,7 +67,10 @@ export function handleTokenRefresh<T = unknown>(
throw error; 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; throw error;
} }

View File

@ -1,5 +1,12 @@
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; import axios, {
import { getAccessToken, handleTokenRefresh } from '../../login/utils/tokenRefresh'; AxiosError,
AxiosInstance,
InternalAxiosRequestConfig,
} from 'axios';
import {
getAccessToken,
handleTokenRefresh,
} from '../../login/utils/tokenRefresh';
import { import {
OrderFilters, OrderFilters,
orderApiParamsSchema, orderApiParamsSchema,
@ -8,9 +15,18 @@ import {
storesResponseSchema, storesResponseSchema,
customersResponseSchema, customersResponseSchema,
sellersResponseSchema, sellersResponseSchema,
unwrapApiData unwrapApiData,
} from '../schemas/order.schema'; } 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 { Store } from '../schemas/store.schema';
import { Seller } from '../schemas/seller.schema'; import { Seller } from '../schemas/seller.schema';
import { Order } from '../types'; import { Order } from '../types';
@ -28,7 +44,7 @@ export const ordersApi: AxiosInstance = axios.create({
/** /**
* Adiciona o token de autenticação aos cabeçalhos da requisição. * Adiciona o token de autenticação aos cabeçalhos da requisição.
* Executa apenas no ambiente do navegador (verifica a existência do objeto window). * Executa apenas no ambiente do navegador (verifica a existência do objeto window).
* *
* @param {InternalAxiosRequestConfig} config - A configuração da requisição Axios * @param {InternalAxiosRequestConfig} config - A configuração da requisição Axios
* @returns {InternalAxiosRequestConfig} A configuração modificada com o cabeçalho Authorization * @returns {InternalAxiosRequestConfig} A configuração modificada com o cabeçalho Authorization
*/ */
@ -47,13 +63,15 @@ ordersApi.interceptors.request.use(addToken);
/** /**
* Trata erros de resposta das requisições da API. * Trata erros de resposta das requisições da API.
* Tenta atualizar o token de autenticação se a requisição falhar. * Tenta atualizar o token de autenticação se a requisição falhar.
* *
* @param {AxiosError} error - O objeto de erro do Axios * @param {AxiosError} error - O objeto de erro do Axios
* @returns {Promise} Resultado da tentativa de atualização do token * @returns {Promise} Resultado da tentativa de atualização do token
* @throws {AxiosError} Se não houver configuração de requisição original disponível * @throws {AxiosError} Se não houver configuração de requisição original disponível
*/ */
const handleResponseError = async (error: AxiosError) => { const handleResponseError = async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (!originalRequest) { if (!originalRequest) {
throw error; throw error;
@ -62,20 +80,25 @@ const handleResponseError = async (error: AxiosError) => {
return handleTokenRefresh(error, originalRequest, ordersApi); return handleTokenRefresh(error, originalRequest, ordersApi);
}; };
ordersApi.interceptors.response.use((response) => response, handleResponseError); ordersApi.interceptors.response.use(
(response) => response,
handleResponseError
);
export const orderService = { export const orderService = {
/** /**
* Busca pedidos com base nos filtros fornecidos. * Busca pedidos com base nos filtros fornecidos.
* Utiliza Zod para limpar e transformar os parâmetros automaticamente. * Utiliza Zod para limpar e transformar os parâmetros automaticamente.
* *
* @param {OrderFilters} filters - Critérios de filtro para buscar pedidos * @param {OrderFilters} filters - Critérios de filtro para buscar pedidos
* @returns {Promise<Order[]>} Array de pedidos que correspondem aos filtros * @returns {Promise<Order[]>} Array de pedidos que correspondem aos filtros
*/ */
findOrders: async (filters: OrderFilters): Promise<Order[]> => { findOrders: async (filters: OrderFilters): Promise<Order[]> => {
try { try {
const cleanParams = orderApiParamsSchema.parse(filters); 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, []); return unwrapApiData(response, ordersResponseSchema, []);
} catch (error) { } catch (error) {
console.error('Erro ao buscar pedidos:', error); console.error('Erro ao buscar pedidos:', error);
@ -85,7 +108,7 @@ export const orderService = {
/** /**
* Busca um pedido específico pelo seu ID. * Busca um pedido específico pelo seu ID.
* *
* @param {number} id - O identificador único do pedido * @param {number} id - O identificador único do pedido
* @returns {Promise<Order | null>} O pedido com o ID especificado, ou null se não encontrado * @returns {Promise<Order | null>} O pedido com o ID especificado, ou null se não encontrado
*/ */
@ -101,7 +124,7 @@ export const orderService = {
/** /**
* Recupera todas as lojas disponíveis. * Recupera todas as lojas disponíveis.
* *
* @returns {Promise<Store[]>} Array de todas as lojas, ou array vazio se nenhuma for encontrada * @returns {Promise<Store[]>} Array de todas as lojas, ou array vazio se nenhuma for encontrada
*/ */
findStores: async (): Promise<Store[]> => { findStores: async (): Promise<Store[]> => {
@ -117,14 +140,18 @@ export const orderService = {
/** /**
* Busca clientes por nome. * Busca clientes por nome.
* Retorna array vazio se o termo de busca tiver menos de 2 caracteres. * Retorna array vazio se o termo de busca tiver menos de 2 caracteres.
* *
* @param {string} name - O nome do cliente a ser buscado (mínimo 2 caracteres) * @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 * @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 []; if (!name || name.trim().length < 2) return [];
try { 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, []); return unwrapApiData(response, customersResponseSchema, []);
} catch (error) { } catch (error) {
console.error('Erro ao buscar clientes:', error); console.error('Erro ao buscar clientes:', error);
@ -151,4 +178,49 @@ export const orderService = {
return []; 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,33 +1,50 @@
'use client'; 'use client';
import TablePagination from '@mui/material/TablePagination'; 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() { function CustomPagination() {
const apiRef = useGridApiContext(); const apiRef = useGridApiContext();
const pageCount = useGridSelector(apiRef, gridPageCountSelector); const pageCount = useGridSelector(apiRef, gridPageCountSelector);
const page = useGridSelector(apiRef, gridPageSelector); const page = useGridSelector(apiRef, gridPageSelector);
const pageSize = useGridSelector(apiRef, gridPageSizeSelector); const pageSize = useGridSelector(apiRef, gridPageSizeSelector);
const rowCount = useGridSelector(apiRef, gridRowCountSelector); const rowCount = useGridSelector(apiRef, gridRowCountSelector);
const labelDisplayedRows = ({ from, to, count }: { from: number; to: number; count: number }) => { const labelDisplayedRows = ({
const currentPage = page + 1; from,
const displayCount = count === -1 ? `mais de ${to}` : count; to,
return `${from}${to} de ${displayCount} | Página ${currentPage} de ${pageCount}`; 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}`;
};
return ( return (
<TablePagination <TablePagination
component="div" component="div"
count={rowCount} count={rowCount}
page={page} page={page}
onPageChange={(event, newPage) => apiRef.current.setPage(newPage)} onPageChange={(event, newPage) => apiRef.current.setPage(newPage)}
rowsPerPage={pageSize} rowsPerPage={pageSize}
onRowsPerPageChange={(event) => apiRef.current.setPageSize(Number.parseInt(event.target.value, 10))} onRowsPerPageChange={(event) =>
labelRowsPerPage="Pedidos por página:" apiRef.current.setPageSize(Number.parseInt(event.target.value, 10))
labelDisplayedRows={labelDisplayedRows} }
/> labelRowsPerPage="Pedidos por página:"
); labelDisplayedRows={labelDisplayedRows}
/>
);
} }
export default CustomPagination; export default CustomPagination;

View File

@ -13,7 +13,7 @@ import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import MoveToInboxIcon from '@mui/icons-material/MoveToInbox'; import MoveToInboxIcon from '@mui/icons-material/MoveToInbox';
import TvIcon from '@mui/icons-material/Tv'; import TvIcon from '@mui/icons-material/Tv';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
import { TabPanel } from './TabPanel'; import { TabPanel } from '@/shared/components';
import { OrderItemsTable } from './tabs/OrderItemsTable'; import { OrderItemsTable } from './tabs/OrderItemsTable';
import { PreBoxPanel } from './tabs/PreBoxPanel'; import { PreBoxPanel } from './tabs/PreBoxPanel';
import { InformationPanel } from './tabs/InformationPanel'; import { InformationPanel } from './tabs/InformationPanel';
@ -25,142 +25,142 @@ import { TV8Panel } from './tabs/TV8Panel';
import { CashAdjustmentPanel } from './tabs/CashAdjustmentPanel'; import { CashAdjustmentPanel } from './tabs/CashAdjustmentPanel';
interface OrderDetailsTabsProps { interface OrderDetailsTabsProps {
orderId: number; orderId: number;
} }
export const OrderDetailsTabs = ({ orderId }: OrderDetailsTabsProps) => { export const OrderDetailsTabs = ({ orderId }: OrderDetailsTabsProps) => {
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue); setActiveTab(newValue);
}; };
return ( return (
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}> <Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs <Tabs
value={activeTab} value={activeTab}
onChange={handleTabChange} onChange={handleTabChange}
variant="scrollable" variant="scrollable"
scrollButtons="auto" scrollButtons="auto"
sx={{ sx={{
'& .MuiTab-root': { '& .MuiTab-root': {
fontSize: '0.875rem', fontSize: '0.875rem',
fontWeight: 500, fontWeight: 500,
textTransform: 'none', textTransform: 'none',
minHeight: 48, minHeight: 48,
color: 'text.secondary', color: 'text.secondary',
'&.Mui-selected': { '&.Mui-selected': {
color: 'primary.main', color: 'primary.main',
fontWeight: 600, fontWeight: 600,
}, },
}, },
'& .MuiTabs-indicator': { '& .MuiTabs-indicator': {
height: 3, height: 3,
}, },
}} }}
> >
<Tab <Tab
icon={<ListAltIcon />} icon={<ListAltIcon />}
iconPosition="start" iconPosition="start"
label="Itens" label="Itens"
id="order-tab-0" id="order-tab-0"
aria-controls="order-tabpanel-0" aria-controls="order-tabpanel-0"
/> />
<Tab <Tab
icon={<InventoryIcon />} icon={<InventoryIcon />}
iconPosition="start" iconPosition="start"
label="Pré-box" label="Pré-box"
id="order-tab-1" id="order-tab-1"
aria-controls="order-tabpanel-1" aria-controls="order-tabpanel-1"
/> />
<Tab <Tab
icon={<InfoIcon />} icon={<InfoIcon />}
iconPosition="start" iconPosition="start"
label="Informações" label="Informações"
id="order-tab-2" id="order-tab-2"
aria-controls="order-tabpanel-2" aria-controls="order-tabpanel-2"
/> />
<Tab <Tab
icon={<TimelineIcon />} icon={<TimelineIcon />}
iconPosition="start" iconPosition="start"
label="Timeline" label="Timeline"
id="order-tab-3" id="order-tab-3"
aria-controls="order-tabpanel-3" aria-controls="order-tabpanel-3"
/> />
<Tab <Tab
icon={<ContentCutIcon />} icon={<ContentCutIcon />}
iconPosition="start" iconPosition="start"
label="Cortes" label="Cortes"
id="order-tab-4" id="order-tab-4"
aria-controls="order-tabpanel-4" aria-controls="order-tabpanel-4"
/> />
<Tab <Tab
icon={<LocalShippingIcon />} icon={<LocalShippingIcon />}
iconPosition="start" iconPosition="start"
label="Entrega" label="Entrega"
id="order-tab-5" id="order-tab-5"
aria-controls="order-tabpanel-5" aria-controls="order-tabpanel-5"
/> />
<Tab <Tab
icon={<MoveToInboxIcon />} icon={<MoveToInboxIcon />}
iconPosition="start" iconPosition="start"
label="Mov. Carga" label="Mov. Carga"
id="order-tab-6" id="order-tab-6"
aria-controls="order-tabpanel-6" aria-controls="order-tabpanel-6"
/> />
<Tab <Tab
icon={<TvIcon />} icon={<TvIcon />}
iconPosition="start" iconPosition="start"
label="TV8" label="TV8"
id="order-tab-7" id="order-tab-7"
aria-controls="order-tabpanel-7" aria-controls="order-tabpanel-7"
/> />
<Tab <Tab
icon={<AccountBalanceWalletIcon />} icon={<AccountBalanceWalletIcon />}
iconPosition="start" iconPosition="start"
label="Acerta Caixa" label="Acerta Caixa"
id="order-tab-8" id="order-tab-8"
aria-controls="order-tabpanel-8" aria-controls="order-tabpanel-8"
/> />
</Tabs> </Tabs>
</Box> </Box>
<TabPanel value={activeTab} index={0}> <TabPanel value={activeTab} index={0}>
<OrderItemsTable orderId={orderId} /> <OrderItemsTable orderId={orderId} />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={1}> <TabPanel value={activeTab} index={1}>
<PreBoxPanel orderId={orderId} /> <PreBoxPanel orderId={orderId} />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={2}> <TabPanel value={activeTab} index={2}>
<InformationPanel orderId={orderId} /> <InformationPanel orderId={orderId} />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={3}> <TabPanel value={activeTab} index={3}>
<TimelinePanel orderId={orderId} /> <TimelinePanel orderId={orderId} />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={4}> <TabPanel value={activeTab} index={4}>
<CuttingPanel orderId={orderId} /> <CuttingPanel orderId={orderId} />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={5}> <TabPanel value={activeTab} index={5}>
<DeliveryPanel orderId={orderId} /> <DeliveryPanel orderId={orderId} />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={6}> <TabPanel value={activeTab} index={6}>
<CargoMovementPanel orderId={orderId} /> <CargoMovementPanel orderId={orderId} />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={7}> <TabPanel value={activeTab} index={7}>
<TV8Panel orderId={orderId} /> <TV8Panel orderId={orderId} />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={8}> <TabPanel value={activeTab} index={8}>
<CashAdjustmentPanel orderId={orderId} /> <CashAdjustmentPanel orderId={orderId} />
</TabPanel> </TabPanel>
</Box> </Box>
); );
}; };

View File

@ -3,164 +3,206 @@ import Typography from '@mui/material/Typography';
import { formatCurrency, formatNumber } from '../utils/orderFormatters'; import { formatCurrency, formatNumber } from '../utils/orderFormatters';
export const createOrderItemsColumns = (): GridColDef[] => [ export const createOrderItemsColumns = (): GridColDef[] => [
{ {
field: 'productId', field: 'productId',
headerName: 'Cód. Produto', headerName: 'Cód. Produto',
width: 110, width: 110,
minWidth: 100, minWidth: 100,
headerAlign: 'right', headerAlign: 'right',
align: 'right', align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => ( renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}> <Typography
{params.value} variant="body2"
</Typography> color="text.primary"
), sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}
}, >
{ {params.value}
field: 'description', </Typography>
headerName: 'Descrição', ),
width: 300, },
minWidth: 250, {
flex: 1, field: 'description',
renderCell: (params: Readonly<GridRenderCellParams>) => ( headerName: 'Descrição',
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem' }} noWrap> width: 300,
{params.value} minWidth: 250,
</Typography> flex: 1,
), renderCell: (params: Readonly<GridRenderCellParams>) => (
}, <Typography
{ variant="body2"
field: 'pacth', color="text.primary"
headerName: 'Unidade', sx={{ fontSize: '0.75rem' }}
width: 100, noWrap
minWidth: 80, >
headerAlign: 'center', {params.value}
align: 'center', </Typography>
renderCell: (params: Readonly<GridRenderCellParams>) => ( ),
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'center' }}> },
{params.value || '-'} {
</Typography> field: 'pacth',
), headerName: 'Unidade',
}, width: 100,
{ minWidth: 80,
field: 'color', headerAlign: 'center',
headerName: 'Cor', align: 'center',
width: 80, renderCell: (params: Readonly<GridRenderCellParams>) => (
minWidth: 70, <Typography
headerAlign: 'center', variant="body2"
align: 'center', color="text.primary"
renderCell: (params: Readonly<GridRenderCellParams>) => ( sx={{ fontSize: '0.75rem', textAlign: 'center' }}
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'center' }}> >
{params.value || '-'} {params.value || '-'}
</Typography> </Typography>
), ),
}, },
{ {
field: 'stockId', field: 'color',
headerName: 'Cód. Estoque', headerName: 'Cor',
width: 110, width: 80,
minWidth: 100, minWidth: 70,
headerAlign: 'right', headerAlign: 'center',
align: 'right', align: 'center',
renderCell: (params: Readonly<GridRenderCellParams>) => ( renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}> <Typography
{params.value} variant="body2"
</Typography> color="text.primary"
), sx={{ fontSize: '0.75rem', textAlign: 'center' }}
}, >
{ {params.value || '-'}
field: 'quantity', </Typography>
headerName: 'Qtd.', ),
width: 90, },
minWidth: 80, {
type: 'number', field: 'stockId',
aggregable: true, headerName: 'Cód. Estoque',
headerAlign: 'right', width: 110,
align: 'right', minWidth: 100,
valueFormatter: (value) => formatNumber(value as number), headerAlign: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => ( align: 'right',
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}> renderCell: (params: Readonly<GridRenderCellParams>) => (
{formatNumber(params.value)} <Typography
</Typography> variant="body2"
), color="text.primary"
}, sx={{ fontSize: '0.75rem', textAlign: 'right' }}
{ >
field: 'salePrice', {params.value}
headerName: 'Preço Unitário', </Typography>
width: 130, ),
minWidth: 120, },
type: 'number', {
headerAlign: 'right', field: 'quantity',
align: 'right', headerName: 'Qtd.',
valueFormatter: (value) => formatCurrency(value as number), width: 90,
renderCell: (params: Readonly<GridRenderCellParams>) => ( minWidth: 80,
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}> type: 'number',
{formatCurrency(params.value)} aggregable: true,
</Typography> headerAlign: 'right',
), align: 'right',
}, valueFormatter: (value) => formatNumber(value as number),
{ renderCell: (params: Readonly<GridRenderCellParams>) => (
field: 'total', <Typography
headerName: 'Valor Total', variant="body2"
width: 130, color="text.primary"
minWidth: 120, sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}
type: 'number', >
aggregable: true, {formatNumber(params.value)}
headerAlign: 'right', </Typography>
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' }}> field: 'salePrice',
{formatCurrency(params.value)} headerName: 'Preço Unitário',
</Typography> width: 130,
), minWidth: 120,
}, type: 'number',
{ headerAlign: 'right',
field: 'deliveryType', align: 'right',
headerName: 'Tipo Entrega', valueFormatter: (value) => formatCurrency(value as number),
width: 140, renderCell: (params: Readonly<GridRenderCellParams>) => (
minWidth: 130, <Typography
renderCell: (params: Readonly<GridRenderCellParams>) => ( variant="body2"
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem' }} noWrap> color="text.primary"
{params.value} sx={{ fontSize: '0.75rem', textAlign: 'right' }}
</Typography> >
), {formatCurrency(params.value)}
}, </Typography>
{ ),
field: 'weight', },
headerName: 'Peso (kg)', {
width: 100, field: 'total',
minWidth: 90, headerName: 'Valor Total',
type: 'number', width: 130,
aggregable: true, minWidth: 120,
headerAlign: 'right', type: 'number',
align: 'right', aggregable: true,
valueFormatter: (value) => formatNumber(value as number), headerAlign: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => ( align: 'right',
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}> valueFormatter: (value) => formatCurrency(value as number),
{formatNumber(params.value)} renderCell: (params: Readonly<GridRenderCellParams>) => (
</Typography> <Typography
), variant="body2"
}, color="text.primary"
{ sx={{ fontSize: '0.75rem', fontWeight: 600, textAlign: 'right' }}
field: 'department', >
headerName: 'Departamento', {formatCurrency(params.value)}
width: 150, </Typography>
minWidth: 140, ),
renderCell: (params: Readonly<GridRenderCellParams>) => ( },
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap> {
{params.value} field: 'deliveryType',
</Typography> headerName: 'Tipo Entrega',
), width: 140,
}, minWidth: 130,
{ renderCell: (params: Readonly<GridRenderCellParams>) => (
field: 'brand', <Typography
headerName: 'Marca', variant="body2"
width: 150, color="text.primary"
minWidth: 140, sx={{ fontSize: '0.75rem' }}
renderCell: (params: Readonly<GridRenderCellParams>) => ( noWrap
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap> >
{params.value} {params.value}
</Typography> </Typography>
), ),
}, },
{
field: 'weight',
headerName: 'Peso (kg)',
width: 100,
minWidth: 90,
type: 'number',
aggregable: true,
headerAlign: 'right',
align: 'right',
valueFormatter: (value) => formatNumber(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography
variant="body2"
color="text.primary"
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
>
{formatNumber(params.value)}
</Typography>
),
},
{
field: 'department',
headerName: 'Departamento',
width: 150,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'brand',
headerName: 'Marca',
width: 150,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{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'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState, useEffect } from 'react';
import { SearchBar } from './SearchBar'; import { SearchBar } from './SearchBar';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Paper from '@mui/material/Paper'; 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 { useOrders } from '../hooks/useOrders';
import { useStores } from '../store/useStores';
import { createOrderColumns } from './OrderTableColumns'; import { createOrderColumns } from './OrderTableColumns';
import { calculateTableHeight } from '../utils/tableHelpers'; import { calculateTableHeight } from '../utils/tableHelpers';
import { normalizeOrder } from '../utils/orderNormalizer'; import { normalizeOrder } from '../utils/orderNormalizer';
@ -17,21 +21,41 @@ import { OrderDetailsTabs } from './OrderDetailsTabs';
export const OrderTable = () => { export const OrderTable = () => {
const { data: orders, isLoading, error } = useOrders(); 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); 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(() => { const rows = useMemo(() => {
if (!Array.isArray(orders) || orders.length === 0) return []; 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]); }, [orders]);
const columns = useMemo( const columns = useMemo(() => createOrderColumns({ storesMap }), [storesMap]);
() => createOrderColumns(),
[]
);
if (error) { if (error) {
return ( return (
@ -47,11 +71,24 @@ export const OrderTable = () => {
const tableHeight = calculateTableHeight(rows.length, 10); const tableHeight = calculateTableHeight(rows.length, 10);
const mobileTableHeight = calculateTableHeight(rows.length, 5, {
minHeight: 300,
rowHeight: 40,
});
return ( return (
<Box> <Box>
<SearchBar /> <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 <DataGridPremium
rows={rows} rows={rows}
columns={columns} columns={columns}
@ -73,7 +110,7 @@ export const OrderTable = () => {
sortModel: [{ field: 'createDate', sort: 'desc' }], sortModel: [{ field: 'createDate', sort: 'desc' }],
}, },
pinnedColumns: { pinnedColumns: {
left: ['orderId', 'customerName'], /// left: ['orderId', 'customerName'],
}, },
}} }}
pageSizeOptions={[10, 25, 50]} pageSizeOptions={[10, 25, 50]}
@ -88,7 +125,7 @@ export const OrderTable = () => {
params.row.orderId === selectedOrderId ? 'Mui-selected' : '' params.row.orderId === selectedOrderId ? 'Mui-selected' : ''
} }
sx={{ sx={{
height: tableHeight, height: { xs: mobileTableHeight, md: tableHeight },
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
@ -253,7 +290,8 @@ export const OrderTable = () => {
}, },
}} }}
localeText={{ localeText={{
noRowsLabel: 'Nenhum pedido encontrado para os filtros selecionados.', noRowsLabel:
'Nenhum pedido encontrado para os filtros selecionados.',
noResultsOverlayLabel: 'Nenhum resultado encontrado.', noResultsOverlayLabel: 'Nenhum resultado encontrado.',
footerTotalRows: 'Total de registros:', footerTotalRows: 'Total de registros:',
footerTotalVisibleRows: (visibleCount, totalCount) => footerTotalVisibleRows: (visibleCount, totalCount) =>
@ -263,11 +301,18 @@ export const OrderTable = () => {
? `${count.toLocaleString()} linha selecionada` ? `${count.toLocaleString()} linha selecionada`
: `${count.toLocaleString()} linhas selecionadas`, : `${count.toLocaleString()} linhas selecionadas`,
}} }}
slotProps={{ slotProps={{
pagination: { pagination: {
labelRowsPerPage: 'Pedidos por página:', 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 pageSize = to >= from ? to - from + 1 : 10;
const currentPage = Math.floor((from - 1) / pageSize) + 1; const currentPage = Math.floor((from - 1) / pageSize) + 1;
const totalPages = Math.ceil(count / pageSize); 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 Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import { formatDate, formatDateTime, formatCurrency, formatNumber } from '../utils/orderFormatters'; import {
import { getStatusChipProps, getPriorityChipProps } from '../utils/tableHelpers'; 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', field: 'orderId',
headerName: 'Pedido', headerName: 'Pedido',
@ -15,7 +30,10 @@ export const createOrderColumns = (): GridColDef[] => [
headerAlign: 'right', headerAlign: 'right',
align: 'right', align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => ( 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} {params.value}
</Typography> </Typography>
), ),
@ -33,7 +51,11 @@ export const createOrderColumns = (): GridColDef[] => [
{formatDate(params.value)} {formatDate(params.value)}
</Typography> </Typography>
{dateTime && ( {dateTime && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.6875rem' }}> <Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.6875rem' }}
>
{dateTime} {dateTime}
</Typography> </Typography>
)} )}
@ -58,15 +80,19 @@ export const createOrderColumns = (): GridColDef[] => [
headerName: 'Filial', headerName: 'Filial',
width: 200, width: 200,
minWidth: 180, minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => ( renderCell: (params: Readonly<GridRenderCellParams>) => {
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap> const storeId = String(params.value);
{params.value} const storeName = storesMap?.get(storeId) || storeId;
</Typography> return (
), <Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{storeName}
</Typography>
);
},
}, },
{ {
field: 'store', field: 'store',
headerName: 'Filial Faturamento', headerName: 'Supervisor',
width: 200, width: 200,
minWidth: 180, minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => ( renderCell: (params: Readonly<GridRenderCellParams>) => (
@ -122,7 +148,10 @@ export const createOrderColumns = (): GridColDef[] => [
align: 'right', align: 'right',
valueFormatter: (value) => formatCurrency(value as number), valueFormatter: (value) => formatCurrency(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => ( 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)} {formatCurrency(params.value)}
</Typography> </Typography>
), ),
@ -135,7 +164,10 @@ export const createOrderColumns = (): GridColDef[] => [
headerAlign: 'right', headerAlign: 'right',
align: 'right', align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => ( 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 : '-'} {params.value && params.value !== '-' ? params.value : '-'}
</Typography> </Typography>
), ),
@ -184,7 +216,10 @@ export const createOrderColumns = (): GridColDef[] => [
align: 'right', align: 'right',
valueFormatter: (value) => formatNumber(value as number), valueFormatter: (value) => formatNumber(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => ( 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)} {formatNumber(params.value)}
</Typography> </Typography>
), ),
@ -208,7 +243,11 @@ export const createOrderColumns = (): GridColDef[] => [
headerAlign: 'right', headerAlign: 'right',
align: 'right', align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => ( 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 || '-'} {params.value || '-'}
</Typography> </Typography>
), ),
@ -290,10 +329,16 @@ export const createOrderColumns = (): GridColDef[] => [
const timeStr = params.row.invoiceTime; const timeStr = params.row.invoiceTime;
return ( return (
<Typography variant="body2" sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}> <Typography
variant="body2"
sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}
>
{dateStr} {dateStr}
{timeStr && ( {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} {timeStr}
</Box> </Box>
)} )}
@ -342,7 +387,11 @@ export const createOrderColumns = (): GridColDef[] => [
headerAlign: 'right', headerAlign: 'right',
align: 'right', align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => ( 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 || '-'} {params.value || '-'}
</Typography> </Typography>
), ),
@ -392,4 +441,4 @@ export const createOrderColumns = (): GridColDef[] => [
), ),
}, },
]; ];
};

View File

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

View File

@ -4,24 +4,20 @@ import { ReactNode } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
interface TabPanelProps { interface TabPanelProps {
children?: ReactNode; children?: ReactNode;
index: number; index: number;
value: number; value: number;
} }
export const TabPanel = ({ children, value, index }: TabPanelProps) => { export const TabPanel = ({ children, value, index }: TabPanelProps) => {
return ( return (
<div <div
role="tabpanel" role="tabpanel"
hidden={value !== index} hidden={value !== index}
id={`order-tabpanel-${index}`} id={`order-tabpanel-${index}`}
aria-labelledby={`order-tab-${index}`} aria-labelledby={`order-tab-${index}`}
> >
{value === index && ( {value === index && <Box sx={{ py: 3 }}>{children}</Box>}
<Box sx={{ py: 3 }}> </div>
{children} );
</Box>
)}
</div>
);
}; };

View File

@ -1,21 +1,63 @@
'use client'; 'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert'; 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 { interface CargoMovementPanelProps {
orderId: number; orderId: number;
} }
export const CargoMovementPanel = ({ orderId }: CargoMovementPanelProps) => { 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 ( return (
<Box> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<Alert severity="info" sx={{ mb: 2 }}> <CircularProgress size={30} />
<Typography variant="body2"> </Box>
Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography>
</Alert>
</Box>
); );
}
if (error) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error">Erro ao carregar movimentação de carga.</Alert>
</Box>
);
}
if (!movements || movements.length === 0) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">Nenhuma movimentação de carga encontrada para este pedido.</Alert>
</Box>
);
}
return (
<DataGridPremium
rows={rows}
columns={columns}
density="compact"
autoHeight
hideFooter
sx={dataGridStylesSimple}
/>
);
}; };

View File

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

@ -5,17 +5,17 @@ import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
interface CashAdjustmentPanelProps { interface CashAdjustmentPanelProps {
orderId: number; orderId: number;
} }
export const CashAdjustmentPanel = ({ orderId }: CashAdjustmentPanelProps) => { export const CashAdjustmentPanel = ({ orderId }: CashAdjustmentPanelProps) => {
return ( return (
<Box> <Box>
<Alert severity="info" sx={{ mb: 2 }}> <Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2"> <Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId} Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography> </Typography>
</Alert> </Alert>
</Box> </Box>
); );
}; };

View File

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

View File

@ -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'; 'use client';
import { useMemo } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert'; 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 { interface DeliveryPanelProps {
orderId: number; orderId: number;
} }
export const DeliveryPanel = ({ orderId }: DeliveryPanelProps) => { 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 ( return (
<Box> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<Alert severity="info" sx={{ mb: 2 }}> <CircularProgress size={30} />
<Typography variant="body2"> </Box>
Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography>
</Alert>
</Box>
); );
}
if (error) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error">Erro ao carregar entregas do pedido.</Alert>
</Box>
);
}
if (!deliveries || deliveries.length === 0) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">Nenhuma entrega encontrada para este pedido.</Alert>
</Box>
);
}
return (
<DataGridPremium
rows={rows}
columns={columns}
density="compact"
autoHeight
hideFooter
sx={dataGridStylesSimple}
/>
);
}; };

View File

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

@ -10,54 +10,56 @@ import { createInformationPanelColumns } from './InformationPanelColumns';
import { dataGridStylesSimple } from '../../utils/dataGridStyles'; import { dataGridStylesSimple } from '../../utils/dataGridStyles';
interface InformationPanelProps { interface InformationPanelProps {
orderId: number; orderId: number;
} }
export const InformationPanel = ({ orderId }: InformationPanelProps) => { export const InformationPanel = ({ orderId }: InformationPanelProps) => {
const { data: order, isLoading, error } = useOrderDetails(orderId); const { data: order, isLoading, error } = useOrderDetails(orderId);
const columns = useMemo(() => createInformationPanelColumns(), []); const columns = useMemo(() => createInformationPanelColumns(), []);
const rows = useMemo(() => { const rows = useMemo(() => {
if (!order) return []; if (!order) return [];
return [{ return [
id: order.orderId || orderId, {
...order id: order.orderId || orderId,
}]; ...order,
}, [order, orderId]); },
];
if (isLoading) { }, [order, orderId]);
return (
<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 detalhes do pedido.</Alert>
</Box>
);
}
if (!order) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">Informações do pedido não encontradas.</Alert>
</Box>
);
}
if (isLoading) {
return ( return (
<DataGridPremium <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
rows={rows} <CircularProgress size={30} />
columns={columns} </Box>
density="compact"
autoHeight
hideFooter
sx={dataGridStylesSimple}
/>
); );
}
if (error) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error">Erro ao carregar detalhes do pedido.</Alert>
</Box>
);
}
if (!order) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">Informações do pedido não encontradas.</Alert>
</Box>
);
}
return (
<DataGridPremium
rows={rows}
columns={columns}
density="compact"
autoHeight
hideFooter
sx={dataGridStylesSimple}
/>
);
}; };

View File

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

View File

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

View File

@ -5,17 +5,17 @@ import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
interface PreBoxPanelProps { interface PreBoxPanelProps {
orderId: number; orderId: number;
} }
export const PreBoxPanel = ({ orderId }: PreBoxPanelProps) => { export const PreBoxPanel = ({ orderId }: PreBoxPanelProps) => {
return ( return (
<Box> <Box>
<Alert severity="info" sx={{ mb: 2 }}> <Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2"> <Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId} Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography> </Typography>
</Alert> </Alert>
</Box> </Box>
); );
}; };

View File

@ -5,17 +5,17 @@ import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
interface TV8PanelProps { interface TV8PanelProps {
orderId: number; orderId: number;
} }
export const TV8Panel = ({ orderId }: TV8PanelProps) => { export const TV8Panel = ({ orderId }: TV8PanelProps) => {
return ( return (
<Box> <Box>
<Alert severity="info" sx={{ mb: 2 }}> <Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2"> <Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId} Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography> </Typography>
</Alert> </Alert>
</Box> </Box>
); );
}; };

View File

@ -5,17 +5,17 @@ import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
interface TimelinePanelProps { interface TimelinePanelProps {
orderId: number; orderId: number;
} }
export const TimelinePanel = ({ orderId }: TimelinePanelProps) => { export const TimelinePanel = ({ orderId }: TimelinePanelProps) => {
return ( return (
<Box> <Box>
<Alert severity="info" sx={{ mb: 2 }}> <Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2"> <Typography variant="body2">
Funcionalidade em desenvolvimento para o pedido {orderId} Funcionalidade em desenvolvimento para o pedido {orderId}
</Typography> </Typography>
</Alert> </Alert>
</Box> </Box>
); );
}; };

View File

@ -1,26 +1,13 @@
{ {
"status": { "status": {
"success": [ "success": ["FATURADO", "F"],
"FATURADO", "error": ["CANCELADO", "C"],
"F" "warning": []
], },
"error": [ "priority": {
"CANCELADO", "error": ["ALTA"],
"C" "warning": ["MÉDIA", "MEDIA"],
], "success": ["BAIXA"],
"warning": [] "default": []
}, }
"priority": { }
"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 ## Documentos Disponíveis
### [order-items-implementation.md](./order-items-implementation.md) ### [order-items-implementation.md](./order-items-implementation.md)
Documentação completa da implementação da funcionalidade de visualização de itens do pedido. Documentação completa da implementação da funcionalidade de visualização de itens do pedido.
**Conteúdo:** **Conteúdo:**
- Arquitetura da solução - Arquitetura da solução
- Arquivos criados e modificados - Arquivos criados e modificados
- Fluxo de execução - Fluxo de execução
@ -20,9 +22,11 @@ Documentação completa da implementação da funcionalidade de visualização d
## Funcionalidades Documentadas ## Funcionalidades Documentadas
### Tabela de Itens do Pedido ### Tabela de Itens do Pedido
Permite visualizar os produtos/itens de um pedido ao clicar na linha da tabela principal. Permite visualizar os produtos/itens de um pedido ao clicar na linha da tabela principal.
**Arquivos principais:** **Arquivos principais:**
- `schemas/order.item.schema.ts` - Schema de validação - `schemas/order.item.schema.ts` - Schema de validação
- `api/order.service.ts` - Serviço de API - `api/order.service.ts` - Serviço de API
- `hooks/useOrderItems.ts` - Hook React Query - `hooks/useOrderItems.ts` - Hook React Query

View File

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

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

View File

@ -5,33 +5,36 @@ import {
parseAsString, parseAsString,
parseAsInteger, parseAsInteger,
parseAsBoolean, parseAsBoolean,
parseAsArrayOf parseAsArrayOf,
} from 'nuqs'; } from 'nuqs';
export const useOrderFilters = () => { export const useOrderFilters = () => {
return useQueryStates({ return useQueryStates(
status: parseAsString, {
sellerName: parseAsString, status: parseAsString,
sellerId: parseAsString, sellerName: parseAsString,
customerName: parseAsString, sellerId: parseAsString,
customerId: parseAsInteger, customerName: parseAsString,
customerId: parseAsInteger,
codfilial: parseAsArrayOf(parseAsString, ','), codfilial: parseAsArrayOf(parseAsString, ','),
codusur2: parseAsArrayOf(parseAsString, ','), codusur2: parseAsArrayOf(parseAsString, ','),
store: parseAsArrayOf(parseAsString, ','), store: parseAsArrayOf(parseAsString, ','),
orderId: parseAsInteger, orderId: parseAsInteger,
productId: parseAsInteger, productId: parseAsInteger,
stockId: parseAsArrayOf(parseAsString, ','), stockId: parseAsArrayOf(parseAsString, ','),
hasPreBox: parseAsBoolean.withDefault(false), hasPreBox: parseAsBoolean.withDefault(false),
includeCheckout: parseAsBoolean.withDefault(false), includeCheckout: parseAsBoolean.withDefault(false),
createDateIni: parseAsString, createDateIni: parseAsString,
createDateEnd: parseAsString, createDateEnd: parseAsString,
searchTriggered: parseAsBoolean.withDefault(false), searchTriggered: parseAsBoolean.withDefault(false),
}, { },
shallow: true, {
history: 'replace', shallow: true,
}); history: 'replace',
}; }
);
};

View File

@ -3,18 +3,18 @@ import { orderService } from '../api/order.service';
/** /**
* Hook to fetch order items for a specific order. * Hook to fetch order items for a specific order.
* *
* @param orderId - The ID of the order to fetch items for * @param orderId - The ID of the order to fetch items for
* @returns Query result with order items data, loading state, and error * @returns Query result with order items data, loading state, and error
*/ */
export function useOrderItems(orderId: number | null | undefined) { export function useOrderItems(orderId: number | null | undefined) {
return useQuery({ return useQuery({
queryKey: ['orderItems', orderId], queryKey: ['orderItems', orderId],
enabled: orderId != null && orderId > 0, enabled: orderId != null && orderId > 0,
queryFn: () => orderService.findOrderItems(orderId!), queryFn: () => orderService.findOrderItems(orderId!),
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1, retry: 1,
retryOnMount: false, retryOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
} }

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