Compare commits

..

6 Commits

Author SHA1 Message Date
joelson 2e94040481 Merge pull request 'feature/auth-state-machine' (#1) from feature/auth-state-machine into master
Reviewed-on: #1
2026-01-15 18:27:41 +00:00
JuruSysadmin 21b9ff5afe feat(ui): standardize auth, refactor profile and update theme density 2026-01-15 14:45:43 -03:00
JuruSysadmin df12e81c1c feat: Implement order management, user profile, and authentication features. 2026-01-15 14:41:31 -03:00
JuruSysadmin 02aaae0cd3 feat: Implement core application structure including Dashboard, Orders, Login, and Profile modules. 2026-01-15 10:42:56 -03:00
JuruSysadmin 5d469f08a7 feat: Implement orders page with table and order details tabs. 2026-01-14 15:20:40 -03:00
JuruSysadmin 544d723f1b feat(orders): implement manual search and information panel
- Refactor SearchBar to use local state (search only triggers on button click)
- Add InformationPanel with DataGrid displaying order details
- Create shared dataGridStyles for consistent table styling
- Simplify OrderItemsTable using shared styles
- Add useOrderDetails hook for fetching order by ID
- Translate status codes to readable text (F->Faturado, C->Cancelado, P->Pendente)
2026-01-14 15:18:51 -03:00
157 changed files with 22250 additions and 17461 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;

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,110 @@ 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, fontSize: '2.25rem', lineHeight: 1.2 },
h2: { fontWeight: 600, fontSize: '1.875rem', lineHeight: 1.2 },
h3: { fontWeight: 600, fontSize: '1.5rem', lineHeight: 1.2 },
h4: { fontWeight: 600, fontSize: '1.3125rem', lineHeight: 1.2 },
h5: { fontWeight: 600, fontSize: '1.125rem', lineHeight: 1.2 },
h6: { fontWeight: 600, fontSize: '1rem', lineHeight: 1.2 },
button: { textTransform: 'none', fontWeight: 500 },
body1: { fontSize: '0.8125rem', fontWeight: 400, lineHeight: 1.5 }, // 13px
body2: { fontSize: '0.75rem', fontWeight: 400, lineHeight: 1.5 }, // 12px
subtitle1: { fontSize: '0.875rem', fontWeight: 400 },
subtitle2: { fontSize: '0.75rem', fontWeight: 400 },
},
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;

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,18 +1,18 @@
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*',
}, },
]; ];
}, },

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

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

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

View File

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

@ -27,13 +27,15 @@ export const useCustomizerStore = create<CustomizerState>()(
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) => ({ toggleMobileSidebar: () =>
isMobileSidebar: !state.isMobileSidebar set((state) => ({
})), isMobileSidebar: !state.isMobileSidebar,
})),
setHydrated: () => set({ isHydrated: true }), setHydrated: () => set({ isHydrated: true }),
}), }),
@ -42,7 +44,7 @@ export const useCustomizerStore = create<CustomizerState>()(
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

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

View File

@ -1,150 +1,146 @@
"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 Stack from '@mui/material/Stack';
import FormGroup from "@mui/material/FormGroup"; import { TextField, Alert } from '@mui/material';
import Stack from "@mui/material/Stack"; import Typography from '@mui/material/Typography';
import { TextField, Alert } from "@mui/material"; import NextLink from 'next/link';
import Typography from "@mui/material/Typography"; import { useForm } from 'react-hook-form';
import NextLink from "next/link"; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from "react-hook-form"; import { loginSchema, LoginInput, AuthLoginProps } from '../interfaces/types';
import { zodResolver } from "@hookform/resolvers/zod"; import { useAuth } from '../hooks/useAuth';
import { loginSchema, LoginInput, AuthLoginProps } from "../interfaces/types"; import CustomFormLabel from '../components/forms/theme-elements/CustomFormLabel';
import { useAuth } from "../hooks/useAuth"; import AuthSocialButtons from './AuthSocialButtons';
import CustomCheckbox from "../components/forms/theme-elements/CustomCheckbox";
import CustomFormLabel from "../components/forms/theme-elements/CustomFormLabel";
import AuthSocialButtons from "./AuthSocialButtons";
const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => { const 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="Entrar com" />
<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="flex-end"
direction="row" direction="row"
alignItems="center" alignItems="center"
my={2} my={2}
spacing={1} spacing={1}
flexWrap="wrap" >
> <Typography
<FormGroup> fontWeight="500"
<FormControlLabel sx={{
control={<CustomCheckbox defaultChecked />} textDecoration: 'none',
label="Manter-me conectado" color: 'primary.main',
sx={{ whiteSpace: 'nowrap' }} }}
/> >
</FormGroup> <NextLink
<Typography href="/"
fontWeight="500" style={{ textDecoration: 'none', color: 'inherit' }}
sx={{ >
textDecoration: "none", Esqueceu sua senha ?
color: "primary.main", </NextLink>
}} </Typography>
> </Stack>
<NextLink href="/" style={{ textDecoration: 'none', color: 'inherit' }}> </Stack>
Esqueceu sua senha ? <Box>
</NextLink> <Button
</Typography> color="primary"
</Stack> variant="contained"
</Stack> size="large"
<Box> fullWidth
<Button type="submit"
color="primary" disabled={loginMutation.isPending}
variant="contained" >
size="large" {loginMutation.isPending ? 'Entrando...' : 'Entrar'}
fullWidth </Button>
type="submit" </Box>
disabled={loginMutation.isPending} </form>
> {subtitle}
{loginMutation.isPending ? 'Logging in...' : 'Sign In'} </>
</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

@ -1,4 +1,5 @@
'use client'; 'use client';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../store/useAuthStore'; import { useAuthStore } from '../store/useAuthStore';
@ -7,42 +8,85 @@ 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 (
<Box
sx={{
display: 'flex',
height: '100vh',
width: '100vw',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
position: 'relative',
}}
>
<Box sx={{ position: 'relative', display: 'flex' }}>
<CircularProgress
variant="determinate"
sx={{
color: (theme) => theme.palette.grey[200],
}}
size={48}
thickness={4}
value={100}
/>
<CircularProgress
variant="indeterminate"
disableShrink
sx={{
color: (theme) => theme.palette.primary.main,
animationDuration: '550ms',
position: 'absolute',
left: 0,
[`& .MuiCircularProgress-circle`]: {
strokeLinecap: 'round',
},
}}
size={48}
thickness={4}
/>
</Box>
<Typography variant="body2" color="textSecondary">
Validando acesso...
</Typography>
</Box>
</Box>
);
}
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,7 +40,7 @@ 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
@ -50,15 +49,14 @@ 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,11 +87,11 @@ 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:

View File

@ -30,17 +30,18 @@ export function useAuth() {
}, },
}); });
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 {

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

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,8 +1,8 @@
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'),
}); });
/** /**
@ -13,13 +13,13 @@ export const loginSchema = z.object({
* - 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);

View File

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

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';
@ -53,7 +69,9 @@ ordersApi.interceptors.request.use(addToken);
* @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,7 +80,10 @@ 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 = {
/** /**
@ -73,14 +94,11 @@ export const orderService = {
* @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 { const cleanParams = orderApiParamsSchema.parse(filters);
const cleanParams = orderApiParamsSchema.parse(filters); const response = await ordersApi.get('/api/v1/orders/find', {
const response = await ordersApi.get('/api/v1/orders/find', { params: cleanParams }); params: cleanParams,
return unwrapApiData(response, ordersResponseSchema, []); });
} catch (error) { return unwrapApiData(response, ordersResponseSchema, []);
console.error('Erro ao buscar pedidos:', error);
return [];
}
}, },
/** /**
@ -90,13 +108,8 @@ export const orderService = {
* @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
*/ */
findById: async (id: number): Promise<Order | null> => { findById: async (id: number): Promise<Order | null> => {
try { const response = await ordersApi.get(`/orders/${id}`);
const response = await ordersApi.get(`/orders/${id}`); return unwrapApiData(response, orderResponseSchema, null);
return unwrapApiData(response, orderResponseSchema, null);
} catch (error) {
console.error(`Erro ao buscar pedido ${id}:`, error);
return null;
}
}, },
/** /**
@ -105,13 +118,8 @@ export const orderService = {
* @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[]> => {
try { const response = await ordersApi.get('/api/v1/data-consult/stores');
const response = await ordersApi.get('/api/v1/data-consult/stores'); return unwrapApiData(response, storesResponseSchema, []);
return unwrapApiData(response, storesResponseSchema, []);
} catch (error) {
console.error('Erro ao buscar lojas:', error);
return [];
}
}, },
/** /**
@ -121,34 +129,54 @@ export const orderService = {
* @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 {
const response = await ordersApi.get(`/api/v1/clientes/${encodeURIComponent(name)}`); const response = await ordersApi.get(
return unwrapApiData(response, customersResponseSchema, []); `/api/v1/clientes/${encodeURIComponent(name)}`
} catch (error) { );
console.error('Erro ao buscar clientes:', error); return unwrapApiData(response, customersResponseSchema, []);
return [];
}
}, },
findsellers: async (): Promise<Seller[]> => { findSellers: async (): Promise<Seller[]> => {
try { const response = await ordersApi.get('/api/v1/data-consult/sellers');
const response = await ordersApi.get('/api/v1/data-consult/sellers'); return unwrapApiData(response, sellersResponseSchema, []);
return unwrapApiData(response, sellersResponseSchema, []);
} catch (error) {
console.error('Erro ao buscar vendedores:', error);
return [];
}
}, },
findOrderItems: async (orderId: number): Promise<OrderItem[]> => { findOrderItems: async (orderId: number): Promise<OrderItem[]> => {
try { const response = await ordersApi.get(`/api/v1/orders/itens/${orderId}`);
const response = await ordersApi.get(`/api/v1/orders/itens/${orderId}`); return unwrapApiData(response, orderItemsResponseSchema, []);
return unwrapApiData(response, orderItemsResponseSchema, []);
} catch (error) {
console.error(`Erro ao buscar itens do pedido ${orderId}:`, error);
return [];
}
}, },
findDelivery: async (
orderId: number,
includeCompletedDeliveries: boolean = true
): Promise<Shipment[]> => {
const response = await ordersApi.get(
`/api/v1/orders/delivery/${orderId}`,
{
params: { includeCompletedDeliveries },
}
);
return unwrapApiData(response, shipmentResponseSchema, []);
},
findCargoMovement: async (orderId: number): Promise<CargoMovement[]> => {
const response = await ordersApi.get(
`/api/v1/orders/transfer/${orderId}`
);
return unwrapApiData(response, cargoMovementResponseSchema, []);
},
findCuttingItems: async (orderId: number): Promise<CuttingItem[]> => {
const response = await ordersApi.get(
`/api/v1/orders/cut-itens/${orderId}`
);
return unwrapApiData(response, cuttingItemResponseSchema, []);
},
}; };

View File

@ -1,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,8 +13,8 @@ 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 './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';
import { TimelinePanel } from './tabs/TimelinePanel'; import { TimelinePanel } from './tabs/TimelinePanel';
@ -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

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

View File

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

@ -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 CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography';
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,28 +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 (isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 10, gap: 2 }}>
<CircularProgress size={80} />
<Typography variant="body2" color="text.secondary">Buscando pedidos...</Typography>
</Box>
);
}
if (error) { if (error) {
return ( return (
@ -54,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}
@ -80,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]}
@ -95,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',
@ -105,7 +135,7 @@ export const OrderTable = () => {
border: 'none', border: 'none',
}, },
'& .MuiDataGrid-columnHeaders': { '& .MuiDataGrid-columnHeaders': {
backgroundColor: 'grey.50', backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
fontWeight: 600, fontWeight: 600,
fontSize: '0.75rem', fontSize: '0.75rem',
borderBottom: '2px solid', borderBottom: '2px solid',
@ -150,7 +180,7 @@ export const OrderTable = () => {
}, },
}, },
'& .MuiDataGrid-row:nth-of-type(even)': { '& .MuiDataGrid-row:nth-of-type(even)': {
backgroundColor: 'grey.50', backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.50',
'&:hover': { '&:hover': {
backgroundColor: 'action.hover', backgroundColor: 'action.hover',
}, },
@ -196,10 +226,10 @@ export const OrderTable = () => {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
minHeight: '48px !important', minHeight: '48px !important',
fontSize: '0.75rem', fontSize: '0.75rem',
backgroundColor: 'grey.50', backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
}, },
'& .MuiDataGrid-aggregationColumnHeader': { '& .MuiDataGrid-aggregationColumnHeader': {
backgroundColor: 'grey.100', backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100',
fontWeight: 600, fontWeight: 600,
fontSize: '0.75rem', fontSize: '0.75rem',
borderBottom: '2px solid', borderBottom: '2px solid',
@ -209,7 +239,7 @@ export const OrderTable = () => {
fontWeight: 600, fontWeight: 600,
}, },
'& .MuiDataGrid-aggregationRow': { '& .MuiDataGrid-aggregationRow': {
backgroundColor: 'grey.100', backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100',
borderTop: '2px solid', borderTop: '2px solid',
borderColor: 'divider', borderColor: 'divider',
minHeight: '40px !important', minHeight: '40px !important',
@ -237,13 +267,13 @@ export const OrderTable = () => {
opacity: 1, opacity: 1,
}, },
'& .MuiDataGrid-pinnedColumnHeaders': { '& .MuiDataGrid-pinnedColumnHeaders': {
backgroundColor: 'grey.50', backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
}, },
'& .MuiDataGrid-pinnedColumns': { '& .MuiDataGrid-pinnedColumns': {
backgroundColor: 'background.paper', backgroundColor: 'background.paper',
}, },
'& .MuiDataGrid-pinnedColumnHeader': { '& .MuiDataGrid-pinnedColumnHeader': {
backgroundColor: 'grey.50', backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
fontWeight: 600, fontWeight: 600,
fontSize: '0.75rem', fontSize: '0.75rem',
borderBottom: '2px solid', borderBottom: '2px solid',
@ -260,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) =>
@ -270,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,393 +3,434 @@ 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[] => [
{
field: 'orderId',
headerName: 'Pedido',
width: 120,
minWidth: 100,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}>
{params.value}
</Typography>
),
},
{
field: 'createDate',
headerName: 'Data',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => {
const dateTime = formatDateTime(params.value);
return (
<Box>
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{formatDate(params.value)}
</Typography>
{dateTime && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.6875rem' }}>
{dateTime}
</Typography>
)}
</Box>
);
},
},
{
field: 'customerName',
headerName: 'Cliente',
width: 300,
minWidth: 250,
flex: 1,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'storeId',
headerName: 'Filial',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'store',
headerName: 'Filial Faturamento',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'status',
headerName: 'Situação',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => {
const status = params.value as string;
const chipProps = getStatusChipProps(status);
return (
<Tooltip title={chipProps.label} arrow placement="top">
<Chip
label={chipProps.label}
color={chipProps.color}
size="small"
sx={{
fontSize: '0.6875rem',
height: 22,
fontWeight: 300,
}}
/>
</Tooltip>
);
},
},
{
field: 'orderType',
headerName: 'Tipo',
width: 140,
minWidth: 120,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'amount',
headerName: 'Valor Total',
width: 130,
minWidth: 120,
type: 'number',
aggregable: true,
headerAlign: 'right',
align: 'right',
valueFormatter: (value) => formatCurrency(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}>
{formatCurrency(params.value)}
</Typography>
),
},
{
field: 'invoiceNumber',
headerName: 'Nota Fiscal',
width: 120,
minWidth: 110,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{params.value && params.value !== '-' ? params.value : '-'}
</Typography>
),
},
{
field: 'billingId',
headerName: 'Cobrança',
width: 120,
minWidth: 110,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value || '-'}
</Typography>
),
},
{
field: 'sellerName',
headerName: 'Vendedor',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'deliveryType',
headerName: 'Tipo de Entrega',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'totalWeight',
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" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{formatNumber(params.value)}
</Typography>
),
},
{
field: 'fatUserName',
headerName: 'Usuário Faturou',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'customerId',
headerName: 'Código Cliente',
width: 120,
minWidth: 110,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{params.value || '-'}
</Typography>
),
},
{
field: 'deliveryDate',
headerName: 'Data Entrega',
width: 130,
minWidth: 120,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{params.value ? formatDate(params.value) : '-'}
</Typography>
),
},
{
field: 'deliveryLocal',
headerName: 'Local Entrega',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'deliveryPriority',
headerName: 'Prioridade',
width: 120,
minWidth: 110,
renderCell: (params: Readonly<GridRenderCellParams>) => {
const priority = params.value;
const chipProps = getPriorityChipProps(priority);
if (!chipProps) { const CELL_FONT_SIZE = '0.75rem';
return ( const CAPTION_FONT_SIZE = '0.6875rem';
<Typography
variant="body2"
sx={{ fontSize: '0.75rem', color: 'text.secondary' }}
noWrap
>
-
</Typography>
);
}
return ( const CHIP_STYLES = {
<Tooltip title={chipProps.label} arrow placement="top"> fontSize: CAPTION_FONT_SIZE,
<Chip height: 22,
label={chipProps.label} fontWeight: 300,
color={chipProps.color} } as const;
size="small"
sx={{ const CHIP_PRIORITY_STYLES = {
fontSize: '0.6875rem', ...CHIP_STYLES,
height: 22, maxWidth: '100%',
fontWeight: 300, '& .MuiChip-label': {
maxWidth: '100%', px: 1,
'& .MuiChip-label': { py: 0,
px: 1, overflow: 'hidden',
py: 0, textOverflow: 'ellipsis',
overflow: 'hidden', whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}}
/>
</Tooltip>
);
},
}, },
{ } as const;
field: 'invoiceDate',
headerName: 'Data Faturamento',
width: 150,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => {
const dateStr = formatDate(params.value);
const timeStr = params.row.invoiceTime;
return (
<Typography variant="body2" sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
{dateStr} interface CellTextProps {
{timeStr && ( value: unknown;
<Box component="span" sx={{ color: 'text.secondary', fontSize: '0.6875rem', ml: 0.5 }}> secondary?: boolean;
{timeStr} fontWeight?: number;
</Box> }
)}
const CellText = ({ value, secondary = false, fontWeight }: CellTextProps) => (
<Typography
variant="body2"
color={secondary ? 'text.secondary' : 'text.primary'}
sx={{ fontSize: CELL_FONT_SIZE, fontWeight }}
noWrap
>
{String(value ?? '-')}
</Typography>
);
interface CellNumericProps {
value: unknown;
formatter?: (val: number) => string;
secondary?: boolean;
fontWeight?: number;
}
const CellNumeric = ({
value,
formatter,
secondary = false,
fontWeight,
}: CellNumericProps) => (
<Typography
variant="body2"
color={secondary ? 'text.secondary' : 'text.primary'}
sx={{ fontSize: CELL_FONT_SIZE, fontWeight, textAlign: 'right' }}
>
{formatter ? formatter(value as number) : String(value ?? '-')}
</Typography>
);
interface CellDateProps {
value: unknown;
showTime?: boolean;
time?: string;
}
const CellDate = ({ value, showTime = false, time }: CellDateProps) => {
const dateStr = formatDate(value as string | undefined);
if (!showTime && !time) {
return (
<Typography variant="body2" sx={{ fontSize: CELL_FONT_SIZE }}>
{dateStr}
</Typography>
);
}
const timeStr = time || formatDateTime(value as string | undefined);
return (
<Box>
<Typography variant="body2" sx={{ fontSize: CELL_FONT_SIZE }}>
{dateStr}
</Typography>
{timeStr && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: CAPTION_FONT_SIZE }}
>
{timeStr}
</Typography> </Typography>
); )}
}, </Box>
}, );
{ };
field: 'invoiceTime',
headerName: 'Hora Faturamento',
width: 120,
minWidth: 110,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{params.value || '-'}
</Typography>
),
},
{
field: 'confirmDeliveryDate',
headerName: 'Data Confirmação Entrega',
width: 150,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{params.value ? formatDate(params.value) : '-'}
</Typography>
),
},
{
field: 'paymentName',
headerName: 'Pagamento',
width: 140,
minWidth: 130,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'sellerId',
headerName: 'RCA',
width: 100,
minWidth: 90,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
{params.value || '-'}
</Typography>
),
},
{
field: 'partnerName',
headerName: 'Parceiro',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'codusur2Name',
headerName: 'RCA 2',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'schedulerDelivery',
headerName: 'Agendamento',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
{
field: 'emitenteNome',
headerName: 'Emitente',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }} noWrap>
{params.value}
</Typography>
),
},
];
interface CreateOrderColumnsOptions {
storesMap?: Map<string, string>;
}
export const createOrderColumns = (
options?: CreateOrderColumnsOptions
): GridColDef[] => {
const storesMap = options?.storesMap;
return [
{
field: 'orderId',
headerName: 'Pedido',
width: 120,
minWidth: 100,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellNumeric value={params.value} fontWeight={500} />
),
},
{
field: 'createDate',
headerName: 'Data',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellDate value={params.value} showTime />
),
},
{
field: 'customerName',
headerName: 'Cliente',
width: 300,
minWidth: 250,
flex: 1,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'customerId',
headerName: 'Código Cliente',
width: 120,
minWidth: 110,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellNumeric value={params.value} secondary />
),
},
{
field: 'storeId',
headerName: 'Filial',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => {
const storeId = String(params.value);
const storeName = storesMap?.get(storeId) || storeId;
return <CellText value={storeName} />;
},
},
{
field: 'store',
headerName: 'Supervisor',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'status',
headerName: 'Situação',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => {
const status = params.value as string;
const chipProps = getStatusChipProps(status);
return (
<Tooltip title={chipProps.label} arrow placement="top">
<Chip
label={chipProps.label}
color={chipProps.color}
size="small"
sx={CHIP_STYLES}
/>
</Tooltip>
);
},
},
{
field: 'orderType',
headerName: 'Tipo',
width: 140,
minWidth: 120,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'amount',
headerName: 'Valor Total',
width: 130,
minWidth: 120,
type: 'number',
aggregable: true,
headerAlign: 'right',
align: 'right',
valueFormatter: (value) => formatCurrency(value as number),
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellNumeric value={params.value} formatter={formatCurrency} fontWeight={500} />
),
},
{
field: 'totalWeight',
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>) => (
<CellNumeric value={params.value} formatter={formatNumber} />
),
},
{
field: 'invoiceNumber',
headerName: 'Nota Fiscal',
width: 120,
minWidth: 110,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => {
const value = params.value && params.value !== '-' ? params.value : '-';
return <CellNumeric value={value} />;
},
},
{
field: 'invoiceDate',
headerName: 'Data Faturamento',
width: 150,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellDate value={params.value} time={params.row.invoiceTime} />
),
},
{
field: 'fatUserName',
headerName: 'Usuário Faturou',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'billingId',
headerName: 'Cobrança',
width: 120,
minWidth: 110,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'paymentName',
headerName: 'Pagamento',
width: 140,
minWidth: 130,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'sellerName',
headerName: 'Vendedor',
width: 200,
minWidth: 180,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'sellerId',
headerName: 'RCA',
width: 100,
minWidth: 90,
headerAlign: 'right',
align: 'right',
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellNumeric value={params.value} secondary />
),
},
{
field: 'codusur2Name',
headerName: 'RCA 2',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'deliveryType',
headerName: 'Tipo de Entrega',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'deliveryDate',
headerName: 'Data Entrega',
width: 130,
minWidth: 120,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellDate value={params.value} />
),
},
{
field: 'deliveryLocal',
headerName: 'Local Entrega',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'deliveryPriority',
headerName: 'Prioridade',
width: 120,
minWidth: 110,
renderCell: (params: Readonly<GridRenderCellParams>) => {
const priority = params.value;
const chipProps = getPriorityChipProps(priority);
if (!chipProps) {
return <CellText value="-" secondary />;
}
return (
<Tooltip title={chipProps.label} arrow placement="top">
<Chip
label={chipProps.label}
color={chipProps.color}
size="small"
sx={CHIP_PRIORITY_STYLES}
/>
</Tooltip>
);
},
},
{
field: 'confirmDeliveryDate',
headerName: 'Data Confirmação Entrega',
width: 150,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellDate value={params.value} />
),
},
{
field: 'schedulerDelivery',
headerName: 'Agendamento',
width: 160,
minWidth: 140,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'partnerName',
headerName: 'Parceiro',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
{
field: 'emitenteNome',
headerName: 'Emitente',
width: 180,
minWidth: 160,
renderCell: (params: Readonly<GridRenderCellParams>) => (
<CellText value={params.value} />
),
},
];
};

View File

@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { useOrderFilters } from '../hooks/useOrderFilters'; import { useOrderFilters } from '../hooks/useOrderFilters';
import { useOrders } from '../hooks/useOrders';
import { import {
Box, Box,
TextField, TextField,
@ -12,6 +13,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,19 +34,63 @@ import 'moment/locale/pt-br';
moment.locale('pt-br'); moment.locale('pt-br');
interface LocalFilters {
status: string | null;
orderId: number | null;
customerId: number | null;
customerName: string | null;
createDateIni: string | null;
createDateEnd: string | null;
store: string[] | null;
stockId: string[] | null;
sellerId: string | null;
sellerName: string | null;
}
const getInitialLocalFilters = (
urlFilters: Partial<LocalFilters>
): LocalFilters => ({
status: urlFilters.status ?? null,
orderId: urlFilters.orderId ?? null,
customerId: urlFilters.customerId ?? null,
customerName: urlFilters.customerName ?? null,
createDateIni: urlFilters.createDateIni ?? null,
createDateEnd: urlFilters.createDateEnd ?? null,
store: urlFilters.store ?? null,
stockId: urlFilters.stockId ?? null,
sellerId: urlFilters.sellerId ?? null,
sellerName: urlFilters.sellerName ?? null,
});
export const SearchBar = () => { export const SearchBar = () => {
const [filters, setFilters] = useOrderFilters(); const [urlFilters, setUrlFilters] = useOrderFilters();
const [localFilters, setLocalFilters] = useState<LocalFilters>(() =>
getInitialLocalFilters(urlFilters)
);
const stores = useStores(); const stores = useStores();
const sellers = useSellers(); const sellers = useSellers();
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 { isFetching } = useOrders();
const [touchedFields, setTouchedFields] = useState<{ const [touchedFields, setTouchedFields] = useState<{
createDateIni?: boolean; createDateIni?: boolean;
createDateEnd?: boolean; createDateEnd?: boolean;
}>({}); }>({});
useEffect(() => {
setLocalFilters(getInitialLocalFilters(urlFilters));
}, [urlFilters]);
const updateLocalFilter = useCallback(
<K extends keyof LocalFilters>(key: K, value: LocalFilters[K]) => {
setLocalFilters((prev) => ({ ...prev, [key]: value }));
},
[]
);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setTouchedFields({}); setTouchedFields({});
setCustomerSearchTerm(''); setCustomerSearchTerm('');
@ -66,58 +114,70 @@ export const SearchBar = () => {
customerName: null, customerName: null,
}; };
setFilters(resetState); setLocalFilters(getInitialLocalFilters(resetState));
}, [setFilters]); setUrlFilters(resetState);
}, [setUrlFilters]);
const validateDates = useCallback(() => { const validateDates = useCallback(() => {
if (!filters.createDateIni || !filters.createDateEnd) { if (!localFilters.createDateIni || !localFilters.createDateEnd) {
return null; return null;
} }
const dateIni = moment(filters.createDateIni, 'YYYY-MM-DD'); const dateIni = moment(localFilters.createDateIni, 'YYYY-MM-DD');
const dateEnd = moment(filters.createDateEnd, 'YYYY-MM-DD'); const dateEnd = moment(localFilters.createDateEnd, 'YYYY-MM-DD');
if (dateEnd.isBefore(dateIni)) { if (dateEnd.isBefore(dateIni)) {
return 'Data final não pode ser anterior à data inicial'; return 'Data final não pode ser anterior à data inicial';
} }
return null; return null;
}, [filters.createDateIni, filters.createDateEnd]); }, [localFilters.createDateIni, localFilters.createDateEnd]);
const handleFilter = useCallback(() => { const handleFilter = useCallback(() => {
if (!filters.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;
} }
setFilters({ setUrlFilters({
...filters, ...localFilters,
searchTriggered: true, searchTriggered: true,
}); });
}, [filters, setFilters, validateDates]); }, [localFilters, setUrlFilters, validateDates]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback(
if (e.key === 'Enter') { (e: React.KeyboardEvent) => {
const isValid = !!filters.createDateIni; if (e.key === 'Enter') {
const dateErr = validateDates(); const isValid = !!localFilters.createDateIni;
if (isValid && !dateErr) { const dateErr = validateDates();
handleFilter(); if (isValid && !dateErr) {
handleFilter();
}
} }
} },
}, [filters.createDateIni, validateDates, handleFilter]); [localFilters.createDateIni, validateDates, handleFilter]
);
const isDateValid = !!filters.createDateIni; const isDateValid = !!localFilters.createDateIni;
const dateError = validateDates(); const dateError = validateDates();
const showDateIniError = touchedFields.createDateIni && !filters.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,
@ -126,8 +186,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) */}
@ -138,10 +197,10 @@ export const SearchBar = () => {
variant="outlined" variant="outlined"
size="small" size="small"
type="number" type="number"
value={filters.orderId ?? ''} value={localFilters.orderId ?? ''}
onChange={(e) => { onChange={(e) => {
const value = e.target.value ? Number(e.target.value) : null; const value = e.target.value ? Number(e.target.value) : null;
setFilters({ orderId: value }); updateLocalFilter('orderId', value);
}} }}
slotProps={{ htmlInput: { min: 0 } }} slotProps={{ htmlInput: { min: 0 } }}
placeholder="Ex: 12345" placeholder="Ex: 12345"
@ -155,10 +214,10 @@ export const SearchBar = () => {
fullWidth fullWidth
label="Situação" label="Situação"
size="small" size="small"
value={filters.status ?? ''} value={localFilters.status ?? ''}
onChange={(e) => { onChange={(e) => {
const value = e.target.value || null; const value = e.target.value || null;
setFilters({ status: value }); updateLocalFilter('status', value);
}} }}
> >
<MenuItem value="">Todos</MenuItem> <MenuItem value="">Todos</MenuItem>
@ -169,36 +228,35 @@ export const SearchBar = () => {
</Grid> </Grid>
{/* Autocomplete do MUI para Cliente */} {/* Autocomplete do MUI para Cliente */}
<Grid size={{ xs: 12, sm: 6, md: 2.5 }}> <Grid size={{ xs: 12, sm: 6, md: 2 }}>
<Autocomplete <Autocomplete
size="small" size="small"
options={customers.options} options={customers.options}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.id === value.id} isOptionEqualToValue={(option, value) => option.id === value.id}
value={customers.options.find(option => value={
filters.customerId === option.customer?.id customers.options.find(
) || null} (option) => localFilters.customerId === option.customer?.id
) || null
}
onChange={(_, newValue) => { onChange={(_, newValue) => {
if (!newValue) { if (!newValue) {
setFilters({ updateLocalFilter('customerName', null);
customerName: null, updateLocalFilter('customerId', null);
customerId: null,
});
setCustomerSearchTerm(''); setCustomerSearchTerm('');
return; return;
} }
setFilters({ updateLocalFilter('customerId', newValue.customer?.id || null);
customerId: newValue.customer?.id || null, updateLocalFilter(
customerName: newValue.customer?.name || null, 'customerName',
}); newValue.customer?.name || null
);
}} }}
onInputChange={(_, newInputValue, reason) => { onInputChange={(_, newInputValue, reason) => {
if (reason === 'clear') { if (reason === 'clear') {
setFilters({ updateLocalFilter('customerName', null);
customerName: null, updateLocalFilter('customerId', null);
customerId: null,
});
setCustomerSearchTerm(''); setCustomerSearchTerm('');
return; return;
} }
@ -206,23 +264,25 @@ export const SearchBar = () => {
if (reason === 'input') { if (reason === 'input') {
setCustomerSearchTerm(newInputValue); setCustomerSearchTerm(newInputValue);
if (!newInputValue) { if (!newInputValue) {
setFilters({ updateLocalFilter('customerName', null);
customerName: null, updateLocalFilter('customerId', null);
customerId: null,
});
setCustomerSearchTerm(''); setCustomerSearchTerm('');
} }
} }
}} }}
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}
@ -232,59 +292,96 @@ export const SearchBar = () => {
</Grid> </Grid>
{/* Campos de Data */} {/* Campos de Data */}
<Grid size={{ xs: 12, sm: 12, md: 4 }}> <Grid size={{ xs: 12, sm: 12, md: 3.5 }}>
<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={filters.createDateIni ? moment(filters.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) => ({
setFilters({ ...prev,
createDateIni: date ? date.format('YYYY-MM-DD') : null, createDateIni: true,
}); }));
updateLocalFilter(
'createDateIni',
date ? date.format('YYYY-MM-DD') : null
);
}} }}
format="DD/MM/YYYY" format="DD/MM/YYYY"
maxDate={filters.createDateEnd ? moment(filters.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={filters.createDateEnd ? moment(filters.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) => ({
setFilters({ ...prev,
createDateEnd: date ? date.format('YYYY-MM-DD') : null, createDateEnd: true,
}); }));
updateLocalFilter(
'createDateEnd',
date ? date.format('YYYY-MM-DD') : null
);
}} }}
format="DD/MM/YYYY" format="DD/MM/YYYY"
minDate={filters.createDateIni ? moment(filters.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>
@ -292,46 +389,97 @@ export const SearchBar = () => {
</LocalizationProvider> </LocalizationProvider>
</Grid> </Grid>
{/* Botões de Ação */} {/* Botão Mais Filtros - inline com filtros primários */}
<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: 2.5 }}
sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: { xs: 'flex-start', md: 'flex-end' } }}
>
<Badge
badgeContent={advancedFiltersCount}
color="primary"
invisible={advancedFiltersCount === 0}
>
<Button
size="small"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
endIcon={
showAdvancedFilters ? <ExpandLessIcon /> : <ExpandMoreIcon />
}
aria-label={showAdvancedFilters ? 'Ocultar filtros avançados' : 'Mostrar filtros avançados'}
sx={{ textTransform: 'none', color: 'text.secondary', minHeight: 40 }}
>
{showAdvancedFilters ? 'Menos filtros' : 'Mais filtros'}
</Button>
</Badge>
</Grid>
{/* Botões de Ação - nova linha abaixo */}
<Grid
size={{ xs: 12 }}
sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}
>
<Box sx={{ display: 'flex', gap: 1, width: { xs: '100%', sm: 'auto' }, flexWrap: 'nowrap' }}>
<Tooltip title="Limpar filtros" arrow> <Tooltip title="Limpar filtros" arrow>
<span> <span>
<Button <Button
variant="outlined" variant="outlined"
color="inherit" color="inherit"
size="small"
onClick={handleReset} onClick={handleReset}
aria-label="Limpar filtros"
sx={{ sx={{
minWidth: 40, minWidth: { xs: 'auto', sm: 90 },
minHeight: 32,
px: 1.5, px: 1.5,
flexShrink: 0,
flex: { xs: 1, sm: 'none' },
fontSize: '0.8125rem',
'&:hover': { '&:hover': {
bgcolor: 'action.hover', bgcolor: 'action.hover',
} },
}} }}
> >
<ResetIcon /> <ResetIcon sx={{ mr: 0.5, fontSize: 18 }} />
Limpar
</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>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
size="small"
onClick={handleFilter} onClick={handleFilter}
disabled={!isDateValid || !!dateError} disabled={!isDateValid || !!dateError || isFetching}
aria-label="Buscar pedidos"
sx={{ sx={{
minWidth: 40, minWidth: { xs: 'auto', sm: 90 },
minHeight: 32,
px: 1.5, px: 1.5,
flexShrink: 0,
flex: { xs: 1, sm: 'none' },
fontSize: '0.8125rem',
'&:disabled': { '&:disabled': {
opacity: 0.6, opacity: 0.6,
} },
}} }}
> >
<SearchIcon /> {isFetching ? (
<CircularProgress size={16} color="inherit" />
) : (
<>
<SearchIcon sx={{ mr: 0.5, fontSize: 18 }} />
Buscar
</>
)}
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
@ -340,16 +488,6 @@ 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 }}>
<Button
size="small"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
endIcon={showAdvancedFilters ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ textTransform: 'none', color: 'text.secondary' }}
>
{showAdvancedFilters ? 'Menos filtros' : 'Mais filtros'}
</Button>
</Box>
<Collapse in={showAdvancedFilters}> <Collapse in={showAdvancedFilters}>
<Grid container spacing={2} sx={{ pt: 2 }}> <Grid container spacing={2} sx={{ pt: 2 }}>
{/* Autocomplete do MUI para Múltiplas Filiais (codfilial) */} {/* Autocomplete do MUI para Múltiplas Filiais (codfilial) */}
@ -359,21 +497,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
filters.store?.includes(option.value) }
value={stores.options.filter((option) =>
localFilters.store?.includes(option.value)
)} )}
onChange={(_, newValue) => { onChange={(_, newValue) => {
setFilters({ updateLocalFilter(
store: newValue.map(option => option.value), '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={filters.store?.length ? `${filters.store.length} selecionadas` : 'Selecione'} placeholder={
localFilters.store?.length
? `${localFilters.store.length} selecionadas`
: 'Selecione'
}
/> />
)} )}
/> />
@ -386,21 +531,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
filters.stockId?.includes(option.value) }
value={stores.options.filter((option) =>
localFilters.stockId?.includes(option.value)
)} )}
onChange={(_, newValue) => { onChange={(_, newValue) => {
setFilters({ updateLocalFilter(
stockId: newValue.map(option => option.value), '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={filters.stockId?.length ? `${filters.stockId.length} selecionadas` : 'Selecione'} placeholder={
localFilters.stockId?.length
? `${localFilters.stockId.length} selecionadas`
: 'Selecione'
}
/> />
)} )}
/> />
@ -412,15 +564,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
filters.sellerId === option.seller.id.toString() }
) || null} value={
sellers.options.find(
(option) =>
localFilters.sellerId === option.seller.id.toString()
) || null
}
onChange={(_, newValue) => { onChange={(_, newValue) => {
setFilters({ updateLocalFilter(
sellerId: newValue?.seller.id.toString() || null, 'sellerId',
sellerName: newValue?.seller.name || null, newValue?.seller.id.toString() || null
}); );
updateLocalFilter(
'sellerName',
newValue?.seller.name || null
);
}} }}
loading={sellers.isLoading} loading={sellers.isLoading}
renderInput={(params) => ( renderInput={(params) => (
@ -443,7 +604,6 @@ 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

@ -1,21 +1,65 @@
'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 { useOrderDetails } from '../../hooks/useOrderDetails';
import { createInformationPanelColumns } from './InformationPanelColumns';
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 columns = useMemo(() => createInformationPanelColumns(), []);
const rows = useMemo(() => {
if (!order) return [];
return [
{
id: order.orderId || orderId,
...order,
},
];
}, [order, orderId]);
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 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

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

View File

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

View File

@ -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,6 +199,7 @@ 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' : ''
@ -195,6 +207,7 @@ getRowClassName={(params) =>
``` ```
#### 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

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

View File

@ -5,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',
}
);
}; };

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