Merge pull request 'feature/auth-state-machine' (#1) from feature/auth-state-machine into master
Reviewed-on: #1
This commit is contained in:
commit
2e94040481
|
|
@ -39,3 +39,6 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 80
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "http-localhost-9000",
|
||||
"projectKey": "Portal-web"
|
||||
}
|
||||
}
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "http-localhost-9000",
|
||||
"projectKey": "Portal-web"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -23,11 +23,13 @@ The application is designed to provide a robust and scalable frontend interface
|
|||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
```
|
||||
|
||||
2. Navigate to the project directory:
|
||||
|
||||
```bash
|
||||
cd portal-web-v2
|
||||
```
|
||||
|
|
@ -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:
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run dev` | Starts the development server with hot-reloading. |
|
||||
| `npm run build` | Compiles the application for production deployment. |
|
||||
| `npm start` | Runs the compiled production build locally. |
|
||||
| `npm run lint` | Runs ESLint to analyze code quality and fix issues. |
|
||||
| `npm test` | Executes the test suite using Jest. |
|
||||
| `npm run test:coverage` | Runs tests and generates a code coverage report. |
|
||||
| Script | Description |
|
||||
| ----------------------- | --------------------------------------------------- |
|
||||
| `npm run dev` | Starts the development server with hot-reloading. |
|
||||
| `npm run build` | Compiles the application for production deployment. |
|
||||
| `npm start` | Runs the compiled production build locally. |
|
||||
| `npm run lint` | Runs ESLint to analyze code quality and fix issues. |
|
||||
| `npm test` | Executes the test suite using Jest. |
|
||||
| `npm run test:coverage` | Runs tests and generates a code coverage report. |
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
|
|
|||
|
|
@ -3,93 +3,98 @@
|
|||
import createCache from '@emotion/cache';
|
||||
import { useServerInsertedHTML } from 'next/navigation';
|
||||
import { CacheProvider as DefaultCacheProvider } from '@emotion/react';
|
||||
import type { EmotionCache, Options as OptionsOfCreateCache } from '@emotion/cache';
|
||||
import type {
|
||||
EmotionCache,
|
||||
Options as OptionsOfCreateCache,
|
||||
} from '@emotion/cache';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
export type NextAppDirEmotionCacheProviderProps = {
|
||||
/** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */
|
||||
options: Omit<OptionsOfCreateCache, 'insertionPoint'>;
|
||||
/** By default <CacheProvider /> from 'import { CacheProvider } from "@emotion/react"' */
|
||||
CacheProvider?: (props: {
|
||||
value: EmotionCache;
|
||||
children: ReactNode;
|
||||
}) => React.JSX.Element | null;
|
||||
/** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */
|
||||
options: Omit<OptionsOfCreateCache, 'insertionPoint'>;
|
||||
/** By default <CacheProvider /> from 'import { CacheProvider } from "@emotion/react"' */
|
||||
CacheProvider?: (props: {
|
||||
value: EmotionCache;
|
||||
children: ReactNode;
|
||||
}) => React.JSX.Element | null;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx
|
||||
export default function NextAppDirEmotionCacheProvider(props: NextAppDirEmotionCacheProviderProps) {
|
||||
const { options, CacheProvider = DefaultCacheProvider, children } = props;
|
||||
export default function NextAppDirEmotionCacheProvider(
|
||||
props: NextAppDirEmotionCacheProviderProps
|
||||
) {
|
||||
const { options, CacheProvider = DefaultCacheProvider, children } = props;
|
||||
|
||||
const [registry] = useState(() => {
|
||||
const cache = createCache(options);
|
||||
cache.compat = true;
|
||||
const prevInsert = cache.insert;
|
||||
let inserted: { name: string; isGlobal: boolean }[] = [];
|
||||
cache.insert = (...args) => {
|
||||
const [selector, serialized] = args;
|
||||
if (cache.inserted[serialized.name] === undefined) {
|
||||
inserted.push({
|
||||
name: serialized.name,
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
const [registry] = useState(() => {
|
||||
const cache = createCache(options);
|
||||
cache.compat = true;
|
||||
const prevInsert = cache.insert;
|
||||
let inserted: { name: string; isGlobal: boolean }[] = [];
|
||||
cache.insert = (...args) => {
|
||||
const [selector, serialized] = args;
|
||||
if (cache.inserted[serialized.name] === undefined) {
|
||||
inserted.push({
|
||||
name: serialized.name,
|
||||
isGlobal: !selector,
|
||||
});
|
||||
}
|
||||
return prevInsert(...args);
|
||||
};
|
||||
const flush = () => {
|
||||
const prevInserted = inserted;
|
||||
inserted = [];
|
||||
return prevInserted;
|
||||
};
|
||||
return { cache, flush };
|
||||
});
|
||||
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
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 <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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,99 +8,110 @@ import { useCustomizerStore } from '@/features/dashboard/store/useCustomizerStor
|
|||
import { LicenseInfo } from '@mui/x-license';
|
||||
import { AuthInitializer } from '@/features/login/components/AuthInitializer';
|
||||
|
||||
const PERPETUAL_LICENSE_KEY = 'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
|
||||
const PERPETUAL_LICENSE_KEY =
|
||||
'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
|
||||
try {
|
||||
LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY);
|
||||
LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY);
|
||||
} 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 }>) {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const activeMode = useCustomizerStore((state) => state.activeMode);
|
||||
const isHydrated = useCustomizerStore((state) => state.isHydrated);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
const safeMode = mounted && isHydrated ? activeMode : 'light';
|
||||
|
||||
const safeMode = mounted && isHydrated ? activeMode : 'light';
|
||||
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: safeMode,
|
||||
primary: {
|
||||
main: '#5d87ff',
|
||||
light: '#ecf2ff',
|
||||
dark: '#4570ea',
|
||||
},
|
||||
secondary: {
|
||||
main: '#49beff',
|
||||
light: '#e8f7ff',
|
||||
dark: '#23afdb',
|
||||
},
|
||||
...(safeMode === 'dark'
|
||||
? {
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#b0bcc8',
|
||||
},
|
||||
background: {
|
||||
default: '#0b1426',
|
||||
paper: '#111c2e',
|
||||
},
|
||||
}
|
||||
: {
|
||||
text: {
|
||||
primary: '#1a1a1a',
|
||||
secondary: '#4a5568',
|
||||
},
|
||||
background: {
|
||||
default: '#ffffff',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
}),
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: safeMode,
|
||||
primary: {
|
||||
main: '#5d87ff',
|
||||
light: '#ecf2ff',
|
||||
dark: '#4570ea',
|
||||
},
|
||||
secondary: {
|
||||
main: '#49beff',
|
||||
light: '#e8f7ff',
|
||||
dark: '#23afdb',
|
||||
},
|
||||
...(safeMode === 'dark'
|
||||
? {
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#b0bcc8',
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Plus Jakarta Sans", "Helvetica", "Arial", sans-serif',
|
||||
h1: { fontWeight: 600 },
|
||||
h6: { fontWeight: 600 },
|
||||
body1: { fontSize: '0.875rem', fontWeight: 400 },
|
||||
background: {
|
||||
default: '#0b1426',
|
||||
paper: '#111c2e',
|
||||
},
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
text: {
|
||||
primary: '#1a1a1a',
|
||||
secondary: '#4a5568',
|
||||
},
|
||||
}),
|
||||
[safeMode]
|
||||
);
|
||||
background: {
|
||||
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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthInitializer>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</AuthInitializer>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthInitializer>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</AuthInitializer>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,21 @@ function OrdersContent() {
|
|||
|
||||
export default function OrdersPageRoute() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<OrdersContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
export { default } from '../../src/features/dashboard/pages/DashboardPage';
|
||||
|
||||
|
|
|
|||
|
|
@ -20,13 +20,21 @@ function ProfileContent() {
|
|||
|
||||
export default function ProfilePageRoute() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ProfileContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
|
|
@ -28,4 +28,4 @@ body {
|
|||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Plus_Jakarta_Sans } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Providers from "./components/Providers";
|
||||
import EmotionRegistry from "./components/EmotionRegistry";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono, Plus_Jakarta_Sans } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import Providers from './components/Providers';
|
||||
import EmotionRegistry from './components/EmotionRegistry';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const plusJakartaSans = Plus_Jakarta_Sans({
|
||||
variable: "--font-plus-jakarta",
|
||||
subsets: ["latin"],
|
||||
variable: '--font-plus-jakarta',
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Portal Jurunense - Login Page",
|
||||
description: "Login page for Modernize dashboard",
|
||||
title: 'Portal Jurunense - Login Page',
|
||||
description: 'Login page for Modernize dashboard',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -36,7 +36,6 @@ export default function RootLayout({
|
|||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${plusJakartaSans.variable} antialiased`}
|
||||
>
|
||||
|
||||
<EmotionRegistry options={{ key: 'mui' }}>
|
||||
<NuqsAdapter>
|
||||
<Providers>{children}</Providers>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import Login from "../../src/features/login/components/LoginForm";
|
||||
import Login from '../../src/features/login/components/LoginForm';
|
||||
|
||||
export default function LoginPage() {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Login from "../src/features/login/components/LoginForm";
|
||||
import Login from '../src/features/login/components/LoginForm';
|
||||
|
||||
export default function Home() {
|
||||
return <Login />;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTs from 'eslint-config-next/typescript';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
|
|
@ -8,10 +11,10 @@ const eslintConfig = defineConfig([
|
|||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
]),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,33 +3,33 @@ import '@testing-library/jest-dom';
|
|||
|
||||
// Mock Next.js router
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter() {
|
||||
return {
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
prefetch: jest.fn(),
|
||||
back: jest.fn(),
|
||||
};
|
||||
},
|
||||
usePathname() {
|
||||
return '';
|
||||
},
|
||||
useSearchParams() {
|
||||
return new URLSearchParams();
|
||||
},
|
||||
useRouter() {
|
||||
return {
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
prefetch: jest.fn(),
|
||||
back: jest.fn(),
|
||||
};
|
||||
},
|
||||
usePathname() {
|
||||
return '';
|
||||
},
|
||||
useSearchParams() {
|
||||
return new URLSearchParams();
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
|
||||
import { LicenseInfo } from '@mui/x-license';
|
||||
|
||||
const PERPETUAL_LICENSE_KEY = 'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
|
||||
const ALTERNATIVE_LICENSE_KEY = '61628ce74db2c1b62783a6d438593bc5Tz1NVUktRG9jLEU9MTY4MzQ0NzgyMTI4NCxTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLEtWPTI=';
|
||||
const PERPETUAL_LICENSE_KEY =
|
||||
'e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y';
|
||||
const ALTERNATIVE_LICENSE_KEY =
|
||||
'61628ce74db2c1b62783a6d438593bc5Tz1NVUktRG9jLEU9MTY4MzQ0NzgyMTI4NCxTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLEtWPTI=';
|
||||
|
||||
try {
|
||||
LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY);
|
||||
LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY);
|
||||
} catch (error) {
|
||||
console.warn('Failed to set perpetual license key', error);
|
||||
try {
|
||||
LicenseInfo.setLicenseKey(ALTERNATIVE_LICENSE_KEY);
|
||||
} catch (fallbackError) {
|
||||
console.error('Failed to set fallback license key', fallbackError);
|
||||
}
|
||||
console.warn('Failed to set perpetual license key', error);
|
||||
try {
|
||||
LicenseInfo.setLicenseKey(ALTERNATIVE_LICENSE_KEY);
|
||||
} catch (fallbackError) {
|
||||
console.error('Failed to set fallback license key', fallbackError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// ... suas outras configurações (como rewrites)
|
||||
allowedDevOrigins: ["portalconsulta.jurunense.com"],
|
||||
allowedDevOrigins: ['portalconsulta.jurunense.com'],
|
||||
transpilePackages: ['@mui/material', '@emotion/react', '@emotion/styled'],
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/auth/:path*",
|
||||
destination: "https://api.auth.jurunense.com/api/v1/:path*",
|
||||
source: '/api/auth/:path*',
|
||||
destination: 'https://api.auth.jurunense.com/api/v1/:path*',
|
||||
},
|
||||
{
|
||||
source: "/api/report-viewer/:path*",
|
||||
destination: "http://10.1.1.205:8068/Viewer/:path*",
|
||||
source: '/api/report-viewer/:path*',
|
||||
destination: 'http://10.1.1.205:8068/Viewer/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
150
package.json
150
package.json
|
|
@ -1,69 +1,85 @@
|
|||
{
|
||||
"name": "portal-web-v2",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mui/icons-material": "^7.3.6",
|
||||
"@mui/lab": "^7.0.1-beta.20",
|
||||
"@mui/material": "^7.3.6",
|
||||
"@mui/material-nextjs": "^7.3.6",
|
||||
"@mui/styled-engine-sc": "^6.0.0-alpha.1",
|
||||
"@mui/x-data-grid": "^8.23.0",
|
||||
"@mui/x-data-grid-generator": "^8.23.0",
|
||||
"@mui/x-data-grid-premium": "^8.23.0",
|
||||
"@mui/x-data-grid-pro": "^8.23.0",
|
||||
"@mui/x-date-pickers": "^8.23.0",
|
||||
"@mui/x-license": "^8.23.0",
|
||||
"@mui/x-tree-view": "^8.23.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
"@tabler/icons-react": "^2.47.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@xstate/react": "^6.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"jest": "^30.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"next": "16.1.1",
|
||||
"next-auth": "latest",
|
||||
"nuqs": "^2.8.6",
|
||||
"react": "19.2.3",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
"simplebar": "^6.3.3",
|
||||
"simplebar-react": "^3.3.2",
|
||||
"stimulsoft-reports-js": "^2026.1.1",
|
||||
"xstate": "^5.25.0",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
"name": "portal-web-v2",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mui/icons-material": "^7.3.6",
|
||||
"@mui/lab": "^7.0.1-beta.20",
|
||||
"@mui/material": "^7.3.6",
|
||||
"@mui/material-nextjs": "^7.3.6",
|
||||
"@mui/styled-engine-sc": "^6.0.0-alpha.1",
|
||||
"@mui/x-data-grid": "^8.23.0",
|
||||
"@mui/x-data-grid-generator": "^8.23.0",
|
||||
"@mui/x-data-grid-premium": "^8.23.0",
|
||||
"@mui/x-data-grid-pro": "^8.23.0",
|
||||
"@mui/x-date-pickers": "^8.23.0",
|
||||
"@mui/x-license": "^8.23.0",
|
||||
"@mui/x-tree-view": "^8.23.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
"@tabler/icons-react": "^2.47.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@xstate/react": "^6.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"jest": "^30.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"next": "16.1.1",
|
||||
"next-auth": "latest",
|
||||
"nuqs": "^2.8.6",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "19.2.3",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
"simplebar": "^6.3.3",
|
||||
"simplebar-react": "^3.3.2",
|
||||
"stimulsoft-reports-js": "^2026.1.1",
|
||||
"xstate": "^5.25.0",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"@types/react-dom": "^19",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import Typography from '@mui/material/Typography';
|
|||
import DashboardOverview from './DashboardOverview';
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography variant="h4" fontWeight="700" mb={3}>
|
||||
Dashboard
|
||||
</Typography>
|
||||
<DashboardOverview />
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography variant="h4" fontWeight="700" mb={3}>
|
||||
Dashboard
|
||||
</Typography>
|
||||
<DashboardOverview />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ const StyledMain = styled(Box, {
|
|||
}),
|
||||
}));
|
||||
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const theme = useTheme();
|
||||
const lgUp = useMediaQuery(theme.breakpoints.up('lg'));
|
||||
|
|
@ -37,10 +36,15 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
if (!isHydrated) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }}>
|
||||
<StyledMain isMobile={!lgUp}>
|
||||
{children}
|
||||
</StyledMain>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<StyledMain isMobile={!lgUp}>{children}</StyledMain>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -49,13 +53,17 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh' }}>
|
||||
<Sidebar />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
<StyledMain isMobile={!lgUp}>
|
||||
{children}
|
||||
</StyledMain>
|
||||
<StyledMain isMobile={!lgUp}>{children}</StyledMain>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,35 +6,34 @@ import Card from '@mui/material/Card';
|
|||
import CardContent from '@mui/material/CardContent';
|
||||
|
||||
export default function DashboardOverview() {
|
||||
const handlePortalTorreClick = () => {
|
||||
window.open('https://portaltorre.jurunense.com/', '_blank');
|
||||
};
|
||||
const handlePortalTorreClick = () => {
|
||||
window.open('https://portaltorre.jurunense.com/', '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Card
|
||||
onClick={handlePortalTorreClick}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="600" mb={1}>
|
||||
Portal Torre
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Acesse o Portal Torre de Controle
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Card
|
||||
onClick={handlePortalTorreClick}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight="600" mb={1}>
|
||||
Portal Torre
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Acesse o Portal Torre de Controle
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,3 @@ export default function Logo() {
|
|||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import SimpleBar from "simplebar-react";
|
||||
import "simplebar-react/dist/simplebar.min.css";
|
||||
import SimpleBar from 'simplebar-react';
|
||||
import 'simplebar-react/dist/simplebar.min.css';
|
||||
import Box from '@mui/material/Box';
|
||||
import { SxProps } from '@mui/system';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { useMediaQuery } from "@mui/material";
|
||||
import { useMediaQuery } from '@mui/material';
|
||||
|
||||
const SimpleBarStyle = styled(SimpleBar)(() => ({
|
||||
maxHeight: "100%",
|
||||
maxHeight: '100%',
|
||||
}));
|
||||
|
||||
interface PropsType {
|
||||
|
|
@ -19,7 +19,7 @@ const Scrollbar = (props: PropsType) => {
|
|||
const lgDown = useMediaQuery((theme: any) => theme.breakpoints.down('lg'));
|
||||
|
||||
if (lgDown) {
|
||||
return <Box sx={{ overflowX: "auto" }}>{children}</Box>;
|
||||
return <Box sx={{ overflowX: 'auto' }}>{children}</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -30,4 +30,3 @@ const Scrollbar = (props: PropsType) => {
|
|||
};
|
||||
|
||||
export default Scrollbar;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { styled, Theme } from "@mui/material/styles";
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { styled, Theme } from '@mui/material/styles';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import { useCustomizerStore } from "../store/useCustomizerStore";
|
||||
import { useCustomizerStore } from '../store/useCustomizerStore';
|
||||
|
||||
import Notifications from "./Notification";
|
||||
import Profile from "./Profile";
|
||||
import Search from "./Search";
|
||||
import Navigation from "./Navigation";
|
||||
import MobileRightSidebar from "./MobileRightSidebar";
|
||||
import Notifications from './Notification';
|
||||
import Profile from './Profile';
|
||||
import Search from './Search';
|
||||
import Navigation from './Navigation';
|
||||
import MobileRightSidebar from './MobileRightSidebar';
|
||||
|
||||
const AppBarStyled = styled(AppBar)(({ theme }) => ({
|
||||
boxShadow: "none",
|
||||
boxShadow: 'none',
|
||||
background: theme.palette.background.paper,
|
||||
justifyContent: "center",
|
||||
backdropFilter: "blur(4px)",
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
}));
|
||||
|
||||
const ToolbarStyled = styled(Toolbar)(({ theme }) => ({
|
||||
width: "100%",
|
||||
width: '100%',
|
||||
color: theme.palette.text.secondary,
|
||||
}));
|
||||
|
||||
const Header = () => {
|
||||
const lgUp = useMediaQuery((theme: Theme) => theme.breakpoints.up("lg"));
|
||||
const lgDown = useMediaQuery((theme: Theme) => theme.breakpoints.down("lg"));
|
||||
const lgUp = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg'));
|
||||
const lgDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('lg'));
|
||||
|
||||
const {
|
||||
activeMode,
|
||||
toggleSidebar,
|
||||
toggleMobileSidebar,
|
||||
setDarkMode,
|
||||
isHydrated
|
||||
isHydrated,
|
||||
} = useCustomizerStore();
|
||||
|
||||
if (!isHydrated) {
|
||||
|
|
@ -70,21 +70,18 @@ const Header = () => {
|
|||
<Box flexGrow={1} />
|
||||
|
||||
<Stack spacing={1} direction="row" alignItems="center">
|
||||
|
||||
{/* ------------------------------------------- */}
|
||||
{/* Theme Toggle (Dark/Light) */}
|
||||
{/* ------------------------------------------- */}
|
||||
<IconButton
|
||||
size="large"
|
||||
color="inherit"
|
||||
onClick={() => setDarkMode(activeMode === "light" ? "dark" : "light")}
|
||||
onClick={() =>
|
||||
setDarkMode(activeMode === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
aria-label="alternar tema"
|
||||
>
|
||||
{activeMode === "light" ? (
|
||||
<DarkModeIcon />
|
||||
) : (
|
||||
<LightModeIcon />
|
||||
)}
|
||||
{activeMode === 'light' ? <DarkModeIcon /> : <LightModeIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Notifications />
|
||||
|
|
@ -100,4 +97,4 @@ const Header = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default Header;
|
||||
|
|
|
|||
|
|
@ -85,8 +85,7 @@ const MobileRightSidebar = () => {
|
|||
</List>
|
||||
</Box>
|
||||
|
||||
<Box px={3} mt={3}>
|
||||
</Box>
|
||||
<Box px={3} mt={3}></Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState } from "react";
|
||||
import { Box, Menu, Typography, Button, Divider, Grid } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import { IconChevronDown, IconHelp } from "@tabler/icons-react";
|
||||
import AppLinks from "./AppLinks";
|
||||
import { useState } from 'react';
|
||||
import { Box, Menu, Typography, Button, Divider, Grid } from '@mui/material';
|
||||
import Link from 'next/link';
|
||||
import { IconChevronDown, IconHelp } from '@tabler/icons-react';
|
||||
import AppLinks from './AppLinks';
|
||||
|
||||
const AppDD = () => {
|
||||
const [anchorEl2, setAnchorEl2] = useState(null);
|
||||
|
|
@ -25,17 +25,17 @@ const AppDD = () => {
|
|||
aria-controls="msgs-menu"
|
||||
aria-haspopup="true"
|
||||
sx={{
|
||||
bgcolor: anchorEl2 ? "primary.light" : "",
|
||||
bgcolor: anchorEl2 ? 'primary.light' : '',
|
||||
color: anchorEl2
|
||||
? "primary.main"
|
||||
? 'primary.main'
|
||||
: (theme) => theme.palette.text.secondary,
|
||||
fontSize: "13px",
|
||||
fontSize: '13px',
|
||||
}}
|
||||
onClick={handleClick2}
|
||||
endIcon={
|
||||
<IconChevronDown
|
||||
size="15"
|
||||
style={{ marginLeft: "-5px", marginTop: "2px" }}
|
||||
style={{ marginLeft: '-5px', marginTop: '2px' }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
@ -50,13 +50,13 @@ const AppDD = () => {
|
|||
keepMounted
|
||||
open={Boolean(anchorEl2)}
|
||||
onClose={handleClose2}
|
||||
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
|
||||
transformOrigin={{ horizontal: "left", vertical: "top" }}
|
||||
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
|
||||
transformOrigin={{ horizontal: 'left', vertical: 'top' }}
|
||||
sx={{
|
||||
"& .MuiMenu-paper": {
|
||||
width: "850px",
|
||||
'& .MuiMenu-paper': {
|
||||
width: '850px',
|
||||
},
|
||||
"& .MuiMenu-paper ul": {
|
||||
'& .MuiMenu-paper ul': {
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
|
|
@ -69,8 +69,8 @@ const AppDD = () => {
|
|||
<Box
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: "flex",
|
||||
xs: 'none',
|
||||
sm: 'flex',
|
||||
},
|
||||
}}
|
||||
alignItems="center"
|
||||
|
|
@ -99,15 +99,17 @@ const AppDD = () => {
|
|||
<Divider orientation="vertical" />
|
||||
</Grid>
|
||||
<Grid size={{ sm: 4 }}>
|
||||
<Box p={4}>
|
||||
</Box>
|
||||
<Box p={4}></Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Menu>
|
||||
</Box>
|
||||
<Button
|
||||
color="inherit"
|
||||
sx={{ color: (theme) => theme.palette.text.secondary, fontSize: "13px" }}
|
||||
sx={{
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
fontSize: '13px',
|
||||
}}
|
||||
variant="text"
|
||||
href="/apps/chats"
|
||||
component={Link}
|
||||
|
|
@ -116,7 +118,10 @@ const AppDD = () => {
|
|||
</Button>
|
||||
<Button
|
||||
color="inherit"
|
||||
sx={{ color: (theme) => theme.palette.text.secondary, fontSize: "13px" }}
|
||||
sx={{
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
fontSize: '13px',
|
||||
}}
|
||||
variant="text"
|
||||
href="/apps/email"
|
||||
component={Link}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
Chip,
|
||||
} from '@mui/material';
|
||||
import * as dropdownData from './data';
|
||||
import Scrollbar from '../components/Scrollbar';
|
||||
import { Scrollbar } from '@/shared/components';
|
||||
|
||||
import { IconBellRinging } from '@tabler/icons-react';
|
||||
import { Stack } from '@mui/system';
|
||||
|
|
@ -62,7 +62,13 @@ const Notifications = () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" py={2} px={4} justifyContent="space-between" alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
py={2}
|
||||
px={4}
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography variant="h6">Notifications</Typography>
|
||||
<Chip label="5 new" color="primary" size="small" />
|
||||
</Stack>
|
||||
|
|
@ -108,7 +114,13 @@ const Notifications = () => {
|
|||
))}
|
||||
</Scrollbar>
|
||||
<Box p={3} pb={1}>
|
||||
<Button href="/apps/email" variant="outlined" component={Link} color="primary" fullWidth>
|
||||
<Button
|
||||
href="/apps/email"
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
color="primary"
|
||||
fullWidth
|
||||
>
|
||||
See all Notifications
|
||||
</Button>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -9,15 +9,17 @@ import {
|
|||
Button,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import * as dropdownData from './data';
|
||||
|
||||
import { IconMail } from '@tabler/icons-react';
|
||||
import { Stack } from '@mui/system';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { useAuthStore } from '../../login/store/useAuthStore';
|
||||
import { useAuth } from '../../login/hooks/useAuth';
|
||||
|
||||
const Profile = () => {
|
||||
const [anchorEl2, setAnchorEl2] = useState(null);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { logout } = useAuth();
|
||||
|
||||
const handleClick2 = (event: any) => {
|
||||
setAnchorEl2(event.currentTarget);
|
||||
};
|
||||
|
|
@ -40,13 +42,14 @@ const Profile = () => {
|
|||
onClick={handleClick2}
|
||||
>
|
||||
<Avatar
|
||||
src={"/images/profile/user-1.jpg"}
|
||||
alt={'ProfileImg'}
|
||||
sx={{
|
||||
width: 35,
|
||||
height: 35,
|
||||
bgcolor: 'primary.main',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{user?.nome?.[0] || user?.userName?.[0] || 'U'}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
{/* ------------------------------------------- */}
|
||||
{/* Message Dropdown */}
|
||||
|
|
@ -68,13 +71,21 @@ const Profile = () => {
|
|||
>
|
||||
<Typography variant="h5">User Profile</Typography>
|
||||
<Stack direction="row" py={3} spacing={2} alignItems="center">
|
||||
<Avatar src={"/images/profile/user-1.jpg"} alt={"ProfileImg"} sx={{ width: 95, height: 95 }} />
|
||||
<Avatar
|
||||
sx={{ width: 95, height: 95, bgcolor: 'primary.main' }}
|
||||
>
|
||||
{user?.nome?.[0] || user?.userName?.[0] || 'U'}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="textPrimary" fontWeight={600}>
|
||||
Mathew Anderson
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="textPrimary"
|
||||
fontWeight={600}
|
||||
>
|
||||
{user?.nome || user?.userName || 'Usuário'}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" color="textSecondary">
|
||||
Designer
|
||||
{user?.nomeFilial || 'Sem filial'}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
|
|
@ -84,80 +95,18 @@ const Profile = () => {
|
|||
gap={1}
|
||||
>
|
||||
<IconMail width={15} height={15} />
|
||||
info@modernize.com
|
||||
{user?.rca ? `RCA: ${user.rca}` : 'Sem e-mail'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</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 bgcolor="primary.light" p={3} mb={3} overflow="hidden" position="relative">
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="h5" mb={2}>
|
||||
Unlimited <br />
|
||||
Access
|
||||
</Typography>
|
||||
<Button variant="contained" color="primary">
|
||||
Upgrade
|
||||
</Button>
|
||||
</Box>
|
||||
<Image src={"/images/backgrounds/unlimited-bg.png"} width={150} height={183} style={{ height: 'auto', width: 'auto' }} alt="unlimited" className="signup-bg" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Button href="/auth/auth1/login" variant="outlined" color="primary" component={Link} fullWidth>
|
||||
Logout
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={logout}
|
||||
fullWidth
|
||||
>
|
||||
Sair
|
||||
</Button>
|
||||
</Box>
|
||||
</Menu>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ interface MenuType {
|
|||
title: string;
|
||||
id: string;
|
||||
subheader: string;
|
||||
children: MenuType[];
|
||||
children: MenuType[];
|
||||
href: string;
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +36,9 @@ const Search = () => {
|
|||
const filterRoutes = (rotr: any, cSearch: string) => {
|
||||
if (rotr.length > 1)
|
||||
return rotr.filter((t: any) =>
|
||||
t.title ? t.href.toLocaleLowerCase().includes(cSearch.toLocaleLowerCase()) : '',
|
||||
t.title
|
||||
? t.href.toLocaleLowerCase().includes(cSearch.toLocaleLowerCase())
|
||||
: ''
|
||||
);
|
||||
|
||||
return rotr;
|
||||
|
|
@ -89,7 +91,11 @@ const Search = () => {
|
|||
return (
|
||||
<Box key={menu.title ? menu.id : menu.subheader}>
|
||||
{menu.title && !menu.children ? (
|
||||
<ListItemButton sx={{ py: 0.5, px: 1 }} href={menu?.href} component={Link}>
|
||||
<ListItemButton
|
||||
sx={{ py: 0.5, px: 1 }}
|
||||
href={menu?.href}
|
||||
component={Link}
|
||||
>
|
||||
<ListItemText
|
||||
primary={menu.title}
|
||||
secondary={menu?.href}
|
||||
|
|
|
|||
|
|
@ -10,51 +10,51 @@ interface NotificationType {
|
|||
const notifications: NotificationType[] = [
|
||||
{
|
||||
id: '1',
|
||||
avatar: "/images/profile/user-2.jpg",
|
||||
title: "Roman Joined the Team!",
|
||||
subtitle: "Congratulate him",
|
||||
avatar: '/images/profile/user-2.jpg',
|
||||
title: 'Roman Joined the Team!',
|
||||
subtitle: 'Congratulate him',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
avatar: "/images/profile/user-3.jpg",
|
||||
title: "New message received",
|
||||
subtitle: "Salma sent you new message",
|
||||
avatar: '/images/profile/user-3.jpg',
|
||||
title: 'New message received',
|
||||
subtitle: 'Salma sent you new message',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
avatar: "/images/profile/user-4.jpg",
|
||||
title: "New Payment received",
|
||||
subtitle: "Check your earnings",
|
||||
avatar: '/images/profile/user-4.jpg',
|
||||
title: 'New Payment received',
|
||||
subtitle: 'Check your earnings',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
avatar: "/images/profile/user-5.jpg",
|
||||
title: "Jolly completed tasks",
|
||||
subtitle: "Assign her new tasks",
|
||||
avatar: '/images/profile/user-5.jpg',
|
||||
title: 'Jolly completed tasks',
|
||||
subtitle: 'Assign her new tasks',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
avatar: "/images/profile/user-6.jpg",
|
||||
title: "Roman Joined the Team!",
|
||||
subtitle: "Congratulate him",
|
||||
avatar: '/images/profile/user-6.jpg',
|
||||
title: 'Roman Joined the Team!',
|
||||
subtitle: 'Congratulate him',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
avatar: "/images/profile/user-7.jpg",
|
||||
title: "New message received",
|
||||
subtitle: "Salma sent you new message",
|
||||
avatar: '/images/profile/user-7.jpg',
|
||||
title: 'New message received',
|
||||
subtitle: 'Salma sent you new message',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
avatar: "/images/profile/user-8.jpg",
|
||||
title: "New Payment received",
|
||||
subtitle: "Check your earnings",
|
||||
avatar: '/images/profile/user-8.jpg',
|
||||
title: 'New Payment received',
|
||||
subtitle: 'Check your earnings',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
avatar: "/images/profile/user-9.jpg",
|
||||
title: "Jolly completed tasks",
|
||||
subtitle: "Assign her new tasks",
|
||||
avatar: '/images/profile/user-9.jpg',
|
||||
title: 'Jolly completed tasks',
|
||||
subtitle: 'Assign her new tasks',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -69,22 +69,22 @@ interface ProfileType {
|
|||
}
|
||||
const profile: ProfileType[] = [
|
||||
{
|
||||
href: "/dashboard/profile",
|
||||
title: "My Profile",
|
||||
subtitle: "Account Settings",
|
||||
icon: "/images/svgs/icon-account.svg",
|
||||
href: '/dashboard/profile',
|
||||
title: 'My Profile',
|
||||
subtitle: 'Account Settings',
|
||||
icon: '/images/svgs/icon-account.svg',
|
||||
},
|
||||
{
|
||||
href: "/apps/email",
|
||||
title: "My Inbox",
|
||||
subtitle: "Messages & Emails",
|
||||
icon: "/images/svgs/icon-inbox.svg",
|
||||
href: '/apps/email',
|
||||
title: 'My Inbox',
|
||||
subtitle: 'Messages & Emails',
|
||||
icon: '/images/svgs/icon-inbox.svg',
|
||||
},
|
||||
{
|
||||
href: "/apps/notes",
|
||||
title: "My Tasks",
|
||||
subtitle: "To-do and Daily Tasks",
|
||||
icon: "/images/svgs/icon-tasks.svg",
|
||||
href: '/apps/notes',
|
||||
title: 'My Tasks',
|
||||
subtitle: 'To-do and Daily Tasks',
|
||||
icon: '/images/svgs/icon-tasks.svg',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -99,46 +99,46 @@ interface AppsLinkType {
|
|||
|
||||
const appsLink: AppsLinkType[] = [
|
||||
{
|
||||
href: "/apps/chats",
|
||||
title: "Chat Application",
|
||||
subtext: "New messages arrived",
|
||||
avatar: "/images/svgs/icon-dd-chat.svg",
|
||||
href: '/apps/chats',
|
||||
title: 'Chat Application',
|
||||
subtext: 'New messages arrived',
|
||||
avatar: '/images/svgs/icon-dd-chat.svg',
|
||||
},
|
||||
{
|
||||
href: "/apps/ecommerce/shop",
|
||||
title: "eCommerce App",
|
||||
subtext: "New stock available",
|
||||
avatar: "/images/svgs/icon-dd-cart.svg",
|
||||
href: '/apps/ecommerce/shop',
|
||||
title: 'eCommerce App',
|
||||
subtext: 'New stock available',
|
||||
avatar: '/images/svgs/icon-dd-cart.svg',
|
||||
},
|
||||
{
|
||||
href: "/apps/notes",
|
||||
title: "Notes App",
|
||||
subtext: "To-do and Daily tasks",
|
||||
avatar: "/images/svgs/icon-dd-invoice.svg",
|
||||
href: '/apps/notes',
|
||||
title: 'Notes App',
|
||||
subtext: 'To-do and Daily tasks',
|
||||
avatar: '/images/svgs/icon-dd-invoice.svg',
|
||||
},
|
||||
{
|
||||
href: "/apps/contacts",
|
||||
title: "Contact Application",
|
||||
subtext: "2 Unsaved Contacts",
|
||||
avatar: "/images/svgs/icon-dd-mobile.svg",
|
||||
href: '/apps/contacts',
|
||||
title: 'Contact Application',
|
||||
subtext: '2 Unsaved Contacts',
|
||||
avatar: '/images/svgs/icon-dd-mobile.svg',
|
||||
},
|
||||
{
|
||||
href: "/apps/tickets",
|
||||
title: "Tickets App",
|
||||
subtext: "Submit tickets",
|
||||
avatar: "/images/svgs/icon-dd-lifebuoy.svg",
|
||||
href: '/apps/tickets',
|
||||
title: 'Tickets App',
|
||||
subtext: 'Submit tickets',
|
||||
avatar: '/images/svgs/icon-dd-lifebuoy.svg',
|
||||
},
|
||||
{
|
||||
href: "/apps/email",
|
||||
title: "Email App",
|
||||
subtext: "Get new emails",
|
||||
avatar: "/images/svgs/icon-dd-message-box.svg",
|
||||
href: '/apps/email',
|
||||
title: 'Email App',
|
||||
subtext: 'Get new emails',
|
||||
avatar: '/images/svgs/icon-dd-message-box.svg',
|
||||
},
|
||||
{
|
||||
href: "/apps/blog/post",
|
||||
title: "Blog App",
|
||||
subtext: "added new blog",
|
||||
avatar: "/images/svgs/icon-dd-application.svg",
|
||||
href: '/apps/blog/post',
|
||||
title: 'Blog App',
|
||||
subtext: 'added new blog',
|
||||
avatar: '/images/svgs/icon-dd-application.svg',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -149,36 +149,36 @@ interface LinkType {
|
|||
|
||||
const pageLinks: LinkType[] = [
|
||||
{
|
||||
href: "/theme-pages/pricing",
|
||||
title: "Pricing Page",
|
||||
href: '/theme-pages/pricing',
|
||||
title: 'Pricing Page',
|
||||
},
|
||||
{
|
||||
href: "/auth/auth1/login",
|
||||
title: "Authentication Design",
|
||||
href: '/auth/auth1/login',
|
||||
title: 'Authentication Design',
|
||||
},
|
||||
{
|
||||
href: "/auth/auth1/register",
|
||||
title: "Register Now",
|
||||
href: '/auth/auth1/register',
|
||||
title: 'Register Now',
|
||||
},
|
||||
{
|
||||
href: "/404",
|
||||
title: "404 Error Page",
|
||||
href: '/404',
|
||||
title: '404 Error Page',
|
||||
},
|
||||
{
|
||||
href: "/apps/note",
|
||||
title: "Notes App",
|
||||
href: '/apps/note',
|
||||
title: 'Notes App',
|
||||
},
|
||||
{
|
||||
href: "/apps/user-profile/profile",
|
||||
title: "User Application",
|
||||
href: '/apps/user-profile/profile',
|
||||
title: 'User Application',
|
||||
},
|
||||
{
|
||||
href: "/apps/blog/post",
|
||||
title: "Blog Design",
|
||||
href: '/apps/blog/post',
|
||||
title: 'Blog Design',
|
||||
},
|
||||
{
|
||||
href: "/apps/ecommerce/checkout",
|
||||
title: "Shopping Cart",
|
||||
href: '/apps/ecommerce/checkout',
|
||||
title: 'Shopping Cart',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,2 @@
|
|||
export { default as Dashboard } from './components/Dashboard';
|
||||
export { default as DashboardPage } from './pages/DashboardPage';
|
||||
|
||||
|
|
|
|||
|
|
@ -8,4 +8,3 @@ export default function DashboardPage() {
|
|||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,4 +36,3 @@ const Menuitems: MenuItemType[] = [
|
|||
];
|
||||
|
||||
export default Menuitems;
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import Typography from '@mui/material/Typography';
|
|||
import Tooltip from '@mui/material/Tooltip';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import SidebarItems from './SidebarItems';
|
||||
import Scrollbar from '../components/Scrollbar';
|
||||
import { Scrollbar } from '@/shared/components';
|
||||
import { useCustomizerStore } from '../store/useCustomizerStore';
|
||||
|
||||
// Mixins para transições
|
||||
|
|
@ -44,8 +44,18 @@ interface StyledDrawerProps {
|
|||
}
|
||||
|
||||
const StyledDrawer = styled(Drawer, {
|
||||
shouldForwardProp: (prop) => prop !== 'isCollapsed' && prop !== 'isMobile' && prop !== 'sidebarWidth' && prop !== 'miniSidebarWidth',
|
||||
})<StyledDrawerProps & { sidebarWidth?: number; miniSidebarWidth?: number }>(({ theme, isCollapsed, isMobile, sidebarWidth = 270, miniSidebarWidth = 87 }) => {
|
||||
shouldForwardProp: (prop) =>
|
||||
prop !== 'isCollapsed' &&
|
||||
prop !== 'isMobile' &&
|
||||
prop !== 'sidebarWidth' &&
|
||||
prop !== 'miniSidebarWidth',
|
||||
})<StyledDrawerProps & { sidebarWidth?: number; miniSidebarWidth?: number }>(({
|
||||
theme,
|
||||
isCollapsed,
|
||||
isMobile,
|
||||
sidebarWidth = 270,
|
||||
miniSidebarWidth = 87,
|
||||
}) => {
|
||||
const desktopWidth = isCollapsed ? miniSidebarWidth : sidebarWidth;
|
||||
const drawerWidth = isMobile ? sidebarWidth : desktopWidth;
|
||||
|
||||
|
|
@ -62,7 +72,9 @@ const StyledDrawer = styled(Drawer, {
|
|||
}),
|
||||
...(!isMobile && {
|
||||
'& .MuiDrawer-paper': {
|
||||
...(isCollapsed ? closedMixin(theme, miniSidebarWidth) : openedMixin(theme, sidebarWidth)),
|
||||
...(isCollapsed
|
||||
? closedMixin(theme, miniSidebarWidth)
|
||||
: openedMixin(theme, sidebarWidth)),
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
}),
|
||||
|
|
@ -106,7 +118,6 @@ const StyledProfileBox = styled(Box, {
|
|||
justifyContent: isCollapsed ? 'center' : 'flex-start',
|
||||
}));
|
||||
|
||||
|
||||
const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
}));
|
||||
|
|
@ -154,7 +165,7 @@ export default function Sidebar() {
|
|||
toggleMobileSidebar,
|
||||
isHydrated,
|
||||
sidebarWidth,
|
||||
miniSidebarWidth
|
||||
miniSidebarWidth,
|
||||
} = useCustomizerStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { logout } = useAuth();
|
||||
|
|
@ -227,7 +238,11 @@ export default function Sidebar() {
|
|||
<StyledProfileContainer>
|
||||
<StyledProfileBox isCollapsed={isCollapse && lgUp}>
|
||||
<Tooltip
|
||||
title={isCollapse && lgUp ? user?.nome || user?.userName || 'Usuário' : ''}
|
||||
title={
|
||||
isCollapse && lgUp
|
||||
? user?.nome || user?.userName || 'Usuário'
|
||||
: ''
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<StyledAvatar>
|
||||
|
|
@ -240,7 +255,10 @@ export default function Sidebar() {
|
|||
noWrap
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
color: (theme) => theme.palette.mode === 'dark' ? theme.palette.text.primary : '#1a1a1a',
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.text.primary
|
||||
: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
{user?.nome || user?.userName || 'Usuário'}
|
||||
|
|
@ -249,7 +267,10 @@ export default function Sidebar() {
|
|||
variant="caption"
|
||||
noWrap
|
||||
sx={{
|
||||
color: (theme) => theme.palette.mode === 'dark' ? theme.palette.text.secondary : '#4a5568',
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.text.secondary
|
||||
: '#4a5568',
|
||||
}}
|
||||
>
|
||||
{user?.nomeFilial || 'Usuário'}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,9 @@ const SidebarItems = ({ open, onItemClick }: SidebarItemsProps) => {
|
|||
{Menuitems.map((item) => {
|
||||
// SubHeader
|
||||
if (item.subheader) {
|
||||
return <NavGroup item={item} hideMenu={hideMenu} key={item.subheader} />;
|
||||
return (
|
||||
<NavGroup item={item} hideMenu={hideMenu} key={item.subheader} />
|
||||
);
|
||||
|
||||
// If Sub Menu
|
||||
} else if (item.children) {
|
||||
|
|
|
|||
|
|
@ -31,27 +31,34 @@ interface StyledListItemButtonProps {
|
|||
}
|
||||
|
||||
const StyledListItemButton = styled(ListItemButton, {
|
||||
shouldForwardProp: (prop) => prop !== 'open' && prop !== 'active' && prop !== 'level' && prop !== 'hideMenu',
|
||||
})<StyledListItemButtonProps>(({ theme, open, active, level = 1, hideMenu }) => ({
|
||||
marginBottom: '2px',
|
||||
padding: '8px 10px',
|
||||
paddingLeft: hideMenu ? '10px' : level > 2 ? `${level * 15}px` : '10px',
|
||||
backgroundColor: open && level < 2 ? theme.palette.primary.main : '',
|
||||
whiteSpace: 'nowrap',
|
||||
borderRadius: '7px',
|
||||
'&:hover': {
|
||||
backgroundColor: active || open
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.primary.light,
|
||||
color: active || open ? 'white' : theme.palette.primary.main,
|
||||
},
|
||||
color:
|
||||
open && level < 2
|
||||
? 'white'
|
||||
: level > 1 && open
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.text.secondary,
|
||||
}));
|
||||
shouldForwardProp: (prop) =>
|
||||
prop !== 'open' &&
|
||||
prop !== 'active' &&
|
||||
prop !== 'level' &&
|
||||
prop !== 'hideMenu',
|
||||
})<StyledListItemButtonProps>(
|
||||
({ theme, open, active, level = 1, hideMenu }) => ({
|
||||
marginBottom: '2px',
|
||||
padding: '8px 10px',
|
||||
paddingLeft: hideMenu ? '10px' : level > 2 ? `${level * 15}px` : '10px',
|
||||
backgroundColor: open && level < 2 ? theme.palette.primary.main : '',
|
||||
whiteSpace: 'nowrap',
|
||||
borderRadius: '7px',
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
active || open
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.primary.light,
|
||||
color: active || open ? 'white' : theme.palette.primary.main,
|
||||
},
|
||||
color:
|
||||
open && level < 2
|
||||
? 'white'
|
||||
: level > 1 && open
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.text.secondary,
|
||||
})
|
||||
);
|
||||
|
||||
const StyledListItemIcon = styled(ListItemIcon)(() => ({
|
||||
minWidth: '36px',
|
||||
|
|
@ -128,9 +135,7 @@ export default function NavCollapse({
|
|||
hideMenu={hideMenu}
|
||||
key={menu?.id}
|
||||
>
|
||||
<StyledListItemIcon>
|
||||
{menuIcon}
|
||||
</StyledListItemIcon>
|
||||
<StyledListItemIcon>{menuIcon}</StyledListItemIcon>
|
||||
<ListItemText color="inherit">
|
||||
{hideMenu ? '' : <>{menu.title}</>}
|
||||
</ListItemText>
|
||||
|
|
|
|||
|
|
@ -31,4 +31,3 @@ export default function NavGroup({ item, hideMenu }: NavGroupProps) {
|
|||
</ListSubheaderStyle>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,14 +28,18 @@ interface StyledListItemButtonProps {
|
|||
}
|
||||
|
||||
const StyledListItemButton = styled(ListItemButton, {
|
||||
shouldForwardProp: (prop) => prop !== 'active' && prop !== 'level' && prop !== 'hideMenu',
|
||||
shouldForwardProp: (prop) =>
|
||||
prop !== 'active' && prop !== 'level' && prop !== 'hideMenu',
|
||||
})<StyledListItemButtonProps>(({ theme, active, level = 1, hideMenu }) => ({
|
||||
whiteSpace: 'nowrap',
|
||||
marginBottom: '2px',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '7px',
|
||||
backgroundColor: level > 1 ? 'transparent !important' : 'inherit',
|
||||
color: active && level > 1 ? `${theme.palette.primary.main}!important` : theme.palette.text.secondary,
|
||||
color:
|
||||
active && level > 1
|
||||
? `${theme.palette.primary.main}!important`
|
||||
: theme.palette.text.secondary,
|
||||
paddingLeft: hideMenu ? '10px' : level > 2 ? `${level * 15}px` : '10px',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.primary.light,
|
||||
|
|
@ -61,7 +65,8 @@ const StyledListItemIcon = styled(ListItemIcon, {
|
|||
})<StyledListItemIconProps>(({ theme, active, level = 1 }) => ({
|
||||
minWidth: '36px',
|
||||
padding: '3px 0',
|
||||
color: active && level > 1 ? `${theme.palette.primary.main}!important` : 'inherit',
|
||||
color:
|
||||
active && level > 1 ? `${theme.palette.primary.main}!important` : 'inherit',
|
||||
}));
|
||||
|
||||
export default function NavItem({
|
||||
|
|
|
|||
|
|
@ -14,4 +14,3 @@ export interface MenuItemType {
|
|||
disabled?: boolean;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,27 +26,29 @@ export const useCustomizerStore = create<CustomizerState>()(
|
|||
isHydrated: false,
|
||||
|
||||
setDarkMode: (mode) => set({ activeMode: mode }),
|
||||
|
||||
toggleSidebar: () => set((state) => ({
|
||||
isCollapse: !state.isCollapse
|
||||
})),
|
||||
|
||||
toggleMobileSidebar: () => set((state) => ({
|
||||
isMobileSidebar: !state.isMobileSidebar
|
||||
})),
|
||||
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({
|
||||
isCollapse: !state.isCollapse,
|
||||
})),
|
||||
|
||||
toggleMobileSidebar: () =>
|
||||
set((state) => ({
|
||||
isMobileSidebar: !state.isMobileSidebar,
|
||||
})),
|
||||
|
||||
setHydrated: () => set({ isHydrated: true }),
|
||||
}),
|
||||
{
|
||||
name: 'modernize-layout-settings',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
activeMode: state.activeMode,
|
||||
isCollapse: state.isCollapse
|
||||
partialize: (state) => ({
|
||||
activeMode: state.activeMode,
|
||||
isCollapse: state.isCollapse,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
state?.setHydrated();
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,37 +1,42 @@
|
|||
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { getAccessToken, handleTokenRefresh } from './utils/tokenRefresh';
|
||||
|
||||
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL;
|
||||
|
||||
export const authApi: AxiosInstance = axios.create({
|
||||
baseURL: AUTH_API_URL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseURL: AUTH_API_URL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const addToken = (config: InternalAxiosRequestConfig) => {
|
||||
if (globalThis.window !== undefined) {
|
||||
const token = getAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (globalThis.window !== undefined) {
|
||||
const token = getAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
authApi.interceptors.request.use(addToken);
|
||||
|
||||
const handleResponseError = async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
if (!originalRequest) {
|
||||
throw error;
|
||||
}
|
||||
if (!originalRequest) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return handleTokenRefresh(error, originalRequest, authApi);
|
||||
return handleTokenRefresh(error, originalRequest, authApi);
|
||||
};
|
||||
|
||||
authApi.interceptors.response.use((response) => response, handleResponseError);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,150 +1,146 @@
|
|||
"use client"
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import FormGroup from "@mui/material/FormGroup";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { TextField, Alert } from "@mui/material";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import NextLink from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { loginSchema, LoginInput, AuthLoginProps } from "../interfaces/types";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import CustomCheckbox from "../components/forms/theme-elements/CustomCheckbox";
|
||||
import CustomFormLabel from "../components/forms/theme-elements/CustomFormLabel";
|
||||
import AuthSocialButtons from "./AuthSocialButtons";
|
||||
'use client';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { TextField, Alert } from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import NextLink from 'next/link';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { loginSchema, LoginInput, AuthLoginProps } from '../interfaces/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import CustomFormLabel from '../components/forms/theme-elements/CustomFormLabel';
|
||||
import AuthSocialButtons from './AuthSocialButtons';
|
||||
|
||||
const AuthLogin = ({ title, subtitle, subtext }: AuthLoginProps) => {
|
||||
const { loginMutation } = useAuth();
|
||||
const { loginMutation } = useAuth();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginInput>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginInput>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: LoginInput) => {
|
||||
loginMutation.mutate(data);
|
||||
};
|
||||
const onSubmit = (data: LoginInput) => {
|
||||
loginMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{title ? (
|
||||
<Typography fontWeight="700" variant="h4" mb={1}>
|
||||
{title}
|
||||
</Typography>
|
||||
) : null}
|
||||
return (
|
||||
<>
|
||||
{title ? (
|
||||
<Typography fontWeight="700" variant="h4" mb={1}>
|
||||
{title}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{subtext}
|
||||
{subtext}
|
||||
|
||||
<AuthSocialButtons title="Sign in with" />
|
||||
<Box mt={4} mb={2}>
|
||||
<Divider>
|
||||
<Typography
|
||||
component="span"
|
||||
color="textSecondary"
|
||||
variant="h6"
|
||||
fontWeight="400"
|
||||
position="relative"
|
||||
px={2}
|
||||
>
|
||||
ou faça login com
|
||||
</Typography>
|
||||
</Divider>
|
||||
</Box>
|
||||
<AuthSocialButtons title="Entrar com" />
|
||||
<Box mt={4} mb={2}>
|
||||
<Divider>
|
||||
<Typography
|
||||
component="span"
|
||||
color="textSecondary"
|
||||
variant="h6"
|
||||
fontWeight="400"
|
||||
position="relative"
|
||||
px={2}
|
||||
>
|
||||
ou faça login com
|
||||
</Typography>
|
||||
</Divider>
|
||||
</Box>
|
||||
|
||||
{loginMutation.isError && (
|
||||
<Box mt={3}>
|
||||
<Alert severity="error">
|
||||
{(() => {
|
||||
const error = loginMutation.error;
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
return axiosError.response?.data?.message || 'Erro ao realizar login';
|
||||
}
|
||||
return 'Erro ao realizar login';
|
||||
})()}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
{loginMutation.isError && (
|
||||
<Box mt={3}>
|
||||
<Alert severity="error">
|
||||
{(() => {
|
||||
const error = loginMutation.error;
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
return (
|
||||
axiosError.response?.data?.message || 'Erro ao realizar login'
|
||||
);
|
||||
}
|
||||
return 'Erro ao realizar login';
|
||||
})()}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack>
|
||||
<Box>
|
||||
<CustomFormLabel htmlFor="username">Usuário</CustomFormLabel>
|
||||
<TextField
|
||||
id="username"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
{...register('username')}
|
||||
error={!!errors.username}
|
||||
helperText={errors.username?.message}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<CustomFormLabel htmlFor="password">Senha</CustomFormLabel>
|
||||
<TextField
|
||||
id="password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
{...register('password')}
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
/>
|
||||
</Box>
|
||||
<Stack
|
||||
justifyContent="space-between"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
my={2}
|
||||
spacing={1}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={<CustomCheckbox defaultChecked />}
|
||||
label="Manter-me conectado"
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
/>
|
||||
</FormGroup>
|
||||
<Typography
|
||||
fontWeight="500"
|
||||
sx={{
|
||||
textDecoration: "none",
|
||||
color: "primary.main",
|
||||
}}
|
||||
>
|
||||
<NextLink href="/" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
Esqueceu sua senha ?
|
||||
</NextLink>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
>
|
||||
{loginMutation.isPending ? 'Logging in...' : 'Sign In'}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
{subtitle}
|
||||
</>
|
||||
);
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack>
|
||||
<Box>
|
||||
<CustomFormLabel htmlFor="username">Usuário</CustomFormLabel>
|
||||
<TextField
|
||||
id="username"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
{...register('username')}
|
||||
error={!!errors.username}
|
||||
helperText={errors.username?.message}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<CustomFormLabel htmlFor="password">Senha</CustomFormLabel>
|
||||
<TextField
|
||||
id="password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
{...register('password')}
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
/>
|
||||
</Box>
|
||||
<Stack
|
||||
justifyContent="flex-end"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
my={2}
|
||||
spacing={1}
|
||||
>
|
||||
<Typography
|
||||
fontWeight="500"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'primary.main',
|
||||
}}
|
||||
>
|
||||
<NextLink
|
||||
href="/"
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
Esqueceu sua senha ?
|
||||
</NextLink>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
>
|
||||
{loginMutation.isPending ? 'Entrando...' : 'Entrar'}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
{subtitle}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLogin;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
import CustomSocialButton from "../components/forms/theme-elements/CustomSocialButton";
|
||||
import { Stack } from "@mui/system";
|
||||
'use client';
|
||||
import CustomSocialButton from '../components/forms/theme-elements/CustomSocialButton';
|
||||
import { Stack } from '@mui/system';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import { signIn } from 'next-auth/react';
|
||||
|
|
@ -18,11 +18,20 @@ const AuthSocialButtons = ({ title }: AuthSocialButtonsProps) => {
|
|||
};
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" justifyContent="center" spacing={2} mt={3} flexWrap="wrap">
|
||||
<CustomSocialButton onClick={handleGoogleSignIn} sx={{ flex: 1, minWidth: '140px' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
spacing={2}
|
||||
mt={3}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<CustomSocialButton
|
||||
onClick={handleGoogleSignIn}
|
||||
sx={{ flex: 1, minWidth: '140px' }}
|
||||
>
|
||||
<Avatar
|
||||
src={"/images/svgs/google-icon.svg"}
|
||||
alt={"icon1"}
|
||||
src={'/images/svgs/google-icon.svg'}
|
||||
alt={'icon1'}
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
|
|
@ -32,18 +41,21 @@ const AuthSocialButtons = ({ title }: AuthSocialButtonsProps) => {
|
|||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "none", sm: "flex" },
|
||||
whiteSpace: "nowrap",
|
||||
mr: { sm: "3px" },
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
whiteSpace: 'nowrap',
|
||||
mr: { sm: '3px' },
|
||||
}}
|
||||
>
|
||||
Google
|
||||
</Box>
|
||||
</CustomSocialButton>
|
||||
<CustomSocialButton onClick={handleGithubSignIn} sx={{ flex: 1, minWidth: '140px' }}>
|
||||
<CustomSocialButton
|
||||
onClick={handleGithubSignIn}
|
||||
sx={{ flex: 1, minWidth: '140px' }}
|
||||
>
|
||||
<Avatar
|
||||
src={"/images/svgs/git-icon.svg"}
|
||||
alt={"icon2"}
|
||||
src={'/images/svgs/git-icon.svg'}
|
||||
alt={'icon2'}
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
|
|
@ -53,9 +65,9 @@ const AuthSocialButtons = ({ title }: AuthSocialButtonsProps) => {
|
|||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "none", sm: "flex" },
|
||||
whiteSpace: "nowrap",
|
||||
mr: { sm: "3px" },
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
whiteSpace: 'nowrap',
|
||||
mr: { sm: '3px' },
|
||||
}}
|
||||
>
|
||||
GitHub
|
||||
|
|
@ -64,6 +76,6 @@ const AuthSocialButtons = ({ title }: AuthSocialButtonsProps) => {
|
|||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default AuthSocialButtons;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
'use client';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
|
@ -7,42 +8,85 @@ import { profileService } from '../../profile/services/profile.service';
|
|||
import { mapToSafeProfile } from '../utils/mappers';
|
||||
|
||||
export function AuthInitializer({ children }: { children: React.ReactNode }) {
|
||||
const { setUser, logout } = useAuthStore();
|
||||
const initialized = useRef(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const { setUser, logout } = useAuthStore();
|
||||
const initialized = useRef(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
|
||||
const validateSession = async () => {
|
||||
try {
|
||||
const validateSession = async () => {
|
||||
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();
|
||||
setUser(mapToSafeProfile(profile));
|
||||
} catch (error) {
|
||||
console.warn('Sessão expirada ou inválida', error);
|
||||
logout();
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
validateSession();
|
||||
}, [setUser, logout]);
|
||||
|
||||
validateSession();
|
||||
}, [setUser, logout]);
|
||||
if (isChecking) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', display: 'flex' }}>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
sx={{
|
||||
color: (theme) => theme.palette.grey[200],
|
||||
}}
|
||||
size={48}
|
||||
thickness={4}
|
||||
value={100}
|
||||
/>
|
||||
<CircularProgress
|
||||
variant="indeterminate"
|
||||
disableShrink
|
||||
sx={{
|
||||
color: (theme) => theme.palette.primary.main,
|
||||
animationDuration: '550ms',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
[`& .MuiCircularProgress-circle`]: {
|
||||
strokeLinecap: 'round',
|
||||
},
|
||||
}}
|
||||
size={48}
|
||||
thickness={4}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Validando acesso...
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isChecking) {
|
||||
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}</>;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import AuthLogin from '../authForms/AuthLogin';
|
|||
const GradientGrid = styled(Grid)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
backgroundColor: '#d2f1df',
|
||||
backgroundImage: 'linear-gradient(45deg, #d2f1df 0%, #d3d7fa 50%, #bad8f4 100%)',
|
||||
backgroundImage:
|
||||
'linear-gradient(45deg, #d2f1df 0%, #d3d7fa 50%, #bad8f4 100%)',
|
||||
backgroundSize: '400% 400%',
|
||||
backgroundPosition: '0% 50%',
|
||||
animation: 'gradient 15s ease infinite',
|
||||
|
|
@ -25,7 +26,12 @@ const GradientGrid = styled(Grid)(({ theme }) => ({
|
|||
export default function Login() {
|
||||
return (
|
||||
<PageContainer title="Login" description="Página de Login">
|
||||
<Grid container spacing={0} justifyContent="center" sx={{ height: '100vh', width: '100%' }}>
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
justifyContent="center"
|
||||
sx={{ height: '100vh', width: '100%' }}
|
||||
>
|
||||
<GradientGrid size={{ xs: 12, lg: 7, xl: 8 }}>
|
||||
<Box
|
||||
display="flex"
|
||||
|
|
@ -54,7 +60,12 @@ export default function Login() {
|
|||
</Box>
|
||||
</GradientGrid>
|
||||
<Grid size={{ xs: 12, lg: 5, xl: 4 }}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" sx={{ backgroundColor: 'white' }}>
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={{ backgroundColor: 'white' }}
|
||||
>
|
||||
<Box p={4} sx={{ width: '100%', maxWidth: '450px' }}>
|
||||
<AuthLogin
|
||||
subtitle={
|
||||
|
|
@ -66,7 +77,14 @@ export default function Login() {
|
|||
color: 'primary.main',
|
||||
}}
|
||||
>
|
||||
<NextLink href="/auth/auth1/register" style={{ textDecoration: 'none', color: 'inherit', whiteSpace: 'nowrap' }}>
|
||||
<NextLink
|
||||
href="/auth/auth1/register"
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Criar uma conta
|
||||
</NextLink>
|
||||
</Typography>
|
||||
|
|
@ -78,5 +96,5 @@ export default function Login() {
|
|||
</Grid>
|
||||
</Grid>
|
||||
</PageContainer>
|
||||
)
|
||||
};
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ type Props = {
|
|||
title?: string;
|
||||
};
|
||||
|
||||
const PageContainer = ({ children }: Props) => (
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const PageContainer = ({ children }: Props) => <div>{children}</div>;
|
||||
|
||||
export default PageContainer;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import SimpleBar from "simplebar-react";
|
||||
import "simplebar-react/dist/simplebar.min.css";
|
||||
import Box from '@mui/material/Box'
|
||||
import SimpleBar from 'simplebar-react';
|
||||
import 'simplebar-react/dist/simplebar.min.css';
|
||||
import Box from '@mui/material/Box';
|
||||
import { SxProps } from '@mui/system';
|
||||
import { styled } from '@mui/material/styles'
|
||||
import { useMediaQuery } from "@mui/material";
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { useMediaQuery } from '@mui/material';
|
||||
|
||||
const SimpleBarStyle = styled(SimpleBar)(() => ({
|
||||
maxHeight: "100%",
|
||||
maxHeight: '100%',
|
||||
}));
|
||||
|
||||
interface PropsType {
|
||||
|
|
@ -19,7 +19,7 @@ const Scrollbar = (props: PropsType) => {
|
|||
const lgDown = useMediaQuery((theme: any) => theme.breakpoints.down('lg'));
|
||||
|
||||
if (lgDown) {
|
||||
return <Box sx={{ overflowX: "auto" }}>{children}</Box>;
|
||||
return <Box sx={{ overflowX: 'auto' }}>{children}</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ const DashboardCard = ({
|
|||
|
||||
return (
|
||||
<Card
|
||||
sx={{ padding: 0, border: !isCardShadow ? `1px solid ${borderColor}` : 'none' }}
|
||||
sx={{
|
||||
padding: 0,
|
||||
border: !isCardShadow ? `1px solid ${borderColor}` : 'none',
|
||||
}}
|
||||
elevation={isCardShadow ? 9 : 0}
|
||||
variant={!isCardShadow ? 'outlined' : undefined}
|
||||
>
|
||||
|
|
@ -43,7 +46,7 @@ const DashboardCard = ({
|
|||
</Typography>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent sx={{p: "30px"}}>
|
||||
<CardContent sx={{ p: '30px' }}>
|
||||
{title ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { useEffect, ReactElement } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function ScrollToTop({ children }: { children: ReactElement | null }) {
|
||||
export default function ScrollToTop({
|
||||
children,
|
||||
}: {
|
||||
children: ReactElement | null;
|
||||
}) {
|
||||
const { pathname } = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from 'react';
|
||||
import { styled } from "@mui/system";
|
||||
import { styled } from '@mui/system';
|
||||
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
|
||||
|
||||
const BpIcon = styled('span')(({ theme }) => ({
|
||||
|
|
@ -21,7 +21,10 @@ const BpIcon = styled('span')(({ theme }) => ({
|
|||
outlineOffset: 2,
|
||||
},
|
||||
'input:hover ~ &': {
|
||||
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary : theme.palette.primary,
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.primary
|
||||
: theme.palette.primary,
|
||||
},
|
||||
'input:disabled ~ &': {
|
||||
boxShadow: 'none',
|
||||
|
|
@ -54,7 +57,9 @@ function CustomCheckbox(props: CheckboxProps) {
|
|||
checkedIcon={
|
||||
<BpCheckedIcon
|
||||
sx={{
|
||||
backgroundColor: props.color ? `${props.color}.main` : 'primary.main',
|
||||
backgroundColor: props.color
|
||||
? `${props.color}.main`
|
||||
: 'primary.main',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { styled } from "@mui/system";
|
||||
import { styled } from '@mui/system';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const CustomFormLabel = styled((props: any) => (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { styled } from "@mui/system";
|
||||
import { styled } from '@mui/system';
|
||||
import { Button } from '@mui/material';
|
||||
|
||||
const CustomSocialButton = styled((props: any) => (
|
||||
|
|
|
|||
|
|
@ -2,18 +2,20 @@ import React from 'react';
|
|||
import { styled } from '@mui/material/styles';
|
||||
import { TextField } from '@mui/material';
|
||||
|
||||
const CustomTextField = styled((props: any) => <TextField {...props} />)(({ theme }) => ({
|
||||
'& .MuiOutlinedInput-input::-webkit-input-placeholder': {
|
||||
color: theme.palette.text.secondary,
|
||||
opacity: '0.8',
|
||||
},
|
||||
'& .MuiOutlinedInput-input.Mui-disabled::-webkit-input-placeholder': {
|
||||
color: theme.palette.text.secondary,
|
||||
opacity: '1',
|
||||
},
|
||||
'& .Mui-disabled .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.grey[200],
|
||||
},
|
||||
}));
|
||||
const CustomTextField = styled((props: any) => <TextField {...props} />)(
|
||||
({ theme }) => ({
|
||||
'& .MuiOutlinedInput-input::-webkit-input-placeholder': {
|
||||
color: theme.palette.text.secondary,
|
||||
opacity: '0.8',
|
||||
},
|
||||
'& .MuiOutlinedInput-input.Mui-disabled::-webkit-input-placeholder': {
|
||||
color: theme.palette.text.secondary,
|
||||
opacity: '1',
|
||||
},
|
||||
'& .Mui-disabled .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.grey[200],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default CustomTextField;
|
||||
|
|
|
|||
|
|
@ -4,25 +4,25 @@ Este documento descreve os contratos de interface para a API de Autenticação.
|
|||
Base URL: /api/v1/auth (ou /api/auth)
|
||||
|
||||
1. Realizar Login
|
||||
Autentica o usuário e retorna os tokens de acesso.
|
||||
Autentica o usuário e retorna os tokens de acesso.
|
||||
|
||||
Método: POST
|
||||
Endpoint: /login
|
||||
Content-Type: application/json
|
||||
Request Body
|
||||
{
|
||||
"username": "usuario.sistema",
|
||||
"password": "senha_secreta"
|
||||
"username": "usuario.sistema",
|
||||
"password": "senha_secreta"
|
||||
}
|
||||
Response (200 OK)
|
||||
Retorna o Access Token no corpo e configura o Refresh Token como um Cookie HttpOnly.
|
||||
|
||||
{
|
||||
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"type": "Bearer",
|
||||
"expiresIn": 900,
|
||||
"refreshToken": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"username": "usuario.sistema"
|
||||
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"type": "Bearer",
|
||||
"expiresIn": 900,
|
||||
"refreshToken": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"username": "usuario.sistema"
|
||||
}
|
||||
Cookies Set:
|
||||
|
||||
|
|
@ -30,8 +30,7 @@ refreshToken
|
|||
: UUID do refresh token (HttpOnly, Secure, 7 dias)
|
||||
Erros Comuns
|
||||
400 Bad Request: Campos obrigatórios ausentes.
|
||||
401/500: Usuário ou senha inválidos.
|
||||
2. Renovar Token (Refresh)
|
||||
401/500: Usuário ou senha inválidos. 2. Renovar Token (Refresh)
|
||||
Gera um novo par de tokens usando um Refresh Token válido.
|
||||
|
||||
Método: POST
|
||||
|
|
@ -41,24 +40,23 @@ O Refresh Token pode ser enviado de duas formas (nesta ordem de prioridade):
|
|||
|
||||
Body JSON:
|
||||
{
|
||||
"refreshToken": "550e8400-e29b-41d4-a716-446655440000"
|
||||
"refreshToken": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
Cookie
|
||||
Cookie
|
||||
refreshToken
|
||||
: Enviado automaticamente pelo navegador.
|
||||
Response (200 OK)
|
||||
Retorna novos tokens e atualiza o cookie.
|
||||
|
||||
{
|
||||
"token": "novatoken...",
|
||||
"type": "Bearer",
|
||||
"expiresIn": 900,
|
||||
"refreshToken": "novorefreshtoken...",
|
||||
"username": "usuario.sistema"
|
||||
"token": "novatoken...",
|
||||
"type": "Bearer",
|
||||
"expiresIn": 900,
|
||||
"refreshToken": "novorefreshtoken...",
|
||||
"username": "usuario.sistema"
|
||||
}
|
||||
Erros Comuns
|
||||
403 Forbidden: Token expirado ou inválido.
|
||||
3. Obter Usuário Atual (Me)
|
||||
403 Forbidden: Token expirado ou inválido. 3. Obter Usuário Atual (Me)
|
||||
Retorna dados detalhados do usuário logado.
|
||||
|
||||
Método: GET
|
||||
|
|
@ -66,21 +64,20 @@ Endpoint: /me
|
|||
Headers: Authorization: Bearer <access_token>
|
||||
Response (200 OK)
|
||||
{
|
||||
"matricula": 12345,
|
||||
"userName": "usuario.sistema",
|
||||
"nome": "João da Silva",
|
||||
"codigoFilial": "1",
|
||||
"nomeFilial": "Matriz",
|
||||
"rca": 100,
|
||||
"discountPercent": 0,
|
||||
"sectorId": 10,
|
||||
"sectorManagerId": 50,
|
||||
"supervisorId": 55
|
||||
"matricula": 12345,
|
||||
"userName": "usuario.sistema",
|
||||
"nome": "João da Silva",
|
||||
"codigoFilial": "1",
|
||||
"nomeFilial": "Matriz",
|
||||
"rca": 100,
|
||||
"discountPercent": 0,
|
||||
"sectorId": 10,
|
||||
"sectorManagerId": 50,
|
||||
"supervisorId": 55
|
||||
}
|
||||
Erros Comuns
|
||||
401 Unauthorized: Token não enviado ou inválido.
|
||||
404 Not Found: Usuário não encontrado no banco.
|
||||
4. Logout
|
||||
404 Not Found: Usuário não encontrado no banco. 4. Logout
|
||||
Invalida a sessão atual.
|
||||
|
||||
Método: POST
|
||||
|
|
@ -90,16 +87,16 @@ Request (Opcional)
|
|||
Pode enviar o refresh token no corpo para forçar sua invalidação, caso não esteja no cookie.
|
||||
|
||||
{
|
||||
"refreshToken": "..."
|
||||
"refreshToken": "..."
|
||||
}
|
||||
Response (200 OK)
|
||||
{
|
||||
"message": "Logout realizado com sucesso"
|
||||
"message": "Logout realizado com sucesso"
|
||||
}
|
||||
Effect:
|
||||
|
||||
Adiciona o Access Token à Blacklist (Redis).
|
||||
Remove o Refresh Token do banco.
|
||||
Remove o Cookie
|
||||
Remove o Cookie
|
||||
refreshToken
|
||||
.
|
||||
.
|
||||
|
|
|
|||
|
|
@ -18,29 +18,30 @@ export function useAuth() {
|
|||
try {
|
||||
const profile = await profileService.getMe();
|
||||
const safeProfile = mapToSafeProfile(profile);
|
||||
|
||||
|
||||
setUser(safeProfile);
|
||||
queryClient.setQueryData(['auth-me'], safeProfile);
|
||||
|
||||
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Falha ao carregar perfil:', error);
|
||||
logout();
|
||||
logout();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const useMe = () => useQuery({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: async () => {
|
||||
const data = await profileService.getMe();
|
||||
const safeData = mapToSafeProfile(data);
|
||||
setUser(safeData);
|
||||
return safeData;
|
||||
},
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const useMe = () =>
|
||||
useQuery({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: async () => {
|
||||
const data = await profileService.getMe();
|
||||
const safeData = mapToSafeProfile(data);
|
||||
setUser(safeData);
|
||||
return safeData;
|
||||
},
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
|
|
@ -48,10 +49,10 @@ export function useAuth() {
|
|||
} finally {
|
||||
clearAuthData();
|
||||
logoutStore();
|
||||
queryClient.clear();
|
||||
globalThis.location.href = '/';
|
||||
queryClient.clear();
|
||||
globalThis.location.href = '/';
|
||||
}
|
||||
};
|
||||
|
||||
return { loginMutation, useMe, logout };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -2,9 +2,17 @@ import { z } from 'zod';
|
|||
import type { ReactNode } from 'react';
|
||||
import type { UserProfile } from '../../profile/types';
|
||||
|
||||
import { loginSchema, tokenResponseSchema, logoutResponseSchema } from '../schemas/schemas';
|
||||
import {
|
||||
loginSchema,
|
||||
tokenResponseSchema,
|
||||
logoutResponseSchema,
|
||||
} from '../schemas/schemas';
|
||||
|
||||
export { loginSchema, tokenResponseSchema, logoutResponseSchema } from '../schemas/schemas';
|
||||
export {
|
||||
loginSchema,
|
||||
tokenResponseSchema,
|
||||
logoutResponseSchema,
|
||||
} from '../schemas/schemas';
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
export type TokenResponse = z.infer<typeof tokenResponseSchema>;
|
||||
|
|
@ -35,4 +43,4 @@ export interface AuthState {
|
|||
setUser: (user: UserProfile | null) => void;
|
||||
logout: () => void;
|
||||
hydrate: () => void;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,115 +1,115 @@
|
|||
import { loginSchema, tokenResponseSchema } from './schemas';
|
||||
|
||||
describe('Login Schemas', () => {
|
||||
describe('loginSchema', () => {
|
||||
it('deve validar credenciais válidas', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
username: 'user123',
|
||||
password: 'pass1234',
|
||||
});
|
||||
describe('loginSchema', () => {
|
||||
it('deve validar credenciais válidas', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
username: 'user123',
|
||||
password: 'pass1234',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBe('user123');
|
||||
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);
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBe('user123');
|
||||
expect(result.data.password).toBe('pass1234');
|
||||
}
|
||||
});
|
||||
|
||||
describe('tokenResponseSchema', () => {
|
||||
it('deve validar resposta de token válida', () => {
|
||||
const result = tokenResponseSchema.safeParse({
|
||||
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
type: 'Bearer',
|
||||
expiresIn: 3600,
|
||||
username: 'user123',
|
||||
});
|
||||
it('deve rejeitar username muito curto', () => {
|
||||
const result = loginSchema.safeParse({
|
||||
username: 'ab',
|
||||
password: 'pass1234',
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(3, 'Usuário é obrigatório'),
|
||||
password: z.string().min(4, 'Senha deve ter no mínimo 4 caracteres'),
|
||||
username: z.string().min(3, 'Usuário é obrigatório'),
|
||||
password: z.string().min(4, 'Senha deve ter no mínimo 4 caracteres'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema de resposta de autenticação
|
||||
*
|
||||
*
|
||||
* Arquitetura Híbrida:
|
||||
* - accessToken: Retornado no body, armazenado em memória
|
||||
* - refreshToken: Enviado via cookie HTTP-only (não acessível ao JS)
|
||||
*/
|
||||
export const tokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
type: z.string(),
|
||||
expiresIn: z.number().positive(),
|
||||
username: z.string(),
|
||||
// refreshToken removido - agora apenas em cookie HTTP-only
|
||||
token: z.string(),
|
||||
type: z.string(),
|
||||
expiresIn: z.number().positive(),
|
||||
username: z.string(),
|
||||
// refreshToken removido - agora apenas em cookie HTTP-only
|
||||
});
|
||||
|
||||
export const logoutResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@ import {
|
|||
LoginInput,
|
||||
TokenResponse,
|
||||
LogoutResponse,
|
||||
tokenResponseSchema
|
||||
tokenResponseSchema,
|
||||
} from '../interfaces/types';
|
||||
import { setTemporaryToken } from '../utils/tokenRefresh';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
|
||||
const handleAuthSuccess = async (data: any): Promise<TokenResponse> => {
|
||||
// 1. Valida o schema do token
|
||||
const validatedData = tokenResponseSchema.parse(data);
|
||||
|
|
@ -39,4 +38,4 @@ export const loginService = {
|
|||
useAuthStore.getState().logout();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import { clearAuthData } from '../utils/tokenRefresh';
|
|||
|
||||
/**
|
||||
* Store de autenticação usando apenas dados não sensíveis
|
||||
*
|
||||
*
|
||||
* Arquitetura Híbrida:
|
||||
* - accessToken: Armazenado em memória (tokenRefresh.ts)
|
||||
* - refreshToken: Gerenciado via cookies HTTP-only pelo backend
|
||||
* - user profile: Persistido no localStorage (dados não sensíveis)
|
||||
*
|
||||
*
|
||||
* O método hydrate() valida a sincronização entre token e estado ao recarregar
|
||||
*/
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
|
|
@ -31,8 +31,7 @@ export const useAuthStore = create<AuthState>()(
|
|||
* Valida sincronização entre token em memória e estado persistido
|
||||
* Chamado automaticamente ao recarregar a página (onRehydrateStorage)
|
||||
*/
|
||||
hydrate: () => {
|
||||
},
|
||||
hydrate: () => {},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
|
|
@ -45,4 +44,4 @@ export const useAuthStore = create<AuthState>()(
|
|||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import { UserProfile } from '../../profile/types';
|
||||
|
||||
|
||||
export const mapToSafeProfile = (data: any): UserProfile => {
|
||||
return {
|
||||
matricula: data.matricula,
|
||||
userName: data.userName,
|
||||
nome: data.nome,
|
||||
codigoFilial: data.codigoFilial,
|
||||
nomeFilial: data.nomeFilial,
|
||||
rca: data.rca,
|
||||
discountPercent: data.discountPercent,
|
||||
sectorId: data.sectorId,
|
||||
sectorManagerId: data.sectorManagerId,
|
||||
supervisorId: data.supervisorId,
|
||||
};
|
||||
return {
|
||||
matricula: data.matricula,
|
||||
userName: data.userName,
|
||||
nome: data.nome,
|
||||
codigoFilial: data.codigoFilial,
|
||||
nomeFilial: data.nomeFilial,
|
||||
rca: data.rca,
|
||||
discountPercent: data.discountPercent,
|
||||
sectorId: data.sectorId,
|
||||
sectorManagerId: data.sectorManagerId,
|
||||
supervisorId: data.supervisorId,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { TokenResponse } from '../interfaces/types';
|
||||
|
||||
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL;
|
||||
|
|
@ -62,7 +67,10 @@ export function handleTokenRefresh<T = unknown>(
|
|||
throw error;
|
||||
}
|
||||
|
||||
if (request.url?.includes('/auth/login') || request.url?.includes('/auth/refresh')) {
|
||||
if (
|
||||
request.url?.includes('/auth/login') ||
|
||||
request.url?.includes('/auth/refresh')
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||
import { getAccessToken, handleTokenRefresh } from '../../login/utils/tokenRefresh';
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import {
|
||||
getAccessToken,
|
||||
handleTokenRefresh,
|
||||
} from '../../login/utils/tokenRefresh';
|
||||
import {
|
||||
OrderFilters,
|
||||
orderApiParamsSchema,
|
||||
|
|
@ -8,9 +15,18 @@ import {
|
|||
storesResponseSchema,
|
||||
customersResponseSchema,
|
||||
sellersResponseSchema,
|
||||
unwrapApiData
|
||||
unwrapApiData,
|
||||
} from '../schemas/order.schema';
|
||||
import { orderItemsResponseSchema, OrderItem } from '../schemas/order.item.schema';
|
||||
import {
|
||||
orderItemsResponseSchema,
|
||||
OrderItem,
|
||||
shipmentResponseSchema,
|
||||
Shipment,
|
||||
cargoMovementResponseSchema,
|
||||
CargoMovement,
|
||||
cuttingItemResponseSchema,
|
||||
CuttingItem,
|
||||
} from '../schemas/order.item.schema';
|
||||
import { Store } from '../schemas/store.schema';
|
||||
import { Seller } from '../schemas/seller.schema';
|
||||
import { Order } from '../types';
|
||||
|
|
@ -28,7 +44,7 @@ export const ordersApi: AxiosInstance = axios.create({
|
|||
/**
|
||||
* Adiciona o token de autenticação aos cabeçalhos da requisição.
|
||||
* Executa apenas no ambiente do navegador (verifica a existência do objeto window).
|
||||
*
|
||||
*
|
||||
* @param {InternalAxiosRequestConfig} config - A configuração da requisição Axios
|
||||
* @returns {InternalAxiosRequestConfig} A configuração modificada com o cabeçalho Authorization
|
||||
*/
|
||||
|
|
@ -47,13 +63,15 @@ ordersApi.interceptors.request.use(addToken);
|
|||
/**
|
||||
* Trata erros de resposta das requisições da API.
|
||||
* Tenta atualizar o token de autenticação se a requisição falhar.
|
||||
*
|
||||
*
|
||||
* @param {AxiosError} error - O objeto de erro do Axios
|
||||
* @returns {Promise} Resultado da tentativa de atualização do token
|
||||
* @throws {AxiosError} Se não houver configuração de requisição original disponível
|
||||
*/
|
||||
const handleResponseError = async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
if (!originalRequest) {
|
||||
throw error;
|
||||
|
|
@ -62,93 +80,103 @@ const handleResponseError = async (error: AxiosError) => {
|
|||
return handleTokenRefresh(error, originalRequest, ordersApi);
|
||||
};
|
||||
|
||||
ordersApi.interceptors.response.use((response) => response, handleResponseError);
|
||||
ordersApi.interceptors.response.use(
|
||||
(response) => response,
|
||||
handleResponseError
|
||||
);
|
||||
|
||||
export const orderService = {
|
||||
/**
|
||||
* Busca pedidos com base nos filtros fornecidos.
|
||||
* Utiliza Zod para limpar e transformar os parâmetros automaticamente.
|
||||
*
|
||||
*
|
||||
* @param {OrderFilters} filters - Critérios de filtro para buscar pedidos
|
||||
* @returns {Promise<Order[]>} Array de pedidos que correspondem aos filtros
|
||||
*/
|
||||
findOrders: async (filters: OrderFilters): Promise<Order[]> => {
|
||||
try {
|
||||
const cleanParams = orderApiParamsSchema.parse(filters);
|
||||
const response = await ordersApi.get('/api/v1/orders/find', { params: cleanParams });
|
||||
return unwrapApiData(response, ordersResponseSchema, []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar pedidos:', error);
|
||||
return [];
|
||||
}
|
||||
const cleanParams = orderApiParamsSchema.parse(filters);
|
||||
const response = await ordersApi.get('/api/v1/orders/find', {
|
||||
params: cleanParams,
|
||||
});
|
||||
return unwrapApiData(response, ordersResponseSchema, []);
|
||||
},
|
||||
|
||||
/**
|
||||
* Busca um pedido específico pelo seu ID.
|
||||
*
|
||||
*
|
||||
* @param {number} id - O identificador único do pedido
|
||||
* @returns {Promise<Order | null>} O pedido com o ID especificado, ou null se não encontrado
|
||||
*/
|
||||
findById: async (id: number): Promise<Order | null> => {
|
||||
try {
|
||||
const response = await ordersApi.get(`/orders/${id}`);
|
||||
return unwrapApiData(response, orderResponseSchema, null);
|
||||
} catch (error) {
|
||||
console.error(`Erro ao buscar pedido ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
const response = await ordersApi.get(`/orders/${id}`);
|
||||
return unwrapApiData(response, orderResponseSchema, null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Recupera todas as lojas disponíveis.
|
||||
*
|
||||
*
|
||||
* @returns {Promise<Store[]>} Array de todas as lojas, ou array vazio se nenhuma for encontrada
|
||||
*/
|
||||
findStores: async (): Promise<Store[]> => {
|
||||
try {
|
||||
const response = await ordersApi.get('/api/v1/data-consult/stores');
|
||||
return unwrapApiData(response, storesResponseSchema, []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar lojas:', error);
|
||||
return [];
|
||||
}
|
||||
const response = await ordersApi.get('/api/v1/data-consult/stores');
|
||||
return unwrapApiData(response, storesResponseSchema, []);
|
||||
},
|
||||
|
||||
/**
|
||||
* Busca clientes por nome.
|
||||
* Retorna array vazio se o termo de busca tiver menos de 2 caracteres.
|
||||
*
|
||||
*
|
||||
* @param {string} name - O nome do cliente a ser buscado (mínimo 2 caracteres)
|
||||
* @returns {Promise<Array<{id: number, name: string, estcob: string}>>} Array de clientes correspondentes com os campos id, name e estcob
|
||||
*/
|
||||
findCustomers: async (name: string): Promise<Array<{ id: number; name: string; estcob: string }>> => {
|
||||
findCustomers: async (
|
||||
name: string
|
||||
): Promise<Array<{ id: number; name: string; estcob: string }>> => {
|
||||
if (!name || name.trim().length < 2) return [];
|
||||
try {
|
||||
const response = await ordersApi.get(`/api/v1/clientes/${encodeURIComponent(name)}`);
|
||||
return unwrapApiData(response, customersResponseSchema, []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar clientes:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await ordersApi.get(
|
||||
`/api/v1/clientes/${encodeURIComponent(name)}`
|
||||
);
|
||||
return unwrapApiData(response, customersResponseSchema, []);
|
||||
},
|
||||
|
||||
findsellers: async (): Promise<Seller[]> => {
|
||||
try {
|
||||
const response = await ordersApi.get('/api/v1/data-consult/sellers');
|
||||
return unwrapApiData(response, sellersResponseSchema, []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar vendedores:', error);
|
||||
return [];
|
||||
}
|
||||
findSellers: async (): Promise<Seller[]> => {
|
||||
const response = await ordersApi.get('/api/v1/data-consult/sellers');
|
||||
return unwrapApiData(response, sellersResponseSchema, []);
|
||||
},
|
||||
|
||||
findOrderItems: async (orderId: number): Promise<OrderItem[]> => {
|
||||
try {
|
||||
const response = await ordersApi.get(`/api/v1/orders/itens/${orderId}`);
|
||||
return unwrapApiData(response, orderItemsResponseSchema, []);
|
||||
} catch (error) {
|
||||
console.error(`Erro ao buscar itens do pedido ${orderId}:`, error);
|
||||
return [];
|
||||
}
|
||||
const response = await ordersApi.get(`/api/v1/orders/itens/${orderId}`);
|
||||
return unwrapApiData(response, orderItemsResponseSchema, []);
|
||||
},
|
||||
|
||||
|
||||
findDelivery: async (
|
||||
orderId: number,
|
||||
includeCompletedDeliveries: boolean = true
|
||||
): Promise<Shipment[]> => {
|
||||
const response = await ordersApi.get(
|
||||
`/api/v1/orders/delivery/${orderId}`,
|
||||
{
|
||||
params: { includeCompletedDeliveries },
|
||||
}
|
||||
);
|
||||
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, []);
|
||||
},
|
||||
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,33 +1,50 @@
|
|||
'use client';
|
||||
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
import { useGridApiContext, useGridSelector, gridPageCountSelector, gridPageSelector, gridPageSizeSelector, gridRowCountSelector } from '@mui/x-data-grid-premium';
|
||||
import {
|
||||
useGridApiContext,
|
||||
useGridSelector,
|
||||
gridPageCountSelector,
|
||||
gridPageSelector,
|
||||
gridPageSizeSelector,
|
||||
gridRowCountSelector,
|
||||
} from '@mui/x-data-grid-premium';
|
||||
|
||||
function CustomPagination() {
|
||||
const apiRef = useGridApiContext();
|
||||
const pageCount = useGridSelector(apiRef, gridPageCountSelector);
|
||||
const page = useGridSelector(apiRef, gridPageSelector);
|
||||
const pageSize = useGridSelector(apiRef, gridPageSizeSelector);
|
||||
const rowCount = useGridSelector(apiRef, gridRowCountSelector);
|
||||
const apiRef = useGridApiContext();
|
||||
const pageCount = useGridSelector(apiRef, gridPageCountSelector);
|
||||
const page = useGridSelector(apiRef, gridPageSelector);
|
||||
const pageSize = useGridSelector(apiRef, gridPageSizeSelector);
|
||||
const rowCount = useGridSelector(apiRef, gridRowCountSelector);
|
||||
|
||||
const labelDisplayedRows = ({ from, to, count }: { from: number; to: number; count: number }) => {
|
||||
const currentPage = page + 1;
|
||||
const displayCount = count === -1 ? `mais de ${to}` : count;
|
||||
return `${from}–${to} de ${displayCount} | Página ${currentPage} de ${pageCount}`;
|
||||
};
|
||||
const labelDisplayedRows = ({
|
||||
from,
|
||||
to,
|
||||
count,
|
||||
}: {
|
||||
from: number;
|
||||
to: number;
|
||||
count: number;
|
||||
}) => {
|
||||
const currentPage = page + 1;
|
||||
const displayCount = count === -1 ? `mais de ${to}` : count;
|
||||
return `${from}–${to} de ${displayCount} | Página ${currentPage} de ${pageCount}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={rowCount}
|
||||
page={page}
|
||||
onPageChange={(event, newPage) => apiRef.current.setPage(newPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(event) => apiRef.current.setPageSize(Number.parseInt(event.target.value, 10))}
|
||||
labelRowsPerPage="Pedidos por página:"
|
||||
labelDisplayedRows={labelDisplayedRows}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={rowCount}
|
||||
page={page}
|
||||
onPageChange={(event, newPage) => apiRef.current.setPage(newPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(event) =>
|
||||
apiRef.current.setPageSize(Number.parseInt(event.target.value, 10))
|
||||
}
|
||||
labelRowsPerPage="Pedidos por página:"
|
||||
labelDisplayedRows={labelDisplayedRows}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomPagination;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
|||
import MoveToInboxIcon from '@mui/icons-material/MoveToInbox';
|
||||
import TvIcon from '@mui/icons-material/Tv';
|
||||
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
|
||||
import { TabPanel } from './TabPanel';
|
||||
import { OrderItemsTable } from './OrderItemsTable';
|
||||
import { TabPanel } from '@/shared/components';
|
||||
import { OrderItemsTable } from './tabs/OrderItemsTable';
|
||||
import { PreBoxPanel } from './tabs/PreBoxPanel';
|
||||
import { InformationPanel } from './tabs/InformationPanel';
|
||||
import { TimelinePanel } from './tabs/TimelinePanel';
|
||||
|
|
@ -25,142 +25,142 @@ import { TV8Panel } from './tabs/TV8Panel';
|
|||
import { CashAdjustmentPanel } from './tabs/CashAdjustmentPanel';
|
||||
|
||||
interface OrderDetailsTabsProps {
|
||||
orderId: number;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export const OrderDetailsTabs = ({ orderId }: OrderDetailsTabsProps) => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
minHeight: 48,
|
||||
color: 'text.secondary',
|
||||
'&.Mui-selected': {
|
||||
color: 'primary.main',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
height: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
icon={<ListAltIcon />}
|
||||
iconPosition="start"
|
||||
label="Itens"
|
||||
id="order-tab-0"
|
||||
aria-controls="order-tabpanel-0"
|
||||
/>
|
||||
<Tab
|
||||
icon={<InventoryIcon />}
|
||||
iconPosition="start"
|
||||
label="Pré-box"
|
||||
id="order-tab-1"
|
||||
aria-controls="order-tabpanel-1"
|
||||
/>
|
||||
<Tab
|
||||
icon={<InfoIcon />}
|
||||
iconPosition="start"
|
||||
label="Informações"
|
||||
id="order-tab-2"
|
||||
aria-controls="order-tabpanel-2"
|
||||
/>
|
||||
<Tab
|
||||
icon={<TimelineIcon />}
|
||||
iconPosition="start"
|
||||
label="Timeline"
|
||||
id="order-tab-3"
|
||||
aria-controls="order-tabpanel-3"
|
||||
/>
|
||||
<Tab
|
||||
icon={<ContentCutIcon />}
|
||||
iconPosition="start"
|
||||
label="Cortes"
|
||||
id="order-tab-4"
|
||||
aria-controls="order-tabpanel-4"
|
||||
/>
|
||||
<Tab
|
||||
icon={<LocalShippingIcon />}
|
||||
iconPosition="start"
|
||||
label="Entrega"
|
||||
id="order-tab-5"
|
||||
aria-controls="order-tabpanel-5"
|
||||
/>
|
||||
<Tab
|
||||
icon={<MoveToInboxIcon />}
|
||||
iconPosition="start"
|
||||
label="Mov. Carga"
|
||||
id="order-tab-6"
|
||||
aria-controls="order-tabpanel-6"
|
||||
/>
|
||||
<Tab
|
||||
icon={<TvIcon />}
|
||||
iconPosition="start"
|
||||
label="TV8"
|
||||
id="order-tab-7"
|
||||
aria-controls="order-tabpanel-7"
|
||||
/>
|
||||
<Tab
|
||||
icon={<AccountBalanceWalletIcon />}
|
||||
iconPosition="start"
|
||||
label="Acerta Caixa"
|
||||
id="order-tab-8"
|
||||
aria-controls="order-tabpanel-8"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
minHeight: 48,
|
||||
color: 'text.secondary',
|
||||
'&.Mui-selected': {
|
||||
color: 'primary.main',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
height: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
icon={<ListAltIcon />}
|
||||
iconPosition="start"
|
||||
label="Itens"
|
||||
id="order-tab-0"
|
||||
aria-controls="order-tabpanel-0"
|
||||
/>
|
||||
<Tab
|
||||
icon={<InventoryIcon />}
|
||||
iconPosition="start"
|
||||
label="Pré-box"
|
||||
id="order-tab-1"
|
||||
aria-controls="order-tabpanel-1"
|
||||
/>
|
||||
<Tab
|
||||
icon={<InfoIcon />}
|
||||
iconPosition="start"
|
||||
label="Informações"
|
||||
id="order-tab-2"
|
||||
aria-controls="order-tabpanel-2"
|
||||
/>
|
||||
<Tab
|
||||
icon={<TimelineIcon />}
|
||||
iconPosition="start"
|
||||
label="Timeline"
|
||||
id="order-tab-3"
|
||||
aria-controls="order-tabpanel-3"
|
||||
/>
|
||||
<Tab
|
||||
icon={<ContentCutIcon />}
|
||||
iconPosition="start"
|
||||
label="Cortes"
|
||||
id="order-tab-4"
|
||||
aria-controls="order-tabpanel-4"
|
||||
/>
|
||||
<Tab
|
||||
icon={<LocalShippingIcon />}
|
||||
iconPosition="start"
|
||||
label="Entrega"
|
||||
id="order-tab-5"
|
||||
aria-controls="order-tabpanel-5"
|
||||
/>
|
||||
<Tab
|
||||
icon={<MoveToInboxIcon />}
|
||||
iconPosition="start"
|
||||
label="Mov. Carga"
|
||||
id="order-tab-6"
|
||||
aria-controls="order-tabpanel-6"
|
||||
/>
|
||||
<Tab
|
||||
icon={<TvIcon />}
|
||||
iconPosition="start"
|
||||
label="TV8"
|
||||
id="order-tab-7"
|
||||
aria-controls="order-tabpanel-7"
|
||||
/>
|
||||
<Tab
|
||||
icon={<AccountBalanceWalletIcon />}
|
||||
iconPosition="start"
|
||||
label="Acerta Caixa"
|
||||
id="order-tab-8"
|
||||
aria-controls="order-tabpanel-8"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<OrderItemsTable orderId={orderId} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<OrderItemsTable orderId={orderId} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<PreBoxPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<PreBoxPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<InformationPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<InformationPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<TimelinePanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<TimelinePanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={4}>
|
||||
<CuttingPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={4}>
|
||||
<CuttingPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={5}>
|
||||
<DeliveryPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={5}>
|
||||
<DeliveryPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={6}>
|
||||
<CargoMovementPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={6}>
|
||||
<CargoMovementPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={7}>
|
||||
<TV8Panel orderId={orderId} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={7}>
|
||||
<TV8Panel orderId={orderId} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={8}>
|
||||
<CashAdjustmentPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
</Box>
|
||||
);
|
||||
<TabPanel value={activeTab} index={8}>
|
||||
<CashAdjustmentPanel orderId={orderId} />
|
||||
</TabPanel>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,164 +3,206 @@ import Typography from '@mui/material/Typography';
|
|||
import { formatCurrency, formatNumber } from '../utils/orderFormatters';
|
||||
|
||||
export const createOrderItemsColumns = (): GridColDef[] => [
|
||||
{
|
||||
field: 'productId',
|
||||
headerName: 'Cód. Produto',
|
||||
width: 110,
|
||||
minWidth: 100,
|
||||
headerAlign: 'right',
|
||||
align: 'right',
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}>
|
||||
{params.value}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
headerName: 'Descrição',
|
||||
width: 300,
|
||||
minWidth: 250,
|
||||
flex: 1,
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem' }} noWrap>
|
||||
{params.value}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'pacth',
|
||||
headerName: 'Unidade',
|
||||
width: 100,
|
||||
minWidth: 80,
|
||||
headerAlign: 'center',
|
||||
align: 'center',
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'center' }}>
|
||||
{params.value || '-'}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'color',
|
||||
headerName: 'Cor',
|
||||
width: 80,
|
||||
minWidth: 70,
|
||||
headerAlign: 'center',
|
||||
align: 'center',
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'center' }}>
|
||||
{params.value || '-'}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'stockId',
|
||||
headerName: 'Cód. Estoque',
|
||||
width: 110,
|
||||
minWidth: 100,
|
||||
headerAlign: 'right',
|
||||
align: 'right',
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
|
||||
{params.value}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
headerName: 'Qtd.',
|
||||
width: 90,
|
||||
minWidth: 80,
|
||||
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', fontWeight: 500, textAlign: 'right' }}>
|
||||
{formatNumber(params.value)}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'salePrice',
|
||||
headerName: 'Preço Unitário',
|
||||
width: 130,
|
||||
minWidth: 120,
|
||||
type: 'number',
|
||||
headerAlign: 'right',
|
||||
align: 'right',
|
||||
valueFormatter: (value) => formatCurrency(value as number),
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem', textAlign: 'right' }}>
|
||||
{formatCurrency(params.value)}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'total',
|
||||
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" color="text.primary" sx={{ fontSize: '0.75rem', fontWeight: 600, textAlign: 'right' }}>
|
||||
{formatCurrency(params.value)}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'deliveryType',
|
||||
headerName: 'Tipo Entrega',
|
||||
width: 140,
|
||||
minWidth: 130,
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography variant="body2" color="text.primary" sx={{ fontSize: '0.75rem' }} noWrap>
|
||||
{params.value}
|
||||
</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>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'productId',
|
||||
headerName: 'Cód. Produto',
|
||||
width: 110,
|
||||
minWidth: 100,
|
||||
headerAlign: 'right',
|
||||
align: 'right',
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{ fontSize: '0.75rem', fontWeight: 500, textAlign: 'right' }}
|
||||
>
|
||||
{params.value}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
headerName: 'Descrição',
|
||||
width: 300,
|
||||
minWidth: 250,
|
||||
flex: 1,
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
noWrap
|
||||
>
|
||||
{params.value}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'pacth',
|
||||
headerName: 'Unidade',
|
||||
width: 100,
|
||||
minWidth: 80,
|
||||
headerAlign: 'center',
|
||||
align: 'center',
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{ fontSize: '0.75rem', textAlign: 'center' }}
|
||||
>
|
||||
{params.value || '-'}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'color',
|
||||
headerName: 'Cor',
|
||||
width: 80,
|
||||
minWidth: 70,
|
||||
headerAlign: 'center',
|
||||
align: 'center',
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{ fontSize: '0.75rem', textAlign: 'center' }}
|
||||
>
|
||||
{params.value || '-'}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'stockId',
|
||||
headerName: 'Cód. Estoque',
|
||||
width: 110,
|
||||
minWidth: 100,
|
||||
headerAlign: 'right',
|
||||
align: 'right',
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
|
||||
>
|
||||
{params.value}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
headerName: 'Qtd.',
|
||||
width: 90,
|
||||
minWidth: 80,
|
||||
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', fontWeight: 500, textAlign: 'right' }}
|
||||
>
|
||||
{formatNumber(params.value)}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'salePrice',
|
||||
headerName: 'Preço Unitário',
|
||||
width: 130,
|
||||
minWidth: 120,
|
||||
type: 'number',
|
||||
headerAlign: 'right',
|
||||
align: 'right',
|
||||
valueFormatter: (value) => formatCurrency(value as number),
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{ fontSize: '0.75rem', textAlign: 'right' }}
|
||||
>
|
||||
{formatCurrency(params.value)}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'total',
|
||||
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"
|
||||
color="text.primary"
|
||||
sx={{ fontSize: '0.75rem', fontWeight: 600, textAlign: 'right' }}
|
||||
>
|
||||
{formatCurrency(params.value)}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'deliveryType',
|
||||
headerName: 'Tipo Entrega',
|
||||
width: 140,
|
||||
minWidth: 130,
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
noWrap
|
||||
>
|
||||
{params.value}
|
||||
</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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { SearchBar } from './SearchBar';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { DataGridPremium, GridCellSelectionModel } from '@mui/x-data-grid-premium';
|
||||
import {
|
||||
DataGridPremium,
|
||||
GridCellSelectionModel,
|
||||
} from '@mui/x-data-grid-premium';
|
||||
import { useOrders } from '../hooks/useOrders';
|
||||
import { useStores } from '../store/useStores';
|
||||
import { createOrderColumns } from './OrderTableColumns';
|
||||
import { calculateTableHeight } from '../utils/tableHelpers';
|
||||
import { normalizeOrder } from '../utils/orderNormalizer';
|
||||
|
|
@ -17,28 +21,41 @@ import { OrderDetailsTabs } from './OrderDetailsTabs';
|
|||
|
||||
export const OrderTable = () => {
|
||||
const { data: orders, isLoading, error } = useOrders();
|
||||
const [cellSelectionModel, setCellSelectionModel] = useState<GridCellSelectionModel>({});
|
||||
const { data: stores } = useStores();
|
||||
const [cellSelectionModel, setCellSelectionModel] =
|
||||
useState<GridCellSelectionModel>({});
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orders || orders.length === 0) {
|
||||
setSelectedOrderId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedOrderId((current) => {
|
||||
if (!current) return null;
|
||||
const exists = orders.some((order: Order) => order.orderId === current);
|
||||
return exists ? current : null;
|
||||
});
|
||||
}, [orders]);
|
||||
|
||||
// Cria mapa de storeId -> storeName
|
||||
const storesMap = useMemo(() => {
|
||||
if (!stores) return new Map<string, string>();
|
||||
return new Map(
|
||||
stores.map((store) => [String(store.id), store.store || store.name || String(store.id)])
|
||||
);
|
||||
}, [stores]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!Array.isArray(orders) || orders.length === 0) return [];
|
||||
|
||||
return orders.map((order: Order, index: number) => normalizeOrder(order, index));
|
||||
return orders.map((order: Order, index: number) =>
|
||||
normalizeOrder(order, index)
|
||||
);
|
||||
}, [orders]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => createOrderColumns(),
|
||||
[]
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const columns = useMemo(() => createOrderColumns({ storesMap }), [storesMap]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -54,11 +71,24 @@ export const OrderTable = () => {
|
|||
|
||||
const tableHeight = calculateTableHeight(rows.length, 10);
|
||||
|
||||
const mobileTableHeight = calculateTableHeight(rows.length, 5, {
|
||||
minHeight: 300,
|
||||
rowHeight: 40,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SearchBar />
|
||||
|
||||
<Paper sx={{ mt: 3, boxShadow: 'none', border: 'none', backgroundColor: 'transparent', overflow: 'hidden' }}>
|
||||
<Paper
|
||||
sx={{
|
||||
mt: { xs: 2, md: 3 },
|
||||
boxShadow: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<DataGridPremium
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
|
|
@ -80,7 +110,7 @@ export const OrderTable = () => {
|
|||
sortModel: [{ field: 'createDate', sort: 'desc' }],
|
||||
},
|
||||
pinnedColumns: {
|
||||
left: ['orderId', 'customerName'],
|
||||
/// left: ['orderId', 'customerName'],
|
||||
},
|
||||
}}
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
|
|
@ -95,7 +125,7 @@ export const OrderTable = () => {
|
|||
params.row.orderId === selectedOrderId ? 'Mui-selected' : ''
|
||||
}
|
||||
sx={{
|
||||
height: tableHeight,
|
||||
height: { xs: mobileTableHeight, md: tableHeight },
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
|
|
@ -105,7 +135,7 @@ export const OrderTable = () => {
|
|||
border: 'none',
|
||||
},
|
||||
'& .MuiDataGrid-columnHeaders': {
|
||||
backgroundColor: 'grey.50',
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
borderBottom: '2px solid',
|
||||
|
|
@ -150,7 +180,7 @@ export const OrderTable = () => {
|
|||
},
|
||||
},
|
||||
'& .MuiDataGrid-row:nth-of-type(even)': {
|
||||
backgroundColor: 'grey.50',
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.50',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
|
|
@ -196,10 +226,10 @@ export const OrderTable = () => {
|
|||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
minHeight: '48px !important',
|
||||
fontSize: '0.75rem',
|
||||
backgroundColor: 'grey.50',
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
|
||||
},
|
||||
'& .MuiDataGrid-aggregationColumnHeader': {
|
||||
backgroundColor: 'grey.100',
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
borderBottom: '2px solid',
|
||||
|
|
@ -209,7 +239,7 @@ export const OrderTable = () => {
|
|||
fontWeight: 600,
|
||||
},
|
||||
'& .MuiDataGrid-aggregationRow': {
|
||||
backgroundColor: 'grey.100',
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100',
|
||||
borderTop: '2px solid',
|
||||
borderColor: 'divider',
|
||||
minHeight: '40px !important',
|
||||
|
|
@ -237,13 +267,13 @@ export const OrderTable = () => {
|
|||
opacity: 1,
|
||||
},
|
||||
'& .MuiDataGrid-pinnedColumnHeaders': {
|
||||
backgroundColor: 'grey.50',
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
|
||||
},
|
||||
'& .MuiDataGrid-pinnedColumns': {
|
||||
backgroundColor: 'background.paper',
|
||||
},
|
||||
'& .MuiDataGrid-pinnedColumnHeader': {
|
||||
backgroundColor: 'grey.50',
|
||||
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.900' : 'grey.50',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
borderBottom: '2px solid',
|
||||
|
|
@ -260,7 +290,8 @@ export const OrderTable = () => {
|
|||
},
|
||||
}}
|
||||
localeText={{
|
||||
noRowsLabel: 'Nenhum pedido encontrado para os filtros selecionados.',
|
||||
noRowsLabel:
|
||||
'Nenhum pedido encontrado para os filtros selecionados.',
|
||||
noResultsOverlayLabel: 'Nenhum resultado encontrado.',
|
||||
footerTotalRows: 'Total de registros:',
|
||||
footerTotalVisibleRows: (visibleCount, totalCount) =>
|
||||
|
|
@ -270,11 +301,18 @@ export const OrderTable = () => {
|
|||
? `${count.toLocaleString()} linha selecionada`
|
||||
: `${count.toLocaleString()} linhas selecionadas`,
|
||||
}}
|
||||
|
||||
slotProps={{
|
||||
pagination: {
|
||||
labelRowsPerPage: 'Pedidos por página:',
|
||||
labelDisplayedRows: ({ from, to, count }: { from: number; to: number; count: number }) => {
|
||||
labelDisplayedRows: ({
|
||||
from,
|
||||
to,
|
||||
count,
|
||||
}: {
|
||||
from: number;
|
||||
to: number;
|
||||
count: number;
|
||||
}) => {
|
||||
const pageSize = to >= from ? to - from + 1 : 10;
|
||||
const currentPage = Math.floor((from - 1) / pageSize) + 1;
|
||||
const totalPages = Math.ceil(count / pageSize);
|
||||
|
|
|
|||
|
|
@ -3,393 +3,434 @@ import Box from '@mui/material/Box';
|
|||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { formatDate, formatDateTime, formatCurrency, formatNumber } from '../utils/orderFormatters';
|
||||
import { getStatusChipProps, getPriorityChipProps } from '../utils/tableHelpers';
|
||||
import {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
} from '../utils/orderFormatters';
|
||||
import {
|
||||
getStatusChipProps,
|
||||
getPriorityChipProps,
|
||||
} from '../utils/tableHelpers';
|
||||
|
||||
export const createOrderColumns = (): GridColDef[] => [
|
||||
{
|
||||
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) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontSize: '0.75rem', color: 'text.secondary' }}
|
||||
noWrap
|
||||
>
|
||||
-
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
const CELL_FONT_SIZE = '0.75rem';
|
||||
const CAPTION_FONT_SIZE = '0.6875rem';
|
||||
|
||||
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,
|
||||
maxWidth: '100%',
|
||||
'& .MuiChip-label': {
|
||||
px: 1,
|
||||
py: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
const CHIP_STYLES = {
|
||||
fontSize: CAPTION_FONT_SIZE,
|
||||
height: 22,
|
||||
fontWeight: 300,
|
||||
} as const;
|
||||
|
||||
const CHIP_PRIORITY_STYLES = {
|
||||
...CHIP_STYLES,
|
||||
maxWidth: '100%',
|
||||
'& .MuiChip-label': {
|
||||
px: 1,
|
||||
py: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
{
|
||||
field: 'invoiceDate',
|
||||
headerName: 'Data Faturamento',
|
||||
width: 150,
|
||||
minWidth: 140,
|
||||
renderCell: (params: Readonly<GridRenderCellParams>) => {
|
||||
const dateStr = formatDate(params.value);
|
||||
const timeStr = params.row.invoiceTime;
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
|
||||
{dateStr}
|
||||
{timeStr && (
|
||||
<Box component="span" sx={{ color: 'text.secondary', fontSize: '0.6875rem', ml: 0.5 }}>
|
||||
{timeStr}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
interface CellTextProps {
|
||||
value: unknown;
|
||||
secondary?: boolean;
|
||||
fontWeight?: number;
|
||||
}
|
||||
|
||||
|
||||
const CellText = ({ value, secondary = false, fontWeight }: CellTextProps) => (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={secondary ? 'text.secondary' : 'text.primary'}
|
||||
sx={{ fontSize: CELL_FONT_SIZE, fontWeight }}
|
||||
noWrap
|
||||
>
|
||||
{String(value ?? '-')}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
interface CellNumericProps {
|
||||
value: unknown;
|
||||
formatter?: (val: number) => string;
|
||||
secondary?: boolean;
|
||||
fontWeight?: number;
|
||||
}
|
||||
|
||||
|
||||
const CellNumeric = ({
|
||||
value,
|
||||
formatter,
|
||||
secondary = false,
|
||||
fontWeight,
|
||||
}: CellNumericProps) => (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={secondary ? 'text.secondary' : 'text.primary'}
|
||||
sx={{ fontSize: CELL_FONT_SIZE, fontWeight, textAlign: 'right' }}
|
||||
>
|
||||
{formatter ? formatter(value as number) : String(value ?? '-')}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
interface CellDateProps {
|
||||
value: unknown;
|
||||
showTime?: boolean;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
const CellDate = ({ value, showTime = false, time }: CellDateProps) => {
|
||||
const dateStr = formatDate(value as string | undefined);
|
||||
|
||||
if (!showTime && !time) {
|
||||
return (
|
||||
<Typography variant="body2" sx={{ fontSize: CELL_FONT_SIZE }}>
|
||||
{dateStr}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const timeStr = time || formatDateTime(value as string | undefined);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontSize: CELL_FONT_SIZE }}>
|
||||
{dateStr}
|
||||
</Typography>
|
||||
{timeStr && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: CAPTION_FONT_SIZE }}
|
||||
>
|
||||
{timeStr}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
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} />
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useOrderFilters } from '../hooks/useOrderFilters';
|
||||
import { useOrders } from '../hooks/useOrders';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
|
|
@ -12,6 +13,9 @@ import {
|
|||
Paper,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
CircularProgress,
|
||||
Badge,
|
||||
type AutocompleteRenderInputParams,
|
||||
} from '@mui/material';
|
||||
import { useStores } from '../store/useStores';
|
||||
import { useCustomers } from '../hooks/useCustomers';
|
||||
|
|
@ -30,19 +34,63 @@ import 'moment/locale/pt-br';
|
|||
|
||||
moment.locale('pt-br');
|
||||
|
||||
interface LocalFilters {
|
||||
status: string | null;
|
||||
orderId: number | null;
|
||||
customerId: number | null;
|
||||
customerName: string | null;
|
||||
createDateIni: string | null;
|
||||
createDateEnd: string | null;
|
||||
store: string[] | null;
|
||||
stockId: string[] | null;
|
||||
sellerId: string | null;
|
||||
sellerName: string | null;
|
||||
}
|
||||
|
||||
const getInitialLocalFilters = (
|
||||
urlFilters: Partial<LocalFilters>
|
||||
): LocalFilters => ({
|
||||
status: urlFilters.status ?? null,
|
||||
orderId: urlFilters.orderId ?? null,
|
||||
customerId: urlFilters.customerId ?? null,
|
||||
customerName: urlFilters.customerName ?? null,
|
||||
createDateIni: urlFilters.createDateIni ?? null,
|
||||
createDateEnd: urlFilters.createDateEnd ?? null,
|
||||
store: urlFilters.store ?? null,
|
||||
stockId: urlFilters.stockId ?? null,
|
||||
sellerId: urlFilters.sellerId ?? null,
|
||||
sellerName: urlFilters.sellerName ?? null,
|
||||
});
|
||||
|
||||
export const SearchBar = () => {
|
||||
const [filters, setFilters] = useOrderFilters();
|
||||
const [urlFilters, setUrlFilters] = useOrderFilters();
|
||||
|
||||
const [localFilters, setLocalFilters] = useState<LocalFilters>(() =>
|
||||
getInitialLocalFilters(urlFilters)
|
||||
);
|
||||
|
||||
const stores = useStores();
|
||||
const sellers = useSellers();
|
||||
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
|
||||
const customers = useCustomers(customerSearchTerm);
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const { isFetching } = useOrders();
|
||||
const [touchedFields, setTouchedFields] = useState<{
|
||||
createDateIni?: boolean;
|
||||
createDateEnd?: boolean;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalFilters(getInitialLocalFilters(urlFilters));
|
||||
}, [urlFilters]);
|
||||
|
||||
const updateLocalFilter = useCallback(
|
||||
<K extends keyof LocalFilters>(key: K, value: LocalFilters[K]) => {
|
||||
setLocalFilters((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setTouchedFields({});
|
||||
setCustomerSearchTerm('');
|
||||
|
|
@ -66,58 +114,70 @@ export const SearchBar = () => {
|
|||
customerName: null,
|
||||
};
|
||||
|
||||
setFilters(resetState);
|
||||
}, [setFilters]);
|
||||
setLocalFilters(getInitialLocalFilters(resetState));
|
||||
setUrlFilters(resetState);
|
||||
}, [setUrlFilters]);
|
||||
|
||||
const validateDates = useCallback(() => {
|
||||
if (!filters.createDateIni || !filters.createDateEnd) {
|
||||
if (!localFilters.createDateIni || !localFilters.createDateEnd) {
|
||||
return null;
|
||||
}
|
||||
const dateIni = moment(filters.createDateIni, 'YYYY-MM-DD');
|
||||
const dateEnd = moment(filters.createDateEnd, 'YYYY-MM-DD');
|
||||
const dateIni = moment(localFilters.createDateIni, 'YYYY-MM-DD');
|
||||
const dateEnd = moment(localFilters.createDateEnd, 'YYYY-MM-DD');
|
||||
if (dateEnd.isBefore(dateIni)) {
|
||||
return 'Data final não pode ser anterior à data inicial';
|
||||
}
|
||||
return null;
|
||||
}, [filters.createDateIni, filters.createDateEnd]);
|
||||
}, [localFilters.createDateIni, localFilters.createDateEnd]);
|
||||
|
||||
const handleFilter = useCallback(() => {
|
||||
if (!filters.createDateIni) {
|
||||
setTouchedFields(prev => ({ ...prev, createDateIni: true }));
|
||||
if (!localFilters.createDateIni) {
|
||||
setTouchedFields((prev) => ({ ...prev, createDateIni: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const dateError = validateDates();
|
||||
if (dateError) {
|
||||
setTouchedFields(prev => ({ ...prev, createDateEnd: true }));
|
||||
setTouchedFields((prev) => ({ ...prev, createDateEnd: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
setFilters({
|
||||
...filters,
|
||||
setUrlFilters({
|
||||
...localFilters,
|
||||
searchTriggered: true,
|
||||
});
|
||||
}, [filters, setFilters, validateDates]);
|
||||
}, [localFilters, setUrlFilters, validateDates]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
const isValid = !!filters.createDateIni;
|
||||
const dateErr = validateDates();
|
||||
if (isValid && !dateErr) {
|
||||
handleFilter();
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
const isValid = !!localFilters.createDateIni;
|
||||
const dateErr = validateDates();
|
||||
if (isValid && !dateErr) {
|
||||
handleFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [filters.createDateIni, validateDates, handleFilter]);
|
||||
},
|
||||
[localFilters.createDateIni, validateDates, handleFilter]
|
||||
);
|
||||
|
||||
const isDateValid = !!filters.createDateIni;
|
||||
const isDateValid = !!localFilters.createDateIni;
|
||||
const dateError = validateDates();
|
||||
const showDateIniError = touchedFields.createDateIni && !filters.createDateIni;
|
||||
const showDateIniError =
|
||||
touchedFields.createDateIni && !localFilters.createDateIni;
|
||||
const showDateEndError = touchedFields.createDateEnd && dateError;
|
||||
|
||||
// Contador de filtros avançados ativos
|
||||
const advancedFiltersCount = [
|
||||
localFilters.store?.length,
|
||||
localFilters.stockId?.length,
|
||||
localFilters.sellerId,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
p: { xs: 2, md: 3 },
|
||||
mb: 2,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
|
|
@ -126,8 +186,7 @@ export const SearchBar = () => {
|
|||
elevation={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Grid container spacing={2} alignItems="flex-end">
|
||||
|
||||
<Grid container spacing={{ xs: 1.5, md: 2 }} alignItems="flex-end">
|
||||
{/* --- Primary Filters (Always Visible) --- */}
|
||||
|
||||
{/* Campo de Texto Simples (Nº Pedido) */}
|
||||
|
|
@ -138,10 +197,10 @@ export const SearchBar = () => {
|
|||
variant="outlined"
|
||||
size="small"
|
||||
type="number"
|
||||
value={filters.orderId ?? ''}
|
||||
value={localFilters.orderId ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : null;
|
||||
setFilters({ orderId: value });
|
||||
updateLocalFilter('orderId', value);
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
placeholder="Ex: 12345"
|
||||
|
|
@ -155,10 +214,10 @@ export const SearchBar = () => {
|
|||
fullWidth
|
||||
label="Situação"
|
||||
size="small"
|
||||
value={filters.status ?? ''}
|
||||
value={localFilters.status ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value || null;
|
||||
setFilters({ status: value });
|
||||
updateLocalFilter('status', value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">Todos</MenuItem>
|
||||
|
|
@ -169,36 +228,35 @@ export const SearchBar = () => {
|
|||
</Grid>
|
||||
|
||||
{/* Autocomplete do MUI para Cliente */}
|
||||
<Grid size={{ xs: 12, sm: 6, md: 2.5 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
|
||||
<Autocomplete
|
||||
size="small"
|
||||
options={customers.options}
|
||||
getOptionLabel={(option) => option.label}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
value={customers.options.find(option =>
|
||||
filters.customerId === option.customer?.id
|
||||
) || null}
|
||||
value={
|
||||
customers.options.find(
|
||||
(option) => localFilters.customerId === option.customer?.id
|
||||
) || null
|
||||
}
|
||||
onChange={(_, newValue) => {
|
||||
if (!newValue) {
|
||||
setFilters({
|
||||
customerName: null,
|
||||
customerId: null,
|
||||
});
|
||||
updateLocalFilter('customerName', null);
|
||||
updateLocalFilter('customerId', null);
|
||||
setCustomerSearchTerm('');
|
||||
return;
|
||||
}
|
||||
|
||||
setFilters({
|
||||
customerId: newValue.customer?.id || null,
|
||||
customerName: newValue.customer?.name || null,
|
||||
});
|
||||
updateLocalFilter('customerId', newValue.customer?.id || null);
|
||||
updateLocalFilter(
|
||||
'customerName',
|
||||
newValue.customer?.name || null
|
||||
);
|
||||
}}
|
||||
onInputChange={(_, newInputValue, reason) => {
|
||||
if (reason === 'clear') {
|
||||
setFilters({
|
||||
customerName: null,
|
||||
customerId: null,
|
||||
});
|
||||
updateLocalFilter('customerName', null);
|
||||
updateLocalFilter('customerId', null);
|
||||
setCustomerSearchTerm('');
|
||||
return;
|
||||
}
|
||||
|
|
@ -206,23 +264,25 @@ export const SearchBar = () => {
|
|||
if (reason === 'input') {
|
||||
setCustomerSearchTerm(newInputValue);
|
||||
if (!newInputValue) {
|
||||
setFilters({
|
||||
customerName: null,
|
||||
customerId: null,
|
||||
});
|
||||
updateLocalFilter('customerName', null);
|
||||
updateLocalFilter('customerId', null);
|
||||
setCustomerSearchTerm('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
loading={customers.isLoading}
|
||||
renderInput={(params: Readonly<any>) => (
|
||||
renderInput={(params: AutocompleteRenderInputParams) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Cliente"
|
||||
placeholder="Digite para buscar..."
|
||||
/>
|
||||
)}
|
||||
noOptionsText={customerSearchTerm.length < 2 ? 'Digite pelo menos 2 caracteres' : 'Nenhum cliente encontrado'}
|
||||
noOptionsText={
|
||||
customerSearchTerm.length < 2
|
||||
? 'Digite pelo menos 2 caracteres'
|
||||
: 'Nenhum cliente encontrado'
|
||||
}
|
||||
loadingText="Buscando clientes..."
|
||||
filterOptions={(x) => x}
|
||||
clearOnBlur={false}
|
||||
|
|
@ -232,59 +292,96 @@ export const SearchBar = () => {
|
|||
</Grid>
|
||||
|
||||
{/* Campos de Data */}
|
||||
<Grid size={{ xs: 12, sm: 12, md: 4 }}>
|
||||
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="pt-br">
|
||||
<Box display="flex" gap={2}>
|
||||
<Grid size={{ xs: 12, sm: 12, md: 3.5 }}>
|
||||
<LocalizationProvider
|
||||
dateAdapter={AdapterMoment}
|
||||
adapterLocale="pt-br"
|
||||
>
|
||||
<Box display="flex" gap={{ xs: 1.5, md: 2 }} flexDirection={{ xs: 'column', sm: 'row' }}>
|
||||
<Box flex={1}>
|
||||
<DatePicker
|
||||
label="Data Inicial"
|
||||
value={filters.createDateIni ? moment(filters.createDateIni, 'YYYY-MM-DD') : null}
|
||||
value={
|
||||
localFilters.createDateIni
|
||||
? moment(localFilters.createDateIni, 'YYYY-MM-DD')
|
||||
: null
|
||||
}
|
||||
onChange={(date: moment.Moment | null) => {
|
||||
setTouchedFields(prev => ({ ...prev, createDateIni: true }));
|
||||
setFilters({
|
||||
createDateIni: date ? date.format('YYYY-MM-DD') : null,
|
||||
});
|
||||
setTouchedFields((prev) => ({
|
||||
...prev,
|
||||
createDateIni: true,
|
||||
}));
|
||||
updateLocalFilter(
|
||||
'createDateIni',
|
||||
date ? date.format('YYYY-MM-DD') : null
|
||||
);
|
||||
}}
|
||||
format="DD/MM/YYYY"
|
||||
maxDate={filters.createDateEnd ? moment(filters.createDateEnd, 'YYYY-MM-DD') : undefined}
|
||||
maxDate={
|
||||
localFilters.createDateEnd
|
||||
? moment(localFilters.createDateEnd, 'YYYY-MM-DD')
|
||||
: undefined
|
||||
}
|
||||
slotProps={{
|
||||
textField: {
|
||||
size: 'small',
|
||||
fullWidth: true,
|
||||
required: true,
|
||||
error: showDateIniError,
|
||||
helperText: showDateIniError ? 'Data inicial é obrigatória' : '',
|
||||
onBlur: () => setTouchedFields(prev => ({ ...prev, createDateIni: true })),
|
||||
helperText: showDateIniError
|
||||
? 'Data inicial é obrigatória'
|
||||
: '',
|
||||
onBlur: () =>
|
||||
setTouchedFields((prev) => ({
|
||||
...prev,
|
||||
createDateIni: true,
|
||||
})),
|
||||
inputProps: {
|
||||
'aria-required': true,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<DatePicker
|
||||
label="Data Final"
|
||||
value={filters.createDateEnd ? moment(filters.createDateEnd, 'YYYY-MM-DD') : null}
|
||||
label="Data Final (opcional)"
|
||||
value={
|
||||
localFilters.createDateEnd
|
||||
? moment(localFilters.createDateEnd, 'YYYY-MM-DD')
|
||||
: null
|
||||
}
|
||||
onChange={(date: moment.Moment | null) => {
|
||||
setTouchedFields(prev => ({ ...prev, createDateEnd: true }));
|
||||
setFilters({
|
||||
createDateEnd: date ? date.format('YYYY-MM-DD') : null,
|
||||
});
|
||||
setTouchedFields((prev) => ({
|
||||
...prev,
|
||||
createDateEnd: true,
|
||||
}));
|
||||
updateLocalFilter(
|
||||
'createDateEnd',
|
||||
date ? date.format('YYYY-MM-DD') : null
|
||||
);
|
||||
}}
|
||||
format="DD/MM/YYYY"
|
||||
minDate={filters.createDateIni ? moment(filters.createDateIni, 'YYYY-MM-DD') : undefined}
|
||||
minDate={
|
||||
localFilters.createDateIni
|
||||
? moment(localFilters.createDateIni, 'YYYY-MM-DD')
|
||||
: undefined
|
||||
}
|
||||
slotProps={{
|
||||
textField: {
|
||||
size: 'small',
|
||||
fullWidth: true,
|
||||
error: !!showDateEndError,
|
||||
helperText: showDateEndError || '',
|
||||
onBlur: () => setTouchedFields(prev => ({ ...prev, createDateEnd: true })),
|
||||
onBlur: () =>
|
||||
setTouchedFields((prev) => ({
|
||||
...prev,
|
||||
createDateEnd: true,
|
||||
})),
|
||||
inputProps: {
|
||||
placeholder: 'Opcional',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
|
@ -292,46 +389,97 @@ export const SearchBar = () => {
|
|||
</LocalizationProvider>
|
||||
</Grid>
|
||||
|
||||
{/* Botões de Ação */}
|
||||
<Grid size={{ xs: 12, sm: 12, md: 1.5 }} sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{/* Botão Mais Filtros - inline com filtros primários */}
|
||||
<Grid
|
||||
size={{ xs: 12, sm: 12, md: 2.5 }}
|
||||
sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: { xs: 'flex-start', md: 'flex-end' } }}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={advancedFiltersCount}
|
||||
color="primary"
|
||||
invisible={advancedFiltersCount === 0}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
endIcon={
|
||||
showAdvancedFilters ? <ExpandLessIcon /> : <ExpandMoreIcon />
|
||||
}
|
||||
aria-label={showAdvancedFilters ? 'Ocultar filtros avançados' : 'Mostrar filtros avançados'}
|
||||
sx={{ textTransform: 'none', color: 'text.secondary', minHeight: 40 }}
|
||||
>
|
||||
{showAdvancedFilters ? 'Menos filtros' : 'Mais filtros'}
|
||||
</Button>
|
||||
</Badge>
|
||||
</Grid>
|
||||
|
||||
{/* Botões de Ação - nova linha abaixo */}
|
||||
<Grid
|
||||
size={{ xs: 12 }}
|
||||
sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1, width: { xs: '100%', sm: 'auto' }, flexWrap: 'nowrap' }}>
|
||||
<Tooltip title="Limpar filtros" arrow>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={handleReset}
|
||||
aria-label="Limpar filtros"
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
minWidth: { xs: 'auto', sm: 90 },
|
||||
minHeight: 32,
|
||||
px: 1.5,
|
||||
flexShrink: 0,
|
||||
flex: { xs: 1, sm: 'none' },
|
||||
fontSize: '0.8125rem',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ResetIcon />
|
||||
<ResetIcon sx={{ mr: 0.5, fontSize: 18 }} />
|
||||
Limpar
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={isDateValid ? 'Buscar pedidos' : 'Preencha a data inicial para buscar'}
|
||||
title={
|
||||
isDateValid
|
||||
? 'Buscar pedidos'
|
||||
: 'Preencha a data inicial para buscar'
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={handleFilter}
|
||||
disabled={!isDateValid || !!dateError}
|
||||
disabled={!isDateValid || !!dateError || isFetching}
|
||||
aria-label="Buscar pedidos"
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
minWidth: { xs: 'auto', sm: 90 },
|
||||
minHeight: 32,
|
||||
px: 1.5,
|
||||
flexShrink: 0,
|
||||
flex: { xs: 1, sm: 'none' },
|
||||
fontSize: '0.8125rem',
|
||||
'&:disabled': {
|
||||
opacity: 0.6,
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SearchIcon />
|
||||
{isFetching ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
<>
|
||||
<SearchIcon sx={{ mr: 0.5, fontSize: 18 }} />
|
||||
Buscar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
|
@ -340,16 +488,6 @@ export const SearchBar = () => {
|
|||
|
||||
{/* --- Advanced Filters (Collapsible) --- */}
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
endIcon={showAdvancedFilters ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
sx={{ textTransform: 'none', color: 'text.secondary' }}
|
||||
>
|
||||
{showAdvancedFilters ? 'Menos filtros' : 'Mais filtros'}
|
||||
</Button>
|
||||
</Box>
|
||||
<Collapse in={showAdvancedFilters}>
|
||||
<Grid container spacing={2} sx={{ pt: 2 }}>
|
||||
{/* Autocomplete do MUI para Múltiplas Filiais (codfilial) */}
|
||||
|
|
@ -359,21 +497,28 @@ export const SearchBar = () => {
|
|||
size="small"
|
||||
options={stores.options}
|
||||
getOptionLabel={(option) => option.label}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
value={stores.options.filter(option =>
|
||||
filters.store?.includes(option.value)
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value.id
|
||||
}
|
||||
value={stores.options.filter((option) =>
|
||||
localFilters.store?.includes(option.value)
|
||||
)}
|
||||
onChange={(_, newValue) => {
|
||||
setFilters({
|
||||
store: newValue.map(option => option.value),
|
||||
});
|
||||
updateLocalFilter(
|
||||
'store',
|
||||
newValue.map((option) => option.value)
|
||||
);
|
||||
}}
|
||||
loading={stores.isLoading}
|
||||
renderInput={(params: Readonly<any>) => (
|
||||
renderInput={(params: AutocompleteRenderInputParams) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Filiais"
|
||||
placeholder={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"
|
||||
options={stores.options}
|
||||
getOptionLabel={(option) => option.label}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
value={stores.options.filter(option =>
|
||||
filters.stockId?.includes(option.value)
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value.id
|
||||
}
|
||||
value={stores.options.filter((option) =>
|
||||
localFilters.stockId?.includes(option.value)
|
||||
)}
|
||||
onChange={(_, newValue) => {
|
||||
setFilters({
|
||||
stockId: newValue.map(option => option.value),
|
||||
});
|
||||
updateLocalFilter(
|
||||
'stockId',
|
||||
newValue.map((option) => option.value)
|
||||
);
|
||||
}}
|
||||
loading={stores.isLoading}
|
||||
renderInput={(params: Readonly<any>) => (
|
||||
renderInput={(params: AutocompleteRenderInputParams) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Filial de Estoque"
|
||||
placeholder={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"
|
||||
options={sellers.options}
|
||||
getOptionLabel={(option) => option.label}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
value={sellers.options.find(option =>
|
||||
filters.sellerId === option.seller.id.toString()
|
||||
) || null}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value.id
|
||||
}
|
||||
value={
|
||||
sellers.options.find(
|
||||
(option) =>
|
||||
localFilters.sellerId === option.seller.id.toString()
|
||||
) || null
|
||||
}
|
||||
onChange={(_, newValue) => {
|
||||
setFilters({
|
||||
sellerId: newValue?.seller.id.toString() || null,
|
||||
sellerName: newValue?.seller.name || null,
|
||||
});
|
||||
updateLocalFilter(
|
||||
'sellerId',
|
||||
newValue?.seller.id.toString() || null
|
||||
);
|
||||
updateLocalFilter(
|
||||
'sellerName',
|
||||
newValue?.seller.name || null
|
||||
);
|
||||
}}
|
||||
loading={sellers.isLoading}
|
||||
renderInput={(params) => (
|
||||
|
|
@ -443,8 +604,7 @@ export const SearchBar = () => {
|
|||
</Grid>
|
||||
</Collapse>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,24 +4,20 @@ import { ReactNode } from 'react';
|
|||
import Box from '@mui/material/Box';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
children?: ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const TabPanel = ({ children, value, index }: TabPanelProps) => {
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`order-tabpanel-${index}`}
|
||||
aria-labelledby={`order-tab-${index}`}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ py: 3 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`order-tabpanel-${index}`}
|
||||
aria-labelledby={`order-tab-${index}`}
|
||||
>
|
||||
{value === index && <Box sx={{ py: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,63 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DataGridPremium } from '@mui/x-data-grid-premium';
|
||||
import { useCargoMovement } from '../../hooks/useCargoMovement';
|
||||
import { createCargoMovementColumns } from './CargoMovementPanelColumns';
|
||||
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
|
||||
|
||||
interface CargoMovementPanelProps {
|
||||
orderId: number;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export const CargoMovementPanel = ({ orderId }: CargoMovementPanelProps) => {
|
||||
const { data: movements, isLoading, error } = useCargoMovement(orderId);
|
||||
|
||||
const columns = useMemo(() => createCargoMovementColumns(), []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!movements || movements.length === 0) return [];
|
||||
return movements.map((movement, index) => ({
|
||||
id: `${movement.transactionId}-${index}`,
|
||||
...movement,
|
||||
}));
|
||||
}, [movements]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={30} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="error">Erro ao carregar movimentação de carga.</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!movements || movements.length === 0) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="info">Nenhuma movimentação de carga encontrada para este pedido.</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridPremium
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
density="compact"
|
||||
autoHeight
|
||||
hideFooter
|
||||
sx={dataGridStylesSimple}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
@ -5,17 +5,17 @@ import Alert from '@mui/material/Alert';
|
|||
import Typography from '@mui/material/Typography';
|
||||
|
||||
interface CashAdjustmentPanelProps {
|
||||
orderId: number;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export const CashAdjustmentPanel = ({ orderId }: CashAdjustmentPanelProps) => {
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,63 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DataGridPremium } from '@mui/x-data-grid-premium';
|
||||
import { useCuttingItems } from '../../hooks/useCuttingItems';
|
||||
import { createCuttingPanelColumns } from './CuttingPanelColumns';
|
||||
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
|
||||
|
||||
interface CuttingPanelProps {
|
||||
orderId: number;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export const CuttingPanel = ({ orderId }: CuttingPanelProps) => {
|
||||
const { data: items, isLoading, error } = useCuttingItems(orderId);
|
||||
|
||||
const columns = useMemo(() => createCuttingPanelColumns(), []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!items || items.length === 0) return [];
|
||||
return items.map((item) => ({
|
||||
id: item.productId,
|
||||
...item,
|
||||
}));
|
||||
}, [items]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={30} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="error">Erro ao carregar itens de corte.</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="info">Nenhum item de corte encontrado para este pedido.</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridPremium
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
density="compact"
|
||||
autoHeight
|
||||
hideFooter
|
||||
sx={dataGridStylesSimple}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
@ -1,21 +1,63 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DataGridPremium } from '@mui/x-data-grid-premium';
|
||||
import { useDelivery } from '../../hooks/useDelivery';
|
||||
import { createDeliveryPanelColumns } from './DeliveryPanelColumns';
|
||||
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
|
||||
|
||||
interface DeliveryPanelProps {
|
||||
orderId: number;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export const DeliveryPanel = ({ orderId }: DeliveryPanelProps) => {
|
||||
const { data: deliveries, isLoading, error } = useDelivery(orderId);
|
||||
|
||||
const columns = useMemo(() => createDeliveryPanelColumns(), []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!deliveries || deliveries.length === 0) return [];
|
||||
return deliveries.map((delivery) => ({
|
||||
id: delivery.shippimentId,
|
||||
...delivery,
|
||||
}));
|
||||
}, [deliveries]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={30} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="error">Erro ao carregar entregas do pedido.</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deliveries || deliveries.length === 0) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="info">Nenhuma entrega encontrada para este pedido.</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridPremium
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
density="compact"
|
||||
autoHeight
|
||||
hideFooter
|
||||
sx={dataGridStylesSimple}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
@ -1,21 +1,65 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DataGridPremium } from '@mui/x-data-grid-premium';
|
||||
import { useOrderDetails } from '../../hooks/useOrderDetails';
|
||||
import { createInformationPanelColumns } from './InformationPanelColumns';
|
||||
import { dataGridStylesSimple } from '../../utils/dataGridStyles';
|
||||
|
||||
interface InformationPanelProps {
|
||||
orderId: number;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export const InformationPanel = ({ orderId }: InformationPanelProps) => {
|
||||
const { data: order, isLoading, error } = useOrderDetails(orderId);
|
||||
|
||||
const columns = useMemo(() => createInformationPanelColumns(), []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!order) return [];
|
||||
return [
|
||||
{
|
||||
id: order.orderId || orderId,
|
||||
...order,
|
||||
},
|
||||
];
|
||||
}, [order, orderId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={30} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="error">Erro ao carregar detalhes do pedido.</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Alert severity="info">Informações do pedido não encontradas.</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridPremium
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
density="compact"
|
||||
autoHeight
|
||||
hideFooter
|
||||
sx={dataGridStylesSimple}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -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}`;
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,17 +5,17 @@ import Alert from '@mui/material/Alert';
|
|||
import Typography from '@mui/material/Typography';
|
||||
|
||||
interface PreBoxPanelProps {
|
||||
orderId: number;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export const PreBoxPanel = ({ orderId }: PreBoxPanelProps) => {
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ import Alert from '@mui/material/Alert';
|
|||
import Typography from '@mui/material/Typography';
|
||||
|
||||
interface TV8PanelProps {
|
||||
orderId: number;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export const TV8Panel = ({ orderId }: TV8PanelProps) => {
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ import Alert from '@mui/material/Alert';
|
|||
import Typography from '@mui/material/Typography';
|
||||
|
||||
interface TimelinePanelProps {
|
||||
orderId: number;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export const TimelinePanel = ({ orderId }: TimelinePanelProps) => {
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Funcionalidade em desenvolvimento para o pedido {orderId}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,26 +1,13 @@
|
|||
{
|
||||
"status": {
|
||||
"success": [
|
||||
"FATURADO",
|
||||
"F"
|
||||
],
|
||||
"error": [
|
||||
"CANCELADO",
|
||||
"C"
|
||||
],
|
||||
"warning": []
|
||||
},
|
||||
"priority": {
|
||||
"error": [
|
||||
"ALTA"
|
||||
],
|
||||
"warning": [
|
||||
"MÉDIA",
|
||||
"MEDIA"
|
||||
],
|
||||
"success": [
|
||||
"BAIXA"
|
||||
],
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
"status": {
|
||||
"success": ["FATURADO", "F"],
|
||||
"error": ["CANCELADO", "C"],
|
||||
"warning": []
|
||||
},
|
||||
"priority": {
|
||||
"error": ["ALTA"],
|
||||
"warning": ["MÉDIA", "MEDIA"],
|
||||
"success": ["BAIXA"],
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ Esta pasta contém a documentação técnica da feature de pedidos do Portal Jur
|
|||
## Documentos Disponíveis
|
||||
|
||||
### [order-items-implementation.md](./order-items-implementation.md)
|
||||
|
||||
Documentação completa da implementação da funcionalidade de visualização de itens do pedido.
|
||||
|
||||
**Conteúdo:**
|
||||
|
||||
- Arquitetura da solução
|
||||
- Arquivos criados e modificados
|
||||
- Fluxo de execução
|
||||
|
|
@ -20,9 +22,11 @@ Documentação completa da implementação da funcionalidade de visualização d
|
|||
## Funcionalidades Documentadas
|
||||
|
||||
### Tabela de Itens do Pedido
|
||||
|
||||
Permite visualizar os produtos/itens de um pedido ao clicar na linha da tabela principal.
|
||||
|
||||
**Arquivos principais:**
|
||||
|
||||
- `schemas/order.item.schema.ts` - Schema de validação
|
||||
- `api/order.service.ts` - Serviço de API
|
||||
- `hooks/useOrderItems.ts` - Hook React Query
|
||||
|
|
|
|||
|
|
@ -25,24 +25,25 @@ graph TD
|
|||
## Arquivos Criados
|
||||
|
||||
### 1. Schema de Validação
|
||||
|
||||
**Arquivo:** `src/features/orders/schemas/order.item.schema.ts`
|
||||
|
||||
Define a estrutura de dados dos itens do pedido usando Zod:
|
||||
|
||||
```typescript
|
||||
export const orderItemSchema = z.object({
|
||||
productId: z.coerce.number(), // Código do produto
|
||||
description: z.string(), // Descrição
|
||||
pacth: z.string(), // Unidade de medida
|
||||
color: z.coerce.number(), // Código da cor
|
||||
stockId: z.coerce.number(), // Código do estoque
|
||||
quantity: z.coerce.number(), // Quantidade
|
||||
salePrice: z.coerce.number(), // Preço unitário
|
||||
deliveryType: z.string(), // Tipo de entrega
|
||||
total: z.coerce.number(), // Valor total
|
||||
weight: z.coerce.number(), // Peso
|
||||
department: z.string(), // Departamento
|
||||
brand: z.string(), // Marca
|
||||
productId: z.coerce.number(), // Código do produto
|
||||
description: z.string(), // Descrição
|
||||
pacth: z.string(), // Unidade de medida
|
||||
color: z.coerce.number(), // Código da cor
|
||||
stockId: z.coerce.number(), // Código do estoque
|
||||
quantity: z.coerce.number(), // Quantidade
|
||||
salePrice: z.coerce.number(), // Preço unitário
|
||||
deliveryType: z.string(), // Tipo de entrega
|
||||
total: z.coerce.number(), // Valor total
|
||||
weight: z.coerce.number(), // Peso
|
||||
department: z.string(), // Departamento
|
||||
brand: z.string(), // Marca
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -51,6 +52,7 @@ export const orderItemSchema = z.object({
|
|||
---
|
||||
|
||||
### 2. Serviço de API
|
||||
|
||||
**Arquivo:** `src/features/orders/api/order.service.ts` (linha 145)
|
||||
|
||||
Adiciona método para buscar itens do pedido:
|
||||
|
|
@ -64,12 +66,13 @@ findOrderItems: async (orderId: number): Promise<OrderItem[]> => {
|
|||
console.error(`Erro ao buscar itens do pedido ${orderId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Endpoint:** `GET /api/v1/orders/itens/{orderId}`
|
||||
|
||||
**Resposta esperada:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
|
|
@ -95,6 +98,7 @@ findOrderItems: async (orderId: number): Promise<OrderItem[]> => {
|
|||
---
|
||||
|
||||
### 3. Hook React Query
|
||||
|
||||
**Arquivo:** `src/features/orders/hooks/useOrderItems.ts`
|
||||
|
||||
Gerencia o estado e cache dos itens:
|
||||
|
|
@ -114,6 +118,7 @@ export function useOrderItems(orderId: number | null | undefined) {
|
|||
```
|
||||
|
||||
**Características:**
|
||||
|
||||
- Cache de 5 minutos
|
||||
- Só executa quando há `orderId` válido
|
||||
- Retry automático (1 tentativa)
|
||||
|
|
@ -122,30 +127,32 @@ export function useOrderItems(orderId: number | null | undefined) {
|
|||
---
|
||||
|
||||
### 4. Definição de Colunas
|
||||
|
||||
**Arquivo:** `src/features/orders/components/OrderItemsTableColumns.tsx`
|
||||
|
||||
Define 12 colunas para a tabela:
|
||||
|
||||
| Campo | Cabeçalho | Tipo | Agregável | Formatação |
|
||||
|-------|-----------|------|-----------|------------|
|
||||
| `productId` | Cód. Produto | number | Não | - |
|
||||
| `description` | Descrição | string | Não | - |
|
||||
| `pacth` | Unidade | string | Não | Centralizado |
|
||||
| `color` | Cor | number | Não | Centralizado |
|
||||
| `stockId` | Cód. Estoque | number | Não | - |
|
||||
| `quantity` | Qtd. | number | Sim | `formatNumber()` |
|
||||
| `salePrice` | Preço Unitário | number | Não | `formatCurrency()` |
|
||||
| `total` | Valor Total | number | Sim | `formatCurrency()` |
|
||||
| `deliveryType` | Tipo Entrega | string | Não | - |
|
||||
| `weight` | Peso (kg) | number | Sim | `formatNumber()` |
|
||||
| `department` | Departamento | string | Não | - |
|
||||
| `brand` | Marca | string | Não | - |
|
||||
| Campo | Cabeçalho | Tipo | Agregável | Formatação |
|
||||
| -------------- | -------------- | ------ | --------- | ------------------ |
|
||||
| `productId` | Cód. Produto | number | Não | - |
|
||||
| `description` | Descrição | string | Não | - |
|
||||
| `pacth` | Unidade | string | Não | Centralizado |
|
||||
| `color` | Cor | number | Não | Centralizado |
|
||||
| `stockId` | Cód. Estoque | number | Não | - |
|
||||
| `quantity` | Qtd. | number | Sim | `formatNumber()` |
|
||||
| `salePrice` | Preço Unitário | number | Não | `formatCurrency()` |
|
||||
| `total` | Valor Total | number | Sim | `formatCurrency()` |
|
||||
| `deliveryType` | Tipo Entrega | string | Não | - |
|
||||
| `weight` | Peso (kg) | number | Sim | `formatNumber()` |
|
||||
| `department` | Departamento | string | Não | - |
|
||||
| `brand` | Marca | string | Não | - |
|
||||
|
||||
**Agregação:** Colunas marcadas com "Sim" suportam totalização automática.
|
||||
|
||||
---
|
||||
|
||||
### 5. Componente da Tabela
|
||||
|
||||
**Arquivo:** `src/features/orders/components/OrderItemsTable.tsx`
|
||||
|
||||
Componente principal que renderiza a tabela de itens:
|
||||
|
|
@ -158,10 +165,11 @@ interface OrderItemsTableProps {
|
|||
export const OrderItemsTable = ({ orderId }: OrderItemsTableProps) => {
|
||||
const { data: items, isLoading, error } = useOrderItems(orderId);
|
||||
// ... renderização
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Estados:**
|
||||
|
||||
- **Loading:** Exibe spinner + mensagem "Carregando itens..."
|
||||
- **Erro:** Exibe Alert vermelho com mensagem de erro
|
||||
- **Vazio:** Exibe Alert azul "Nenhum item encontrado"
|
||||
|
|
@ -170,16 +178,19 @@ export const OrderItemsTable = ({ orderId }: OrderItemsTableProps) => {
|
|||
---
|
||||
|
||||
### 6. Integração na Tabela Principal
|
||||
|
||||
**Arquivo:** `src/features/orders/components/OrderTable.tsx`
|
||||
|
||||
**Mudanças realizadas:**
|
||||
|
||||
#### a) Estado para pedido selecionado
|
||||
|
||||
```typescript
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
||||
```
|
||||
|
||||
#### b) Handler de clique na linha
|
||||
|
||||
```typescript
|
||||
onRowClick={(params) => {
|
||||
const orderId = params.row.orderId;
|
||||
|
|
@ -188,13 +199,15 @@ onRowClick={(params) => {
|
|||
```
|
||||
|
||||
#### c) Classe CSS para linha selecionada
|
||||
|
||||
```typescript
|
||||
getRowClassName={(params) =>
|
||||
getRowClassName={(params) =>
|
||||
params.row.orderId === selectedOrderId ? 'Mui-selected' : ''
|
||||
}
|
||||
```
|
||||
|
||||
#### d) Estilo visual da linha selecionada
|
||||
|
||||
```typescript
|
||||
'& .MuiDataGrid-row.Mui-selected': {
|
||||
backgroundColor: 'primary.light',
|
||||
|
|
@ -205,6 +218,7 @@ getRowClassName={(params) =>
|
|||
```
|
||||
|
||||
#### e) Renderização condicional da tabela de itens
|
||||
|
||||
```typescript
|
||||
{selectedOrderId && <OrderItemsTable orderId={selectedOrderId} />}
|
||||
```
|
||||
|
|
@ -226,11 +240,13 @@ getRowClassName={(params) =>
|
|||
## Problemas Encontrados e Soluções
|
||||
|
||||
### Problema 1: "Nenhum item encontrado"
|
||||
|
||||
**Sintoma:** Mesmo com dados na API, a mensagem "Nenhum item encontrado" aparecia.
|
||||
|
||||
**Causa:** A API retorna campos numéricos como strings (`"123"` ao invés de `123`), mas o schema Zod esperava `number`.
|
||||
|
||||
**Solução:** Usar `z.coerce.number()` em todos os campos numéricos:
|
||||
|
||||
```diff
|
||||
- productId: z.number(),
|
||||
+ productId: z.coerce.number(),
|
||||
|
|
@ -241,9 +257,11 @@ getRowClassName={(params) =>
|
|||
---
|
||||
|
||||
### Problema 2: Nomes de colunas genéricos
|
||||
|
||||
**Sintoma:** Colunas com nomes pouco descritivos ("Código", "Lote").
|
||||
|
||||
**Solução:** Renomear baseado nos dados reais:
|
||||
|
||||
- `Código` → `Cód. Produto`
|
||||
- `Lote` → `Unidade` (campo contém unidade de medida)
|
||||
- `Estoque` → `Cód. Estoque`
|
||||
|
|
@ -254,6 +272,7 @@ getRowClassName={(params) =>
|
|||
## Estilização
|
||||
|
||||
### Cabeçalho da Tabela
|
||||
|
||||
```typescript
|
||||
'& .MuiDataGrid-columnHeaders': {
|
||||
backgroundColor: 'grey.50',
|
||||
|
|
@ -265,6 +284,7 @@ getRowClassName={(params) =>
|
|||
```
|
||||
|
||||
### Linhas
|
||||
|
||||
```typescript
|
||||
'& .MuiDataGrid-row': {
|
||||
minHeight: '36px !important',
|
||||
|
|
@ -278,6 +298,7 @@ getRowClassName={(params) =>
|
|||
```
|
||||
|
||||
### Células
|
||||
|
||||
```typescript
|
||||
'& .MuiDataGrid-cell': {
|
||||
fontSize: '0.75rem',
|
||||
|
|
@ -310,9 +331,9 @@ getRowClassName={(params) =>
|
|||
|
||||
**Resultado na tabela:**
|
||||
|
||||
| Cód. Produto | Descrição | Unidade | Qtd. | Preço Unitário | Valor Total | Departamento | Marca |
|
||||
|--------------|-----------|---------|------|----------------|-------------|--------------|-------|
|
||||
| 2813 | TELHA ONDINA 2,44X50MM 4MM | UN | 1 | R$ 25,99 | R$ 25,99 | MATERIAIS DE CONSTRUCAO | BRASILIT |
|
||||
| Cód. Produto | Descrição | Unidade | Qtd. | Preço Unitário | Valor Total | Departamento | Marca |
|
||||
| ------------ | -------------------------- | ------- | ---- | -------------- | ----------- | ----------------------- | -------- |
|
||||
| 2813 | TELHA ONDINA 2,44X50MM 4MM | UN | 1 | R$ 25,99 | R$ 25,99 | MATERIAIS DE CONSTRUCAO | BRASILIT |
|
||||
|
||||
## Checklist de Implementação
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -33,16 +33,16 @@ export function useCustomers(searchTerm: string) {
|
|||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const options = query.data?.map((customer, index) => ({
|
||||
value: customer.id.toString(),
|
||||
label: customer.name,
|
||||
id: `customer-${customer.id}-${index}`,
|
||||
customer: customer,
|
||||
})) ?? [];
|
||||
const options =
|
||||
query.data?.map((customer, index) => ({
|
||||
value: customer.id.toString(),
|
||||
label: customer.name,
|
||||
id: `customer-${customer.id}-${index}`,
|
||||
customer: customer,
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
...query,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -5,33 +5,36 @@ import {
|
|||
parseAsString,
|
||||
parseAsInteger,
|
||||
parseAsBoolean,
|
||||
parseAsArrayOf
|
||||
parseAsArrayOf,
|
||||
} from 'nuqs';
|
||||
|
||||
export const useOrderFilters = () => {
|
||||
return useQueryStates({
|
||||
status: parseAsString,
|
||||
sellerName: parseAsString,
|
||||
sellerId: parseAsString,
|
||||
customerName: parseAsString,
|
||||
customerId: parseAsInteger,
|
||||
return useQueryStates(
|
||||
{
|
||||
status: parseAsString,
|
||||
sellerName: parseAsString,
|
||||
sellerId: parseAsString,
|
||||
customerName: parseAsString,
|
||||
customerId: parseAsInteger,
|
||||
|
||||
codfilial: parseAsArrayOf(parseAsString, ','),
|
||||
codusur2: parseAsArrayOf(parseAsString, ','),
|
||||
store: parseAsArrayOf(parseAsString, ','),
|
||||
orderId: parseAsInteger,
|
||||
productId: parseAsInteger,
|
||||
stockId: parseAsArrayOf(parseAsString, ','),
|
||||
codfilial: parseAsArrayOf(parseAsString, ','),
|
||||
codusur2: parseAsArrayOf(parseAsString, ','),
|
||||
store: parseAsArrayOf(parseAsString, ','),
|
||||
orderId: parseAsInteger,
|
||||
productId: parseAsInteger,
|
||||
stockId: parseAsArrayOf(parseAsString, ','),
|
||||
|
||||
hasPreBox: parseAsBoolean.withDefault(false),
|
||||
includeCheckout: parseAsBoolean.withDefault(false),
|
||||
hasPreBox: parseAsBoolean.withDefault(false),
|
||||
includeCheckout: parseAsBoolean.withDefault(false),
|
||||
|
||||
createDateIni: parseAsString,
|
||||
createDateEnd: parseAsString,
|
||||
createDateIni: parseAsString,
|
||||
createDateEnd: parseAsString,
|
||||
|
||||
searchTriggered: parseAsBoolean.withDefault(false),
|
||||
}, {
|
||||
shallow: true,
|
||||
history: 'replace',
|
||||
});
|
||||
};
|
||||
searchTriggered: parseAsBoolean.withDefault(false),
|
||||
},
|
||||
{
|
||||
shallow: true,
|
||||
history: 'replace',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue