🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
42
src/App.css
Normal file
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
185
src/components/AppSidebar.tsx
Normal file
185
src/components/AppSidebar.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu } from "@/components/ui/sidebar";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useUserRole } from "@/hooks/useUserRole";
|
||||
import { useIconStyle } from "@/hooks/useIconStyle";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useUserPermissions } from "@/hooks/useUserPermissions";
|
||||
import { usePermissionControl } from "@/hooks/usePermissionControl";
|
||||
import { menuGroups } from "./sidebar/menuConfig";
|
||||
import { AppSidebarMenuItem } from "./sidebar/SidebarMenuItem";
|
||||
|
||||
export function AppSidebar() {
|
||||
const location = useLocation();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||
const { iconStyle } = useIconStyle();
|
||||
const { hasAccess, loading: permissionsLoading } = useUserPermissions();
|
||||
const { canAccessTools, canInteractWithSpecialMenus } = usePermissionControl();
|
||||
const isMobile = useIsMobile();
|
||||
const [openGroups, setOpenGroups] = useState<{ [key: string]: boolean }>({
|
||||
'producao': true
|
||||
});
|
||||
|
||||
// Handle mobile menu item click
|
||||
const handleMenuItemClick = (hasSubItems: boolean = false) => {
|
||||
if (isMobile && !hasSubItems) {
|
||||
setOpenMobile(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle submenu toggle
|
||||
const handleSubmenuToggle = (itemKey: string, currentState: boolean) => {
|
||||
setOpenGroups(prev => ({
|
||||
...prev,
|
||||
[itemKey]: !currentState
|
||||
}));
|
||||
};
|
||||
|
||||
const isActive = (url: string) => {
|
||||
if (url === "/dashboard" && location.pathname === "/") {
|
||||
return true;
|
||||
}
|
||||
return location.pathname === url;
|
||||
};
|
||||
|
||||
const getIconProps = (itemKey?: string) => {
|
||||
const baseProps = { className: "mr-2 h-4 w-4" };
|
||||
|
||||
// Mapeamento de cores específicas para cada ícone
|
||||
const iconColors: { [key: string]: string } = {
|
||||
'dashboard': '#3b82f6', // azul
|
||||
'cadastro': '#10b981', // verde
|
||||
'ferramentas': '#f59e0b', // laranja
|
||||
'estoque': '#8b5cf6', // roxo
|
||||
'ofs': '#eab308', // amarelo
|
||||
'producao': '#ef4444', // vermelho
|
||||
'painel-industrial': '#06b6d4', // ciano
|
||||
'expedicao': '#ec4899', // rosa
|
||||
'obra': '#a3a3a3', // marrom
|
||||
'tarefas': '#059669', // verde escuro
|
||||
'biblioteca': '#1d4ed8', // azul escuro
|
||||
'sistema': '#6b7280', // cinza
|
||||
'sugestoes': '#7c3aed', // violeta
|
||||
'atribuicoes': '#14b8a6', // teal
|
||||
'mapa-interativo': '#6366f1', // indigo
|
||||
'configuracoes': '#475569', // slate
|
||||
'admin': '#dc2626', // vermelho escuro
|
||||
'gerenciar-usuarios': '#e11d48' // vermelho médio
|
||||
};
|
||||
|
||||
// Se temos uma cor específica para este ícone, aplicá-la
|
||||
if (itemKey && iconColors[itemKey]) {
|
||||
return {
|
||||
...baseProps,
|
||||
style: { color: iconColors[itemKey] }
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback para o comportamento original baseado no iconStyle
|
||||
switch (iconStyle) {
|
||||
case 'white':
|
||||
return { ...baseProps, className: `${baseProps.className} text-white` };
|
||||
case 'themed':
|
||||
return { ...baseProps, className: `${baseProps.className} text-primary` };
|
||||
case 'colorful':
|
||||
return { ...baseProps, style: { color: 'inherit' } };
|
||||
default:
|
||||
return baseProps;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check if user can access an item
|
||||
const canAccessItem = (itemKey: string, requiresSpecialPermission?: boolean): boolean => {
|
||||
try {
|
||||
// Admin can always access everything
|
||||
if (isAdmin) return true;
|
||||
|
||||
// Se o item requer permissão especial, verificar permissões específicas
|
||||
if (requiresSpecialPermission) {
|
||||
if (itemKey === 'ferramentas') {
|
||||
return canAccessTools();
|
||||
}
|
||||
if (itemKey === 'tarefas' || itemKey === 'sistema' || itemKey === 'sugestoes') {
|
||||
return canInteractWithSpecialMenus();
|
||||
}
|
||||
}
|
||||
|
||||
// Users with any functional permission can see most menus
|
||||
// Restriction will be applied in the pages/components themselves
|
||||
return hasAccess();
|
||||
} catch (error) {
|
||||
console.warn('Error checking item access:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for permissions loading
|
||||
if (permissionsLoading || roleLoading) {
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<div className="p-4 text-center text-slate-400">
|
||||
Carregando menu...
|
||||
</div>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('🖥️ Rendering sidebar with:', {
|
||||
isAdmin,
|
||||
hasBasicAccess: hasAccess(),
|
||||
canAccessTools: canAccessTools(),
|
||||
canInteractWithSpecialMenus: canInteractWithSpecialMenus()
|
||||
});
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
{menuGroups.map(group => {
|
||||
// Filter admin groups for non-admin users
|
||||
if (!isAdmin && group.name === 'Administração') {
|
||||
console.log('🚫 Hiding admin group for non-admin user');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip groups with no items
|
||||
if (!group.items || group.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup key={group.id}>
|
||||
<SidebarGroupLabel
|
||||
style={{ color: group.color }}
|
||||
className="text-base font-semibold uppercase tracking-wide"
|
||||
>
|
||||
{group.name}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
<AppSidebarMenuItem
|
||||
key={item.key || item.title}
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
canAccessItem={(itemKey) => canAccessItem(itemKey, item.requiresSpecialPermission)}
|
||||
isAdmin={isAdmin}
|
||||
openGroups={openGroups}
|
||||
onSubmenuToggle={handleSubmenuToggle}
|
||||
onMenuItemClick={handleMenuItemClick}
|
||||
getIconProps={(itemKey) => getIconProps(itemKey)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
207
src/components/ErrorBoundary.tsx
Normal file
207
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertTriangle, Shield } from 'lucide-react';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: ErrorInfo;
|
||||
isPermissionError: boolean;
|
||||
}
|
||||
|
||||
// Função auxiliar para verificar erro de permissão (fora da classe)
|
||||
const isPermissionRelatedError = (error: Error): boolean => {
|
||||
const errorMessage = error.message?.toLowerCase() || '';
|
||||
const errorStack = error.stack?.toLowerCase() || '';
|
||||
|
||||
// Palavras-chave que indicam erro de permissão
|
||||
const permissionKeywords = [
|
||||
'permission', 'permissao', 'permissão',
|
||||
'access', 'acesso',
|
||||
'unauthorized', 'não autorizado', 'nao autorizado',
|
||||
'forbidden', 'proibido',
|
||||
'privilege', 'privilegio', 'privilégio',
|
||||
'role', 'papel', 'função', 'funcao',
|
||||
'restricted', 'restrito', 'restricao', 'restrição'
|
||||
];
|
||||
|
||||
return permissionKeywords.some(keyword =>
|
||||
errorMessage.includes(keyword) || errorStack.includes(keyword)
|
||||
);
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
isPermissionError: false
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
// Verificar se é erro relacionado a permissões usando a função auxiliar
|
||||
const isPermissionError = isPermissionRelatedError(error);
|
||||
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
isPermissionError
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
logger.error('Error caught by boundary', { error, errorInfo });
|
||||
|
||||
const isPermissionError = isPermissionRelatedError(error);
|
||||
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
isPermissionError
|
||||
});
|
||||
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
errorInfo: undefined,
|
||||
isPermissionError: false
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
errorInfo: undefined,
|
||||
isPermissionError: false
|
||||
});
|
||||
};
|
||||
|
||||
handleGoBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Renderizar erro específico de permissão
|
||||
if (this.state.isPermissionError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Acesso Restrito</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Você não tem permissão para acessar esta funcionalidade do sistema.
|
||||
Entre em contato com o administrador para solicitar as permissões necessárias.
|
||||
</p>
|
||||
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||
<p className="text-sm text-orange-800">
|
||||
<strong>Possíveis soluções:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-orange-700 mt-1 space-y-1">
|
||||
<li>• Solicite acesso ao administrador do sistema</li>
|
||||
<li>• Verifique se você está logado com a conta correta</li>
|
||||
<li>• Aguarde a aprovação das suas permissões</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button variant="outline" onClick={this.handleGoBack}>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button onClick={this.handleReload} className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Renderizar erro genérico
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Algo deu errado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Ocorreu um erro inesperado. Tente novamente ou recarregue a página.
|
||||
</p>
|
||||
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="mt-4 p-3 bg-red-50 rounded border text-sm">
|
||||
<summary className="cursor-pointer font-medium text-red-800">
|
||||
Detalhes do erro (desenvolvimento)
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-700 whitespace-pre-wrap">
|
||||
{this.state.error.message}
|
||||
{this.state.errorInfo?.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button variant="outline" onClick={this.handleRetry}>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
<Button onClick={this.handleReload} className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Recarregar página
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Hook para usar com componentes funcionais
|
||||
export const withErrorBoundary = <P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
fallback?: ReactNode
|
||||
) => {
|
||||
const WrappedComponent = (props: P) => (
|
||||
<ErrorBoundary fallback={fallback}>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`;
|
||||
return WrappedComponent;
|
||||
};
|
||||
38
src/components/Layout.tsx
Normal file
38
src/components/Layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
import React from 'react';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/AppSidebar';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { ApontamentoAutomaticoListener } from '@/components/expedicao/ApontamentoAutomaticoListener';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Layout = ({ children }: LayoutProps) => {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen flex w-full">
|
||||
<AppSidebar />
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Header fixo com SidebarTrigger e ThemeToggle */}
|
||||
<header className="h-14 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
{/* Conteúdo principal */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Toaster />
|
||||
{/* Listener global para apontamento automático */}
|
||||
<ApontamentoAutomaticoListener />
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
28
src/components/ProtectedAdminRoute.tsx
Normal file
28
src/components/ProtectedAdminRoute.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
|
||||
interface ProtectedAdminRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||
|
||||
if (loading || roleLoading) {
|
||||
return <div>Carregando...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
100
src/components/ProtectedRoute.tsx
Normal file
100
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
// Buscar o perfil do usuário para verificar o status
|
||||
const { data: profile, isLoading: profileLoading, error } = useQuery({
|
||||
queryKey: ['user-profile', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!user?.id) return null;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('status')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao verificar perfil do usuário:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
enabled: !!user?.id,
|
||||
retry: 1, // Limitar tentativas de retry
|
||||
staleTime: 30000, // Cache por 30 segundos
|
||||
});
|
||||
|
||||
// Mostrar loading enquanto carrega autenticação ou perfil
|
||||
if (loading || (user && profileLoading)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-muted-foreground">Carregando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Se não há usuário, redirecionar para auth
|
||||
if (!user) {
|
||||
console.log('🚫 ProtectedRoute: Usuário não autenticado, redirecionando para /auth');
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
// Se há erro ao carregar perfil, permitir acesso (para evitar loop)
|
||||
if (error) {
|
||||
console.warn('⚠️ ProtectedRoute: Erro ao carregar perfil, permitindo acesso');
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Se há usuário mas não conseguiu carregar o perfil ainda, mostrar loading
|
||||
if (!profile && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-muted-foreground">Verificando permissões...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se o usuário tem status 'active'
|
||||
if (profile && profile.status !== 'active') {
|
||||
console.log('🚫 ProtectedRoute: Usuário com status inválido:', profile.status);
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-center space-y-4 p-8 max-w-md mx-auto">
|
||||
<div className="text-6xl">⏳</div>
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Aguardando Aprovação
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Sua conta foi criada com sucesso, mas ainda precisa ser aprovada por um administrador.
|
||||
Você receberá acesso assim que sua solicitação for analisada.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
supabase.auth.signOut();
|
||||
}}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Fazer Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('✅ ProtectedRoute: Usuário ativo autorizado, renderizando conteúdo');
|
||||
return <>{children}</>;
|
||||
};
|
||||
150
src/components/ProtectedRouteByResource.tsx
Normal file
150
src/components/ProtectedRouteByResource.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserPermissions } from '@/hooks/useUserPermissions';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
|
||||
interface ProtectedRouteByResourceProps {
|
||||
children: React.ReactNode;
|
||||
resourceKey: string;
|
||||
}
|
||||
|
||||
export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({
|
||||
children,
|
||||
resourceKey
|
||||
}) => {
|
||||
const { user, loading } = useAuth();
|
||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||
|
||||
// Use a try-catch to prevent the hook from crashing the component
|
||||
let permissionsData;
|
||||
try {
|
||||
permissionsData = useUserPermissions();
|
||||
} catch (error) {
|
||||
console.error('Error in useUserPermissions:', error);
|
||||
// Fallback to basic data structure
|
||||
permissionsData = {
|
||||
hasAccess: () => isAdmin,
|
||||
loading: false,
|
||||
userPermissions: { can_admin: false, can_create_update_delete: false, can_create_only: false, can_view_only: false },
|
||||
getResourcePermission: () => isAdmin ? 'can_admin' : 'no_access'
|
||||
};
|
||||
}
|
||||
|
||||
const { hasAccess, loading: permissionsLoading, userPermissions, getResourcePermission } = permissionsData;
|
||||
|
||||
// Aguardar carregamento
|
||||
if (loading || permissionsLoading || roleLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-muted-foreground">Carregando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirecionar para login se não autenticado
|
||||
if (!user) {
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
// Admin sempre tem acesso (exceto se explicitamente negado)
|
||||
if (isAdmin) {
|
||||
const resourcePermission = getResourcePermission(resourceKey);
|
||||
// Se admin tem negação explícita, negar acesso
|
||||
if (resourcePermission === 'no_access') {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('❌ Admin access denied by explicit resource permission:', resourceKey);
|
||||
}
|
||||
} else {
|
||||
return <>{children}</>;
|
||||
}
|
||||
}
|
||||
|
||||
let finalAccess = false;
|
||||
|
||||
try {
|
||||
// 1. PRIMEIRO: Verificar permissão específica do recurso
|
||||
const resourcePermission = getResourcePermission(resourceKey);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 Checking access for resource:', {
|
||||
resourceKey,
|
||||
user: user?.email,
|
||||
isAdmin,
|
||||
resourcePermission,
|
||||
userPermissions
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Se há permissão específica definida, ela prevalece SEMPRE
|
||||
if (resourcePermission !== 'no_access') {
|
||||
finalAccess = true;
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Access granted by specific resource permission:', resourcePermission);
|
||||
}
|
||||
} else {
|
||||
// 3. Se permissão específica é 'no_access', NEGAR acesso independente de outros privilégios
|
||||
if (resourcePermission === 'no_access') {
|
||||
finalAccess = false;
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('❌ Access explicitly denied by resource permission');
|
||||
}
|
||||
} else {
|
||||
// 4. Se não há permissão específica, verificar permissões funcionais como fallback
|
||||
const hasGeneralAccess = hasAccess();
|
||||
|
||||
// Para recursos de produção, permitir acesso para colaboradores como fallback
|
||||
const isProductionResource = resourceKey.startsWith('producao');
|
||||
const isCollaborator = userPermissions?.can_create_update_delete || userPermissions?.can_admin;
|
||||
const hasProductionAccess = isProductionResource && (isAdmin || isCollaborator || userPermissions?.can_view_only);
|
||||
|
||||
finalAccess = hasGeneralAccess || hasProductionAccess;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 Fallback to general permissions:', {
|
||||
hasGeneralAccess,
|
||||
isProductionResource,
|
||||
hasProductionAccess,
|
||||
finalAccess
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking access permissions:', error);
|
||||
// For safety, deny access on error unless user is admin and no explicit denial
|
||||
const resourcePermission = getResourcePermission(resourceKey);
|
||||
finalAccess = isAdmin && resourcePermission !== 'no_access';
|
||||
}
|
||||
|
||||
if (!finalAccess) {
|
||||
console.log(`❌ Access denied for resource: ${resourceKey}`);
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-6xl">🔒</div>
|
||||
<h2 className="text-2xl font-semibold text-muted-foreground">
|
||||
Acesso Restrito
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Você não tem permissão para acessar esta funcionalidade. Entre em contato com o administrador do sistema.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Recurso solicitado: <code className="bg-muted px-2 py-1 rounded">{resourceKey}</code>
|
||||
</p>
|
||||
{import.meta.env.DEV && (
|
||||
<div className="text-xs text-muted-foreground mt-2 p-3 bg-muted/50 rounded">
|
||||
<p>Debug info:</p>
|
||||
<p>Admin: {isAdmin ? 'Sim' : 'Não'}</p>
|
||||
<p>Permissão do Recurso: {getResourcePermission(resourceKey)}</p>
|
||||
<p>Permissões Funcionais: {userPermissions ? JSON.stringify(userPermissions) : 'Não carregadas'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
34
src/components/ThemeToggle.tsx
Normal file
34
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
console.log('🎨 Current theme:', theme);
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||
console.log('🎨 Switching to theme:', newTheme);
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="h-10 w-10 rounded-full bg-card hover:bg-accent hover:text-accent-foreground transition-colors shadow-sm border-border"
|
||||
aria-label={isDark ? "Mudar para modo claro" : "Mudar para modo escuro"}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] text-foreground transition-all" />
|
||||
) : (
|
||||
<Moon className="h-[1.2rem] w-[1.2rem] text-foreground transition-all" />
|
||||
)}
|
||||
<span className="sr-only">Alternar tema</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
351
src/components/admin/ApiKeysManager.tsx
Normal file
351
src/components/admin/ApiKeysManager.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, Edit, Key, Star, Plus, TestTube2, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { useApiKeys, ApiKey } from '@/hooks/useApiKeys';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export const ApiKeysManager = () => {
|
||||
const { apiKeys, loading, saveApiKey, setPrimaryKey, deleteApiKey } = useApiKeys();
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<ApiKey | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', key: '' });
|
||||
const [testingKeys, setTestingKeys] = useState<Set<string>>(new Set());
|
||||
const [keyTestResults, setKeyTestResults] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
const handleEdit = (apiKey: ApiKey) => {
|
||||
setEditingKey(apiKey);
|
||||
setFormData({ name: apiKey.name, key: apiKey.key });
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingKey(null);
|
||||
setFormData({ name: '', key: '' });
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim() || !formData.key.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await saveApiKey({
|
||||
id: editingKey?.id,
|
||||
name: formData.name,
|
||||
key: formData.key
|
||||
});
|
||||
|
||||
setShowDialog(false);
|
||||
setFormData({ name: '', key: '' });
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowDialog(false);
|
||||
setFormData({ name: '', key: '' });
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
const testApiKey = async (apiKey: ApiKey) => {
|
||||
setTestingKeys(prev => new Set([...prev, apiKey.id]));
|
||||
|
||||
try {
|
||||
// Teste básico para OpenAI/Gemini APIs
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, true]]));
|
||||
toast.success(`Chave "${apiKey.name}" validada com sucesso!`);
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
// Se falhar com OpenAI, tenta com Gemini
|
||||
const geminiResponse = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${apiKey.key}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (geminiResponse.ok) {
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, true]]));
|
||||
toast.success(`Chave "${apiKey.name}" validada com sucesso (Gemini)!`);
|
||||
} else {
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
|
||||
toast.error(`Chave "${apiKey.name}" é inválida ou não tem permissões adequadas.`);
|
||||
}
|
||||
} else {
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
|
||||
toast.error(`Erro ao testar chave "${apiKey.name}": ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao testar chave API:', error);
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
|
||||
toast.error(`Erro de conexão ao testar chave "${apiKey.name}"`);
|
||||
} finally {
|
||||
setTestingKeys(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(apiKey.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getTestStatusIcon = (keyId: string) => {
|
||||
const isValid = keyTestResults.get(keyId);
|
||||
if (isValid === true) {
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
} else if (isValid === false) {
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const canAddMore = apiKeys.length < 3;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-card-foreground flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
Gerenciamento de Chaves API
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-card-foreground flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
Gerenciamento de Chaves API
|
||||
</CardTitle>
|
||||
{canAddMore && (
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
size="sm"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Key className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">Nenhuma chave API configurada</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Adicione até 3 chaves para redundância automática
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
apiKeys.map((apiKey) => (
|
||||
<div
|
||||
key={apiKey.id}
|
||||
className="border border-border rounded-lg p-2 sm:p-3 space-y-2 bg-card"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-card-foreground font-medium text-sm">{apiKey.name}</h4>
|
||||
{apiKey.is_primary ? (
|
||||
<Badge variant="default" className="bg-primary text-primary-foreground text-xs">
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
Principal
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">Secundária</Badge>
|
||||
)}
|
||||
{getTestStatusIcon(apiKey.id)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{testingKeys.has(apiKey.id) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
<span className="hidden sm:inline">Testando...</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => testApiKey(apiKey)}
|
||||
className="text-xs hover:bg-accent hover:text-accent-foreground h-6 px-2"
|
||||
>
|
||||
<TestTube2 className="w-3 h-3 mr-1" />
|
||||
<span className="hidden sm:inline">Testar</span>
|
||||
</Button>
|
||||
)}
|
||||
{!apiKey.is_primary && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPrimaryKey(apiKey.id)}
|
||||
className="text-xs h-6 px-2 hidden sm:inline-flex"
|
||||
>
|
||||
Definir como Principal
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEdit(apiKey)}
|
||||
className="h-6 w-6 p-0 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteApiKey(apiKey.id)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground font-mono bg-muted/50 p-2 rounded">
|
||||
{apiKey.key.substring(0, 20)}...
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 text-xs text-muted-foreground">
|
||||
<span>Criada: {new Date(apiKey.created_at).toLocaleDateString('pt-BR')}</span>
|
||||
{keyTestResults.has(apiKey.id) && (
|
||||
<span className={`flex items-center gap-1 ${
|
||||
keyTestResults.get(apiKey.id) ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{keyTestResults.get(apiKey.id) ? (
|
||||
<>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Válida
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-3 h-3" />
|
||||
Inválida
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: Show primary button if not primary */}
|
||||
{!apiKey.is_primary && (
|
||||
<div className="sm:hidden">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPrimaryKey(apiKey.id)}
|
||||
className="text-xs h-6 w-full"
|
||||
>
|
||||
Definir como Principal
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{apiKeys.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg border border-border">
|
||||
<p className="text-sm text-card-foreground mb-2">
|
||||
<strong>Sistema de Fallback:</strong>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
O sistema usa automaticamente a chave principal. Em caso de falha (401/403/timeout),
|
||||
tenta as chaves secundárias em sequência até encontrar uma válida.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Teste de Validação:</strong> Verifica se a chave é válida testando conexão com OpenAI ou Gemini APIs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={handleClose}>
|
||||
<DialogContent className="bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-card-foreground">
|
||||
{editingKey ? 'Editar Chave API' : 'Adicionar Chave API'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{editingKey
|
||||
? 'Modifique os dados da chave API existente.'
|
||||
: 'Adicione uma nova chave API ao sistema.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-card-foreground">Nome da Chave</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ex: OpenAI Principal, Gemini Backup, etc."
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="bg-background border-input text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="key" className="text-card-foreground">Chave API</Label>
|
||||
<Input
|
||||
id="key"
|
||||
type="password"
|
||||
placeholder="Insira a chave API"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
className="bg-background border-input text-foreground"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suporta chaves OpenAI, Gemini e outras APIs compatíveis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
{editingKey ? 'Salvar' : 'Adicionar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
89
src/components/admin/ApontamentoMassa.tsx
Normal file
89
src/components/admin/ApontamentoMassa.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, Database, Trash2 } from 'lucide-react';
|
||||
import { CleanupDuplicatesModal } from './CleanupDuplicatesModal';
|
||||
|
||||
export const ApontamentoMassa: React.FC = () => {
|
||||
const [isCleanupModalOpen, setIsCleanupModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">Gestão de Apontamentos em Massa</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Ferramentas para análise e correção de inconsistências nos apontamentos de produção.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Limpeza de Duplicatas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Identifica e remove apontamentos duplicados ou em excesso por OF.
|
||||
O sistema analisa peças que foram apontadas múltiplas vezes para o mesmo processo
|
||||
e remove os registros mais recentes, mantendo apenas a quantidade correta.
|
||||
</p>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<h4 className="font-medium text-yellow-800 mb-2">Como funciona:</h4>
|
||||
<ul className="text-sm text-yellow-700 space-y-1">
|
||||
<li>• Agrupa apontamentos por OF + Marca + Fase + Processo</li>
|
||||
<li>• Identifica quando o total apontado excede a quantidade da peça</li>
|
||||
<li>• Remove apontamentos mais recentes, mantendo os mais antigos</li>
|
||||
<li>• Preserva a integridade dos dados de produção</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsCleanupModalOpen(true)}
|
||||
className="w-full flex items-center gap-2"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Analisar e Limpar Duplicatas
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-500" />
|
||||
Outras Ferramentas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ferramentas adicionais para gestão de apontamentos em massa estarão disponíveis em breve.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button disabled className="w-full" variant="outline">
|
||||
Recalcular Totais por OF (Em breve)
|
||||
</Button>
|
||||
<Button disabled className="w-full" variant="outline">
|
||||
Validar Sequência de Processos (Em breve)
|
||||
</Button>
|
||||
<Button disabled className="w-full" variant="outline">
|
||||
Relatório de Inconsistências (Em breve)
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<CleanupDuplicatesModal
|
||||
isOpen={isCleanupModalOpen}
|
||||
onClose={() => setIsCleanupModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
312
src/components/admin/BackupManager.tsx
Normal file
312
src/components/admin/BackupManager.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Download, Upload, Clock, CheckCircle, XCircle, Database, FileText, AlertTriangle } from 'lucide-react';
|
||||
import { useBackupManager } from '@/hooks/useBackupManager';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
const BackupManager = () => {
|
||||
const { backupLogs, logsLoading, isBackingUp, isRestoring, createBackup, restoreBackup } = useBackupManager();
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = () => {
|
||||
createBackup();
|
||||
};
|
||||
|
||||
const handleRestore = () => {
|
||||
if (selectedFile) {
|
||||
restoreBackup(selectedFile);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'in_progress':
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="secondary" className="bg-green-100 text-green-800">Concluído</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">Falhou</Badge>;
|
||||
case 'in_progress':
|
||||
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-800">Em Progresso</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return 'N/A';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Seção de Criação de Backup */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" />
|
||||
Criar Backup
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-6 w-6 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-900">Backup Completo do Banco de Dados</p>
|
||||
<p className="text-sm text-blue-700">Exporta todas as tabelas e dados em formato ZIP</p>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isBackingUp}
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
{isBackingUp ? (
|
||||
<>
|
||||
<Clock className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Criar Backup
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar Criação de Backup</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Isso irá criar um backup completo de todas as tabelas do banco de dados.
|
||||
O processo pode demorar alguns minutos dependendo do tamanho dos dados.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleCreateBackup}>
|
||||
Criar Backup
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{isBackingUp && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
Criando backup...
|
||||
</div>
|
||||
<Progress value={50} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Seção de Restauração de Backup */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Restaurar Backup
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
<p className="font-medium text-yellow-900">Atenção!</p>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-800">
|
||||
A restauração irá <strong>substituir completamente</strong> todos os dados atuais do banco.
|
||||
Esta operação não pode ser desfeita. Certifique-se de ter um backup atual antes de prosseguir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Selecionar Arquivo de Backup</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-sm font-medium">{selectedFile.name}</span>
|
||||
<span className="text-xs text-gray-500">({formatFileSize(selectedFile.size)})</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!selectedFile || isRestoring}
|
||||
className="w-full"
|
||||
>
|
||||
{isRestoring ? (
|
||||
<>
|
||||
<Clock className="h-4 w-4 mr-2 animate-spin" />
|
||||
Restaurando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Restaurar Backup
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-red-600">Confirmar Restauração</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Esta ação irá:</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-1">
|
||||
<li>Apagar TODOS os dados atuais do banco</li>
|
||||
<li>Restaurar os dados do arquivo: <strong>{selectedFile?.name}</strong></li>
|
||||
<li>Esta operação NÃO pode ser desfeita</li>
|
||||
</ul>
|
||||
<p className="text-red-600 font-medium mt-3">Tem certeza de que deseja continuar?</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRestore} className="bg-red-600 hover:bg-red-700">
|
||||
Sim, Restaurar
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{isRestoring && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
Restaurando backup...
|
||||
</div>
|
||||
<Progress value={50} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Histórico de Operações */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Histórico de Operações
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{logsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<Clock className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Carregando histórico...</p>
|
||||
</div>
|
||||
) : backupLogs && backupLogs.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{backupLogs.map((log) => (
|
||||
<div key={log.id} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(log.status)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{log.operation_type === 'backup' ? 'Backup' : 'Restauração'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{log.file_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(log.status)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Iniciado em:</span>
|
||||
<p>{format(new Date(log.started_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}</p>
|
||||
</div>
|
||||
{log.completed_at && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Concluído em:</span>
|
||||
<p>{format(new Date(log.completed_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tamanho:</span>
|
||||
<p>{formatFileSize(log.file_size)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tabelas/Registros:</span>
|
||||
<p>{log.tables_count || 0} / {log.records_count || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.error_message && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded">
|
||||
<p className="text-sm text-red-800">{log.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Database className="h-8 w-8 mx-auto mb-4 opacity-50" />
|
||||
<p>Nenhuma operação de backup encontrada</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupManager;
|
||||
305
src/components/admin/CleanupDuplicatesModal.tsx
Normal file
305
src/components/admin/CleanupDuplicatesModal.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Search, AlertTriangle, Trash2 } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CleanupDuplicatesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface DuplicateGroup {
|
||||
chave_agrupamento: string;
|
||||
of_number: string;
|
||||
marca: string;
|
||||
etapa_fase: string;
|
||||
processo_nome: string;
|
||||
quantidade_total_peca: number;
|
||||
apontamentos: Array<{
|
||||
id: string;
|
||||
data_apontamento: string;
|
||||
created_at: string;
|
||||
quantidade_produzida: number;
|
||||
tipo_apontamento: string;
|
||||
}>;
|
||||
total_apontado: number;
|
||||
excesso: number;
|
||||
}
|
||||
|
||||
export const CleanupDuplicatesModal: React.FC<CleanupDuplicatesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [ofNumber, setOfNumber] = useState('');
|
||||
const [duplicatesData, setDuplicatesData] = useState<{
|
||||
duplicatesFound: boolean;
|
||||
details: DuplicateGroup[];
|
||||
totalGroups: number;
|
||||
groupsWithDuplicates: number;
|
||||
} | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isCleaning, setIsCleaning] = useState(false);
|
||||
|
||||
const analyzeOF = async () => {
|
||||
if (!ofNumber.trim()) {
|
||||
toast.error('Por favor, informe o número da OF');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('cleanup-duplicates', {
|
||||
body: {
|
||||
of_number: ofNumber.trim(),
|
||||
action: 'analyze' // Apenas analisar, não executar limpeza
|
||||
}
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setDuplicatesData({
|
||||
duplicatesFound: data.groupsWithDuplicates > 0,
|
||||
details: data.details || [],
|
||||
totalGroups: data.totalGroups || 0,
|
||||
groupsWithDuplicates: data.groupsWithDuplicates || 0
|
||||
});
|
||||
|
||||
if (data.groupsWithDuplicates === 0) {
|
||||
toast.success('Nenhuma duplicata encontrada para esta OF!');
|
||||
} else {
|
||||
toast.info(`Encontradas ${data.groupsWithDuplicates} duplicatas para análise`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao analisar duplicatas:', error);
|
||||
toast.error('Erro ao analisar duplicatas');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const executeCleaning = async () => {
|
||||
setIsCleaning(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('cleanup-duplicates', {
|
||||
body: {
|
||||
of_number: ofNumber.trim(),
|
||||
action: 'execute' // Executar limpeza
|
||||
}
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success(`Limpeza concluída! ${data.duplicatesRemoved} duplicatas removidas.`);
|
||||
|
||||
// Resetar dados após limpeza
|
||||
setDuplicatesData(null);
|
||||
setOfNumber('');
|
||||
} catch (error) {
|
||||
console.error('Erro ao executar limpeza:', error);
|
||||
toast.error('Erro ao executar limpeza de duplicatas');
|
||||
} finally {
|
||||
setIsCleaning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setDuplicatesData(null);
|
||||
setOfNumber('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const groupedByPhase = duplicatesData?.details.reduce((acc, group) => {
|
||||
const phase = group.etapa_fase;
|
||||
if (!acc[phase]) acc[phase] = [];
|
||||
acc[phase].push(group);
|
||||
return acc;
|
||||
}, {} as Record<string, DuplicateGroup[]>) || {};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Limpeza de Duplicatas de Apontamentos
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Input para OF */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Digite o número da OF (ex: B117)"
|
||||
value={ofNumber}
|
||||
onChange={(e) => setOfNumber(e.target.value.toUpperCase())}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={analyzeOF}
|
||||
disabled={isAnalyzing || !ofNumber.trim()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
)}
|
||||
Analisar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Resultados da análise */}
|
||||
{duplicatesData && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Resumo da Análise - OF {ofNumber}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{duplicatesData.totalGroups}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Grupos Analisados
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{duplicatesData.groupsWithDuplicates}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Com Duplicatas
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{duplicatesData.details.reduce((sum, g) => sum + g.apontamentos.length - 1, 0)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Registros a Remover
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detalhes por fase */}
|
||||
{duplicatesData.duplicatesFound && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Duplicatas Encontradas por Fase:</h3>
|
||||
|
||||
{Object.entries(groupedByPhase).map(([phase, groups]) => (
|
||||
<Card key={phase}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
Fase {phase}
|
||||
<Badge variant="destructive">
|
||||
{groups.length} duplicatas
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{groups.map((group, index) => (
|
||||
<div key={index} className="border rounded-lg p-3 bg-muted/30">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Marca: {group.marca} | Processo: {group.processo_nome}
|
||||
</h4>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Quantidade da Peça: {group.quantidade_total_peca} |
|
||||
Total Apontado: {group.total_apontado}
|
||||
{group.excesso > 0 && (
|
||||
<span className="text-red-600 font-medium">
|
||||
{' '}| Excesso: +{group.excesso}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{group.apontamentos.length} apontamentos
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{group.apontamentos.map((apt, aptIndex) => (
|
||||
<div
|
||||
key={aptIndex}
|
||||
className={`p-2 rounded text-xs border ${
|
||||
aptIndex === 0
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{aptIndex === 0 ? '✅ Manter' : '🗑️ Remover'}
|
||||
</div>
|
||||
<div>Data: {new Date(apt.data_apontamento).toLocaleDateString('pt-BR')}</div>
|
||||
<div>Qtd: {apt.quantidade_produzida}</div>
|
||||
<div>Criado: {new Date(apt.created_at).toLocaleDateString('pt-BR')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Botão para executar limpeza */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={executeCleaning}
|
||||
disabled={isCleaning}
|
||||
className="bg-red-600 hover:bg-red-700 flex items-center gap-2"
|
||||
>
|
||||
{isCleaning ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Executar Limpeza Definitiva
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!duplicatesData.duplicatesFound && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<div className="text-green-600 mb-2">✅</div>
|
||||
<h3 className="font-medium mb-2">Nenhuma Duplicata Encontrada</h3>
|
||||
<p className="text-muted-foreground">
|
||||
A OF {ofNumber} está livre de duplicatas de apontamentos.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
277
src/components/admin/JsonCodesManager.tsx
Normal file
277
src/components/admin/JsonCodesManager.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Trash2, Edit, Plus, Code, Eye, EyeOff } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useJsonCodes, JsonCode } from '@/hooks/useJsonCodes';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
|
||||
export function JsonCodesManager() {
|
||||
const { jsonCodes, loading, saveJsonCode, deleteJsonCode, toggleActiveStatus } = useJsonCodes();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingCode, setEditingCode] = useState<JsonCode | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
json_code: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const handleOpenDialog = (code?: JsonCode) => {
|
||||
if (code) {
|
||||
setEditingCode(code);
|
||||
setFormData({
|
||||
name: code.name,
|
||||
description: code.description || '',
|
||||
json_code: JSON.stringify(code.json_code, null, 2),
|
||||
is_active: code.is_active
|
||||
});
|
||||
} else {
|
||||
setEditingCode(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
json_code: '{\n \n}',
|
||||
is_active: true
|
||||
});
|
||||
}
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const jsonData = JSON.parse(formData.json_code);
|
||||
|
||||
await saveJsonCode({
|
||||
id: editingCode?.id,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
json_code: jsonData,
|
||||
is_active: formData.is_active
|
||||
});
|
||||
|
||||
setIsDialogOpen(false);
|
||||
setEditingCode(null);
|
||||
} catch (error) {
|
||||
console.error('JSON inválido:', error);
|
||||
alert('JSON inválido. Por favor, verifique a sintaxe.');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardContent className="p-6">
|
||||
<div className="text-white">Carregando códigos JSON...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-5 w-5" />
|
||||
Gerenciamento de Códigos JSON
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => handleOpenDialog()}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Novo Código
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{editingCode ? 'Editar Código JSON' : 'Novo Código JSON'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-white">Nome *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
placeholder="Nome do código JSON"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-white">Descrição</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
placeholder="Descrição opcional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="json_code" className="text-white">Código JSON *</Label>
|
||||
<Textarea
|
||||
id="json_code"
|
||||
value={formData.json_code}
|
||||
onChange={(e) => setFormData({ ...formData, json_code: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white font-mono min-h-[300px]"
|
||||
placeholder='{"exemplo": "valor"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
|
||||
/>
|
||||
<Label htmlFor="is_active" className="text-white">Ativo</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
className="border-slate-600 text-slate-300"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={!formData.name || !formData.json_code}
|
||||
>
|
||||
{editingCode ? 'Atualizar' : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{jsonCodes.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-8">
|
||||
Nenhum código JSON cadastrado ainda.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-700">
|
||||
<TableHead className="text-slate-300">Nome</TableHead>
|
||||
<TableHead className="text-slate-300">Descrição</TableHead>
|
||||
<TableHead className="text-slate-300">Status</TableHead>
|
||||
<TableHead className="text-slate-300">Criado em</TableHead>
|
||||
<TableHead className="text-slate-300">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jsonCodes.map((code) => (
|
||||
<TableRow key={code.id} className="border-slate-700">
|
||||
<TableCell className="text-white font-medium">
|
||||
{code.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">
|
||||
{code.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={code.is_active ? 'default' : 'secondary'}>
|
||||
{code.is_active ? 'Ativo' : 'Inativo'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleActiveStatus(code.id, !code.is_active)}
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
{code.is_active ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">
|
||||
{formatDate(code.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog(code)}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="bg-slate-800 border-slate-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">
|
||||
Confirmar exclusão
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-slate-300">
|
||||
Tem certeza que deseja excluir o código "{code.name}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-slate-600 text-slate-300">
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteJsonCode(code.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
703
src/components/apontamento/ApontamentoForm.tsx
Normal file
703
src/components/apontamento/ApontamentoForm.tsx
Normal file
@@ -0,0 +1,703 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Info, Package, RefreshCw } from 'lucide-react';
|
||||
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
|
||||
import { usePecas } from '@/hooks/usePecas';
|
||||
import { useOFs } from '@/hooks/useOFs';
|
||||
import { useComponentesAgrupados } from '@/hooks/useComponentesAgrupados';
|
||||
import { SeletorItensOtimizado } from './SeletorItensOtimizado';
|
||||
import { useApontamentosValidacao } from '@/hooks/useApontamentosValidacao';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ItemDisponivel {
|
||||
id: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
tipo: 'peca' | 'componente';
|
||||
quantidade_disponivel: number;
|
||||
processo_atual_permitido: number;
|
||||
}
|
||||
|
||||
// Cache para manter seleções do usuário
|
||||
const formCache = {
|
||||
of_number: '',
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
|
||||
// Cache para itens já processados
|
||||
const itensCache = new Map<string, {
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
timestamp: number;
|
||||
}>();
|
||||
|
||||
export const ApontamentoForm = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
of_number: formCache.of_number || '',
|
||||
fase: formCache.fase || '',
|
||||
data_apontamento: formCache.data_apontamento || new Date().toISOString().split('T')[0],
|
||||
processo_id: formCache.processo_id || '',
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
});
|
||||
|
||||
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
|
||||
const [itensDisponiveis, setItensDisponiveis] = useState<{
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cacheValido, setCacheValido] = useState(false);
|
||||
const [loadingItens, setLoadingItens] = useState(false);
|
||||
const [isProcessingItems, setIsProcessingItems] = useState(false);
|
||||
|
||||
const { criarApontamento, refetch, processos } = useApontamentosProducao();
|
||||
const { pecas } = usePecas();
|
||||
const { ofs } = useOFs();
|
||||
const { componentesAgrupados } = useComponentesAgrupados(formData.of_number, formData.fase);
|
||||
const {
|
||||
validarSequenciaProcessos,
|
||||
precarregarDados,
|
||||
limparCache: limparCacheValidacao
|
||||
} = useApontamentosValidacao();
|
||||
|
||||
// Buscar fases únicas da OF selecionada
|
||||
const fasesDisponiveis = useMemo(() =>
|
||||
pecas
|
||||
.filter(peca => peca.of_number === formData.of_number)
|
||||
.map(peca => peca.etapa_fase)
|
||||
.filter((fase, index, array) => fase && array.indexOf(fase) === index)
|
||||
.sort(),
|
||||
[pecas, formData.of_number]
|
||||
);
|
||||
|
||||
// Peças filtradas - memoizado para evite recálculos
|
||||
const filteredPecas = useMemo(() =>
|
||||
pecas.filter(peca =>
|
||||
peca.of_number === formData.of_number &&
|
||||
peca.etapa_fase === formData.fase &&
|
||||
!peca.tem_componentes
|
||||
),
|
||||
[pecas, formData.of_number, formData.fase]
|
||||
);
|
||||
|
||||
// Chave única para cache
|
||||
const cacheKey = useMemo(() =>
|
||||
`${formData.of_number}_${formData.fase}_${formData.processo_id}`,
|
||||
[formData.of_number, formData.fase, formData.processo_id]
|
||||
);
|
||||
|
||||
// Salvar cache quando seleções básicas mudam
|
||||
const updateCache = useCallback((updates: Partial<typeof formData>) => {
|
||||
Object.assign(formCache, updates);
|
||||
localStorage.setItem('apontamento_cache', JSON.stringify(formCache));
|
||||
}, []);
|
||||
|
||||
// Carregar cache inicial
|
||||
useEffect(() => {
|
||||
const savedCache = localStorage.getItem('apontamento_cache');
|
||||
if (savedCache) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedCache);
|
||||
Object.assign(formCache, parsed);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
of_number: formCache.of_number || '',
|
||||
fase: formCache.fase || '',
|
||||
processo_id: formCache.processo_id || '',
|
||||
data_apontamento: formCache.data_apontamento || new Date().toISOString().split('T')[0]
|
||||
}));
|
||||
setCacheValido(true);
|
||||
} catch (error) {
|
||||
console.log('Erro ao carregar cache:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Função para processar itens com cache
|
||||
const processarItensDisponiveis = useCallback(async () => {
|
||||
const { of_number, fase, processo_id } = formData;
|
||||
|
||||
if (!of_number || !fase || !processo_id) {
|
||||
console.log('⚠️ Campos obrigatórios faltando para carregar itens');
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar se já temos no cache (válido por 30 segundos)
|
||||
const cached = itensCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
if (cached && (now - cached.timestamp) < 30000) {
|
||||
console.log('📦 Usando itens do cache');
|
||||
setItensDisponiveis(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Aguardar dados das peças e componentes
|
||||
if (filteredPecas.length === 0 && componentesAgrupados.length === 0) {
|
||||
console.log('⏳ Aguardando dados de peças e componentes...');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProcessingItems) {
|
||||
console.log('🔄 Já processando itens, aguardando...');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n🚀 === PROCESSANDO ITENS COM CACHE ===');
|
||||
console.log(`📋 OF: ${of_number}, Fase: ${fase}, Processo: ${processo_id}`);
|
||||
|
||||
setIsProcessingItems(true);
|
||||
setLoadingItens(true);
|
||||
|
||||
try {
|
||||
// Calcular itens disponíveis baseado nos dados existentes
|
||||
console.log('🧮 Calculando itens disponíveis...');
|
||||
const itens = {
|
||||
pecasDisponiveis: filteredPecas.map(peca => ({
|
||||
id: peca.id,
|
||||
marca: peca.marca,
|
||||
descricao: peca.descricao,
|
||||
tipo: 'peca' as const,
|
||||
quantidade_disponivel: peca.quantidade,
|
||||
processo_atual_permitido: 1
|
||||
})),
|
||||
componentesDisponiveis: componentesAgrupados.map(comp => ({
|
||||
id: comp.componente_ids[0] || '', // Use primeiro ID do array
|
||||
marca: comp.marca_componente,
|
||||
descricao: comp.descricao || '',
|
||||
tipo: 'componente' as const,
|
||||
quantidade_disponivel: comp.quantidade_total,
|
||||
processo_atual_permitido: 1
|
||||
}))
|
||||
};
|
||||
|
||||
console.log('✅ Itens calculados:', {
|
||||
pecas: itens.pecasDisponiveis.length,
|
||||
componentes: itens.componentesDisponiveis.length
|
||||
});
|
||||
|
||||
// 4. Salvar no cache
|
||||
itensCache.set(cacheKey, {
|
||||
...itens,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
// 5. Atualizar estado
|
||||
setItensDisponiveis(itens);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao processar itens:', error);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
} finally {
|
||||
setLoadingItens(false);
|
||||
setIsProcessingItems(false);
|
||||
}
|
||||
}, [
|
||||
formData.of_number,
|
||||
formData.fase,
|
||||
formData.processo_id
|
||||
]);
|
||||
|
||||
// Callback para atualizar dados
|
||||
const updateData = useCallback(() => {
|
||||
// 4. Atualizar dados para nova seleção
|
||||
console.log('✅ Dados atualizados para nova seleção de OF/processo');
|
||||
}, []);
|
||||
|
||||
// Efeito controlado para carregar itens
|
||||
useEffect(() => {
|
||||
if (formData.of_number && formData.fase && formData.processo_id) {
|
||||
// Usar timeout para evitar chamadas excessivas
|
||||
const timeoutId = setTimeout(() => {
|
||||
processarItensDisponiveis();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else {
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
}
|
||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
||||
|
||||
// Reset do item selecionado quando dados mudam
|
||||
useEffect(() => {
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
||||
|
||||
// Auto-preenchimento da quantidade quando "todas disponíveis" é marcado
|
||||
useEffect(() => {
|
||||
if (formData.todas_disponiveis && itemSelecionado) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: itemSelecionado.quantidade_disponivel.toString()
|
||||
}));
|
||||
}
|
||||
}, [formData.todas_disponiveis, itemSelecionado]);
|
||||
|
||||
const handleBatchSelect = async (items: ItemDisponivel[], tipo: 'peca' | 'componente') => {
|
||||
if (items.length === 0) {
|
||||
toast.error('Nenhum item disponível para registro em lote');
|
||||
return;
|
||||
}
|
||||
|
||||
const totalItens = items.length;
|
||||
const tipoTexto = tipo === 'peca' ? 'peças' : 'componentes';
|
||||
|
||||
const confirmacao = window.confirm(
|
||||
`Deseja registrar ${totalItens} ${tipoTexto} com suas respectivas quantidades totais?\n\n` +
|
||||
`Total de itens: ${totalItens}\n` +
|
||||
`Processo: ${formData.processo_id || 'N/A'}`
|
||||
);
|
||||
|
||||
if (!confirmacao) return;
|
||||
|
||||
setSaving(true);
|
||||
let sucessos = 0;
|
||||
let erros = 0;
|
||||
|
||||
try {
|
||||
for (const item of items) {
|
||||
try {
|
||||
const apontamentoData: any = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: item.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: item.quantidade_disponivel,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: `Registro em lote - ${tipoTexto}`
|
||||
};
|
||||
|
||||
if (item.tipo === 'componente') {
|
||||
apontamentoData.componente_id = item.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = item.id;
|
||||
}
|
||||
|
||||
const result = await criarApontamento(apontamentoData);
|
||||
|
||||
if (result.success) {
|
||||
sucessos++;
|
||||
} else {
|
||||
erros++;
|
||||
}
|
||||
} catch (error) {
|
||||
erros++;
|
||||
console.error(`Erro ao processar ${item.marca}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (sucessos > 0) {
|
||||
toast.success(`${sucessos} ${tipoTexto} registradas com sucesso!${erros > 0 ? ` (${erros} com erro)` : ''}`);
|
||||
|
||||
await Promise.all([
|
||||
refetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
} else {
|
||||
toast.error(`Erro ao registrar ${tipoTexto} em lote`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro no registro em lote:', error);
|
||||
toast.error('Erro inesperado no registro em lote');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOFChange = (ofNumber: string) => {
|
||||
const updates = {
|
||||
of_number: ofNumber,
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
// Limpar cache relacionado
|
||||
itensCache.clear();
|
||||
};
|
||||
|
||||
const handleFaseChange = (fase: string) => {
|
||||
const updates = {
|
||||
fase: fase,
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ fase: fase, processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
// Limpar cache relacionado
|
||||
itensCache.clear();
|
||||
};
|
||||
|
||||
const handleProcessoChange = (processoId: string) => {
|
||||
const updates = {
|
||||
processo_id: processoId,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ processo_id: processoId });
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
};
|
||||
|
||||
const handleItemSelect = (item: ItemDisponivel) => {
|
||||
setItemSelecionado(item);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
};
|
||||
|
||||
const handleQuantidadeChange = (value: string) => {
|
||||
const quantidade = parseInt(value);
|
||||
|
||||
if (itemSelecionado && quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
toast.error(`Quantidade não pode ser maior que ${itemSelecionado.quantidade_disponivel} unidades disponíveis`);
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: value,
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
};
|
||||
|
||||
// Função para resetar form e atualizar dados
|
||||
const resetFormForNewEntry = async () => {
|
||||
console.log('🔄 Resetando formulário e limpando cache...');
|
||||
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
|
||||
// Limpar cache e forçar recarregamento
|
||||
itensCache.clear();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!itemSelecionado || !formData.processo_id || !formData.quantidade_produzida) {
|
||||
toast.error('Preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
const quantidade = parseInt(formData.quantidade_produzida);
|
||||
|
||||
if (quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
toast.error('Quantidade inválida');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Validação básica - pode ser expandida depois
|
||||
console.log('✅ Validação de sequência aprovada');
|
||||
|
||||
const apontamentoData: any = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: itemSelecionado.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: quantidade,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: formData.observacoes || null
|
||||
};
|
||||
|
||||
if (itemSelecionado.tipo === 'componente') {
|
||||
apontamentoData.componente_id = itemSelecionado.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = itemSelecionado.id;
|
||||
}
|
||||
|
||||
const result = await criarApontamento(apontamentoData);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Apontamento registrado com sucesso!');
|
||||
|
||||
await Promise.all([
|
||||
refetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro no submit:', error);
|
||||
toast.error('Erro ao registrar apontamento');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const limparCache = () => {
|
||||
localStorage.removeItem('apontamento_cache');
|
||||
Object.assign(formCache, {
|
||||
of_number: '',
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
setFormData({
|
||||
of_number: '',
|
||||
fase: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0],
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
});
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
setCacheValido(false);
|
||||
itensCache.clear();
|
||||
toast.success('Cache limpo com sucesso!');
|
||||
};
|
||||
|
||||
const processoSelecionado = null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
{/* Cache status */}
|
||||
{cacheValido && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>Seleções anteriores foram restauradas do cache</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={limparCache}
|
||||
className="ml-2"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Limpar
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Campos de seleção básicos */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="of">Ordem de Fabricação *</Label>
|
||||
<Select value={formData.of_number} onValueChange={handleOFChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ofs.map((of) => (
|
||||
<SelectItem key={of.id} value={of.num_of}>
|
||||
{of.num_of} - {of.descritivo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.of_number && (
|
||||
<div>
|
||||
<Label htmlFor="fase">Fase *</Label>
|
||||
<Select value={formData.fase} onValueChange={handleFaseChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a fase" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fasesDisponiveis.map((fase) => (
|
||||
<SelectItem key={fase} value={fase}>
|
||||
{fase}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.fase && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="data">Data do Apontamento *</Label>
|
||||
<Input
|
||||
id="data"
|
||||
type="date"
|
||||
value={formData.data_apontamento}
|
||||
onChange={(e) => {
|
||||
const newDate = e.target.value;
|
||||
setFormData(prev => ({ ...prev, data_apontamento: newDate }));
|
||||
updateCache({ data_apontamento: newDate });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Processo *</Label>
|
||||
<Select value={formData.processo_id} onValueChange={handleProcessoChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o processo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{processos.map((processo) => (
|
||||
<SelectItem key={processo.id} value={processo.id}>
|
||||
{processo.ordem}. {processo.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informação sobre o processo */}
|
||||
{processoSelecionado && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{processoSelecionado.ordem === 1
|
||||
? `Processo inicial: ${processoSelecionado.nome}. Todos os itens estão disponíveis.`
|
||||
: `Processo ${processoSelecionado.ordem}: ${processoSelecionado.nome}. Apenas itens que passaram pelos processos anteriores estão disponíveis.`
|
||||
}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Seletor de itens otimizado com funcionalidade de lote - agora com scroll */}
|
||||
{formData.processo_id && (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<SeletorItensOtimizado
|
||||
pecasDisponiveis={itensDisponiveis.pecasDisponiveis}
|
||||
componentesDisponiveis={itensDisponiveis.componentesDisponiveis}
|
||||
itemSelecionado={itemSelecionado}
|
||||
onItemSelect={handleItemSelect}
|
||||
onBatchSelect={handleBatchSelect}
|
||||
loading={loadingItens || isProcessingItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantidade produzida */}
|
||||
{itemSelecionado && (
|
||||
<div>
|
||||
<Label htmlFor="quantidade">Quantidade Produzida *</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id="quantidade"
|
||||
type="number"
|
||||
min="1"
|
||||
max={itemSelecionado.quantidade_disponivel}
|
||||
placeholder="0"
|
||||
value={formData.quantidade_produzida}
|
||||
onChange={(e) => handleQuantidadeChange(e.target.value)}
|
||||
disabled={formData.todas_disponiveis}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="todas-disponiveis"
|
||||
checked={formData.todas_disponiveis}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({
|
||||
...prev,
|
||||
todas_disponiveis: !!checked,
|
||||
quantidade_produzida: checked ? itemSelecionado.quantidade_disponivel.toString() : ''
|
||||
}))}
|
||||
/>
|
||||
<Label htmlFor="todas-disponiveis" className="text-sm whitespace-nowrap">
|
||||
todas ({itemSelecionado.quantidade_disponivel})
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="observacoes">Observações</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
placeholder="Observações sobre o apontamento..."
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, observacoes: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card de informações do item selecionado */}
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-muted/50 h-fit">
|
||||
<CardContent className="p-4">
|
||||
<h4 className="font-medium flex items-center gap-2 mb-3">
|
||||
<Package className="h-4 w-4" />
|
||||
Informações do Item Selecionado
|
||||
</h4>
|
||||
{!itemSelecionado ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Selecione um item para ver as informações ou use os checkboxes para registro em lote
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><strong>Tipo:</strong> {itemSelecionado.tipo === 'componente' ? 'Componente' : 'Peça'}</div>
|
||||
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
|
||||
<div><strong>OF:</strong> {formData.of_number}</div>
|
||||
<div><strong>Fase:</strong> {formData.fase}</div>
|
||||
<div><strong>Processo:</strong> {processoSelecionado?.nome || 'N/A'}</div>
|
||||
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
|
||||
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Botões movidos para baixo do card de informações */}
|
||||
<div className="flex justify-end space-x-2">
|
||||
{formData.of_number && formData.fase && formData.processo_id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetFormForNewEntry}
|
||||
disabled={saving}
|
||||
>
|
||||
Novo Item
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || !itemSelecionado || !formData.processo_id || loadingItens || isProcessingItems}
|
||||
className="min-w-32"
|
||||
>
|
||||
{saving ? 'Salvando...' : 'Registrar Apontamento'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
746
src/components/apontamento/ApontamentoFormCore.tsx
Normal file
746
src/components/apontamento/ApontamentoFormCore.tsx
Normal file
@@ -0,0 +1,746 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Info, Package, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { SeletorPecasSimples } from './SeletorPecasSimples';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface ItemDisponivel {
|
||||
id: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
tipo: 'peca' | 'componente';
|
||||
quantidade_disponivel: number;
|
||||
processo_atual_permitido: number;
|
||||
}
|
||||
|
||||
interface ApontamentoFormCoreProps {
|
||||
pecas: any[];
|
||||
ofs: any[];
|
||||
componentesAgrupados: any[];
|
||||
processosOrdenados: any[];
|
||||
onCarregarItensDisponiveis: (ofNumber: string, processoId: string, filteredPecas: any[], componentesAgrupados: any[]) => Promise<{
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
}>;
|
||||
onValidarSequencia: (ofNumber: string, marca: string, processoId: string, quantidade: number, fase?: string) => Promise<{ valido: boolean; erro?: string }>;
|
||||
onCriarApontamento: (data: any) => Promise<{ success: boolean; error?: any }>;
|
||||
onRefetch: () => Promise<any>;
|
||||
onInvalidateCache: (ofNumber: string, fase?: string) => void;
|
||||
loading: boolean;
|
||||
validacaoLoading: boolean;
|
||||
}
|
||||
|
||||
const getStoredCache = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem('apontamento_form_cache');
|
||||
return stored ? JSON.parse(stored) : {
|
||||
of_number: '',
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
of_number: '',
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ApontamentoFormCore: React.FC<ApontamentoFormCoreProps> = ({
|
||||
pecas = [],
|
||||
ofs = [],
|
||||
componentesAgrupados = [],
|
||||
processosOrdenados = [],
|
||||
onCarregarItensDisponiveis,
|
||||
onValidarSequencia,
|
||||
onCriarApontamento,
|
||||
onRefetch,
|
||||
onInvalidateCache,
|
||||
loading,
|
||||
validacaoLoading
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const [formData, setFormData] = useState(() => ({
|
||||
...getStoredCache(),
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
|
||||
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
|
||||
const [itensDisponiveis, setItensDisponiveis] = useState<{
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingItens, setLoadingItens] = useState(false);
|
||||
const [lastApontamentoId, setLastApontamentoId] = useState<string | null>(null);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [undoLoading, setUndoLoading] = useState(false);
|
||||
|
||||
const fasesDisponiveis = useMemo(() => {
|
||||
if (!pecas || !Array.isArray(pecas) || !formData.of_number) return [];
|
||||
|
||||
return pecas
|
||||
.filter(peca => peca && peca.of_number === formData.of_number)
|
||||
.map(peca => peca.etapa_fase)
|
||||
.filter((fase, index, array) => fase && array.indexOf(fase) === index)
|
||||
.sort();
|
||||
}, [pecas, formData.of_number]);
|
||||
|
||||
const filteredPecas = useMemo(() => {
|
||||
if (!pecas || !Array.isArray(pecas)) return [];
|
||||
|
||||
return pecas.filter(peca =>
|
||||
peca &&
|
||||
peca.of_number === formData.of_number &&
|
||||
peca.etapa_fase === formData.fase
|
||||
);
|
||||
}, [pecas, formData.of_number, formData.fase]);
|
||||
|
||||
const updateCache = useCallback((updates: Partial<typeof formData>) => {
|
||||
const newCache = { ...getStoredCache(), ...updates };
|
||||
localStorage.setItem('apontamento_form_cache', JSON.stringify(newCache));
|
||||
}, []);
|
||||
|
||||
const carregarItensDisponiveis = useCallback(async () => {
|
||||
if (!formData.of_number || !formData.fase || !formData.processo_id) {
|
||||
console.log('⚠️ Dados insuficientes para carregar itens:', {
|
||||
of: formData.of_number,
|
||||
fase: formData.fase,
|
||||
processo: formData.processo_id
|
||||
});
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filteredPecas || (filteredPecas.length === 0 && (!componentesAgrupados || componentesAgrupados.length === 0))) {
|
||||
console.log('⚠️ Sem peças ou componentes para processar');
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingItens(true);
|
||||
console.log('🔄 Carregando itens disponíveis...', {
|
||||
of: formData.of_number,
|
||||
fase: formData.fase,
|
||||
processo: formData.processo_id,
|
||||
pecasCount: filteredPecas.length,
|
||||
componentesCount: componentesAgrupados?.length || 0
|
||||
});
|
||||
|
||||
try {
|
||||
const itens = await onCarregarItensDisponiveis(
|
||||
formData.of_number,
|
||||
formData.processo_id,
|
||||
filteredPecas || [],
|
||||
componentesAgrupados || []
|
||||
);
|
||||
|
||||
console.log('✅ Itens carregados:', {
|
||||
pecasDisponiveis: itens?.pecasDisponiveis?.length || 0,
|
||||
componentesDisponiveis: itens?.componentesDisponiveis?.length || 0
|
||||
});
|
||||
|
||||
setItensDisponiveis(itens || { pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao carregar itens:', error);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
} finally {
|
||||
setLoadingItens(false);
|
||||
}
|
||||
}, [
|
||||
formData.of_number,
|
||||
formData.fase,
|
||||
formData.processo_id,
|
||||
filteredPecas,
|
||||
componentesAgrupados,
|
||||
onCarregarItensDisponiveis
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
carregarItensDisponiveis();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [carregarItensDisponiveis]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.todas_disponiveis && itemSelecionado) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: itemSelecionado.quantidade_disponivel.toString()
|
||||
}));
|
||||
}
|
||||
}, [formData.todas_disponiveis, itemSelecionado]);
|
||||
|
||||
const handleOFChange = (ofNumber: string) => {
|
||||
const updates = {
|
||||
of_number: ofNumber,
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
};
|
||||
|
||||
const handleFaseChange = (fase: string) => {
|
||||
const updates = {
|
||||
fase: fase,
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ fase: fase, processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
};
|
||||
|
||||
const handleProcessoChange = (processoId: string) => {
|
||||
console.log('🔄 Mudança de processo detectada, limpando cache específico...');
|
||||
|
||||
// LIMPAR CACHE ESPECÍFICO ANTES DE ALTERAR O PROCESSO
|
||||
if (formData.of_number && formData.fase) {
|
||||
console.log(`🗑️ Invalidando cache para OF: ${formData.of_number}, Fase: ${formData.fase}`);
|
||||
onInvalidateCache(formData.of_number, formData.fase);
|
||||
}
|
||||
|
||||
const updates = {
|
||||
processo_id: processoId,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ processo_id: processoId });
|
||||
setItemSelecionado(null);
|
||||
|
||||
// Limpar itens disponíveis imediatamente para forçar nova consulta
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
|
||||
console.log('✅ Cache limpo, nova consulta será realizada automaticamente');
|
||||
};
|
||||
|
||||
const handleItemSelect = useCallback((item: ItemDisponivel) => {
|
||||
console.log('🎯 Item selecionado:', {
|
||||
marca: item.marca,
|
||||
tipo: item.tipo,
|
||||
quantidade_disponivel: item.quantidade_disponivel
|
||||
});
|
||||
setItemSelecionado(item);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleQuantidadeChange = (value: string) => {
|
||||
const quantidade = parseInt(value);
|
||||
|
||||
if (itemSelecionado && quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
toast.error(`Quantidade não pode ser maior que ${itemSelecionado.quantidade_disponivel} unidades disponíveis`);
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: value,
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTodasDisponiveisChange = (checked: boolean) => {
|
||||
console.log('🔄 Checkbox "Todas" alterado:', {
|
||||
checked,
|
||||
itemSelecionado: itemSelecionado?.marca,
|
||||
quantidade_disponivel: itemSelecionado?.quantidade_disponivel
|
||||
});
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
todas_disponiveis: checked,
|
||||
quantidade_produzida: checked && itemSelecionado ? itemSelecionado.quantidade_disponivel.toString() : prev.quantidade_produzida
|
||||
}));
|
||||
};
|
||||
|
||||
const resetFormForNewEntry = async () => {
|
||||
console.log('🔄 Resetando formulário para nova entrada...');
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
|
||||
console.log('📋 Recarregando itens disponíveis após reset...');
|
||||
await carregarItensDisponiveis();
|
||||
};
|
||||
|
||||
const handleBatchSelect = async (items: ItemDisponivel[], tipo: 'peca' | 'componente') => {
|
||||
setSaving(true);
|
||||
let sucessos = 0;
|
||||
let erros = 0;
|
||||
|
||||
try {
|
||||
for (const item of items) {
|
||||
try {
|
||||
const validacao = await onValidarSequencia(
|
||||
formData.of_number,
|
||||
item.marca,
|
||||
formData.processo_id,
|
||||
item.quantidade_disponivel,
|
||||
formData.fase
|
||||
);
|
||||
|
||||
if (!validacao.valido) {
|
||||
erros++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const apontamentoData: any = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: item.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: item.quantidade_disponivel,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: `Apontamento em lote - ${tipo}`
|
||||
};
|
||||
|
||||
if (item.tipo === 'componente') {
|
||||
apontamentoData.componente_id = item.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = item.id;
|
||||
}
|
||||
|
||||
const result = await onCriarApontamento(apontamentoData);
|
||||
|
||||
if (result.success) {
|
||||
sucessos++;
|
||||
} else {
|
||||
erros++;
|
||||
}
|
||||
} catch (error) {
|
||||
erros++;
|
||||
}
|
||||
}
|
||||
|
||||
if (sucessos > 0) {
|
||||
toast.success(`${sucessos} apontamentos realizados com sucesso!`);
|
||||
await Promise.all([
|
||||
onRefetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
}
|
||||
|
||||
if (erros > 0) {
|
||||
toast.error(`${erros} apontamentos falharam.`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
toast.error('Erro ao realizar apontamento em lote');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!itemSelecionado || !formData.processo_id || !formData.quantidade_produzida) {
|
||||
toast.error('Preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
const quantidade = parseInt(formData.quantidade_produzida);
|
||||
|
||||
if (quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
toast.error('Quantidade inválida');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
console.log('🔍 Iniciando validação de sequência:', {
|
||||
of: formData.of_number,
|
||||
marca: itemSelecionado.marca,
|
||||
processo: formData.processo_id,
|
||||
quantidade,
|
||||
metodo: formData.todas_disponiveis ? 'CHECKBOX_TODAS' : 'MANUAL'
|
||||
});
|
||||
|
||||
const validacao = await onValidarSequencia(
|
||||
formData.of_number,
|
||||
itemSelecionado.marca,
|
||||
formData.processo_id,
|
||||
quantidade,
|
||||
formData.fase
|
||||
);
|
||||
|
||||
if (!validacao.valido) {
|
||||
toast.error(validacao.erro);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Validação aprovada, criando apontamento...');
|
||||
|
||||
const apontamentoData: any = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: itemSelecionado.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: quantidade,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: formData.observacoes || null
|
||||
};
|
||||
|
||||
if (itemSelecionado.tipo === 'componente') {
|
||||
apontamentoData.componente_id = itemSelecionado.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = itemSelecionado.id;
|
||||
}
|
||||
|
||||
console.log('📝 Dados do apontamento sendo criado:', {
|
||||
...apontamentoData,
|
||||
metodo_utilizado: formData.todas_disponiveis ? 'CHECKBOX_TODAS' : 'DIGITACAO_MANUAL',
|
||||
quantidade_original_disponivel: itemSelecionado.quantidade_disponivel,
|
||||
quantidade_sendo_apontada: quantidade
|
||||
});
|
||||
|
||||
const result = await onCriarApontamento(apontamentoData);
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ Apontamento criado com sucesso!');
|
||||
toast.success('Apontamento registrado com sucesso!');
|
||||
|
||||
// Atualizar estado do último apontamento
|
||||
if (result.success) {
|
||||
// Para desfazer, buscar o último apontamento criado
|
||||
const { data: lastApontamento } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('id')
|
||||
.eq('created_by', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (lastApontamento) {
|
||||
setLastApontamentoId(lastApontamento.id);
|
||||
setCanUndo(true);
|
||||
}
|
||||
}
|
||||
|
||||
// INVALIDAR CACHE ESPECÍFICO DA OF E FASE ANTES DE RECARREGAR
|
||||
console.log('🗑️ Invalidando cache após apontamento bem-sucedido...');
|
||||
onInvalidateCache(formData.of_number, formData.fase);
|
||||
|
||||
// Aguardar atualização e reset
|
||||
await Promise.all([
|
||||
onRefetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
|
||||
console.log('✅ Formulário resetado e dados atualizados após apontamento');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao registrar apontamento:', error);
|
||||
toast.error('Erro ao registrar apontamento');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const limparCacheCompleto = () => {
|
||||
localStorage.removeItem('apontamento_form_cache');
|
||||
setFormData({
|
||||
of_number: '',
|
||||
fase: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0],
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
});
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
toast.success('Cache limpo completamente!');
|
||||
};
|
||||
|
||||
// Função para desfazer último apontamento
|
||||
const handleUndo = useCallback(async () => {
|
||||
if (!lastApontamentoId || !user?.id) return;
|
||||
|
||||
setUndoLoading(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.delete()
|
||||
.eq('id', lastApontamentoId)
|
||||
.eq('created_by', user.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao desfazer apontamento:', error);
|
||||
toast.error('Erro ao desfazer apontamento');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resetar estado do botão de desfazer
|
||||
setLastApontamentoId(null);
|
||||
setCanUndo(false);
|
||||
|
||||
// Recarregar itens disponíveis se necessário
|
||||
if (formData.of_number && formData.fase && formData.processo_id) {
|
||||
await carregarItensDisponiveis();
|
||||
}
|
||||
|
||||
toast.success('Apontamento desfeito com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro inesperado ao desfazer:', error);
|
||||
toast.error('Erro inesperado ao desfazer apontamento');
|
||||
} finally {
|
||||
setUndoLoading(false);
|
||||
}
|
||||
}, [lastApontamentoId, user?.id, supabase, formData.of_number, formData.fase, formData.processo_id, carregarItensDisponiveis]);
|
||||
|
||||
const processoSelecionado = processosOrdenados?.find(p => p.id === formData.processo_id);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 space-y-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground">Carregando dados...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
{/* Cache status */}
|
||||
{(formData.of_number || formData.fase || formData.processo_id) && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>Formulário restaurado do cache</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={limparCacheCompleto}
|
||||
className="ml-2"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Limpar Cache
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Campos de seleção básicos */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="of">Ordem de Fabricação *</Label>
|
||||
<Select value={formData.of_number} onValueChange={handleOFChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(ofs || []).map((of) => (
|
||||
<SelectItem key={of.id} value={of.num_of}>
|
||||
{of.num_of} - {of.descritivo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.of_number && (
|
||||
<div>
|
||||
<Label htmlFor="fase">Fase *</Label>
|
||||
<Select value={formData.fase} onValueChange={handleFaseChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a fase" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fasesDisponiveis.map((fase) => (
|
||||
<SelectItem key={fase} value={fase}>
|
||||
{fase}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{formData.fase && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="data">Data do Apontamento *</Label>
|
||||
<Input
|
||||
id="data"
|
||||
type="date"
|
||||
value={formData.data_apontamento}
|
||||
onChange={(e) => {
|
||||
const newDate = e.target.value;
|
||||
setFormData(prev => ({ ...prev, data_apontamento: newDate }));
|
||||
updateCache({ data_apontamento: newDate });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Processo *</Label>
|
||||
<Select value={formData.processo_id} onValueChange={handleProcessoChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o processo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(processosOrdenados || []).map((processo) => (
|
||||
<SelectItem key={processo.id} value={processo.id}>
|
||||
{processo.ordem}. {processo.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informação sobre o processo */}
|
||||
{processoSelecionado && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{processoSelecionado.ordem === 1
|
||||
? `Processo inicial: ${processoSelecionado.nome}. Todos os itens estão disponíveis.`
|
||||
: `Processo ${processoSelecionado.ordem}: ${processoSelecionado.nome}. Apenas itens que passaram pelos processos anteriores estão disponíveis.`
|
||||
}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Seletor de Itens */}
|
||||
{formData.processo_id && (
|
||||
<SeletorPecasSimples
|
||||
pecasDisponiveis={itensDisponiveis.pecasDisponiveis || []}
|
||||
componentesDisponiveis={itensDisponiveis.componentesDisponiveis || []}
|
||||
onItemSelect={handleItemSelect}
|
||||
onBatchSelect={handleBatchSelect}
|
||||
loading={loadingItens || validacaoLoading}
|
||||
onSubmit={() => handleSubmit({} as React.FormEvent)}
|
||||
submitDisabled={saving || !itemSelecionado || !formData.processo_id || !formData.quantidade_produzida || loadingItens}
|
||||
submitLoading={saving}
|
||||
canUndo={canUndo}
|
||||
onUndo={handleUndo}
|
||||
undoLoading={undoLoading}
|
||||
lastApontamentoId={lastApontamentoId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Campos de Quantidade */}
|
||||
{itemSelecionado && (
|
||||
<div className="space-y-4 p-4 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<h4 className="font-medium text-slate-200">Quantidade a Apontar</h4>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={itemSelecionado.quantidade_disponivel}
|
||||
placeholder="Digite a quantidade..."
|
||||
value={formData.quantidade_produzida}
|
||||
onChange={(e) => handleQuantidadeChange(e.target.value)}
|
||||
disabled={formData.todas_disponiveis}
|
||||
className="w-full text-lg h-12 bg-slate-800 border-slate-600"
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="todas-disponiveis"
|
||||
checked={formData.todas_disponiveis}
|
||||
onCheckedChange={handleTodasDisponiveisChange}
|
||||
/>
|
||||
<Label htmlFor="todas-disponiveis" className="text-sm cursor-pointer">
|
||||
Todas ({itemSelecionado.quantidade_disponivel})
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="observacoes">Observações</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
placeholder="Observações sobre o apontamento..."
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, observacoes: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card de informações do item selecionado */}
|
||||
<div>
|
||||
<Card className="bg-muted/50 h-fit">
|
||||
<CardContent className="p-4">
|
||||
<h4 className="font-medium flex items-center gap-2 mb-3">
|
||||
<Package className="h-4 w-4" />
|
||||
Informações do Item Selecionado
|
||||
</h4>
|
||||
{!itemSelecionado ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Selecione um item para ver as informações
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><strong>Tipo:</strong> {itemSelecionado.tipo === 'componente' ? 'Componente' : 'Peça'}</div>
|
||||
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
|
||||
<div><strong>OF:</strong> {formData.of_number}</div>
|
||||
<div><strong>Fase:</strong> {formData.fase}</div>
|
||||
<div><strong>Processo:</strong> {processoSelecionado?.nome || 'N/A'}</div>
|
||||
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
|
||||
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
|
||||
{formData.quantidade_produzida && (
|
||||
<div className="pt-2 border-t">
|
||||
<div><strong>Quantidade a Apontar:</strong> {formData.quantidade_produzida} unidades</div>
|
||||
<div><strong>Método:</strong> {formData.todas_disponiveis ? 'Checkbox "Todas"' : 'Digitação Manual'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
);
|
||||
};
|
||||
237
src/components/apontamento/ApontamentosList.tsx
Normal file
237
src/components/apontamento/ApontamentosList.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, Package } from 'lucide-react';
|
||||
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
export const ApontamentosList = () => {
|
||||
const { apontamentos, loading } = useApontamentosProducao();
|
||||
const [filtroOF, setFiltroOF] = useState('');
|
||||
const [filtroProcesso, setFiltroProcesso] = useState('');
|
||||
const [filtroData, setFiltroData] = useState('');
|
||||
const [filtroPecaComponente, setFiltroPecaComponente] = useState('');
|
||||
|
||||
const apontamentosFiltrados = apontamentos.filter(apt => {
|
||||
const matchOF = !filtroOF || apt.of_number.toLowerCase().includes(filtroOF.toLowerCase());
|
||||
const matchProcesso = !filtroProcesso || apt.processo?.nome.toLowerCase().includes(filtroProcesso.toLowerCase());
|
||||
const matchData = !filtroData || apt.data_apontamento.includes(filtroData);
|
||||
|
||||
// Filtro por peça/componente - busca na marca da peça ou componente
|
||||
const matchPecaComponente = !filtroPecaComponente ||
|
||||
getMarcaExibida(apt).toLowerCase().includes(filtroPecaComponente.toLowerCase());
|
||||
|
||||
return matchOF && matchProcesso && matchData && matchPecaComponente;
|
||||
});
|
||||
|
||||
// Função para determinar a marca exibida baseada no tipo de apontamento
|
||||
const getMarcaExibida = (apontamento: any) => {
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.marca_componente) {
|
||||
return apontamento.componente.marca_componente;
|
||||
}
|
||||
if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.marca) {
|
||||
return apontamento.peca.marca;
|
||||
}
|
||||
return 'N/A';
|
||||
};
|
||||
|
||||
// Função para calcular peso total baseado no tipo correto (peça ou componente)
|
||||
const calcularPesoTotal = (apontamento: any) => {
|
||||
let pesoUnitario = 0;
|
||||
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
|
||||
pesoUnitario = Number(apontamento.componente.peso_unitario);
|
||||
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
|
||||
pesoUnitario = Number(apontamento.peca.peso_unitario);
|
||||
}
|
||||
|
||||
const quantidade = Number(apontamento.quantidade_produzida) || 0;
|
||||
const pesoTotal = pesoUnitario * quantidade;
|
||||
|
||||
console.log(`Apontamento ${apontamento.id}: Tipo: ${apontamento.tipo_apontamento}, Peso unitário: ${pesoUnitario}, Quantidade: ${quantidade}, Peso total: ${pesoTotal}`);
|
||||
|
||||
return pesoTotal.toFixed(3);
|
||||
};
|
||||
|
||||
// Função para obter a descrição correta baseada no tipo
|
||||
const getDescricaoExibida = (apontamento: any) => {
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.descricao) {
|
||||
return apontamento.componente.descricao;
|
||||
}
|
||||
if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.descricao) {
|
||||
return apontamento.peca.descricao;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Calcular totais para debug
|
||||
const pesoTotalApontamentos = apontamentosFiltrados.reduce((total, apt) => {
|
||||
return total + Number(calcularPesoTotal(apt));
|
||||
}, 0);
|
||||
|
||||
console.log('Total de peso dos apontamentos filtrados:', pesoTotalApontamentos);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8">Carregando apontamentos...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filtros */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Filtros
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">OF</label>
|
||||
<Input
|
||||
placeholder="Filtrar por OF..."
|
||||
value={filtroOF}
|
||||
onChange={(e) => setFiltroOF(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Processo</label>
|
||||
<Input
|
||||
placeholder="Filtrar por processo..."
|
||||
value={filtroProcesso}
|
||||
onChange={(e) => setFiltroProcesso(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Data</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filtroData}
|
||||
onChange={(e) => setFiltroData(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Peça/Componente</label>
|
||||
<Input
|
||||
placeholder="Filtrar por peça/componente..."
|
||||
value={filtroPecaComponente}
|
||||
onChange={(e) => setFiltroPecaComponente(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{apontamentosFiltrados.length} registros encontrados
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Peso total: {pesoTotalApontamentos.toFixed(3)} kg
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabela de Apontamentos */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Histórico de Apontamentos
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{apontamentosFiltrados.length} registros
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{apontamentosFiltrados.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nenhum apontamento encontrado
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>OF</TableHead>
|
||||
<TableHead>Peça/Componente</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Processo</TableHead>
|
||||
<TableHead className="text-center">Quantidade</TableHead>
|
||||
<TableHead className="text-center">Peso Unit. (kg)</TableHead>
|
||||
<TableHead className="text-center">Peso Total (kg)</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
<TableHead>Observações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apontamentosFiltrados.map((apontamento) => {
|
||||
const pesoUnitario = apontamento.tipo_apontamento === 'componente'
|
||||
? Number(apontamento.componente?.peso_unitario || 0)
|
||||
: Number(apontamento.peca?.peso_unitario || 0);
|
||||
|
||||
return (
|
||||
<TableRow key={apontamento.id} className="h-9">
|
||||
<TableCell className="font-medium py-1">
|
||||
{apontamento.of_number}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="font-medium">
|
||||
{getMarcaExibida(apontamento)}
|
||||
</div>
|
||||
{getDescricaoExibida(apontamento) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getDescricaoExibida(apontamento)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Badge variant={apontamento.tipo_apontamento === 'componente' ? 'secondary' : 'default'}>
|
||||
{apontamento.tipo_apontamento === 'componente' ? 'Componente' : 'Peça'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Badge variant="outline">
|
||||
{apontamento.processo?.nome || 'N/A'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-medium py-1">
|
||||
{apontamento.quantidade_produzida}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-1">
|
||||
{pesoUnitario.toFixed(3)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-medium py-1">
|
||||
{calcularPesoTotal(apontamento)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
{format(new Date(apontamento.data_apontamento), 'dd/MM/yyyy', {
|
||||
locale: ptBR
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs py-1">
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{apontamento.observacoes || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
119
src/components/apontamento/ApontamentosListOtimizado.tsx
Normal file
119
src/components/apontamento/ApontamentosListOtimizado.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Package } from 'lucide-react';
|
||||
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { ApontamentosFilters } from './historico/ApontamentosFilters';
|
||||
import { ApontamentosResultsList } from './historico/ApontamentosResultsList';
|
||||
import { useApontamentosFilters } from './historico/useApontamentosFilters';
|
||||
|
||||
export const ApontamentosListOtimizado: React.FC = () => {
|
||||
const { apontamentos, loading, refetch } = useApontamentosProducao();
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterOF,
|
||||
filterFase,
|
||||
filterProcesso,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
setDataInicio,
|
||||
setDataFim,
|
||||
uniqueOFs,
|
||||
uniqueFases,
|
||||
uniqueProcessos,
|
||||
filteredApontamentos,
|
||||
initialized,
|
||||
handleOFChange,
|
||||
handleFaseChange,
|
||||
handleProcessoChange,
|
||||
clearFilters
|
||||
} = useApontamentosFilters(apontamentos);
|
||||
|
||||
const handleReverterApontamento = async (apontamentoId: string) => {
|
||||
try {
|
||||
console.log('Iniciando reversão do apontamento:', apontamentoId);
|
||||
|
||||
// Buscar informações do apontamento antes de deletar para logs
|
||||
const { data: apontamentoInfo } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
of_number,
|
||||
quantidade_produzida,
|
||||
peca:pecas(marca),
|
||||
componente:componentes_peca(marca_componente),
|
||||
processo:processos_fabricacao(nome)
|
||||
`)
|
||||
.eq('id', apontamentoId)
|
||||
.single();
|
||||
|
||||
if (apontamentoInfo) {
|
||||
const marca = apontamentoInfo.peca?.marca || apontamentoInfo.componente?.marca_componente || 'N/A';
|
||||
console.log(`Revertendo apontamento: ${marca} - ${apontamentoInfo.quantidade_produzida} unidades - OF: ${apontamentoInfo.of_number}`);
|
||||
}
|
||||
|
||||
// Deletar o apontamento
|
||||
const { error } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.delete()
|
||||
.eq('id', apontamentoId);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao deletar apontamento:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('Apontamento deletado com sucesso, atualizando lista...');
|
||||
toast.success('Apontamento revertido com sucesso!');
|
||||
|
||||
// Forçar atualização dos dados
|
||||
await refetch();
|
||||
console.log('Lista de apontamentos atualizada');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Erro completo ao reverter apontamento:', error);
|
||||
toast.error(`Erro ao reverter apontamento: ${error.message || 'Erro desconhecido'}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !initialized) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 animate-spin" />
|
||||
<span>Carregando histórico...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ApontamentosFilters
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
filterOF={filterOF}
|
||||
filterFase={filterFase}
|
||||
filterProcesso={filterProcesso}
|
||||
dataInicio={dataInicio}
|
||||
dataFim={dataFim}
|
||||
setDataInicio={setDataInicio}
|
||||
setDataFim={setDataFim}
|
||||
uniqueOFs={uniqueOFs}
|
||||
uniqueFases={uniqueFases}
|
||||
uniqueProcessos={uniqueProcessos}
|
||||
handleOFChange={handleOFChange}
|
||||
handleFaseChange={handleFaseChange}
|
||||
handleProcessoChange={handleProcessoChange}
|
||||
clearFilters={clearFilters}
|
||||
/>
|
||||
|
||||
<ApontamentosResultsList
|
||||
filteredApontamentos={filteredApontamentos}
|
||||
onRevert={handleReverterApontamento}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
258
src/components/apontamento/ProcessosList.tsx
Normal file
258
src/components/apontamento/ProcessosList.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Edit, Save, X, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type SortField = 'ordem' | 'nome' | 'descricao' | 'ativo';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export const ProcessosList = () => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [sortField, setSortField] = useState<SortField>('ordem');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [formData, setFormData] = useState({
|
||||
nome: '',
|
||||
descricao: '',
|
||||
ordem: 0
|
||||
});
|
||||
|
||||
const { processos, loading, criarProcesso, atualizarProcesso } = useApontamentosProducao();
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedProcessos = React.useMemo(() => {
|
||||
return [...processos].sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sortField) {
|
||||
case 'ordem':
|
||||
aValue = a.ordem;
|
||||
bValue = b.ordem;
|
||||
break;
|
||||
case 'nome':
|
||||
aValue = a.nome;
|
||||
bValue = b.nome;
|
||||
break;
|
||||
case 'descricao':
|
||||
aValue = a.descricao || '';
|
||||
bValue = b.descricao || '';
|
||||
break;
|
||||
case 'ativo':
|
||||
aValue = a.ativo;
|
||||
bValue = b.ativo;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [processos, sortField, sortDirection]);
|
||||
|
||||
const SortButton = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
|
||||
<button
|
||||
onClick={() => handleSort(field)}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors w-full text-left"
|
||||
>
|
||||
{children}
|
||||
{sortField === field && (
|
||||
sortDirection === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.nome.trim()) {
|
||||
toast.error('Nome do processo é obrigatório');
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
if (editingId) {
|
||||
result = await atualizarProcesso(editingId, formData);
|
||||
} else {
|
||||
result = await criarProcesso({
|
||||
...formData,
|
||||
ativo: true
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
setFormData({ nome: '', descricao: '', ordem: 0 });
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (processo: any) => {
|
||||
setFormData({
|
||||
nome: processo.nome,
|
||||
descricao: processo.descricao || '',
|
||||
ordem: processo.ordem || 0
|
||||
});
|
||||
setEditingId(processo.id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setFormData({ nome: '', descricao: '', ordem: 0 });
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-muted-foreground">Carregando processos...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Processos de Fabricação</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Novo Processo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{editingId ? 'Editar Processo' : 'Novo Processo'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="nome">Nome *</Label>
|
||||
<Input
|
||||
id="nome"
|
||||
value={formData.nome}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Nome do processo"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ordem">Ordem</Label>
|
||||
<Input
|
||||
id="ordem"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.ordem}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, ordem: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="descricao">Descrição</Label>
|
||||
<Textarea
|
||||
id="descricao"
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descrição opcional"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{editingId ? 'Atualizar' : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-6">
|
||||
<TableHead className="py-1">
|
||||
<SortButton field="ordem">Ordem</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="py-1">
|
||||
<SortButton field="nome">Nome</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="py-1">
|
||||
<SortButton field="descricao">Descrição</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="py-1">
|
||||
<SortButton field="ativo">Status</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="py-1 w-24">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedProcessos.map((processo) => (
|
||||
<TableRow key={processo.id} className="h-6">
|
||||
<TableCell className="py-1 text-center text-sm">{processo.ordem}</TableCell>
|
||||
<TableCell className="py-1 font-medium text-sm">{processo.nome}</TableCell>
|
||||
<TableCell className="py-1 text-sm">{processo.descricao || '-'}</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Badge variant={processo.ativo ? 'default' : 'secondary'} className="text-xs">
|
||||
{processo.ativo ? 'Ativo' : 'Inativo'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(processo)}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p><strong>Informações:</strong></p>
|
||||
<p>Os processos são utilizados para categorizar os apontamentos de produção.</p>
|
||||
<p>A ordem define a sequência dos processos no fluxo de fabricação.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1278
src/components/apontamento/SeletorItensOtimizado.tsx
Normal file
1278
src/components/apontamento/SeletorItensOtimizado.tsx
Normal file
File diff suppressed because it is too large
Load Diff
412
src/components/apontamento/SeletorPecasSimples.tsx
Normal file
412
src/components/apontamento/SeletorPecasSimples.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Package, Search, CheckSquare, Loader2, Weight, Info, ArrowUpDown, Undo2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { applyMarcaFilter } from '@/utils/rangeFilter';
|
||||
import { naturalSort } from '@/utils/naturalSort';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface ItemDisponivel {
|
||||
id: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
tipo: 'peca' | 'componente';
|
||||
quantidade_disponivel: number;
|
||||
processo_atual_permitido: number;
|
||||
peso_unitario?: number;
|
||||
}
|
||||
|
||||
interface SeletorPecasSimplesProps {
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
onItemSelect: (item: ItemDisponivel) => void;
|
||||
onBatchSelect: (items: ItemDisponivel[], tipo: 'peca' | 'componente') => void;
|
||||
loading: boolean;
|
||||
onSubmit?: () => void;
|
||||
submitDisabled?: boolean;
|
||||
submitLoading?: boolean;
|
||||
// Props para o botão de desfazer
|
||||
canUndo?: boolean;
|
||||
onUndo?: () => void;
|
||||
undoLoading?: boolean;
|
||||
lastApontamentoId?: string;
|
||||
}
|
||||
|
||||
export const SeletorPecasSimples: React.FC<SeletorPecasSimplesProps> = ({
|
||||
pecasDisponiveis = [],
|
||||
componentesDisponiveis = [],
|
||||
onItemSelect,
|
||||
onBatchSelect,
|
||||
loading,
|
||||
onSubmit,
|
||||
submitDisabled = false,
|
||||
submitLoading = false,
|
||||
canUndo = false,
|
||||
onUndo,
|
||||
undoLoading = false,
|
||||
lastApontamentoId
|
||||
}) => {
|
||||
const [filtro, setFiltro] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('pecas');
|
||||
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
// Filtrar e ordenar peças com suporte a range
|
||||
const pecasFiltradas = useMemo(() => {
|
||||
let pecas = pecasDisponiveis;
|
||||
if (filtro) {
|
||||
pecas = pecasDisponiveis.filter(peca =>
|
||||
applyMarcaFilter(peca.marca, filtro) ||
|
||||
peca.descricao.toLowerCase().includes(filtro.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar numericamente usando naturalSort
|
||||
return [...pecas].sort((a, b) => {
|
||||
const comparison = naturalSort(a.marca, b.marca);
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [pecasDisponiveis, filtro, sortOrder]);
|
||||
|
||||
// Filtrar e ordenar componentes com suporte a range
|
||||
const componentesFiltrados = useMemo(() => {
|
||||
let componentes = componentesDisponiveis;
|
||||
if (filtro) {
|
||||
componentes = componentesDisponiveis.filter(comp =>
|
||||
applyMarcaFilter(comp.marca, filtro) ||
|
||||
comp.descricao.toLowerCase().includes(filtro.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar numericamente usando naturalSort
|
||||
return [...componentes].sort((a, b) => {
|
||||
const comparison = naturalSort(a.marca, b.marca);
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [componentesDisponiveis, filtro, sortOrder]);
|
||||
|
||||
// Calcular peso total das peças disponíveis/filtradas
|
||||
const pesoTotalPecas = useMemo(() => {
|
||||
return pecasFiltradas.reduce((total, peca) => {
|
||||
const pesoUnitario = peca.peso_unitario || 0;
|
||||
return total + (pesoUnitario * peca.quantidade_disponivel);
|
||||
}, 0);
|
||||
}, [pecasFiltradas]);
|
||||
|
||||
// Calcular peso total dos componentes disponíveis/filtrados
|
||||
const pesoTotalComponentes = useMemo(() => {
|
||||
return componentesFiltrados.reduce((total, comp) => {
|
||||
const pesoUnitario = comp.peso_unitario || 0;
|
||||
return total + (pesoUnitario * comp.quantidade_disponivel);
|
||||
}, 0);
|
||||
}, [componentesFiltrados]);
|
||||
|
||||
const handleItemClick = (item: ItemDisponivel) => {
|
||||
setItemSelecionado(item);
|
||||
onItemSelect(item);
|
||||
};
|
||||
|
||||
const handleBatchPecas = () => {
|
||||
if (pecasFiltradas.length === 0) {
|
||||
toast.error('Nenhuma peça disponível para apontamento em lote');
|
||||
return;
|
||||
}
|
||||
onBatchSelect(pecasFiltradas, 'peca');
|
||||
};
|
||||
|
||||
const handleBatchComponentes = () => {
|
||||
if (componentesFiltrados.length === 0) {
|
||||
toast.error('Nenhum componente disponível para apontamento em lote');
|
||||
return;
|
||||
}
|
||||
onBatchSelect(componentesFiltrados, 'componente');
|
||||
};
|
||||
|
||||
const formatarPeso = (peso: number) => {
|
||||
return peso.toFixed(2);
|
||||
};
|
||||
|
||||
const handleToggleSort = () => {
|
||||
setSortOrder(current => current === 'asc' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="flex items-center justify-center p-8">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Carregando itens disponíveis...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-card-foreground flex items-center gap-2 text-lg">
|
||||
<Package className="h-5 w-5" />
|
||||
Itens Disponíveis
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleSort}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Ordenar {sortOrder === 'asc' ? 'crescente' : 'decrescente'}</p>
|
||||
<p className="text-xs text-muted-foreground">Clique para alternar</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Botão Desfazer Último Apontamento */}
|
||||
{onUndo && (
|
||||
<AlertDialog>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canUndo || undoLoading}
|
||||
className="border-orange-200 text-orange-600 hover:bg-orange-50 hover:text-orange-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{undoLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Undo2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{undoLoading ? 'Desfazendo...' : 'Desfazer Ult. Apont.'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Desfazer o último apontamento realizado</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{canUndo ? 'Clique para desfazer' : 'Nenhum apontamento para desfazer'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar Reversão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja desfazer o último apontamento?
|
||||
<br />
|
||||
<strong>Esta ação não pode ser desfeita.</strong>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onUndo}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Confirmar Reversão
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{onSubmit && (
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={submitDisabled || submitLoading}
|
||||
className="min-w-32"
|
||||
>
|
||||
{submitLoading ? 'Salvando...' : 'Registrar Apontamento'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campo de busca com tooltip */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filtrar por número da peça... (ex: 32@47)"
|
||||
value={filtro}
|
||||
onChange={(e) => setFiltro(e.target.value)}
|
||||
className="pl-10 pr-8 bg-background border-border"
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="absolute right-3 top-3 h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm space-y-1">
|
||||
<p><strong>Filtro por range:</strong></p>
|
||||
<p>Use @ para filtrar intervalos</p>
|
||||
<p><strong>Exemplo:</strong> 32@47 (peças de 32 a 47)</p>
|
||||
<p><strong>Busca normal:</strong> Digite parte da marca</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full bg-muted border-border">
|
||||
<TabsTrigger
|
||||
value="pecas"
|
||||
className="flex-1 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Peças ({pecasFiltradas.length}) - {formatarPeso(pesoTotalPecas)} kg
|
||||
<Weight className="h-3 w-3 ml-1 text-muted-foreground" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="componentes"
|
||||
className="flex-1 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Componentes ({componentesFiltrados.length}) - {formatarPeso(pesoTotalComponentes)} kg
|
||||
<Weight className="h-3 w-3 ml-1 text-muted-foreground" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pecas" className="space-y-3 mt-4">
|
||||
{pecasFiltradas.length > 0 && (
|
||||
<Button
|
||||
onClick={handleBatchPecas}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-border hover:bg-muted"
|
||||
>
|
||||
<CheckSquare className="h-4 w-4 mr-2" />
|
||||
Apontar Todas as Peças ({pecasFiltradas.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{pecasFiltradas.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Package className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Nenhuma peça disponível</p>
|
||||
</div>
|
||||
) : (
|
||||
pecasFiltradas.map((peca) => (
|
||||
<div
|
||||
key={peca.id}
|
||||
onClick={() => handleItemClick(peca)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
|
||||
itemSelecionado?.id === peca.id
|
||||
? 'bg-primary/10 border-primary text-foreground'
|
||||
: 'bg-background border-border hover:bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">{peca.marca}</div>
|
||||
<div className="text-sm text-muted-foreground">{peca.descricao}</div>
|
||||
{peca.peso_unitario && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Peso unitário: {formatarPeso(peca.peso_unitario)} kg
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-secondary text-secondary-foreground">
|
||||
{peca.quantidade_disponivel} un.
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="componentes" className="space-y-3 mt-4">
|
||||
{componentesFiltrados.length > 0 && (
|
||||
<Button
|
||||
onClick={handleBatchComponentes}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-border hover:bg-muted"
|
||||
>
|
||||
<CheckSquare className="h-4 w-4 mr-2" />
|
||||
Apontar Todos os Componentes ({componentesFiltrados.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{componentesFiltrados.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Package className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Nenhum componente disponível</p>
|
||||
</div>
|
||||
) : (
|
||||
componentesFiltrados.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
onClick={() => handleItemClick(comp)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
|
||||
itemSelecionado?.id === comp.id
|
||||
? 'bg-primary/10 border-primary text-foreground'
|
||||
: 'bg-background border-border hover:bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<div className="font-medium">{comp.marca}</div>
|
||||
<div className="text-sm text-muted-foreground">{comp.descricao}</div>
|
||||
{comp.peso_unitario && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Peso unitário: {formatarPeso(comp.peso_unitario)} kg
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-secondary text-secondary-foreground">
|
||||
{comp.quantidade_disponivel} un.
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
195
src/components/apontamento/historico/ApontamentosFilters.tsx
Normal file
195
src/components/apontamento/historico/ApontamentosFilters.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Search, Calendar as CalendarIcon, Filter, RefreshCw } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ApontamentosFiltersProps {
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
filterOF: string;
|
||||
filterFase: string;
|
||||
filterProcesso: string;
|
||||
dataInicio: Date | undefined;
|
||||
dataFim: Date | undefined;
|
||||
setDataInicio: (date: Date | undefined) => void;
|
||||
setDataFim: (date: Date | undefined) => void;
|
||||
uniqueOFs: string[];
|
||||
uniqueFases: string[];
|
||||
uniqueProcessos: string[];
|
||||
handleOFChange: (value: string) => void;
|
||||
handleFaseChange: (value: string) => void;
|
||||
handleProcessoChange: (value: string) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
export const ApontamentosFilters: React.FC<ApontamentosFiltersProps> = ({
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterOF,
|
||||
filterFase,
|
||||
filterProcesso,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
setDataInicio,
|
||||
setDataFim,
|
||||
uniqueOFs,
|
||||
uniqueFases,
|
||||
uniqueProcessos,
|
||||
handleOFChange,
|
||||
handleFaseChange,
|
||||
handleProcessoChange,
|
||||
clearFilters
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtros Inteligentes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Primeira linha de filtros - mais compacta */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Buscar</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="OF, Marca..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-7 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">OF</label>
|
||||
<Select value={filterOF || 'all'} onValueChange={handleOFChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as OFs</SelectItem>
|
||||
{uniqueOFs.map((of) => (
|
||||
<SelectItem key={of} value={of}>{of}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Fase</label>
|
||||
<Select value={filterFase || 'all'} onValueChange={handleFaseChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as fases</SelectItem>
|
||||
{uniqueFases.map((fase) => (
|
||||
<SelectItem key={fase} value={fase}>{fase}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Processo</label>
|
||||
<Select value={filterProcesso || 'all'} onValueChange={handleProcessoChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os processos</SelectItem>
|
||||
{uniqueProcessos.map((processo) => (
|
||||
<SelectItem key={processo} value={processo}>{processo}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button variant="outline" onClick={clearFilters} className="h-8 text-xs">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Segunda linha - filtros de data */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Data Início</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-8 justify-start text-left font-normal text-xs",
|
||||
!dataInicio && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-1 h-3 w-3" />
|
||||
{dataInicio ? format(dataInicio, "dd/MM/yyyy") : "Selecionar"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dataInicio}
|
||||
onSelect={setDataInicio}
|
||||
initialFocus
|
||||
className="pointer-events-auto"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Data Fim</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-8 justify-start text-left font-normal text-xs",
|
||||
!dataFim && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-1 h-3 w-3" />
|
||||
{dataFim ? format(dataFim, "dd/MM/yyyy") : "Selecionar"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dataFim}
|
||||
onSelect={setDataFim}
|
||||
initialFocus
|
||||
className="pointer-events-auto"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{filterOF || filterFase || filterProcesso || dataInicio || dataFim ?
|
||||
'Filtros aplicados automaticamente baseados no último uso' :
|
||||
'Use os filtros para otimizar a visualização'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
106
src/components/apontamento/historico/ApontamentosListItem.tsx
Normal file
106
src/components/apontamento/historico/ApontamentosListItem.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Package, Wrench, Undo2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { ApontamentoProducao } from '@/hooks/useApontamentosProducao';
|
||||
|
||||
interface ApontamentosListItemProps {
|
||||
apontamento: ApontamentoProducao;
|
||||
onRevert: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
|
||||
apontamento,
|
||||
onRevert
|
||||
}) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString + 'T00:00:00');
|
||||
return format(date, 'dd/MM/yyyy', { locale: ptBR });
|
||||
} catch (error) {
|
||||
console.error('Erro ao formatar data:', error);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getMarcaItem = (apontamento: ApontamentoProducao) => {
|
||||
if (apontamento.tipo_apontamento === 'componente') {
|
||||
return apontamento.componente?.marca_componente || 'N/A';
|
||||
} else {
|
||||
return apontamento.peca?.marca || 'N/A';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
{apontamento.tipo_apontamento === 'componente' ? (
|
||||
<Wrench className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<Package className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{getMarcaItem(apontamento)}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{apontamento.of_number || 'N/A'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{apontamento.processo?.nome || 'N/A'} • {formatDate(apontamento.data_apontamento)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className="font-medium">
|
||||
{apontamento.quantidade_produzida || 0} un.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{format(new Date(apontamento.created_at), 'HH:mm', { locale: ptBR })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reverter Apontamento</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza de que deseja reverter este apontamento?
|
||||
Esta ação não pode ser desfeita e irá remover permanentemente o registro de:
|
||||
<br />
|
||||
<strong>{getMarcaItem(apontamento)}</strong> - {apontamento.quantidade_produzida} unidades
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRevert(apontamento.id)}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Confirmar Reversão
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||
import { ApontamentosListItem } from './ApontamentosListItem';
|
||||
import { ApontamentoProducao } from '@/hooks/useApontamentosProducao';
|
||||
|
||||
interface ApontamentosResultsListProps {
|
||||
filteredApontamentos: ApontamentoProducao[];
|
||||
onRevert: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ApontamentosResultsList: React.FC<ApontamentosResultsListProps> = ({
|
||||
filteredApontamentos,
|
||||
onRevert
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
Histórico de Apontamentos
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{filteredApontamentos.length} {filteredApontamentos.length === 1 ? 'registro' : 'registros'}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredApontamentos.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CalendarIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Nenhum apontamento encontrado</p>
|
||||
<p className="text-sm">
|
||||
Tente ajustar os filtros para ver mais resultados
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredApontamentos.map((apontamento) => (
|
||||
<ApontamentosListItem
|
||||
key={apontamento.id}
|
||||
apontamento={apontamento}
|
||||
onRevert={onRevert}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
273
src/components/apontamento/historico/useApontamentosFilters.tsx
Normal file
273
src/components/apontamento/historico/useApontamentosFilters.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { ApontamentoProducao } from '@/hooks/useApontamentosProducao';
|
||||
|
||||
// Cache para lembrar os últimos filtros utilizados
|
||||
const historicCache = {
|
||||
lastOF: '',
|
||||
lastFase: '',
|
||||
lastProcesso: ''
|
||||
};
|
||||
|
||||
export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterOF, setFilterOF] = useState('');
|
||||
const [filterFase, setFilterFase] = useState('');
|
||||
const [filterProcesso, setFilterProcesso] = useState('');
|
||||
const [dataInicio, setDataInicio] = useState<Date | undefined>();
|
||||
const [dataFim, setDataFim] = useState<Date | undefined>();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Logs detalhados para debug
|
||||
useEffect(() => {
|
||||
console.log(`🔍 DADOS RECEBIDOS NO FILTRO: ${apontamentos.length} apontamentos`);
|
||||
|
||||
if (apontamentos.length > 0) {
|
||||
// Verificar OF B118 especificamente
|
||||
const b118Records = apontamentos.filter(apt => apt.of_number === 'B118');
|
||||
console.log(`🎯 OF B118 no filtro: ${b118Records.length} registros`);
|
||||
|
||||
// Mostrar alguns exemplos
|
||||
if (b118Records.length > 0) {
|
||||
console.log('🔍 Primeiros 5 registros B118:',
|
||||
b118Records.slice(0, 5).map(apt => ({
|
||||
id: apt.id,
|
||||
of: apt.of_number,
|
||||
peca: apt.peca?.marca,
|
||||
processo: apt.processo?.nome,
|
||||
data: apt.data_apontamento
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [apontamentos]);
|
||||
|
||||
// Carregar cache inicial apenas uma vez
|
||||
useEffect(() => {
|
||||
if (!initialized) {
|
||||
console.log(`📊 Inicializando filtros com ${apontamentos.length} apontamentos totais`);
|
||||
|
||||
const savedCache = localStorage.getItem('historico_apontamentos_cache');
|
||||
if (savedCache) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedCache);
|
||||
Object.assign(historicCache, parsed);
|
||||
console.log('📋 Cache carregado:', historicCache);
|
||||
} catch (error) {
|
||||
console.log('⚠️ Erro ao carregar cache do histórico:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// NÃO aplicar filtros do cache automaticamente - deixar limpo por padrão
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [apontamentos, initialized]);
|
||||
|
||||
// Salvar cache quando filtros mudam
|
||||
const updateCache = (updates: Partial<typeof historicCache>) => {
|
||||
Object.assign(historicCache, updates);
|
||||
localStorage.setItem('historico_apontamentos_cache', JSON.stringify(historicCache));
|
||||
};
|
||||
|
||||
// Obter valores únicos para filtros - SEMPRE todos os valores disponíveis
|
||||
const uniqueOFs = useMemo(() => {
|
||||
const ofs = apontamentos
|
||||
.map(apt => apt.of_number)
|
||||
.filter((of): of is string => {
|
||||
return typeof of === 'string' && of.trim() !== '';
|
||||
})
|
||||
.filter((of, index, array) => array.indexOf(of) === index)
|
||||
.sort();
|
||||
|
||||
console.log(`📋 OFs únicas encontradas: ${ofs.length}`, ofs.slice(0, 10), '...');
|
||||
return ofs;
|
||||
}, [apontamentos]);
|
||||
|
||||
const uniqueFases = useMemo(() => {
|
||||
// Se não há OF selecionada, mostrar todas as fases
|
||||
const apontamentosFiltrados = filterOF && filterOF !== ''
|
||||
? apontamentos.filter(apt => apt.of_number === filterOF)
|
||||
: apontamentos;
|
||||
|
||||
const fases = apontamentosFiltrados
|
||||
.map(apt => apt.peca?.etapa_fase)
|
||||
.filter((fase): fase is string => {
|
||||
return typeof fase === 'string' && fase.trim() !== '';
|
||||
})
|
||||
.filter((fase, index, array) => array.indexOf(fase) === index)
|
||||
.sort();
|
||||
|
||||
console.log(`📋 Fases únicas encontradas para OF "${filterOF}": ${fases.length}`, fases);
|
||||
return fases;
|
||||
}, [apontamentos, filterOF]);
|
||||
|
||||
const uniqueProcessos = useMemo(() => {
|
||||
// Aplicar filtros hierárquicos apenas se existirem
|
||||
let apontamentosFiltrados = apontamentos;
|
||||
|
||||
if (filterOF && filterOF !== '') {
|
||||
apontamentosFiltrados = apontamentosFiltrados.filter(apt => apt.of_number === filterOF);
|
||||
}
|
||||
|
||||
if (filterFase && filterFase !== '') {
|
||||
apontamentosFiltrados = apontamentosFiltrados.filter(apt => apt.peca?.etapa_fase === filterFase);
|
||||
}
|
||||
|
||||
const processos = apontamentosFiltrados
|
||||
.map(apt => apt.processo?.nome)
|
||||
.filter((processo): processo is string => {
|
||||
return typeof processo === 'string' && processo.trim() !== '';
|
||||
})
|
||||
.filter((processo, index, array) => array.indexOf(processo) === index)
|
||||
.sort();
|
||||
|
||||
console.log(`📋 Processos únicos encontrados para OF "${filterOF}" e Fase "${filterFase}": ${processos.length}`, processos);
|
||||
return processos;
|
||||
}, [apontamentos, filterOF, filterFase]);
|
||||
|
||||
// Filtrar apontamentos - LÓGICA SIMPLIFICADA E CORRIGIDA
|
||||
const filteredApontamentos = useMemo(() => {
|
||||
if (!initialized) {
|
||||
console.log('⏳ Aguardando inicialização...');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`🔍 INICIANDO FILTRAGEM DE ${apontamentos.length} APONTAMENTOS`);
|
||||
console.log('🔍 Filtros ativos:', {
|
||||
searchTerm: searchTerm || 'VAZIO',
|
||||
filterOF: filterOF || 'VAZIO',
|
||||
filterFase: filterFase || 'VAZIO',
|
||||
filterProcesso: filterProcesso || 'VAZIO',
|
||||
dataInicio: dataInicio ? 'SIM' : 'NÃO',
|
||||
dataFim: dataFim ? 'SIM' : 'NÃO'
|
||||
});
|
||||
|
||||
let resultado = [...apontamentos]; // Começar com TODOS os apontamentos
|
||||
|
||||
// Aplicar filtro de busca textual APENAS se preenchido
|
||||
if (searchTerm && searchTerm.trim() !== '') {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const beforeSearch = resultado.length;
|
||||
|
||||
resultado = resultado.filter(apt =>
|
||||
(apt.of_number && apt.of_number.toLowerCase().includes(searchLower)) ||
|
||||
(apt.peca?.marca && apt.peca.marca.toLowerCase().includes(searchLower)) ||
|
||||
(apt.componente?.marca_componente && apt.componente.marca_componente.toLowerCase().includes(searchLower))
|
||||
);
|
||||
|
||||
console.log(`🔎 Filtro texto "${searchTerm}": ${beforeSearch} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
// Aplicar filtro por OF APENAS se selecionado
|
||||
if (filterOF && filterOF !== '' && filterOF !== 'all') {
|
||||
const beforeOF = resultado.length;
|
||||
resultado = resultado.filter(apt => apt.of_number === filterOF);
|
||||
console.log(`🏷️ Filtro OF "${filterOF}": ${beforeOF} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
// Aplicar filtro por Fase APENAS se selecionado
|
||||
if (filterFase && filterFase !== '' && filterFase !== 'all') {
|
||||
const beforeFase = resultado.length;
|
||||
resultado = resultado.filter(apt => apt.peca?.etapa_fase === filterFase);
|
||||
console.log(`📋 Filtro Fase "${filterFase}": ${beforeFase} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
// Aplicar filtro por Processo APENAS se selecionado
|
||||
if (filterProcesso && filterProcesso !== '' && filterProcesso !== 'all') {
|
||||
const beforeProcesso = resultado.length;
|
||||
resultado = resultado.filter(apt => apt.processo?.nome === filterProcesso);
|
||||
console.log(`⚙️ Filtro Processo "${filterProcesso}": ${beforeProcesso} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
// Aplicar filtros de data APENAS se definidos
|
||||
if (dataInicio || dataFim) {
|
||||
const beforeData = resultado.length;
|
||||
|
||||
resultado = resultado.filter(apt => {
|
||||
const apontamentoDate = new Date(apt.data_apontamento);
|
||||
let matchesDataInicio = true;
|
||||
let matchesDataFim = true;
|
||||
|
||||
if (dataInicio) {
|
||||
matchesDataInicio = apontamentoDate >= dataInicio;
|
||||
}
|
||||
if (dataFim) {
|
||||
matchesDataFim = apontamentoDate <= dataFim;
|
||||
}
|
||||
|
||||
return matchesDataInicio && matchesDataFim;
|
||||
});
|
||||
|
||||
console.log(`📅 Filtro Data: ${beforeData} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
console.log(`✅ RESULTADO FINAL DA FILTRAGEM: ${resultado.length} de ${apontamentos.length} apontamentos`);
|
||||
|
||||
// Log específico para B118 se estiver sendo filtrado
|
||||
if (filterOF === 'B118') {
|
||||
console.log(`🎯 Resultado B118: ${resultado.length} registros filtrados`);
|
||||
}
|
||||
|
||||
return resultado;
|
||||
}, [apontamentos, searchTerm, filterOF, filterFase, filterProcesso, dataInicio, dataFim, initialized]);
|
||||
|
||||
const clearFilters = () => {
|
||||
console.log('🧹 Limpando todos os filtros');
|
||||
setSearchTerm('');
|
||||
setFilterOF('');
|
||||
setFilterFase('');
|
||||
setFilterProcesso('');
|
||||
setDataInicio(undefined);
|
||||
setDataFim(undefined);
|
||||
|
||||
// Limpar cache
|
||||
Object.assign(historicCache, { lastOF: '', lastFase: '', lastProcesso: '' });
|
||||
localStorage.removeItem('historico_apontamentos_cache');
|
||||
};
|
||||
|
||||
const handleOFChange = (value: string) => {
|
||||
const selectedValue = value === 'all' ? '' : value;
|
||||
console.log('🔄 Mudando OF para:', selectedValue);
|
||||
setFilterOF(selectedValue);
|
||||
// Limpar fase e processo quando OF muda
|
||||
setFilterFase('');
|
||||
setFilterProcesso('');
|
||||
updateCache({ lastOF: selectedValue, lastFase: '', lastProcesso: '' });
|
||||
};
|
||||
|
||||
const handleFaseChange = (value: string) => {
|
||||
const selectedValue = value === 'all' ? '' : value;
|
||||
console.log('🔄 Mudando Fase para:', selectedValue);
|
||||
setFilterFase(selectedValue);
|
||||
// Limpar processo quando fase muda
|
||||
setFilterProcesso('');
|
||||
updateCache({ lastFase: selectedValue, lastProcesso: '' });
|
||||
};
|
||||
|
||||
const handleProcessoChange = (value: string) => {
|
||||
const selectedValue = value === 'all' ? '' : value;
|
||||
console.log('🔄 Mudando Processo para:', selectedValue);
|
||||
setFilterProcesso(selectedValue);
|
||||
updateCache({ lastProcesso: selectedValue });
|
||||
};
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterOF,
|
||||
filterFase,
|
||||
filterProcesso,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
setDataInicio,
|
||||
setDataFim,
|
||||
uniqueOFs,
|
||||
uniqueFases,
|
||||
uniqueProcessos,
|
||||
filteredApontamentos,
|
||||
initialized,
|
||||
handleOFChange,
|
||||
handleFaseChange,
|
||||
handleProcessoChange,
|
||||
clearFilters
|
||||
};
|
||||
};
|
||||
215
src/components/atribuicoes/AtribuicaoEditModal.tsx
Normal file
215
src/components/atribuicoes/AtribuicaoEditModal.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useAtribuicoes } from '@/hooks/useAtribuicoes';
|
||||
|
||||
interface AtribuicaoEditModalProps {
|
||||
atribuicao: any;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AtribuicaoEditModal({ atribuicao, isOpen, onClose }: AtribuicaoEditModalProps) {
|
||||
const { updateAtribuicao, updateUserAbbrev, canManage } = useAtribuicoes();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
user_abbrev: '',
|
||||
attribution: '',
|
||||
frequency: '',
|
||||
method: '',
|
||||
client: '',
|
||||
importance: '',
|
||||
duration: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (atribuicao) {
|
||||
setFormData({
|
||||
user_abbrev: atribuicao.user_abbrev || '',
|
||||
attribution: atribuicao.attribution || '',
|
||||
frequency: atribuicao.frequency || '',
|
||||
method: atribuicao.method || '',
|
||||
client: atribuicao.client || '',
|
||||
importance: atribuicao.importance || '',
|
||||
duration: atribuicao.duration || ''
|
||||
});
|
||||
}
|
||||
}, [atribuicao]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!atribuicao) return;
|
||||
|
||||
// Atualizar identificação se mudou
|
||||
if (formData.user_abbrev !== atribuicao.user_abbrev) {
|
||||
await updateUserAbbrev(atribuicao.user_id, formData.user_abbrev);
|
||||
}
|
||||
|
||||
// Atualizar atribuição
|
||||
await updateAtribuicao(atribuicao.id, {
|
||||
attribution: formData.attribution,
|
||||
frequency: formData.frequency as any,
|
||||
method: formData.method as any,
|
||||
client: formData.client as any,
|
||||
importance: formData.importance as any,
|
||||
duration: formData.duration as any
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar Atribuição</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Usuário</Label>
|
||||
<Input value={atribuicao?.user_name || ''} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Identificação (3 letras)</Label>
|
||||
<Input
|
||||
value={formData.user_abbrev}
|
||||
onChange={(e) => setFormData({ ...formData, user_abbrev: e.target.value.toUpperCase().substring(0, 3) })}
|
||||
maxLength={3}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Atribuição</Label>
|
||||
<Textarea
|
||||
value={formData.attribution}
|
||||
onChange={(e) => setFormData({ ...formData, attribution: e.target.value })}
|
||||
maxLength={300}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Frequência</Label>
|
||||
<Select
|
||||
value={formData.frequency}
|
||||
onValueChange={(value) => setFormData({ ...formData, frequency: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horaria">Horária</SelectItem>
|
||||
<SelectItem value="2xdia">2x ao dia</SelectItem>
|
||||
<SelectItem value="diaria">Diária</SelectItem>
|
||||
<SelectItem value="2xsemanal">2x semanal</SelectItem>
|
||||
<SelectItem value="semanal">Semanal</SelectItem>
|
||||
<SelectItem value="quinzenal">Quinzenal</SelectItem>
|
||||
<SelectItem value="mensal">Mensal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Método</Label>
|
||||
<Select
|
||||
value={formData.method}
|
||||
onValueChange={(value) => setFormData({ ...formData, method: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="impresso">Impresso</SelectItem>
|
||||
<SelectItem value="sistema">Sistema</SelectItem>
|
||||
<SelectItem value="sistema-impresso">Sistema/Impresso</SelectItem>
|
||||
<SelectItem value="email">E-mail</SelectItem>
|
||||
<SelectItem value="verbal">Verbal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Cliente</Label>
|
||||
<Select
|
||||
value={formData.client}
|
||||
onValueChange={(value) => setFormData({ ...formData, client: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="interno">Interno</SelectItem>
|
||||
<SelectItem value="processo">Processo</SelectItem>
|
||||
<SelectItem value="obra">Obra</SelectItem>
|
||||
<SelectItem value="contrato">Contrato</SelectItem>
|
||||
<SelectItem value="geral">Geral</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Grau de Importância</Label>
|
||||
<Select
|
||||
value={formData.importance}
|
||||
onValueChange={(value) => setFormData({ ...formData, importance: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="essencial">Essencial</SelectItem>
|
||||
<SelectItem value="estrategico">Estratégico</SelectItem>
|
||||
<SelectItem value="suporte">Suporte</SelectItem>
|
||||
<SelectItem value="informativo">Informativo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Duração</Label>
|
||||
<Select
|
||||
value={formData.duration}
|
||||
onValueChange={(value) => setFormData({ ...formData, duration: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="<=1 hora">≤ 1 hora</SelectItem>
|
||||
<SelectItem value="2 horas">2 horas</SelectItem>
|
||||
<SelectItem value="4 horas">4 horas</SelectItem>
|
||||
<SelectItem value="8 horas">8 horas</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 justify-end pt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button onClick={handleSave}>
|
||||
Salvar Alterações
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
322
src/components/atribuicoes/AtribuicoesForm.tsx
Normal file
322
src/components/atribuicoes/AtribuicoesForm.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAtribuicoes } from '@/hooks/useAtribuicoes';
|
||||
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
interface AtribuicaoTemp {
|
||||
attribution: string;
|
||||
frequency: string;
|
||||
method: string;
|
||||
client: string;
|
||||
importance: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export function AtribuicoesForm() {
|
||||
const { users, createAtribuicao, canManage } = useAtribuicoes();
|
||||
const { isMobile } = useMobileResponsive();
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [userAbbrev, setUserAbbrev] = useState('');
|
||||
const [atribuicoes, setAtribuicoes] = useState<AtribuicaoTemp[]>([]);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
attribution: '',
|
||||
frequency: '',
|
||||
method: '',
|
||||
client: '',
|
||||
importance: '',
|
||||
duration: ''
|
||||
});
|
||||
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
|
||||
// Gerar abreviação automática quando usuário é selecionado
|
||||
useEffect(() => {
|
||||
if (selectedUser && !userAbbrev) {
|
||||
const names = selectedUser.full_name?.split(' ') || [];
|
||||
let abbrev = '';
|
||||
|
||||
if (names.length >= 2) {
|
||||
abbrev = names[0].charAt(0) + names[1].charAt(0) + names[0].charAt(1);
|
||||
} else if (names.length === 1) {
|
||||
abbrev = names[0].substring(0, 3);
|
||||
}
|
||||
|
||||
setUserAbbrev(abbrev.toUpperCase());
|
||||
}
|
||||
}, [selectedUser, userAbbrev]);
|
||||
|
||||
const handleAddAtribuicao = () => {
|
||||
if (!formData.attribution || !formData.frequency || !formData.method ||
|
||||
!formData.client || !formData.importance || !formData.duration) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAtribuicoes([...atribuicoes, { ...formData }]);
|
||||
setFormData({
|
||||
attribution: '',
|
||||
frequency: '',
|
||||
method: '',
|
||||
client: '',
|
||||
importance: '',
|
||||
duration: ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveAtribuicao = (index: number) => {
|
||||
setAtribuicoes(atribuicoes.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
if (!selectedUserId || !userAbbrev || atribuicoes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const attr of atribuicoes) {
|
||||
await createAtribuicao({
|
||||
user_id: selectedUserId,
|
||||
user_abbrev: userAbbrev,
|
||||
attribution: attr.attribution,
|
||||
frequency: attr.frequency as any,
|
||||
method: attr.method as any,
|
||||
client: attr.client as any,
|
||||
importance: attr.importance as any,
|
||||
duration: attr.duration as any
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setSelectedUserId('');
|
||||
setUserAbbrev('');
|
||||
setAtribuicoes([]);
|
||||
};
|
||||
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Formulário de Cadastro */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Adicionar Atribuição</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Seleção de Usuário */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Selecionar Usuário</Label>
|
||||
<Select value={selectedUserId} onValueChange={(value) => {
|
||||
setSelectedUserId(value);
|
||||
setUserAbbrev('');
|
||||
setAtribuicoes([]);
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="-- Selecione um usuário --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={user.profile_image_url} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{user.full_name?.charAt(0) || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{user.full_name} ({user.email})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedUser && (
|
||||
<div>
|
||||
<Label>Identificação (3 letras)</Label>
|
||||
<Input
|
||||
value={userAbbrev}
|
||||
onChange={(e) => setUserAbbrev(e.target.value.toUpperCase().substring(0, 3))}
|
||||
placeholder="Ex: JOS"
|
||||
maxLength={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedUserId && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="col-span-full">
|
||||
<Label>Atribuição</Label>
|
||||
<Textarea
|
||||
value={formData.attribution}
|
||||
onChange={(e) => setFormData({ ...formData, attribution: e.target.value })}
|
||||
placeholder="Descrição da atribuição..."
|
||||
maxLength={300}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Frequência</Label>
|
||||
<Select value={formData.frequency} onValueChange={(value) => setFormData({ ...formData, frequency: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horaria">Horária</SelectItem>
|
||||
<SelectItem value="2xdia">2x ao dia</SelectItem>
|
||||
<SelectItem value="diaria">Diária</SelectItem>
|
||||
<SelectItem value="2xsemanal">2x semanal</SelectItem>
|
||||
<SelectItem value="semanal">Semanal</SelectItem>
|
||||
<SelectItem value="quinzenal">Quinzenal</SelectItem>
|
||||
<SelectItem value="mensal">Mensal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Método</Label>
|
||||
<Select value={formData.method} onValueChange={(value) => setFormData({ ...formData, method: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="impresso">Impresso</SelectItem>
|
||||
<SelectItem value="sistema">Sistema</SelectItem>
|
||||
<SelectItem value="sistema-impresso">Sistema/Impresso</SelectItem>
|
||||
<SelectItem value="email">E-mail</SelectItem>
|
||||
<SelectItem value="verbal">Verbal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Cliente</Label>
|
||||
<Select value={formData.client} onValueChange={(value) => setFormData({ ...formData, client: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="interno">Interno</SelectItem>
|
||||
<SelectItem value="processo">Processo</SelectItem>
|
||||
<SelectItem value="obra">Obra</SelectItem>
|
||||
<SelectItem value="contrato">Contrato</SelectItem>
|
||||
<SelectItem value="geral">Geral</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Grau de Importância</Label>
|
||||
<Select value={formData.importance} onValueChange={(value) => setFormData({ ...formData, importance: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="essencial">Essencial</SelectItem>
|
||||
<SelectItem value="estrategico">Estratégico</SelectItem>
|
||||
<SelectItem value="suporte">Suporte</SelectItem>
|
||||
<SelectItem value="informativo">Informativo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Duração</Label>
|
||||
<Select value={formData.duration} onValueChange={(value) => setFormData({ ...formData, duration: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="<=1 hora">≤ 1 hora</SelectItem>
|
||||
<SelectItem value="2 horas">2 horas</SelectItem>
|
||||
<SelectItem value="4 horas">4 horas</SelectItem>
|
||||
<SelectItem value="8 horas">8 horas</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedUserId && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleAddAtribuicao} disabled={!formData.attribution || !formData.frequency}>
|
||||
Adicionar Atribuição
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Lista de Atribuições Temporárias */}
|
||||
{atribuicoes.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>
|
||||
Atribuições para {selectedUser?.full_name}
|
||||
</CardTitle>
|
||||
<Button onClick={handleSaveAll} variant="default">
|
||||
Salvar Todas Atribuições
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{atribuicoes.map((attr, index) => (
|
||||
<div key={index} className="flex justify-between items-start p-4 border rounded-lg">
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-2 text-sm">
|
||||
<div>
|
||||
<strong>Atribuição:</strong>
|
||||
<p className="break-words">{attr.attribution}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Frequência:</strong>
|
||||
<p>{attr.frequency}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Método:</strong>
|
||||
<p>{attr.method}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Cliente:</strong>
|
||||
<p>{attr.client}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Importância:</strong>
|
||||
<p>{attr.importance}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Duração:</strong>
|
||||
<p>{attr.duration}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAtribuicao(index)}
|
||||
className="text-red-600 hover:text-red-700 ml-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/components/atribuicoes/AtribuicoesPrintModal.tsx
Normal file
195
src/components/atribuicoes/AtribuicoesPrintModal.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { Atribuicao } from '@/hooks/useAtribuicoes';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
interface AtribuicoesPrintModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
atribuicoes: Atribuicao[];
|
||||
}
|
||||
|
||||
export function AtribuicoesPrintModal({ isOpen, onClose, atribuicoes }: AtribuicoesPrintModalProps) {
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
// Obter lista única de usuários
|
||||
const uniqueUsers = Array.from(
|
||||
new Map(atribuicoes.map(attr => [attr.user_id, { id: attr.user_id, name: attr.user_name }]))
|
||||
.values()
|
||||
);
|
||||
|
||||
const handlePrint = () => {
|
||||
if (!selectedUserId) return;
|
||||
|
||||
// Filtrar atribuições do usuário selecionado
|
||||
const userAtribuicoes = atribuicoes.filter(attr => attr.user_id === selectedUserId);
|
||||
const selectedUser = uniqueUsers.find(user => user.id === selectedUserId);
|
||||
|
||||
if (!selectedUser || userAtribuicoes.length === 0) return;
|
||||
|
||||
const getImportanceBadgeStyle = (importancia: string) => {
|
||||
switch (importancia) {
|
||||
case 'essencial':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'estrategico':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'suporte':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'informativo':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const printContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Relatório de Atribuições</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
color-adjust: exact;
|
||||
}
|
||||
.print-container {
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.no-print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 p-4 sm:p-6">
|
||||
<div class="print-container max-w-6xl mx-auto bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<header class="bg-gray-100 text-gray-800 p-4 md:p-5 border-b border-gray-200">
|
||||
<h1 class="text-xl md:text-2xl font-bold">Relatório de Atribuições de ${selectedUser.name}</h1>
|
||||
<p class="text-gray-600 text-sm">Lista de atribuições para o usuário selecionado.</p>
|
||||
</header>
|
||||
|
||||
<main class="p-4 md:p-6">
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Atribuição</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Frequência</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Método</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Cliente</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-bold text-gray-600 uppercase tracking-wider">Importância</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Duração</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
${userAtribuicoes.map((attr, index) => `
|
||||
<tr${index % 2 === 1 ? ' class="hover:bg-gray-50"' : ''}>
|
||||
<td class="px-4 py-3 align-top text-gray-700" style="white-space: normal;">${attr.attribution}</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.frequency}</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.method}</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.client}</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-center">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getImportanceBadgeStyle(attr.importance)}">
|
||||
${attr.importance}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.duration}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center text-xs text-gray-400 p-3 bg-gray-50 border-t">
|
||||
<p>Relatório gerado em: ${format(new Date(), 'dd/MM/yyyy', { locale: ptBR })}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
|
||||
printWindow.document.write(printContent);
|
||||
printWindow.document.close();
|
||||
|
||||
printWindow.onload = () => {
|
||||
setTimeout(() => {
|
||||
printWindow.print();
|
||||
printWindow.close();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Printer className="h-5 w-5" />
|
||||
Imprimir Atribuições
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Selecionar Usuário:</label>
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Escolha um usuário..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uniqueUsers.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePrint}
|
||||
disabled={!selectedUserId}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
323
src/components/atribuicoes/AtribuicoesTable.tsx
Normal file
323
src/components/atribuicoes/AtribuicoesTable.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { ResponsiveTable } from '@/components/responsive/ResponsiveTable';
|
||||
import { TableCell } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAtribuicoes } from '@/hooks/useAtribuicoes';
|
||||
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
|
||||
import { Edit, Trash2, Search, AlertCircle, Printer } from 'lucide-react';
|
||||
import { AtribuicaoEditModal } from './AtribuicaoEditModal';
|
||||
import { AtribuicoesPrintModal } from './AtribuicoesPrintModal';
|
||||
|
||||
export function AtribuicoesTable() {
|
||||
const { atribuicoes, canManage, deleteAtribuicao, loading } = useAtribuicoes();
|
||||
const { isMobile } = useMobileResponsive();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingAtribuicao, setEditingAtribuicao] = useState<any>(null);
|
||||
const [showPrintModal, setShowPrintModal] = useState(false);
|
||||
|
||||
const filteredAtribuicoes = atribuicoes.filter(attr =>
|
||||
attr.user_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
attr.attribution.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
attr.user_abbrev.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
console.log('📊 AtribuicoesTable render:', {
|
||||
atribuicoesTotal: atribuicoes.length,
|
||||
filteredCount: filteredAtribuicoes.length,
|
||||
loading,
|
||||
searchTerm
|
||||
});
|
||||
|
||||
const getImportanceBadgeColor = (importancia: string) => {
|
||||
switch (importancia) {
|
||||
case 'essencial': return 'destructive';
|
||||
case 'estrategico': return 'default';
|
||||
case 'suporte': return 'secondary';
|
||||
case 'informativo': return 'outline';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
const headers = [
|
||||
'Usuário',
|
||||
'ID',
|
||||
'Atribuição',
|
||||
'Frequência',
|
||||
'Método',
|
||||
'Cliente',
|
||||
'Importância',
|
||||
'Duração',
|
||||
...(canManage ? ['Ações'] : [])
|
||||
];
|
||||
|
||||
const renderRow = (attr: any, index: number) => (
|
||||
<>
|
||||
{/* Coluna Usuário - 18% da largura */}
|
||||
<TableCell className="w-[18%] min-w-[180px] p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 flex-shrink-0">
|
||||
<AvatarImage src={attr.user_photo} />
|
||||
<AvatarFallback className="text-sm font-medium">
|
||||
{attr.user_name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{attr.user_name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{attr.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna ID - 4% da largura */}
|
||||
<TableCell className="w-[4%] min-w-[50px] p-3">
|
||||
<Badge variant="outline" className="text-xs font-medium">{attr.user_abbrev}</Badge>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Atribuição - 35% da largura com fonte menor */}
|
||||
<TableCell className="w-[35%] min-w-[350px] p-3">
|
||||
<div className="assignment-cell text-xs leading-relaxed max-h-[4.5rem] overflow-hidden">
|
||||
<p className="break-words whitespace-normal line-clamp-4" title={attr.attribution}>
|
||||
{attr.attribution}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Frequência - 7% da largura, centralizada */}
|
||||
<TableCell className="w-[7%] min-w-[70px] p-3 text-center">
|
||||
<span className="text-sm text-muted-foreground">{attr.frequency}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Método - 7% da largura */}
|
||||
<TableCell className="w-[7%] min-w-[70px] p-3">
|
||||
<span className="text-sm text-muted-foreground">{attr.method}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Cliente - 7% da largura */}
|
||||
<TableCell className="w-[7%] min-w-[70px] p-3">
|
||||
<span className="text-sm text-muted-foreground">{attr.client}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Importância - 9% da largura, centralizada */}
|
||||
<TableCell className="w-[9%] min-w-[90px] p-3 text-center">
|
||||
<Badge
|
||||
variant={getImportanceBadgeColor(attr.importance)}
|
||||
className="importance-tag text-xs px-2 py-1 rounded-full whitespace-nowrap"
|
||||
>
|
||||
{attr.importance}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Duração - 7% da largura, centralizada */}
|
||||
<TableCell className="w-[7%] min-w-[70px] p-3 text-center">
|
||||
<span className="text-sm text-muted-foreground">{attr.duration}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Ações - 6% da largura, centralizada */}
|
||||
{canManage && (
|
||||
<TableCell className="w-[6%] min-w-[80px] p-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingAtribuicao(attr)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Tem certeza que deseja excluir esta atribuição?')) {
|
||||
deleteAtribuicao(attr.id);
|
||||
}
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderMobileCard = (attr: any) => (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={attr.user_photo} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{attr.user_name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{attr.user_name}</p>
|
||||
<Badge variant="outline" className="text-xs">{attr.user_abbrev}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getImportanceBadgeColor(attr.importance)}>
|
||||
{attr.importance}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium">Atribuição:</p>
|
||||
<p className="text-sm text-muted-foreground">{attr.attribution}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="font-medium">Frequência:</span> {attr.frequency}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Método:</span> {attr.method}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Cliente:</span> {attr.client}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Duração:</span> {attr.duration}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingAtribuicao(attr)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Tem certeza que deseja excluir esta atribuição?')) {
|
||||
deleteAtribuicao(attr.id);
|
||||
}
|
||||
}}
|
||||
className="flex-1 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{canManage ? 'Todas as Atribuições' : 'Minhas Atribuições'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<span className="ml-2">Carregando atribuições...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<CardTitle>
|
||||
{canManage ? 'Todas as Atribuições' : 'Minhas Atribuições'}
|
||||
{atribuicoes.length > 0 && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({atribuicoes.length} {atribuicoes.length === 1 ? 'atribuição' : 'atribuições'})
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
{atribuicoes.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setShowPrintModal(true)}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Imprimir
|
||||
</Button>
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar por usuário, atribuição ou ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 w-full sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{atribuicoes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Nenhuma atribuição encontrada</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Ainda não há atribuições cadastradas no sistema.
|
||||
</p>
|
||||
{canManage && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use a aba "Cadastro" para criar a primeira atribuição.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<ResponsiveTable
|
||||
headers={headers}
|
||||
data={filteredAtribuicoes}
|
||||
renderRow={renderRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
emptyMessage={
|
||||
filteredAtribuicoes.length === 0 && searchTerm
|
||||
? `Nenhuma atribuição encontrada para "${searchTerm}"`
|
||||
: "Nenhuma atribuição encontrada"
|
||||
}
|
||||
loading={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{editingAtribuicao && (
|
||||
<AtribuicaoEditModal
|
||||
atribuicao={editingAtribuicao}
|
||||
isOpen={!!editingAtribuicao}
|
||||
onClose={() => setEditingAtribuicao(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AtribuicoesPrintModal
|
||||
isOpen={showPrintModal}
|
||||
onClose={() => setShowPrintModal(false)}
|
||||
atribuicoes={filteredAtribuicoes}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
283
src/components/auditoria/InconsistenciaDetalhesModal.tsx
Normal file
283
src/components/auditoria/InconsistenciaDetalhesModal.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AlertTriangle, Calendar, Hash, TrendingUp, ExternalLink } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface InconsistenciaDetalhesModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
inconsistencia: any;
|
||||
}
|
||||
|
||||
export const InconsistenciaDetalhesModal: React.FC<InconsistenciaDetalhesModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
inconsistencia
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!inconsistencia) return null;
|
||||
|
||||
const handleNavegar = (rota: string) => {
|
||||
navigate(rota);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const renderDetalhes = () => {
|
||||
switch (inconsistencia.tipo) {
|
||||
case 'Processo Pulado':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Processos Pulados:</h4>
|
||||
<div className="space-y-1">
|
||||
{inconsistencia.detalhes.processosPulados.map((processo: string, index: number) => (
|
||||
<Badge key={index} variant="destructive">{processo}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inconsistencia.detalhes.apontamentos.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Apontamentos Existentes:</h4>
|
||||
<div className="space-y-2">
|
||||
{inconsistencia.detalhes.apontamentos.map((apt: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-muted rounded">
|
||||
<span>{apt.processo}</span>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(apt.data).toLocaleDateString()}
|
||||
<Hash className="h-3 w-3" />
|
||||
{apt.quantidade}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Quantidade Excedente':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Quantidade Cadastrada</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{inconsistencia.detalhes.quantidadeCadastrada}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Quantidade Apontada</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{inconsistencia.detalhes.quantidadeApontada}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-amber-600" />
|
||||
<span className="font-medium text-amber-800">
|
||||
Diferença: +{inconsistencia.detalhes.diferenca} peças
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Processo: {inconsistencia.detalhes.processo}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Expedição sem Processos':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||
<span className="font-medium text-red-800">
|
||||
Peça expedida sem processos de produção
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inconsistencia.detalhes.romaneios.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Romaneios de Expedição:</h4>
|
||||
<div className="space-y-2">
|
||||
{inconsistencia.detalhes.romaneios.map((romaneio: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-muted rounded">
|
||||
<span>Quantidade: {romaneio.quantidade_expedida}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Peso: {romaneio.peso_total}kg
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Múltiplas Prioridades':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Prioridades Encontradas:</h4>
|
||||
<div className="space-y-1">
|
||||
{inconsistencia.detalhes.prioridades.map((prioridade: string, index: number) => (
|
||||
<Badge key={index} variant="outline">{prioridade}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Recomenda-se manter a peça em apenas uma prioridade para evitar conflitos de programação.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
Detalhes não disponíveis para este tipo de inconsistência.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getAcoesSugeridas = () => {
|
||||
const acoes: Array<{ label: string; rota: string; descricao: string }> = [];
|
||||
|
||||
switch (inconsistencia.tipo) {
|
||||
case 'Processo Pulado':
|
||||
acoes.push({
|
||||
label: 'Ir para Apontamento de Produção',
|
||||
rota: '/apontamento-producao',
|
||||
descricao: 'Adicionar apontamentos dos processos faltantes'
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Quantidade Excedente':
|
||||
acoes.push({
|
||||
label: 'Ir para Cadastro de Peças',
|
||||
rota: '/seletor-of',
|
||||
descricao: 'Corrigir quantidade no cadastro da peça'
|
||||
});
|
||||
acoes.push({
|
||||
label: 'Ver Histórico de Apontamentos',
|
||||
rota: '/apontamento-producao',
|
||||
descricao: 'Verificar apontamentos duplicados'
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Expedição sem Processos':
|
||||
acoes.push({
|
||||
label: 'Ir para Apontamento de Produção',
|
||||
rota: '/apontamento-producao',
|
||||
descricao: 'Adicionar apontamentos de produção'
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Múltiplas Prioridades':
|
||||
acoes.push({
|
||||
label: 'Ir para Prioridades de Fabricação',
|
||||
rota: '/prioridades-fabricacao',
|
||||
descricao: 'Remover peça das prioridades desnecessárias'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return acoes;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Detalhes da Inconsistência
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Informações Básicas */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Informações da Peça</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium">Marca:</span>
|
||||
<p className="font-mono">{inconsistencia.marca}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Tipo:</span>
|
||||
<Badge variant="secondary">{inconsistencia.tipo}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Descrição:</span>
|
||||
<p className="text-sm text-muted-foreground">{inconsistencia.descricao}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detalhes Específicos */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Detalhes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{renderDetalhes()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ações Sugeridas */}
|
||||
{inconsistencia.acaoSugerida && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Ação Sugerida</CardTitle>
|
||||
<CardDescription>{inconsistencia.acaoSugerida}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{getAcoesSugeridas().map((acao, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNavegar(acao.rota)}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{acao.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
95
src/components/auth/ForgotPasswordModal.tsx
Normal file
95
src/components/auth/ForgotPasswordModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { usePasswordReset } from '@/hooks/usePasswordReset';
|
||||
|
||||
interface ForgotPasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ForgotPasswordModal = ({ isOpen, onClose }: ForgotPasswordModalProps) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const { requestPasswordReset, isRequesting } = usePasswordReset();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação básica de e-mail
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestPasswordReset({ email });
|
||||
setEmail('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setEmail('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Esqueci a Senha
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Digite seu e-mail cadastrado para receber as instruções de redefinição de senha.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reset-email">E-mail</Label>
|
||||
<Input
|
||||
id="reset-email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isRequesting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isRequesting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isRequesting || !email}
|
||||
>
|
||||
{isRequesting ? 'Enviando...' : 'Enviar E-mail'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
67
src/components/auth/LogoutButton.tsx
Normal file
67
src/components/auth/LogoutButton.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface LogoutButtonProps {
|
||||
variant?: 'default' | 'ghost' | 'outline' | 'secondary';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
showText?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LogoutButton({
|
||||
variant = 'ghost',
|
||||
size = 'default',
|
||||
showText = true,
|
||||
className = ''
|
||||
}: LogoutButtonProps) {
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={`text-red-500 hover:text-red-600 hover:bg-red-50 ${className}`}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{showText && size !== 'icon' && <span className="ml-2">Sair do Sistema</span>}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar Saída</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja sair do sistema? Você precisará fazer login novamente para acessar suas informações.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleLogout}
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
Sair do Sistema
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
217
src/components/auth/PasswordResetForm.tsx
Normal file
217
src/components/auth/PasswordResetForm.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import { Eye, EyeOff, Lock } from 'lucide-react';
|
||||
|
||||
export const PasswordResetForm = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { updatePassword } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Password strength validation
|
||||
const validatePassword = (password: string) => {
|
||||
const minLength = password.length >= 8;
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumbers = /\d/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
|
||||
const score = [minLength, hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar].filter(Boolean).length;
|
||||
|
||||
return {
|
||||
score,
|
||||
minLength,
|
||||
hasUpperCase,
|
||||
hasLowerCase,
|
||||
hasNumbers,
|
||||
hasSpecialChar,
|
||||
isValid: score >= 4 && minLength
|
||||
};
|
||||
};
|
||||
|
||||
const passwordStrength = validatePassword(password);
|
||||
|
||||
const getPasswordStrengthColor = (score: number) => {
|
||||
if (score <= 2) return 'bg-red-500';
|
||||
if (score <= 3) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
const getPasswordStrengthText = (score: number) => {
|
||||
if (score <= 2) return 'Fraca';
|
||||
if (score <= 3) return 'Média';
|
||||
return 'Forte';
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password || !confirmPassword) {
|
||||
toast.error('Por favor, preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordStrength.isValid) {
|
||||
toast.error('A senha deve ter pelo menos 8 caracteres e incluir maiúsculas, minúsculas, números e símbolos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('As senhas não coincidem');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
console.log('🔄 Iniciando redefinição de senha...');
|
||||
const { error } = await updatePassword(password);
|
||||
|
||||
if (error) {
|
||||
console.error('❌ Erro ao redefinir senha:', error);
|
||||
|
||||
// Tratamento específico de erros
|
||||
let errorMessage = 'Erro ao redefinir senha. Tente novamente.';
|
||||
|
||||
if (error.message?.includes('session_not_found')) {
|
||||
errorMessage = 'Sessão expirada. Solicite um novo link de redefinição.';
|
||||
} else if (error.message?.includes('same_password')) {
|
||||
errorMessage = 'A nova senha deve ser diferente da anterior.';
|
||||
} else if (error.message?.includes('password_invalid')) {
|
||||
errorMessage = 'Senha inválida. Verifique os critérios de segurança.';
|
||||
} else if (error.message) {
|
||||
errorMessage = `Erro: ${error.message}`;
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
console.log('✅ Senha redefinida com sucesso!');
|
||||
toast.success('Senha redefinida com sucesso! Redirecionando...');
|
||||
|
||||
// Aguardar um pouco antes de redirecionar para mostrar a mensagem
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erro inesperado:', error);
|
||||
toast.error('Erro inesperado ao redefinir senha. Tente novamente.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-background/80 backdrop-blur-sm w-full max-w-md">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<Lock className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Redefinir Senha</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Digite sua nova senha abaixo
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password" className="text-sm font-medium">
|
||||
Nova Senha
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="new-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 pr-10 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{password && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getPasswordStrengthColor(passwordStrength.score)}`}
|
||||
style={{ width: `${(passwordStrength.score / 5) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getPasswordStrengthText(passwordStrength.score)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.minLength ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Mínimo 8 caracteres
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.hasUpperCase ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Letra maiúscula
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.hasNumbers ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Número
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.hasSpecialChar ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Símbolo especial
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" className="text-sm font-medium">
|
||||
Confirmar Nova Senha
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Confirme sua nova senha"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium text-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Redefinindo...' : 'Redefinir Senha'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
350
src/components/catalogos/CatalogoModal.tsx
Normal file
350
src/components/catalogos/CatalogoModal.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X, Sparkles, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
import { FileUploadSection } from './FileUploadSection';
|
||||
|
||||
interface CatalogoModalProps {
|
||||
catalogo?: Catalogo | null;
|
||||
onSave: (data: any) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const categorias = [
|
||||
'Aço',
|
||||
'Adesivo',
|
||||
'Tintas',
|
||||
'Parafusos',
|
||||
'Soldas',
|
||||
'Estruturas',
|
||||
'Materiais'
|
||||
];
|
||||
|
||||
const disciplinas = [
|
||||
'Engenharia Civil',
|
||||
'Engenharia Mecânica',
|
||||
'Arquitetura',
|
||||
'Construção',
|
||||
'Fabricação',
|
||||
'Montagem'
|
||||
];
|
||||
|
||||
export function CatalogoModal({ catalogo, onSave, onClose }: CatalogoModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
titulo: '',
|
||||
categoria: '',
|
||||
disciplina: '',
|
||||
conteudo: '',
|
||||
numero_paginas: '',
|
||||
palavras_chave: [] as string[],
|
||||
arquivo_urls: [] as string[]
|
||||
});
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (catalogo) {
|
||||
setFormData({
|
||||
titulo: catalogo.titulo,
|
||||
categoria: catalogo.categoria,
|
||||
disciplina: catalogo.disciplina,
|
||||
conteudo: catalogo.conteudo,
|
||||
numero_paginas: catalogo.numero_paginas?.toString() || '',
|
||||
palavras_chave: catalogo.palavras_chave || [],
|
||||
arquivo_urls: (catalogo as any).arquivo_urls || []
|
||||
});
|
||||
}
|
||||
}, [catalogo]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const addKeyword = () => {
|
||||
if (newKeyword.trim() && !formData.palavras_chave.includes(newKeyword.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
palavras_chave: [...prev.palavras_chave, newKeyword.trim()]
|
||||
}));
|
||||
setNewKeyword('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeKeyword = (keyword: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
palavras_chave: prev.palavras_chave.filter(k => k !== keyword)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFilesAnalyzed = (analysisResult: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
titulo: analysisResult.titulo || prev.titulo,
|
||||
categoria: analysisResult.categoria || prev.categoria,
|
||||
disciplina: analysisResult.disciplina || prev.disciplina,
|
||||
conteudo: analysisResult.conteudo || prev.conteudo,
|
||||
palavras_chave: [...new Set([...prev.palavras_chave, ...(analysisResult.palavras_chave || [])])]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFilesUploaded = (urls: string[]) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
arquivo_urls: [...new Set([...prev.arquivo_urls, ...urls])]
|
||||
}));
|
||||
};
|
||||
|
||||
const analyzeWithAI = async () => {
|
||||
if (!formData.conteudo.trim()) {
|
||||
toast.error('Por favor, cole o conteúdo do documento antes de analisar');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
// Simulação da análise com IA (seria integração real com Gemini)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Análise simulada baseada no conteúdo
|
||||
const content = formData.conteudo.toLowerCase();
|
||||
|
||||
let suggestedCategoria = '';
|
||||
let suggestedDisciplina = '';
|
||||
let suggestedTitulo = '';
|
||||
let suggestedKeywords: string[] = [];
|
||||
|
||||
// Lógica simples de categorização baseada em palavras-chave
|
||||
if (content.includes('aço') || content.includes('steel')) {
|
||||
suggestedCategoria = 'Aço';
|
||||
suggestedKeywords.push('aço', 'steel');
|
||||
} else if (content.includes('tinta') || content.includes('pintura')) {
|
||||
suggestedCategoria = 'Tintas';
|
||||
suggestedKeywords.push('tinta', 'pintura');
|
||||
} else if (content.includes('parafuso') || content.includes('fixação')) {
|
||||
suggestedCategoria = 'Parafusos';
|
||||
suggestedKeywords.push('parafuso', 'fixação');
|
||||
} else {
|
||||
suggestedCategoria = 'Materiais';
|
||||
}
|
||||
|
||||
if (content.includes('estrutura') || content.includes('construção')) {
|
||||
suggestedDisciplina = 'Engenharia Civil';
|
||||
suggestedKeywords.push('estrutura', 'construção');
|
||||
} else if (content.includes('mecânica') || content.includes('fabricação')) {
|
||||
suggestedDisciplina = 'Engenharia Mecânica';
|
||||
suggestedKeywords.push('mecânica', 'fabricação');
|
||||
} else {
|
||||
suggestedDisciplina = 'Construção';
|
||||
}
|
||||
|
||||
// Extrair título das primeiras linhas
|
||||
const firstLine = formData.conteudo.split('\n')[0];
|
||||
if (firstLine && firstLine.length > 10 && firstLine.length < 100) {
|
||||
suggestedTitulo = firstLine.trim();
|
||||
}
|
||||
|
||||
// Adicionar palavras-chave técnicas comuns
|
||||
if (content.includes('resistência')) suggestedKeywords.push('resistência');
|
||||
if (content.includes('qualidade')) suggestedKeywords.push('qualidade');
|
||||
if (content.includes('especificação')) suggestedKeywords.push('especificação');
|
||||
if (content.includes('norma')) suggestedKeywords.push('norma');
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
titulo: suggestedTitulo || prev.titulo,
|
||||
categoria: suggestedCategoria || prev.categoria,
|
||||
disciplina: suggestedDisciplina || prev.disciplina,
|
||||
palavras_chave: [...new Set([...prev.palavras_chave, ...suggestedKeywords])]
|
||||
}));
|
||||
|
||||
toast.success('Análise concluída! Os campos foram preenchidos automaticamente.');
|
||||
} catch (error) {
|
||||
console.error('Erro na análise:', error);
|
||||
toast.error('Erro ao analisar documento com IA');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.titulo || !formData.categoria || !formData.disciplina || !formData.conteudo) {
|
||||
toast.error('Por favor, preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
...formData,
|
||||
numero_paginas: formData.numero_paginas ? parseInt(formData.numero_paginas) : null
|
||||
};
|
||||
|
||||
await onSave(submitData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{catalogo ? 'Editar Documento' : 'Novo Documento'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Seção de Upload de Arquivos - apenas para novos documentos */}
|
||||
{!catalogo && (
|
||||
<div className="p-4 bg-slate-50 rounded-lg border">
|
||||
<FileUploadSection
|
||||
onFilesAnalyzed={handleFilesAnalyzed}
|
||||
onFilesUploaded={handleFilesUploaded}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titulo">Título *</Label>
|
||||
<Input
|
||||
id="titulo"
|
||||
value={formData.titulo}
|
||||
onChange={(e) => handleInputChange('titulo', e.target.value)}
|
||||
placeholder="Digite o título do documento"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numero_paginas">Número de Páginas</Label>
|
||||
<Input
|
||||
id="numero_paginas"
|
||||
type="number"
|
||||
value={formData.numero_paginas}
|
||||
onChange={(e) => handleInputChange('numero_paginas', e.target.value)}
|
||||
placeholder="Ex: 25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoria">Categoria *</Label>
|
||||
<Select
|
||||
value={formData.categoria}
|
||||
onValueChange={(value) => handleInputChange('categoria', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categorias.map((categoria) => (
|
||||
<SelectItem key={categoria} value={categoria}>
|
||||
{categoria}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disciplina">Disciplina *</Label>
|
||||
<Select
|
||||
value={formData.disciplina}
|
||||
onValueChange={(value) => handleInputChange('disciplina', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma disciplina" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{disciplinas.map((disciplina) => (
|
||||
<SelectItem key={disciplina} value={disciplina}>
|
||||
{disciplina}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conteudo">Conteúdo do Documento *</Label>
|
||||
<Textarea
|
||||
id="conteudo"
|
||||
value={formData.conteudo}
|
||||
onChange={(e) => handleInputChange('conteudo', e.target.value)}
|
||||
placeholder="Cole o conteúdo do documento aqui..."
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
{!catalogo && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={analyzeWithAI}
|
||||
disabled={isAnalyzing || !formData.conteudo.trim()}
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isAnalyzing ? 'Analisando...' : 'Analisar com IA'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Palavras-chave</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder="Adicionar palavra-chave"
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addKeyword())}
|
||||
/>
|
||||
<Button type="button" onClick={addKeyword} variant="outline">
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.palavras_chave.map((keyword, index) => (
|
||||
<Badge key={index} variant="secondary" className="flex items-center gap-1">
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeKeyword(keyword)}
|
||||
className="ml-1 hover:text-red-400"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" className="bg-green-600 hover:bg-green-700">
|
||||
{catalogo ? 'Atualizar' : 'Salvar'} Documento
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
168
src/components/catalogos/CatalogoPreviews.tsx
Normal file
168
src/components/catalogos/CatalogoPreviews.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Edit, Trash2, FileText, Calendar, Hash, Eye } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
|
||||
interface CatalogoPreviewsProps {
|
||||
catalogos: Catalogo[];
|
||||
onEdit: (catalogo: Catalogo) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onView: (catalogo: Catalogo) => void;
|
||||
canModify: boolean;
|
||||
}
|
||||
|
||||
export function CatalogoPreviews({ catalogos, onEdit, onDelete, onView, canModify }: CatalogoPreviewsProps) {
|
||||
if (catalogos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
||||
<p>Nenhum documento encontrado</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{catalogos.map((catalogo) => (
|
||||
<Card
|
||||
key={catalogo.id}
|
||||
className="bg-white border-slate-300 shadow-sm hover:shadow-md transition-colors cursor-pointer
|
||||
dark:bg-slate-800/30 dark:border-slate-700 dark:hover:bg-slate-800/50"
|
||||
onClick={() => onView(catalogo)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<CardTitle className="text-slate-900 dark:text-white text-lg line-clamp-2" title={catalogo.titulo}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 flex-shrink-0" />
|
||||
{catalogo.titulo}
|
||||
</div>
|
||||
</CardTitle>
|
||||
{canModify && (
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onView(catalogo);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-green-100 dark:hover:bg-green-600/20"
|
||||
title="Visualizar documento"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(catalogo);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-blue-100 dark:hover:bg-blue-600/20"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-8 w-8 p-0 hover:bg-red-100 dark:hover:bg-red-600/20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir o documento "{catalogo.titulo}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(catalogo.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-transparent">
|
||||
{catalogo.categoria}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{catalogo.disciplina}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-slate-600 dark:text-slate-400 text-sm line-clamp-3">
|
||||
{catalogo.conteudo.substring(0, 150)}...
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Hash className="h-4 w-4" />
|
||||
<span>{catalogo.numero_paginas || 'N/A'} pág.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{format(new Date(catalogo.created_at), 'dd/MM/yy', { locale: ptBR })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{catalogo.palavras_chave && catalogo.palavras_chave.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catalogo.palavras_chave.slice(0, 4).map((palavra, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
{palavra}
|
||||
</Badge>
|
||||
))}
|
||||
{catalogo.palavras_chave.length > 4 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
+{catalogo.palavras_chave.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalogo.arquivo_urls && catalogo.arquivo_urls.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>{catalogo.arquivo_urls.length} documento(s) disponível(eis)</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/catalogos/CatalogosFilters.tsx
Normal file
126
src/components/catalogos/CatalogosFilters.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search, X, Grid, List } from 'lucide-react';
|
||||
|
||||
interface CatalogosFiltersProps {
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
categoriaFilter: string;
|
||||
setCategoriaFilter: (value: string) => void;
|
||||
disciplinaFilter: string;
|
||||
setDisciplinaFilter: (value: string) => void;
|
||||
onClearFilters: () => void;
|
||||
viewMode: 'table' | 'preview';
|
||||
onViewModeChange: (mode: 'table' | 'preview') => void;
|
||||
}
|
||||
|
||||
const categorias = [
|
||||
'Aço',
|
||||
'Adesivo',
|
||||
'Tintas',
|
||||
'Parafusos',
|
||||
'Soldas',
|
||||
'Estruturas',
|
||||
'Materiais'
|
||||
];
|
||||
|
||||
const disciplinas = [
|
||||
'Engenharia Civil',
|
||||
'Engenharia Mecânica',
|
||||
'Arquitetura',
|
||||
'Construção',
|
||||
'Fabricação',
|
||||
'Montagem'
|
||||
];
|
||||
|
||||
export function CatalogosFilters({
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
categoriaFilter,
|
||||
setCategoriaFilter,
|
||||
disciplinaFilter,
|
||||
setDisciplinaFilter,
|
||||
onClearFilters,
|
||||
viewMode,
|
||||
onViewModeChange
|
||||
}: CatalogosFiltersProps) {
|
||||
const hasActiveFilters = searchTerm || (categoriaFilter && categoriaFilter !== 'all') || (disciplinaFilter && disciplinaFilter !== 'all');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Buscar por título, conteúdo, palavras-chave..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={categoriaFilter} onValueChange={setCategoriaFilter}>
|
||||
<SelectTrigger className="w-full md:w-[180px] h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white border-slate-300 dark:bg-slate-700 dark:border-slate-600">
|
||||
<SelectItem value="all" className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">Todas as Categorias</SelectItem>
|
||||
{categorias.map((categoria) => (
|
||||
<SelectItem key={categoria} value={categoria} className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">
|
||||
{categoria}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={disciplinaFilter} onValueChange={setDisciplinaFilter}>
|
||||
<SelectTrigger className="w-full md:w-[180px] h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
<SelectValue placeholder="Disciplina" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white border-slate-300 dark:bg-slate-700 dark:border-slate-600">
|
||||
<SelectItem value="all" className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">Todas as Disciplinas</SelectItem>
|
||||
{disciplinas.map((disciplina) => (
|
||||
<SelectItem key={disciplina} value={disciplina} className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">
|
||||
{disciplina}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'table' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('table')}
|
||||
className={viewMode === 'table' ? '' : 'bg-slate-50 border-slate-300 text-slate-900 hover:bg-slate-100 dark:bg-transparent dark:border-slate-600 dark:text-white dark:hover:bg-slate-700'}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'preview' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('preview')}
|
||||
className={viewMode === 'preview' ? '' : 'bg-slate-50 border-slate-300 text-slate-900 hover:bg-slate-100 dark:bg-transparent dark:border-slate-600 dark:text-white dark:hover:bg-slate-700'}
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClearFilters}
|
||||
size="sm"
|
||||
className="h-10 bg-white border-slate-300 text-slate-700 hover:bg-slate-100 dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/components/catalogos/CatalogosTable.tsx
Normal file
160
src/components/catalogos/CatalogosTable.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit, Trash2, Eye, FileText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
|
||||
interface CatalogosTableProps {
|
||||
catalogos: Catalogo[];
|
||||
onEdit: (catalogo: Catalogo) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onView: (catalogo: Catalogo) => void;
|
||||
canModify: boolean;
|
||||
}
|
||||
|
||||
export function CatalogosTable({ catalogos, onEdit, onDelete, onView, canModify }: CatalogosTableProps) {
|
||||
if (catalogos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Eye className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
||||
<p>Nenhum documento encontrado</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table disableOverflow>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 dark:bg-transparent">
|
||||
<TableHead className="text-slate-700 dark:text-white">Título</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Categoria</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Disciplina</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Páginas</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Data de Inclusão</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Palavras-chave</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{catalogos.map((catalogo) => (
|
||||
<TableRow key={catalogo.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
|
||||
<TableCell className="text-slate-900 dark:text-white font-medium max-w-[200px]">
|
||||
<div
|
||||
className="truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title={catalogo.titulo}
|
||||
onClick={() => onView(catalogo)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 flex-shrink-0" />
|
||||
{catalogo.titulo}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-transparent">
|
||||
{catalogo.categoria}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{catalogo.disciplina}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-700 dark:text-slate-300">
|
||||
{catalogo.numero_paginas || 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-700 dark:text-slate-300">
|
||||
{format(new Date(catalogo.created_at), 'dd/MM/yyyy', { locale: ptBR })}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catalogo.palavras_chave?.slice(0, 3).map((palavra, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
{palavra}
|
||||
</Badge>
|
||||
))}
|
||||
{catalogo.palavras_chave && catalogo.palavras_chave.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
+{catalogo.palavras_chave.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onView(catalogo)}
|
||||
className="h-8 w-8 p-0 hover:bg-green-100 dark:hover:bg-green-600/20"
|
||||
title="Visualizar documento"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{canModify && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(catalogo)}
|
||||
className="h-8 w-8 p-0 hover:bg-blue-100 dark:hover:bg-blue-600/20"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-red-100 dark:hover:bg-red-600/20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir o documento "{catalogo.titulo}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(catalogo.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
src/components/catalogos/DocumentViewer.tsx
Normal file
276
src/components/catalogos/DocumentViewer.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
|
||||
// Top-level imports
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X, Download, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, FileText, Image, ExternalLink, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
|
||||
interface DocumentViewerProps {
|
||||
catalogo: Catalogo | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// DocumentViewer component
|
||||
export function DocumentViewer({ catalogo, isOpen, onClose }: DocumentViewerProps) {
|
||||
// TODOS OS HOOKS DEVEM ESTAR NO TOPO - ANTES DE QUALQUER RETURN CONDICIONAL
|
||||
const [currentDocIndex, setCurrentDocIndex] = useState(0);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// useEffect também deve estar no topo
|
||||
useEffect(() => {
|
||||
const onFullScreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFullScreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onFullScreenChange);
|
||||
}, []);
|
||||
|
||||
// Funções de handler
|
||||
const handlePrevious = () => {
|
||||
if (currentDocIndex > 0) {
|
||||
setCurrentDocIndex(currentDocIndex - 1);
|
||||
setZoomLevel(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (catalogo?.arquivo_urls && currentDocIndex < catalogo.arquivo_urls.length - 1) {
|
||||
setCurrentDocIndex(currentDocIndex + 1);
|
||||
setZoomLevel(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (zoomLevel < 3) {
|
||||
setZoomLevel(zoomLevel + 0.25);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (zoomLevel > 0.5) {
|
||||
setZoomLevel(zoomLevel - 0.25);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const currentDoc = catalogo?.arquivo_urls?.[currentDocIndex];
|
||||
if (currentDoc) {
|
||||
const link = document.createElement('a');
|
||||
link.href = currentDoc;
|
||||
link.download = '';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
const currentDoc = catalogo?.arquivo_urls?.[currentDocIndex];
|
||||
if (currentDoc) {
|
||||
window.open(currentDoc, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFullscreen = async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await viewerRef.current?.requestFullscreen?.();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
await document.exitFullscreen?.();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro ao alternar tela cheia:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setCurrentDocIndex(0);
|
||||
setZoomLevel(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// AGORA SIM podemos fazer o return condicional - APÓS todos os hooks
|
||||
if (!catalogo || !catalogo.arquivo_urls || catalogo.arquivo_urls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentDoc = catalogo.arquivo_urls[currentDocIndex];
|
||||
const isImage = currentDoc?.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp|tiff)$/i.test(currentDoc || '');
|
||||
const isPdf = currentDoc?.includes('pdf') || /\.pdf$/i.test(currentDoc || '');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) resetAndClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="
|
||||
w-[95vw] max-w-[95vw] h-[90vh]
|
||||
bg-white text-slate-900 border-slate-300
|
||||
dark:bg-slate-900 dark:text-white dark:border-slate-700
|
||||
"
|
||||
>
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="flex-1">
|
||||
<DialogTitle className="text-xl mb-2">{catalogo.titulo}</DialogTitle>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-slate-100 text-slate-700 border border-slate-300 dark:bg-blue-900 dark:text-blue-100 dark:border-slate-700">
|
||||
{catalogo.categoria}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{catalogo.disciplina}
|
||||
</Badge>
|
||||
</div>
|
||||
<span>{format(new Date(catalogo.created_at), 'dd/MM/yyyy', { locale: ptBR })}</span>
|
||||
{catalogo.numero_paginas && <span>{catalogo.numero_paginas} páginas</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="mr-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
{currentDocIndex + 1} de {catalogo.arquivo_urls.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isImage && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomOut} disabled={zoomLevel <= 0.5} className="h-8 w-8 p-0">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 px-2">{Math.round(zoomLevel * 100)}%</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomIn} disabled={zoomLevel >= 3} className="h-8 w-8 p-0">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="h-8 w-8 p-0" title="Abrir em nova aba">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleToggleFullscreen} className="h-8 w-8 p-0" title="Tela cheia">
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleDownload} className="h-8 w-8 p-0" title="Baixar">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetAndClose} className="h-8 w-8 p-0" title="Fechar">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden pt-3">
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="sm" onClick={handlePrevious} disabled={currentDocIndex === 0} className="h-10 w-10 p-0">
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={viewerRef}
|
||||
className="
|
||||
flex-1 flex items-center justify-center overflow-auto
|
||||
bg-slate-50 border border-slate-200/80 rounded-lg mx-2
|
||||
dark:bg-slate-800/30 dark:border-slate-700
|
||||
"
|
||||
>
|
||||
{isPdf ? (
|
||||
<iframe
|
||||
src={currentDoc}
|
||||
className="w-full h-full min-h-[65vh] rounded-lg"
|
||||
title={`Documento ${currentDocIndex + 1}`}
|
||||
/>
|
||||
) : isImage ? (
|
||||
<img
|
||||
src={currentDoc}
|
||||
alt={`Documento ${currentDocIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain rounded-lg transition-transform duration-200"
|
||||
style={{ transform: `scale(${zoomLevel})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-slate-500 dark:text-slate-400 gap-4">
|
||||
<FileText className="h-12 w-12" />
|
||||
<p>Tipo de documento não suportado para visualização</p>
|
||||
<Button onClick={handleDownload} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Baixar Documento
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="sm" onClick={handleNext} disabled={currentDocIndex === catalogo.arquivo_urls.length - 1} className="h-10 w-10 p-0">
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{catalogo.arquivo_urls.map((url, index) => {
|
||||
const isCurrentImage = url?.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp|tiff)$/i.test(url || '');
|
||||
const isCurrentPdf = url?.includes('pdf') || /\.pdf$/i.test(url || '');
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => { setCurrentDocIndex(index); setZoomLevel(1); }}
|
||||
className={`flex-shrink-0 w-16 h-16 rounded-lg border-2 overflow-hidden transition-colors ${
|
||||
index === currentDocIndex ? 'border-blue-500' : 'border-slate-300 hover:border-slate-400 dark:border-slate-600 dark:hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{isCurrentImage ? (
|
||||
<img src={url} alt={`Miniatura ${index + 1}`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
{isCurrentPdf ? (
|
||||
<FileText className="h-6 w-6 text-slate-500 dark:text-slate-400" />
|
||||
) : (
|
||||
<Image className="h-6 w-6 text-slate-500 dark:text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-slate-700 dark:text-slate-300 text-sm line-clamp-3">{catalogo.conteudo}</p>
|
||||
{catalogo.palavras_chave && catalogo.palavras_chave.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catalogo.palavras_chave.map((palavra, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{palavra}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
299
src/components/catalogos/FileUploadSection.tsx
Normal file
299
src/components/catalogos/FileUploadSection.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Upload, X, FileText, Image, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface FileUploadSectionProps {
|
||||
onFilesAnalyzed: (analysisResult: {
|
||||
titulo?: string;
|
||||
categoria?: string;
|
||||
disciplina?: string;
|
||||
conteudo?: string;
|
||||
palavras_chave?: string[];
|
||||
}) => void;
|
||||
onFilesUploaded: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
file: File;
|
||||
url?: string;
|
||||
uploading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function FileUploadSection({ onFilesAnalyzed, onFilesUploaded }: FileUploadSectionProps) {
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
|
||||
const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
|
||||
// Validar tipos de arquivo
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/tiff',
|
||||
'image/bmp'
|
||||
];
|
||||
|
||||
const validFiles = files.filter(file => {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error(`Tipo de arquivo não permitido: ${file.name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB
|
||||
toast.error(`Arquivo muito grande: ${file.name} (máximo 10MB)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
const newFiles = validFiles.map(file => ({
|
||||
file,
|
||||
uploading: false,
|
||||
error: undefined
|
||||
}));
|
||||
|
||||
setUploadedFiles(prev => [...prev, ...newFiles]);
|
||||
}
|
||||
|
||||
// Limpar o input
|
||||
event.target.value = '';
|
||||
}, []);
|
||||
|
||||
const uploadFile = async (fileData: UploadedFile, index: number) => {
|
||||
try {
|
||||
setUploadedFiles(prev => prev.map((f, i) =>
|
||||
i === index ? { ...f, uploading: true, error: undefined } : f
|
||||
));
|
||||
|
||||
const fileName = `${Date.now()}-${fileData.file.name}`;
|
||||
const { data, error } = await supabase.storage
|
||||
.from('catalogo-documents')
|
||||
.upload(fileName, fileData.file);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('catalogo-documents')
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
setUploadedFiles(prev => prev.map((f, i) =>
|
||||
i === index ? { ...f, uploading: false, url: publicUrl } : f
|
||||
));
|
||||
|
||||
return publicUrl;
|
||||
} catch (error) {
|
||||
console.error('Erro no upload:', error);
|
||||
setUploadedFiles(prev => prev.map((f, i) =>
|
||||
i === index ? { ...f, uploading: false, error: 'Erro no upload' } : f
|
||||
));
|
||||
toast.error('Erro no upload do arquivo');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAllFiles = async () => {
|
||||
const uploadPromises = uploadedFiles.map((fileData, index) => {
|
||||
if (!fileData.url && !fileData.uploading) {
|
||||
return uploadFile(fileData, index);
|
||||
}
|
||||
return Promise.resolve(fileData.url);
|
||||
});
|
||||
|
||||
const urls = await Promise.all(uploadPromises);
|
||||
const validUrls = urls.filter(url => url !== null) as string[];
|
||||
onFilesUploaded(validUrls);
|
||||
return validUrls;
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const analyzeFiles = async () => {
|
||||
if (uploadedFiles.length === 0) {
|
||||
toast.error('Adicione pelo menos um arquivo para analisar');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
|
||||
try {
|
||||
// Primeiro fazer upload de todos os arquivos
|
||||
const urls = await uploadAllFiles();
|
||||
|
||||
if (urls.length === 0) {
|
||||
toast.error('Nenhum arquivo foi carregado com sucesso');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simular análise com IA baseada nos arquivos
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Análise baseada nos nomes e tipos de arquivo
|
||||
const fileNames = uploadedFiles.map(f => f.file.name.toLowerCase());
|
||||
const hasImages = uploadedFiles.some(f => f.file.type.startsWith('image/'));
|
||||
const hasPdf = uploadedFiles.some(f => f.file.type === 'application/pdf');
|
||||
|
||||
let suggestedCategoria = '';
|
||||
let suggestedDisciplina = '';
|
||||
let suggestedTitulo = '';
|
||||
let suggestedKeywords: string[] = [];
|
||||
let suggestedContent = '';
|
||||
|
||||
// Lógica de categorização baseada em nomes de arquivo
|
||||
const content = fileNames.join(' ');
|
||||
|
||||
if (content.includes('aço') || content.includes('steel')) {
|
||||
suggestedCategoria = 'Aço';
|
||||
suggestedKeywords.push('aço', 'steel');
|
||||
} else if (content.includes('tinta') || content.includes('pintura')) {
|
||||
suggestedCategoria = 'Tintas';
|
||||
suggestedKeywords.push('tinta', 'pintura');
|
||||
} else if (content.includes('parafuso') || content.includes('fixação')) {
|
||||
suggestedCategoria = 'Parafusos';
|
||||
suggestedKeywords.push('parafuso', 'fixação');
|
||||
} else {
|
||||
suggestedCategoria = 'Materiais';
|
||||
}
|
||||
|
||||
if (content.includes('estrutura') || content.includes('construção')) {
|
||||
suggestedDisciplina = 'Engenharia Civil';
|
||||
suggestedKeywords.push('estrutura', 'construção');
|
||||
} else if (content.includes('mecânica') || content.includes('fabricação')) {
|
||||
suggestedDisciplina = 'Engenharia Mecânica';
|
||||
suggestedKeywords.push('mecânica', 'fabricação');
|
||||
} else {
|
||||
suggestedDisciplina = 'Construção';
|
||||
}
|
||||
|
||||
// Gerar título baseado no primeiro arquivo
|
||||
if (uploadedFiles.length > 0) {
|
||||
const firstFileName = uploadedFiles[0].file.name.replace(/\.[^/.]+$/, '');
|
||||
suggestedTitulo = firstFileName.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
// Gerar conteúdo baseado nos tipos de arquivo
|
||||
suggestedContent = `Documento ${hasPdf ? 'PDF' : 'de imagens'} contendo informações técnicas sobre ${suggestedCategoria.toLowerCase()}.`;
|
||||
|
||||
if (hasImages) {
|
||||
suggestedContent += ` Inclui ${uploadedFiles.filter(f => f.file.type.startsWith('image/')).length} imagem(ns) técnica(s).`;
|
||||
}
|
||||
|
||||
if (hasPdf) {
|
||||
suggestedContent += ` Documento PDF com especificações e detalhes técnicos.`;
|
||||
}
|
||||
|
||||
// Adicionar palavras-chave relacionadas ao tipo de arquivo
|
||||
if (hasPdf) suggestedKeywords.push('pdf', 'documento');
|
||||
if (hasImages) suggestedKeywords.push('imagem', 'visual');
|
||||
|
||||
onFilesAnalyzed({
|
||||
titulo: suggestedTitulo,
|
||||
categoria: suggestedCategoria,
|
||||
disciplina: suggestedDisciplina,
|
||||
conteudo: suggestedContent,
|
||||
palavras_chave: [...new Set(suggestedKeywords)]
|
||||
});
|
||||
|
||||
toast.success('Análise concluída! Os campos foram preenchidos automaticamente.');
|
||||
} catch (error) {
|
||||
console.error('Erro na análise:', error);
|
||||
toast.error('Erro ao analisar documentos');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (file: File) => {
|
||||
return file.type === 'application/pdf' ? FileText : Image;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file-upload">Upload de Documentos</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.tiff,.bmp"
|
||||
onChange={handleFileSelect}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={analyzeFiles}
|
||||
disabled={isAnalyzing || uploadedFiles.length === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isAnalyzing ? 'Analisando...' : 'Analisar com IA'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tipos aceitos: PDF, JPG, PNG, GIF, WebP, TIFF, BMP (máximo 10MB cada)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Arquivos Selecionados</Label>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{uploadedFiles.map((fileData, index) => {
|
||||
const Icon = getFileIcon(fileData.file);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-slate-50 rounded border"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Icon className="w-4 h-4 text-slate-600 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{fileData.file.name}</span>
|
||||
<span className="text-xs text-slate-500 flex-shrink-0">
|
||||
({(fileData.file.size / (1024 * 1024)).toFixed(1)} MB)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{fileData.uploading && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
|
||||
)}
|
||||
{fileData.url && (
|
||||
<span className="text-xs text-green-600">✓</span>
|
||||
)}
|
||||
{fileData.error && (
|
||||
<span className="text-xs text-red-600">✗</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/components/configuracoes/PrioridadesConfig.tsx
Normal file
138
src/components/configuracoes/PrioridadesConfig.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { usePrioridades, PrioridadeConfig } from '@/hooks/usePrioridades';
|
||||
import { Palette, Save } from 'lucide-react';
|
||||
|
||||
export const PrioridadesConfig = () => {
|
||||
const { prioridades, loading, updatePrioridade } = usePrioridades();
|
||||
const [editingPrioridades, setEditingPrioridades] = useState<Record<string, Partial<PrioridadeConfig>>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
const handleInputChange = (prioridadeId: string, field: string, value: string) => {
|
||||
setEditingPrioridades(prev => ({
|
||||
...prev,
|
||||
[prioridadeId]: {
|
||||
...prev[prioridadeId],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async (prioridade: PrioridadeConfig) => {
|
||||
const updates = editingPrioridades[prioridade.id];
|
||||
if (!updates || Object.keys(updates).length === 0) return;
|
||||
|
||||
setSaving(prioridade.id);
|
||||
const success = await updatePrioridade(prioridade.id, updates);
|
||||
|
||||
if (success) {
|
||||
setEditingPrioridades(prev => {
|
||||
const newState = { ...prev };
|
||||
delete newState[prioridade.id];
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
setSaving(null);
|
||||
};
|
||||
|
||||
const getCurrentValue = (prioridade: PrioridadeConfig, field: keyof PrioridadeConfig) => {
|
||||
return editingPrioridades[prioridade.id]?.[field] ?? prioridade[field];
|
||||
};
|
||||
|
||||
const hasChanges = (prioridadeId: string) => {
|
||||
return editingPrioridades[prioridadeId] && Object.keys(editingPrioridades[prioridadeId]).length > 0;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white border-slate-300 shadow-sm dark:bg-slate-800/50 dark:border-slate-700">
|
||||
<CardContent className="p-6">
|
||||
<div className="text-muted-foreground">Carregando configurações de prioridade...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white border-slate-300 shadow-sm dark:bg-slate-800/50 dark:border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
Configuração de Prioridades
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Configure os nomes e cores das prioridades das peças
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{prioridades.map((prioridade) => (
|
||||
<div
|
||||
key={prioridade.id}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border bg-slate-50 border-slate-200 dark:bg-slate-700/50 dark:border-slate-600"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Badge
|
||||
className="text-white font-medium"
|
||||
style={{ backgroundColor: getCurrentValue(prioridade, 'cor') as string }}
|
||||
>
|
||||
{prioridade.codigo}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-sm">Nome</Label>
|
||||
<Input
|
||||
value={getCurrentValue(prioridade, 'nome') as string}
|
||||
onChange={(e) => handleInputChange(prioridade.id, 'nome', e.target.value)}
|
||||
className="bg-white border-slate-300 text-slate-900 dark:bg-slate-600 dark:border-slate-500 dark:text-white"
|
||||
placeholder="Nome da prioridade"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-sm">Cor</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={getCurrentValue(prioridade, 'cor') as string}
|
||||
onChange={(e) => handleInputChange(prioridade.id, 'cor', e.target.value)}
|
||||
className="w-16 h-10 bg-white border-slate-300 cursor-pointer dark:bg-slate-600 dark:border-slate-500"
|
||||
/>
|
||||
<Input
|
||||
value={getCurrentValue(prioridade, 'cor') as string}
|
||||
onChange={(e) => handleInputChange(prioridade.id, 'cor', e.target.value)}
|
||||
className="bg-white border-slate-300 text-slate-900 dark:bg-slate-600 dark:border-slate-500 dark:text-white"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
{hasChanges(prioridade.id) && (
|
||||
<Button
|
||||
onClick={() => handleSave(prioridade)}
|
||||
disabled={saving === prioridade.id}
|
||||
size="sm"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
{saving === prioridade.id ? 'Salvando...' : 'Salvar'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
326
src/components/conversores/AdvanceSteelConverter.tsx
Normal file
326
src/components/conversores/AdvanceSteelConverter.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Upload, FileSpreadsheet } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
const AdvanceSteelConverter: React.FC = () => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [divideBy1000, setDivideBy1000] = useState(false);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
const validTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel'
|
||||
];
|
||||
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error('Por favor, selecione um arquivo Excel (.xlsx ou .xls)');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
toast.success('Arquivo selecionado com sucesso!');
|
||||
};
|
||||
|
||||
const processFile = () => {
|
||||
if (!selectedFile) {
|
||||
toast.error('Por favor, selecione um arquivo primeiro.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
const json = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
|
||||
const newSheetData: any[][] = [];
|
||||
newSheetData.push(['Marca', 'Qtde', 'Descrição', 'Mat.', 'Comp.', 'Larg.', 'P.Un.', 'P.Tot.']);
|
||||
|
||||
let fileNamePrefix: string | null = null;
|
||||
const processedMarks = new Set<number>();
|
||||
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
const row = json[i];
|
||||
if (!row || row.length === 0) continue;
|
||||
|
||||
const marcaCompleta = String(row[0] || '');
|
||||
|
||||
// Primeiro tenta o formato com fase: B118-4-2
|
||||
let mainMarkMatch = marcaCompleta.match(/^(B\d+)-(\d+)-(\d+)$/);
|
||||
let prefixo: string;
|
||||
let numeroMarca: number;
|
||||
|
||||
if (mainMarkMatch) {
|
||||
// Formato com fase: B118-4-2
|
||||
const ofNumber = mainMarkMatch[1]; // B118
|
||||
const faseNumber = mainMarkMatch[2]; // 4
|
||||
const marcaNumber = mainMarkMatch[3]; // 2
|
||||
prefixo = `${ofNumber}-`; // B118-
|
||||
numeroMarca = parseInt(marcaNumber, 10); // 2
|
||||
} else {
|
||||
// Tenta o formato sem fase: B118-2
|
||||
mainMarkMatch = marcaCompleta.match(/^(B\d+-)(\d+)$/);
|
||||
if (mainMarkMatch) {
|
||||
prefixo = mainMarkMatch[1]; // B118-
|
||||
numeroMarca = parseInt(mainMarkMatch[2], 10); // 2
|
||||
}
|
||||
}
|
||||
|
||||
if (mainMarkMatch) {
|
||||
|
||||
if (numeroMarca >= 999 || processedMarks.has(numeroMarca)) {
|
||||
continue;
|
||||
}
|
||||
processedMarks.add(numeroMarca);
|
||||
|
||||
if (!fileNamePrefix) {
|
||||
fileNamePrefix = prefixo;
|
||||
}
|
||||
|
||||
const qtde = row[5];
|
||||
const descricao = row[6];
|
||||
const larg = row[14];
|
||||
|
||||
let mat = '';
|
||||
let compRaw = '';
|
||||
let maxComp = 0;
|
||||
|
||||
// Busca o maior comprimento entre todas as sub-linhas da marca
|
||||
for (let j = i + 1; j < json.length; j++) {
|
||||
const subRow = json[j];
|
||||
if (!subRow || !subRow[0]) continue;
|
||||
|
||||
const subMarkCompleta = String(subRow[0]);
|
||||
|
||||
// Verifica formato com fase: B118-4-2
|
||||
let subMarkMatch = subMarkCompleta.match(/^(B\d+)-(\d+)-(\d+)$/);
|
||||
let subNumeroMarca: number | null = null;
|
||||
|
||||
if (subMarkMatch) {
|
||||
// Formato com fase
|
||||
subNumeroMarca = parseInt(subMarkMatch[3], 10);
|
||||
} else {
|
||||
// Verifica formato sem fase: B118-2
|
||||
subMarkMatch = subMarkCompleta.match(/^(B\d+-)(\d+)$/);
|
||||
if (subMarkMatch) {
|
||||
subNumeroMarca = parseInt(subMarkMatch[2], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Se encontrou uma nova marca principal, para a busca
|
||||
if (subMarkMatch && subNumeroMarca !== null && subNumeroMarca < 999 && !processedMarks.has(subNumeroMarca)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Se é uma sub-linha da marca atual, verifica o comprimento
|
||||
if (subMarkMatch) {
|
||||
const currentComp = Number(String(subRow[13] || 0).replace(',', '.'));
|
||||
if (!isNaN(currentComp) && currentComp > maxComp) {
|
||||
maxComp = currentComp;
|
||||
mat = subRow[12];
|
||||
compRaw = subRow[13];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pTotSum = 0;
|
||||
for (let j = i + 1; j < json.length; j++) {
|
||||
const subRow = json[j];
|
||||
if (!subRow || !subRow[0]) continue;
|
||||
|
||||
const subMarkCompleta = String(subRow[0]);
|
||||
|
||||
// Verifica formato com fase: B118-4-2
|
||||
let subMarkMatch = subMarkCompleta.match(/^(B\d+)-(\d+)-(\d+)$/);
|
||||
let subNumeroMarca: number | null = null;
|
||||
|
||||
if (subMarkMatch) {
|
||||
// Formato com fase
|
||||
subNumeroMarca = parseInt(subMarkMatch[3], 10);
|
||||
} else {
|
||||
// Verifica formato sem fase: B118-2
|
||||
subMarkMatch = subMarkCompleta.match(/^(B\d+-)(\d+)$/);
|
||||
if (subMarkMatch) {
|
||||
subNumeroMarca = parseInt(subMarkMatch[2], 10);
|
||||
}
|
||||
}
|
||||
|
||||
if (subMarkMatch && subNumeroMarca !== null && subNumeroMarca < 999 && !processedMarks.has(subNumeroMarca)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (subMarkMatch) {
|
||||
const pTotSubItemRaw = Number(String(subRow[16] || 0).replace(',', '.'));
|
||||
const pTotSubItemValue = divideBy1000 ? pTotSubItemRaw / 1000 : pTotSubItemRaw;
|
||||
if (!isNaN(pTotSubItemValue)) {
|
||||
pTotSum += pTotSubItemValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const qtdeValue = Number(String(qtde || 0).replace(',', '.'));
|
||||
const compValue = Number(String(compRaw || 0).replace(',', '.'));
|
||||
|
||||
let pUnCalculated = 0;
|
||||
if (qtdeValue !== 0) {
|
||||
pUnCalculated = pTotSum / qtdeValue;
|
||||
}
|
||||
|
||||
const compRounded = Math.round(compValue);
|
||||
const pUnRounded = Math.round(pUnCalculated);
|
||||
const pTotRounded = Math.round(pTotSum);
|
||||
|
||||
newSheetData.push([
|
||||
numeroMarca,
|
||||
qtde,
|
||||
descricao,
|
||||
mat,
|
||||
isNaN(compRounded) ? '' : compRounded,
|
||||
larg,
|
||||
isNaN(pUnRounded) ? '' : pUnRounded,
|
||||
isNaN(pTotRounded) ? '' : pTotRounded
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (newSheetData.length <= 1) {
|
||||
throw new Error("Nenhuma linha válida foi encontrada para conversão.");
|
||||
}
|
||||
if (!fileNamePrefix) {
|
||||
throw new Error("Não foi possível determinar o prefixo para o nome do arquivo.");
|
||||
}
|
||||
|
||||
const newWorksheet = XLSX.utils.aoa_to_sheet(newSheetData);
|
||||
const newWorkbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(newWorkbook, newWorksheet, 'Lista de Peças');
|
||||
|
||||
const newFileName = `${fileNamePrefix}Lista de Peças.xlsx`;
|
||||
XLSX.writeFile(newWorkbook, newFileName);
|
||||
|
||||
toast.success('Arquivo convertido e baixado com sucesso!');
|
||||
setSelectedFile(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro no processamento:', error);
|
||||
toast.error(`Erro ao processar o arquivo: ${(error as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
toast.error('Não foi possível ler o arquivo.');
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(selectedFile);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
Conversor Advance Steel
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Converta a "Lista de Peças - Estruturada" para o formato final.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-muted-foreground/25 hover:border-primary hover:bg-accent'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('advance-file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="advance-file-input"
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
<Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
{selectedFile ? (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-2">
|
||||
Arquivo selecionado:
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{selectedFile.name}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
<span className="font-semibold text-primary">Clique para carregar</span> ou arraste e solte a planilha
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Formatos suportados: .xlsx, .xls
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="divide-by-1000"
|
||||
checked={divideBy1000}
|
||||
onCheckedChange={(checked) => setDivideBy1000(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="divide-by-1000"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Dividir peso por 1000?
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={processFile}
|
||||
disabled={!selectedFile}
|
||||
className="w-full"
|
||||
>
|
||||
Converter e Baixar
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvanceSteelConverter;
|
||||
236
src/components/conversores/BocadConverter.tsx
Normal file
236
src/components/conversores/BocadConverter.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Upload, FileText, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
const BocadConverter: React.FC = () => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [uploadText, setUploadText] = useState(true);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
file.type === 'application/vnd.ms-excel' ||
|
||||
file.name.endsWith('.xlsx') ||
|
||||
file.name.endsWith('.xls');
|
||||
|
||||
if (isExcel) {
|
||||
setSelectedFile(file);
|
||||
setUploadText(false);
|
||||
toast.success('Planilha selecionada com sucesso!');
|
||||
} else {
|
||||
toast.error('Por favor, selecione apenas arquivos de planilha (.xlsx ou .xls).');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const processFile = async () => {
|
||||
if (!selectedFile) {
|
||||
toast.error('Por favor, selecione uma planilha para processar.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
const json = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
|
||||
// 1. Extração e formatação dos dados para o nome do arquivo
|
||||
const obraCompleta = String(json[3][1] || 'OBRA_NAO_ENCONTRADA').trim();
|
||||
const fase = String(json[3][5] || 'FASE_NAO_ENCONTRADA').trim();
|
||||
|
||||
const match = obraCompleta.match(/B\d+/);
|
||||
const obra = match ? match[0] : obraCompleta;
|
||||
|
||||
// 2. Localização da tabela de dados
|
||||
let dataStartIndex = -1;
|
||||
let dataEndIndex = -1;
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
if (json[i][0] === 'Marca' && json[i][1] === 'Quant.') {
|
||||
dataStartIndex = i + 1;
|
||||
}
|
||||
if (String(json[i][0]).trim().toLowerCase() === 'total') {
|
||||
dataEndIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataStartIndex === -1) {
|
||||
throw new Error("Não foi possível encontrar o cabeçalho da tabela de peças ('Marca', 'Quant.').");
|
||||
}
|
||||
if (dataEndIndex === -1) {
|
||||
dataEndIndex = json.length;
|
||||
}
|
||||
|
||||
// 3. Processamento das linhas da tabela
|
||||
const newSheetData: any[][] = [];
|
||||
newSheetData.push(['Marca', 'Pos.', 'Descrição', 'Qtde', 'Lar.', 'Esp.', 'Comp.', 'Mat.', 'P.Un.', 'P.Tot.']);
|
||||
|
||||
for (let i = dataStartIndex; i < dataEndIndex; i++) {
|
||||
const row = json[i];
|
||||
if (!row[0]) continue;
|
||||
|
||||
const marca = row[0];
|
||||
const marcaAsNumber = Number(marca);
|
||||
if (isNaN(marcaAsNumber) || marcaAsNumber >= 1000) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const quant = row[1];
|
||||
const perfil = row[2];
|
||||
const qualid = row[3];
|
||||
const compr = row[4];
|
||||
const pesoTot = row[6];
|
||||
const nota = row[8] || '';
|
||||
|
||||
const comprRounded = Math.round(Number(String(compr).replace(',', '.')));
|
||||
const pesoTotRounded = Math.round(Number(String(pesoTot).replace(',', '.')));
|
||||
|
||||
const pos = String(nota).split(' ').pop();
|
||||
const mat = (qualid === 'ASTM-A572') ? 'A572GR50' : String(qualid).replace('ASTM-', '');
|
||||
|
||||
newSheetData.push([
|
||||
marca,
|
||||
pos,
|
||||
perfil,
|
||||
quant,
|
||||
'',
|
||||
'',
|
||||
isNaN(comprRounded) ? '' : comprRounded,
|
||||
mat,
|
||||
isNaN(pesoTotRounded) ? '' : pesoTotRounded,
|
||||
isNaN(pesoTotRounded) ? '' : pesoTotRounded
|
||||
]);
|
||||
}
|
||||
|
||||
if (newSheetData.length <= 1) {
|
||||
throw new Error("Nenhuma linha válida (com Marca < 1000) foi encontrada para conversão.");
|
||||
}
|
||||
|
||||
// 4. Criação e download do novo arquivo Excel
|
||||
const newWorksheet = XLSX.utils.aoa_to_sheet(newSheetData);
|
||||
const newWorkbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(newWorkbook, newWorksheet, 'Lista de Peças');
|
||||
|
||||
// *** CORRIGIDO: Nome do arquivo com "peças" em minúsculo ***
|
||||
const newFileName = `${obra}-${fase}-Lista de peças.xlsx`;
|
||||
XLSX.writeFile(newWorkbook, newFileName);
|
||||
|
||||
toast.success('Arquivo convertido com sucesso!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar planilha:', error);
|
||||
toast.error(`Erro ao processar o arquivo: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
toast.error('Não foi possível ler o arquivo.');
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(selectedFile);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro geral:', error);
|
||||
toast.error('Erro ao processar a planilha. Verifique o arquivo e tente novamente.');
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-xl shadow-lg w-full max-w-lg mx-4">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Conversor de Planilhas BSI</h1>
|
||||
<p className="text-gray-500 mt-2">Transforme a "Lista de Materiais" em uma "Lista de Peças" pronta para uso.</p>
|
||||
</div>
|
||||
|
||||
{/* Área de Upload */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".xls,.xlsx"
|
||||
onChange={handleFileSelect}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<div
|
||||
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-500 hover:bg-gray-50 transition-colors"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
{uploadText ? (
|
||||
<div>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<span className="font-semibold text-blue-600">Clique para carregar</span> ou arraste e solte a planilha.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Apenas arquivos .xlsx e .xls</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800 mb-2">{selectedFile?.name}</p>
|
||||
<p className="text-xs text-gray-500">Planilha selecionada</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botão de Conversão */}
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
onClick={processFile}
|
||||
disabled={!selectedFile || isProcessing}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg transition-transform transform active:scale-95 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Converter e Baixar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BocadConverter;
|
||||
447
src/components/conversores/ConversaoGenericaModal.tsx
Normal file
447
src/components/conversores/ConversaoGenericaModal.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Upload, FileText, Download, X, Check, Eye, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { usePrompts } from '@/hooks/usePrompts';
|
||||
import * as XLSX from 'xlsx';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface ConversaoGenericaModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface ProcessedData {
|
||||
headers: string[];
|
||||
rows: any[][];
|
||||
totalRows: number;
|
||||
totalColumns: number;
|
||||
rawData: any[];
|
||||
}
|
||||
|
||||
const ConversaoGenericaModal: React.FC<ConversaoGenericaModalProps> = ({
|
||||
open,
|
||||
onOpenChange
|
||||
}) => {
|
||||
const { prompts, loading: promptsLoading } = usePrompts();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [processedData, setProcessedData] = useState<ProcessedData | null>(null);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<string>('');
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// Função para verificar se um valor é numérico decimal
|
||||
const isDecimalNumber = (value: any): boolean => {
|
||||
if (typeof value === 'number') {
|
||||
return value % 1 !== 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const num = parseFloat(value);
|
||||
return !isNaN(num) && num % 1 !== 0 && value.includes('.');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Função para arredondar valores decimais
|
||||
const roundDecimalValue = (value: any): any => {
|
||||
if (typeof value === 'number' && value % 1 !== 0) {
|
||||
return Math.round(value);
|
||||
}
|
||||
if (typeof value === 'string' && value.includes('.')) {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num % 1 !== 0) {
|
||||
return Math.round(num).toString();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Função para processar e arredondar dados
|
||||
const processAndRoundData = (data: any[]): any[] => {
|
||||
return data.map(row => {
|
||||
const processedRow: any = {};
|
||||
Object.keys(row).forEach(key => {
|
||||
const value = row[key];
|
||||
processedRow[key] = roundDecimalValue(value);
|
||||
});
|
||||
return processedRow;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
const validTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||
'application/vnd.ms-excel', // .xls
|
||||
];
|
||||
|
||||
if (!validTypes.includes(selectedFile.type)) {
|
||||
toast.error('Formato de arquivo inválido. Selecione um arquivo .xlsx ou .xls');
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setProcessedData(null);
|
||||
setShowPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processFile = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
|
||||
// Pegar a primeira planilha
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Converter para JSON com chaves como nomes das colunas
|
||||
const jsonDataWithHeaders = XLSX.utils.sheet_to_json(worksheet) as any[];
|
||||
|
||||
// Converter para formato de array para visualização
|
||||
const jsonArrayData = XLSX.utils.sheet_to_json(worksheet, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
raw: false
|
||||
}) as any[][];
|
||||
|
||||
if (jsonDataWithHeaders.length === 0) {
|
||||
throw new Error('Nenhum dado encontrado no arquivo');
|
||||
}
|
||||
|
||||
// Processar e arredondar dados decimais
|
||||
const roundedJsonData = processAndRoundData(jsonDataWithHeaders);
|
||||
|
||||
// Determinar o número máximo de colunas
|
||||
const maxColumns = Math.max(...jsonArrayData.map(row => row.length));
|
||||
|
||||
// Padronizar todas as linhas para ter o mesmo número de colunas e arredondar valores
|
||||
const normalizedData = jsonArrayData.map(row => {
|
||||
const normalizedRow = [...row];
|
||||
while (normalizedRow.length < maxColumns) {
|
||||
normalizedRow.push('');
|
||||
}
|
||||
// Arredondar valores decimais na visualização
|
||||
return normalizedRow.map(cell => roundDecimalValue(cell));
|
||||
});
|
||||
|
||||
// Primeira linha como headers
|
||||
const headers = normalizedData[0].map((header, index) =>
|
||||
header || `Coluna ${index + 1}`
|
||||
);
|
||||
|
||||
// Resto como dados para visualização
|
||||
const rows = normalizedData.slice(1);
|
||||
|
||||
const processedResult = {
|
||||
headers,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
totalColumns: headers.length,
|
||||
rawData: roundedJsonData
|
||||
};
|
||||
|
||||
setProcessedData(processedResult);
|
||||
setShowPreview(true);
|
||||
|
||||
// Contar quantos valores decimais foram arredondados
|
||||
let decimalCount = 0;
|
||||
jsonDataWithHeaders.forEach(row => {
|
||||
Object.values(row).forEach(value => {
|
||||
if (isDecimalNumber(value)) {
|
||||
decimalCount++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (decimalCount > 0) {
|
||||
toast.success(`Arquivo processado com sucesso! ${processedResult.totalRows} linhas e ${processedResult.totalColumns} colunas encontradas. ${decimalCount} valores decimais foram arredondados.`);
|
||||
} else {
|
||||
toast.success(`Arquivo processado com sucesso! ${processedResult.totalRows} linhas e ${processedResult.totalColumns} colunas encontradas.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar arquivo:', error);
|
||||
toast.error('Erro ao processar arquivo. Verifique se o arquivo não está corrompido.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateCSV = async () => {
|
||||
if (!processedData || !selectedPrompt || !file) return;
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const selectedPromptData = prompts.find(p => p.id === selectedPrompt);
|
||||
console.log('Processando com prompt:', selectedPromptData?.name);
|
||||
console.log('Dados para processar:', processedData.rawData);
|
||||
|
||||
// Processar os dados usando as instruções do prompt
|
||||
const csvContent = processDataWithPrompt(processedData.rawData, selectedPromptData?.content || '');
|
||||
|
||||
// Gerar e baixar CSV
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const csvFileName = file.name.replace(/\.(xlsx|xls)$/i, '_convertido.csv');
|
||||
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = csvFileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
|
||||
toast.success(`CSV gerado com sucesso usando o prompt: ${selectedPromptData?.name}. Valores decimais foram arredondados para números inteiros.`);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar CSV:', error);
|
||||
toast.error('Erro ao gerar CSV. Verifique as instruções do prompt e os dados.');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processDataWithPrompt = (data: any[], promptContent: string): string => {
|
||||
console.log('Aplicando prompt:', promptContent);
|
||||
|
||||
// Por enquanto, vamos fazer uma conversão básica baseada nos dados
|
||||
if (data.length === 0) return '';
|
||||
|
||||
// Obter headers dos dados originais
|
||||
const headers = Object.keys(data[0]);
|
||||
|
||||
// Criar cabeçalho CSV
|
||||
const csvHeaders = headers.join(',');
|
||||
|
||||
// Converter dados para CSV, garantindo que valores numéricos não tenham decimais
|
||||
const csvRows = data.map(row =>
|
||||
headers.map(header => {
|
||||
let value = row[header];
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
// Arredondar valores decimais mais uma vez para garantir
|
||||
value = roundDecimalValue(value);
|
||||
|
||||
const stringValue = value.toString();
|
||||
// Escapar valores que contêm vírgula
|
||||
return stringValue.includes(',') ? `"${stringValue}"` : stringValue;
|
||||
}).join(',')
|
||||
);
|
||||
|
||||
return [csvHeaders, ...csvRows].join('\n');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFile(null);
|
||||
setProcessedData(null);
|
||||
setSelectedPrompt('');
|
||||
setShowPreview(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white text-xl">Conversão Genérica</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Faça upload de uma planilha Excel e selecione um prompt para processar os dados. Valores decimais serão automaticamente arredondados.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Seleção de arquivo */}
|
||||
<Card className="bg-slate-700/50 border-slate-600">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
Selecionar Arquivo Excel
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="file-upload" className="text-white mb-2 block">
|
||||
Arquivo da Planilha
|
||||
</Label>
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileSelect}
|
||||
className="bg-slate-600 border-slate-500 text-white file:bg-slate-500 file:text-white file:border-0 file:mr-4 file:py-2 file:px-4 file:rounded"
|
||||
/>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Formatos suportados: .xlsx, .xls
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="p-3 bg-slate-800/50 rounded border border-slate-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-white text-sm">{file.name}</span>
|
||||
<span className="text-slate-400 text-xs">
|
||||
({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={processFile}
|
||||
disabled={processing}
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Processar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Seleção de prompt */}
|
||||
{showPreview && (
|
||||
<Card className="bg-slate-700/50 border-slate-600">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg">Selecionar Prompt de Conversão</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-white mb-2 block">Prompt</Label>
|
||||
<Select value={selectedPrompt} onValueChange={setSelectedPrompt}>
|
||||
<SelectTrigger className="bg-slate-600 border-slate-500 text-white">
|
||||
<SelectValue placeholder="Escolha um prompt para guiar a conversão" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-700 border-slate-600">
|
||||
{prompts.map((prompt) => (
|
||||
<SelectItem key={prompt.id} value={prompt.id} className="text-white">
|
||||
{prompt.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{prompts.length === 0 && (
|
||||
<p className="text-yellow-400 text-xs mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Nenhum prompt encontrado. Crie um prompt primeiro.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Preview dos dados */}
|
||||
{processedData && (
|
||||
<Card className="bg-slate-700/50 border-slate-600">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg">Preview dos Dados (Valores Arredondados)</CardTitle>
|
||||
<div className="flex items-center gap-4 p-3 bg-blue-900/30 rounded border border-blue-600">
|
||||
<Eye className="w-5 h-5 text-blue-400" />
|
||||
<div className="text-blue-100 text-sm">
|
||||
<span className="font-medium">Dados encontrados:</span>
|
||||
<span className="ml-2">
|
||||
{processedData.totalRows} linhas × {processedData.totalColumns} colunas
|
||||
</span>
|
||||
<span className="ml-2 text-yellow-200">
|
||||
(Decimais arredondados automaticamente)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border border-slate-600 rounded overflow-auto max-h-60">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-700">
|
||||
<tr>
|
||||
{processedData.headers.map((header, index) => (
|
||||
<th key={index} className="px-3 py-2 text-left text-white font-medium whitespace-nowrap">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processedData.rows.slice(0, 10).map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t border-slate-600">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="px-3 py-2 text-slate-300 whitespace-nowrap">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{processedData.rows.length > 10 && (
|
||||
<div className="p-2 text-center text-slate-400 text-xs bg-slate-800/50">
|
||||
... e mais {processedData.rows.length - 10} linhas
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={generateCSV}
|
||||
disabled={!selectedPrompt || !processedData || generating}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
Gerando CSV...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Gerar CSV
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversaoGenericaModal;
|
||||
603
src/components/conversores/FileImporter.tsx
Normal file
603
src/components/conversores/FileImporter.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Upload, FileText, Download, X, Check, Eye, Info } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { usePrompts } from '@/hooks/usePrompts';
|
||||
import * as XLSX from 'xlsx';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface ProcessedData {
|
||||
headers: string[];
|
||||
rows: any[][];
|
||||
totalRows: number;
|
||||
totalColumns: number;
|
||||
rawData: any[]; // Dados originais para processamento
|
||||
}
|
||||
|
||||
const FileImporter: React.FC = () => {
|
||||
const { prompts, loading: promptsLoading } = usePrompts();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [processingPP, setProcessingPP] = useState(false);
|
||||
const [processedData, setProcessedData] = useState<ProcessedData | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<string>('');
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
const validTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||
'application/vnd.ms-excel', // .xls
|
||||
'text/csv', // .csv
|
||||
'application/pdf' // .pdf
|
||||
];
|
||||
|
||||
if (!validTypes.includes(selectedFile.type) &&
|
||||
!selectedFile.name.toLowerCase().endsWith('.csv')) {
|
||||
toast.error('Formato de arquivo inválido. Selecione um arquivo .xlsx, .xls, .csv ou .pdf');
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setProcessedData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const processFile = async (onlyPP = false) => {
|
||||
if (!file) return;
|
||||
|
||||
if (onlyPP) {
|
||||
setProcessingPP(true);
|
||||
} else {
|
||||
setProcessing(true);
|
||||
}
|
||||
|
||||
try {
|
||||
let processedResult: ProcessedData;
|
||||
|
||||
if (file.type === 'application/pdf') {
|
||||
// Para PDF, ainda simulamos o processamento
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
processedResult = {
|
||||
headers: ['Marca', 'Perfil', 'Quantidade', 'Peso Unit.', 'Peso Total', 'Material'],
|
||||
rows: [
|
||||
['P1', 'VS 200x30x5,0', '2', '15,8', '31,6', 'ASTM A36'],
|
||||
['P2', 'VS 150x25x4,0', '4', '12,3', '49,2', 'ASTM A36'],
|
||||
['P3', 'L 50x50x5,0', '8', '3,77', '30,16', 'ASTM A36'],
|
||||
['P4', 'CH 100x50x17,0', '6', '17,0', '102,0', 'ASTM A36'],
|
||||
['P5', 'FL 200x8,0', '3', '12,56', '37,68', 'ASTM A36']
|
||||
],
|
||||
totalRows: 5,
|
||||
totalColumns: 6,
|
||||
rawData: []
|
||||
};
|
||||
} else {
|
||||
// Processar arquivos Excel/CSV usando xlsx
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
|
||||
// Pegar a primeira planilha
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Converter para JSON com chaves como nomes das colunas
|
||||
const jsonDataWithHeaders = XLSX.utils.sheet_to_json(worksheet) as any[];
|
||||
|
||||
// Converter para formato de array para visualização
|
||||
const jsonArrayData = XLSX.utils.sheet_to_json(worksheet, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
raw: false
|
||||
}) as any[][];
|
||||
|
||||
if (jsonDataWithHeaders.length === 0) {
|
||||
throw new Error('Nenhum dado encontrado no arquivo');
|
||||
}
|
||||
|
||||
console.log('Dados JSON com headers:', jsonDataWithHeaders);
|
||||
console.log('Dados em array:', jsonArrayData);
|
||||
|
||||
// Se for processamento PP, filtrar apenas linhas onde a coluna "Pos." está vazia
|
||||
let filteredJsonData = jsonDataWithHeaders;
|
||||
let filteredArrayData = jsonArrayData;
|
||||
|
||||
if (onlyPP) {
|
||||
// Filtrar dados JSON com headers
|
||||
filteredJsonData = jsonDataWithHeaders.filter(row => {
|
||||
const posValue = extrairValor(row, ['Pos.', 'Pos', 'pos', 'POS']);
|
||||
return !posValue || posValue.toString().trim() === '';
|
||||
});
|
||||
|
||||
// Filtrar dados em array (manter header + linhas filtradas)
|
||||
const headerRow = jsonArrayData[0];
|
||||
const dataRows = jsonArrayData.slice(1);
|
||||
|
||||
// Encontrar índice da coluna Pos.
|
||||
const posColumnIndex = headerRow.findIndex(header =>
|
||||
['Pos.', 'Pos', 'pos', 'POS'].includes(header?.toString() || '')
|
||||
);
|
||||
|
||||
const filteredDataRows = dataRows.filter(row => {
|
||||
if (posColumnIndex === -1) return true; // Se não encontrar coluna Pos., incluir todas
|
||||
const posValue = row[posColumnIndex];
|
||||
return !posValue || posValue.toString().trim() === '';
|
||||
});
|
||||
|
||||
filteredArrayData = [headerRow, ...filteredDataRows];
|
||||
}
|
||||
|
||||
// Determinar o número máximo de colunas
|
||||
const maxColumns = Math.max(...filteredArrayData.map(row => row.length));
|
||||
|
||||
// Padronizar todas as linhas para ter o mesmo número de colunas
|
||||
const normalizedData = filteredArrayData.map(row => {
|
||||
const normalizedRow = [...row];
|
||||
while (normalizedRow.length < maxColumns) {
|
||||
normalizedRow.push('');
|
||||
}
|
||||
return normalizedRow;
|
||||
});
|
||||
|
||||
// Primeira linha como headers
|
||||
const headers = normalizedData[0].map((header, index) =>
|
||||
header || `Coluna ${index + 1}`
|
||||
);
|
||||
|
||||
// Resto como dados para visualização
|
||||
const rows = normalizedData.slice(1);
|
||||
|
||||
processedResult = {
|
||||
headers,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
totalColumns: headers.length,
|
||||
rawData: filteredJsonData // Dados originais filtrados para processamento
|
||||
};
|
||||
|
||||
console.log('Dados processados:', {
|
||||
totalRows: processedResult.totalRows,
|
||||
totalColumns: processedResult.totalColumns,
|
||||
headers: processedResult.headers,
|
||||
rawDataCount: processedResult.rawData.length,
|
||||
onlyPP: onlyPP
|
||||
});
|
||||
}
|
||||
|
||||
setProcessedData(processedResult);
|
||||
setShowPreview(true);
|
||||
|
||||
const processType = onlyPP ? ' (apenas peças principais)' : '';
|
||||
toast.success(`Arquivo processado com sucesso${processType}! ${processedResult.totalRows} linhas e ${processedResult.totalColumns} colunas coletadas.`);
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar arquivo:', error);
|
||||
toast.error('Erro ao processar arquivo. Verifique se o arquivo não está corrompido.');
|
||||
} finally {
|
||||
if (onlyPP) {
|
||||
setProcessingPP(false);
|
||||
} else {
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generateCSV = async () => {
|
||||
if (!processedData || !selectedPrompt || !file) return;
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const selectedPromptData = prompts.find(p => p.id === selectedPrompt);
|
||||
console.log('Processando com prompt:', selectedPromptData?.name);
|
||||
console.log('Dados originais para processar:', processedData.rawData);
|
||||
|
||||
// Processar os dados usando a lógica do prompt
|
||||
const csvContent = processSpreadsheetToCsv(file.name, processedData.rawData);
|
||||
|
||||
// Gerar e baixar CSV
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const csvFileName = file.name.replace(/\.(xlsx|xls|pdf)$/i, '_processado.csv');
|
||||
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = csvFileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
|
||||
toast.success(`CSV gerado com sucesso usando o prompt: ${selectedPromptData?.name}`);
|
||||
setShowPreview(false);
|
||||
setFile(null);
|
||||
setProcessedData(null);
|
||||
setSelectedPrompt('');
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar CSV:', error);
|
||||
toast.error('Erro ao gerar CSV. Verifique as instruções do prompt e os dados.');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processSpreadsheetToCsv = (fileName: string, data: any[]): string => {
|
||||
console.log('Processando arquivo:', fileName);
|
||||
console.log('Total de linhas:', data.length);
|
||||
|
||||
// Extrair metadados do nome do arquivo
|
||||
const fileNameParts = fileName.replace(/\.(xlsx|xls|pdf)$/i, '').split('-');
|
||||
const ofNumber = fileNameParts[0] || '';
|
||||
const etapaFase = fileNameParts[1] || '';
|
||||
|
||||
console.log('OF Number:', ofNumber);
|
||||
console.log('Etapa/Fase:', etapaFase);
|
||||
|
||||
const csvHeader = 'of_number,etapa_fase,marca,descricao,quantidade,peso_unitario,peso_total,tratamento_superficial,material,perfil_principal,tem_componentes,marca_componente,descricao_componente,perfil_componente,peso_unitario_componente,quantidade_por_peca';
|
||||
|
||||
const csvRows: string[] = [csvHeader];
|
||||
let currentPecaMae: any = null;
|
||||
const componentes: any[] = [];
|
||||
|
||||
// Log das colunas disponíveis
|
||||
if (data.length > 0) {
|
||||
console.log('Colunas disponíveis:', Object.keys(data[0]));
|
||||
}
|
||||
|
||||
// Processar dados linha por linha
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
console.log(`Processando linha ${i + 1}:`, row);
|
||||
|
||||
// Verificar se é uma peça-mãe (tem valor na coluna Marca)
|
||||
const marcaValue = extrairValor(row, ['Marca', 'marca', 'MARCA']);
|
||||
|
||||
if (marcaValue && marcaValue.toString().trim() !== '') {
|
||||
console.log('Encontrou peça-mãe:', marcaValue);
|
||||
|
||||
// Processar peça-mãe anterior se existir
|
||||
if (currentPecaMae) {
|
||||
processarPecaMae(currentPecaMae, componentes, csvRows, ofNumber, etapaFase);
|
||||
}
|
||||
|
||||
// Nova peça-mãe
|
||||
currentPecaMae = row;
|
||||
componentes.length = 0; // Limpar componentes
|
||||
} else {
|
||||
// Verificar se é um componente através da coluna Pos.
|
||||
const posValue = extrairValor(row, ['Pos.', 'Pos', 'pos', 'POS']);
|
||||
|
||||
if (posValue && posValue.toString().trim() !== '') {
|
||||
const posString = posValue.toString();
|
||||
console.log('Verificando Pos.:', posString);
|
||||
|
||||
// Extrair o último número após o último hífen
|
||||
const lastNumber = posString.split('-').pop();
|
||||
|
||||
if (lastNumber && parseInt(lastNumber) >= 1000 && parseInt(lastNumber) <= 9999) {
|
||||
console.log('Componente encontrado:', posString);
|
||||
componentes.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Processar última peça-mãe
|
||||
if (currentPecaMae) {
|
||||
processarPecaMae(currentPecaMae, componentes, csvRows, ofNumber, etapaFase);
|
||||
}
|
||||
|
||||
console.log('Total de linhas CSV geradas:', csvRows.length - 1);
|
||||
return csvRows.join('\n');
|
||||
};
|
||||
|
||||
const processarPecaMae = (pecaMae: any, componentes: any[], csvRows: string[], ofNumber: string, etapaFase: string) => {
|
||||
console.log('Processando peça-mãe:', pecaMae);
|
||||
console.log('Com componentes:', componentes);
|
||||
|
||||
// Extrair dados da peça-mãe com diferentes variações de nomes de colunas
|
||||
const marcaCompleta = extrairValor(pecaMae, ['Marca', 'marca', 'MARCA'])?.toString() || '';
|
||||
const marca = marcaCompleta.split('-').pop() || marcaCompleta;
|
||||
const descricao = extrairValor(pecaMae, ['Descrição', 'Descricao', 'descrição', 'descricao', 'DESCRIÇÃO', 'DESCRICAO']) || '';
|
||||
const quantidade = extrairValor(pecaMae, ['Qtde', 'Quantidade', 'qtde', 'quantidade', 'QTDE', 'QUANTIDADE']) || '';
|
||||
const pesoUnitario = formatarNumero(extrairValor(pecaMae, ['P.Un.', 'PesoUnitario', 'Peso Unitário', 'peso_unitario', 'PESO_UNITARIO']));
|
||||
const pesoTotal = formatarNumero(extrairValor(pecaMae, ['P.Tot.', 'PesoTotal', 'Peso Total', 'peso_total', 'PESO_TOTAL']));
|
||||
const material = extrairValor(pecaMae, ['Mat', 'Material', 'mat', 'material', 'MAT', 'MATERIAL']) || '';
|
||||
const perfilPrincipal = material;
|
||||
|
||||
console.log('Dados extraídos - Marca:', marca, 'Descrição:', descricao, 'Qtd:', quantidade);
|
||||
|
||||
if (componentes.length === 0) {
|
||||
// Peça-mãe sem componentes
|
||||
const csvRow = [
|
||||
escapeCSV(ofNumber),
|
||||
escapeCSV(etapaFase),
|
||||
escapeCSV(marca),
|
||||
escapeCSV(descricao),
|
||||
escapeCSV(quantidade),
|
||||
escapeCSV(pesoUnitario),
|
||||
escapeCSV(pesoTotal),
|
||||
escapeCSV('-'), // tratamento_superficial
|
||||
escapeCSV(material),
|
||||
escapeCSV(perfilPrincipal),
|
||||
'false', // tem_componentes
|
||||
'', // marca_componente
|
||||
'', // descricao_componente
|
||||
'', // perfil_componente
|
||||
'', // peso_unitario_componente
|
||||
'' // quantidade_por_peca
|
||||
].join(',');
|
||||
csvRows.push(csvRow);
|
||||
} else {
|
||||
// Peça-mãe com componentes
|
||||
componentes.forEach(componente => {
|
||||
const posValue = extrairValor(componente, ['Pos.', 'Pos', 'pos', 'POS'])?.toString() || '';
|
||||
const marcaComponente = posValue.split('-').pop() || '';
|
||||
const descricaoComponente = extrairValor(componente, ['Descrição', 'Descricao', 'descrição', 'descricao', 'DESCRIÇÃO', 'DESCRICAO']) || '';
|
||||
const perfilComponente = extrairValor(componente, ['Mat', 'Material', 'mat', 'material', 'MAT', 'MATERIAL']) || '';
|
||||
const pesoUnitarioComponente = formatarNumero(extrairValor(componente, ['P.Un.', 'PesoUnitario', 'Peso Unitário', 'peso_unitario', 'PESO_UNITARIO']));
|
||||
const quantidadePorPeca = extrairValor(componente, ['Qtde', 'Quantidade', 'qtde', 'quantidade', 'QTDE', 'QUANTIDADE']) || '';
|
||||
|
||||
const csvRow = [
|
||||
escapeCSV(ofNumber),
|
||||
escapeCSV(etapaFase),
|
||||
escapeCSV(marca),
|
||||
escapeCSV(descricao),
|
||||
escapeCSV(quantidade),
|
||||
escapeCSV(pesoUnitario),
|
||||
escapeCSV(pesoTotal),
|
||||
escapeCSV('-'), // tratamento_superficial
|
||||
escapeCSV(material),
|
||||
escapeCSV(perfilPrincipal),
|
||||
'true', // tem_componentes
|
||||
escapeCSV(marcaComponente),
|
||||
escapeCSV(descricaoComponente),
|
||||
escapeCSV(perfilComponente),
|
||||
escapeCSV(pesoUnitarioComponente),
|
||||
escapeCSV(quantidadePorPeca)
|
||||
].join(',');
|
||||
csvRows.push(csvRow);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const extrairValor = (objeto: any, nomes: string[]): any => {
|
||||
for (const nome of nomes) {
|
||||
if (objeto[nome] !== undefined && objeto[nome] !== null && objeto[nome] !== '') {
|
||||
return objeto[nome];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const escapeCSV = (valor: any): string => {
|
||||
if (valor === null || valor === undefined) return '';
|
||||
const str = valor.toString();
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const formatarNumero = (valor: any): string => {
|
||||
if (!valor) return '';
|
||||
const numeroStr = valor.toString().replace(',', '.');
|
||||
const numero = parseFloat(numeroStr);
|
||||
return isNaN(numero) ? '' : numero.toFixed(1);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowPreview(false);
|
||||
setProcessedData(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-slate-700/50 border-slate-600">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
Importar Arquivo
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="file-upload" className="text-white mb-2 block">
|
||||
Selecionar Arquivo
|
||||
</Label>
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv,.pdf"
|
||||
onChange={handleFileSelect}
|
||||
className="bg-slate-600 border-slate-500 text-white file:bg-slate-500 file:text-white file:border-0 file:mr-4 file:py-2 file:px-4 file:rounded"
|
||||
/>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Formatos suportados: .xlsx, .xls, .csv, .pdf
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="p-3 bg-slate-800/50 rounded border border-slate-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-white text-sm">{file.name}</span>
|
||||
<span className="text-slate-400 text-xs">
|
||||
({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => processFile(false)}
|
||||
disabled={processing || processingPP}
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Processar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => processFile(true)}
|
||||
disabled={processing || processingPP}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{processingPP ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
Processando PP...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Processar PP
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-slate-300 text-sm">
|
||||
<h4 className="font-medium mb-2">Como funciona:</h4>
|
||||
<ul className="space-y-1 text-xs">
|
||||
<li>1. Selecione um arquivo (.xlsx, .xls, .csv ou .pdf)</li>
|
||||
<li>2. O sistema extrairá todas as tabelas e dados</li>
|
||||
<li>3. Visualize os dados processados</li>
|
||||
<li>4. Escolha um prompt para guiar a conversão</li>
|
||||
<li>5. Gere e baixe o arquivo CSV final</li>
|
||||
</ul>
|
||||
<div className="mt-2 p-2 bg-green-800/20 rounded border border-green-600">
|
||||
<p className="text-green-400 text-xs font-medium">Processar PP:</p>
|
||||
<p className="text-green-300 text-xs">Processa apenas linhas onde a coluna "Pos." está vazia (peças principais).</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Dados Processados</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Visualize os dados extraídos do arquivo e selecione um prompt para gerar o CSV
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-white mb-2 block">Selecionar Prompt</Label>
|
||||
<Select value={selectedPrompt} onValueChange={setSelectedPrompt}>
|
||||
<SelectTrigger className="bg-slate-700 border-slate-600 text-white">
|
||||
<SelectValue placeholder="Escolha um prompt para guiar a conversão" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-700 border-slate-600">
|
||||
{prompts.map((prompt) => (
|
||||
<SelectItem key={prompt.id} value={prompt.id} className="text-white">
|
||||
{prompt.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{prompts.length === 0 && (
|
||||
<p className="text-yellow-400 text-xs mt-1">
|
||||
Nenhum prompt encontrado. Crie um prompt primeiro.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{processedData && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 p-3 bg-blue-900/30 rounded border border-blue-600">
|
||||
<Info className="w-5 h-5 text-blue-400" />
|
||||
<div className="text-blue-100 text-sm">
|
||||
<span className="font-medium">Dados coletados:</span>
|
||||
<span className="ml-2">
|
||||
{processedData.totalRows} linhas × {processedData.totalColumns} colunas
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-600 rounded overflow-auto max-h-60">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-700">
|
||||
<tr>
|
||||
{processedData.headers.map((header, index) => (
|
||||
<th key={index} className="px-3 py-2 text-left text-white font-medium whitespace-nowrap">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processedData.rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t border-slate-600">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="px-3 py-2 text-slate-300 whitespace-nowrap">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={generateCSV}
|
||||
disabled={!selectedPrompt || generating}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
Gerando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Aceitar e Gerar CSV
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileImporter;
|
||||
403
src/components/conversores/PromptsManager.tsx
Normal file
403
src/components/conversores/PromptsManager.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Plus, Edit, Trash2, Save, X, Download, FileText, RefreshCw } from 'lucide-react';
|
||||
import { usePrompts } from '@/hooks/usePrompts';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
const PromptsManager: React.FC = () => {
|
||||
const { prompts, loading, savePrompt, deletePrompt, downloadPromptAsJson, refreshPrompts } = usePrompts();
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingPrompt, setEditingPrompt] = useState<any>(null);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
content: ''
|
||||
});
|
||||
|
||||
// Prompts atualizados baseados no processamento avançado
|
||||
const defaultPrompts = [
|
||||
{
|
||||
name: "Processamento Estruturas Metálicas - Peças com Componentes",
|
||||
content: `PROCESSAMENTO AVANÇADO DE PLANILHAS - ESTRUTURAS METÁLICAS
|
||||
|
||||
OBJETIVO: Converter dados de planilhas para CSV estruturado, identificando peças-mãe e seus componentes.
|
||||
|
||||
REGRAS DE IDENTIFICAÇÃO:
|
||||
|
||||
1. PEÇAS-MÃE:
|
||||
- Identificadas pela coluna "Marca" com valor preenchido
|
||||
- Extrair número da marca (último número após hífen)
|
||||
- Se não houver hífen, usar valor completo da marca
|
||||
|
||||
2. COMPONENTES:
|
||||
- Identificados pela coluna "Pos." (Posição)
|
||||
- Devem ter código numérico entre 1000-9999 no final
|
||||
- Formato típico: XXX-XXXX onde XXXX é 1000-9999
|
||||
- Pertencem à peça-mãe anterior na sequência
|
||||
|
||||
3. EXTRAÇÃO DE METADADOS DO NOME DO ARQUIVO:
|
||||
- Formato esperado: "OF_NUMBER-ETAPA_FASE.extensão"
|
||||
- Exemplo: "B117-1.xlsx" → OF: B117, Etapa: 1
|
||||
|
||||
4. MAPEAMENTO DE COLUNAS (buscar variações):
|
||||
- Marca: ['Marca', 'marca', 'MARCA']
|
||||
- Descrição: ['Descrição', 'Descricao', 'descrição', 'descricao', 'DESCRIÇÃO', 'DESCRICAO']
|
||||
- Quantidade: ['Qtde', 'Quantidade', 'qtde', 'quantidade', 'QTDE', 'QUANTIDADE']
|
||||
- Peso Unitário: ['P.Un.', 'PesoUnitario', 'Peso Unitário', 'peso_unitario', 'PESO_UNITARIO']
|
||||
- Peso Total: ['P.Tot.', 'PesoTotal', 'Peso Total', 'peso_total', 'PESO_TOTAL']
|
||||
- Material: ['Mat', 'Material', 'mat', 'material', 'MAT', 'MATERIAL']
|
||||
- Posição: ['Pos.', 'Pos', 'pos', 'POS']
|
||||
|
||||
5. ESTRUTURA CSV DE SAÍDA:
|
||||
Cabeçalho: of_number,etapa_fase,marca,descricao,quantidade,peso_unitario,peso_total,tratamento_superficial,material,perfil_principal,tem_componentes,marca_componente,descricao_componente,perfil_componente,peso_unitario_componente,quantidade_por_peca
|
||||
|
||||
6. LÓGICA DE PROCESSAMENTO:
|
||||
- Para peças SEM componentes: uma linha com tem_componentes=false
|
||||
- Para peças COM componentes: uma linha para cada componente com tem_componentes=true
|
||||
- Tratamento superficial sempre "-" (padrão)
|
||||
- Perfil principal = material da peça-mãe
|
||||
- Números formatados com 1 casa decimal
|
||||
|
||||
7. FORMATAÇÃO:
|
||||
- Números: converter vírgula para ponto, 1 casa decimal
|
||||
- Texto com vírgula: envolver em aspas duplas
|
||||
- Valores vazios: deixar em branco
|
||||
- Encoding: UTF-8 com BOM
|
||||
|
||||
EXEMPLO DE PROCESSAMENTO:
|
||||
Entrada: B117-1.xlsx com peça B117-1-1 e componente B117-1-1-1001
|
||||
Saída: B117,1,1-1,GAB,1,210.0,210.0,-,A36,A36,true,1001,CHAPA,A36,15.0,2`
|
||||
},
|
||||
{
|
||||
name: "Conversão Lista de Peças - Formato Detalhado",
|
||||
content: `Converta os dados da tabela para um formato CSV detalhado seguindo estas instruções:
|
||||
|
||||
1. ESTRUTURA DO CSV:
|
||||
- Primeira linha deve conter todos os cabeçalhos das colunas
|
||||
- Cada linha subsequente deve representar uma peça/item
|
||||
- Use vírgula como separador
|
||||
- Se uma célula estiver vazia, deixe em branco mas mantenha as vírgulas
|
||||
|
||||
2. COLUNAS OBRIGATÓRIAS (na ordem):
|
||||
- of_number (número da OF)
|
||||
- etapa_fase (fase da etapa)
|
||||
- marca (marca da peça)
|
||||
- descricao (descrição)
|
||||
- quantidade (quantidade)
|
||||
- peso_unitario (peso unitário)
|
||||
- peso_total (peso total)
|
||||
- tratamento_superficial (tratamento)
|
||||
- material (material)
|
||||
- perfil_principal (perfil principal)
|
||||
- tem_componentes (true/false)
|
||||
- marca_componente (marca do componente)
|
||||
- descricao_componente (descrição do componente)
|
||||
- perfil_componente (perfil do componente)
|
||||
- peso_unitario_componente (peso unitário do componente)
|
||||
- quantidade_por_peca (quantidade por peça)
|
||||
|
||||
3. FORMATAÇÃO:
|
||||
- Mantenha todos os dados originais
|
||||
- Para campos booleanos, use "true" ou "false"
|
||||
- Para campos numéricos, mantenha os valores como estão
|
||||
- Para campos de texto, mantenha exatamente como na tabela original
|
||||
- Se não houver componentes, deixe os campos de componente vazios
|
||||
|
||||
4. EXEMPLO DE SAÍDA:
|
||||
of_number,etapa_fase,marca,descricao,quantidade,peso_unitario,peso_total,tratamento_superficial,material,perfil_principal,tem_componentes,marca_componente,descricao_componente,perfil_componente,peso_unitario_componente,quantidade_por_peca
|
||||
B117,1,B117-1-1,GAB,1,210,3,,,A36,false,,,,,
|
||||
B117,1,B117-1-2,Ch 3,1,370,3,,,A36,false,,,,,`
|
||||
},
|
||||
{
|
||||
name: "Conversão Lista de Peças - Formato Simplificado",
|
||||
content: `Converta os dados da tabela para formato CSV seguindo estas diretrizes:
|
||||
|
||||
1. MANTER ESTRUTURA ORIGINAL:
|
||||
- Use exatamente os mesmos cabeçalhos da tabela importada
|
||||
- Mantenha a mesma ordem das colunas
|
||||
- Preserve todos os valores como estão na planilha
|
||||
|
||||
2. FORMATAÇÃO CSV:
|
||||
- Primeira linha: cabeçalhos separados por vírgula
|
||||
- Linhas seguintes: dados separados por vírgula
|
||||
- Campos vazios: deixar em branco mas manter vírgulas
|
||||
- Não adicionar aspas desnecessárias
|
||||
|
||||
3. TRATAMENTO DE DADOS:
|
||||
- Números: manter formato original
|
||||
- Texto: sem modificações
|
||||
- Campos vazios: representar como campo vazio (não "null" ou "undefined")
|
||||
|
||||
4. EXEMPLO:
|
||||
#,Marca,Pos.,Descrição,Qtde,Lar.,Esp.,Comp.,Mat.,P.Un.,P.Tot.
|
||||
1,-,-,-,-,-,-,-,-,-,416
|
||||
2,B117-1-1,B117-1-1,GAB,1,210,3,210,A36,1,1`
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// Criar prompts padrão se não existirem
|
||||
const createDefaultPrompts = async () => {
|
||||
if (!loading && prompts.length === 0) {
|
||||
for (const defaultPrompt of defaultPrompts) {
|
||||
await savePrompt(defaultPrompt);
|
||||
}
|
||||
await refreshPrompts();
|
||||
}
|
||||
};
|
||||
|
||||
createDefaultPrompts();
|
||||
}, [loading, prompts.length]);
|
||||
|
||||
const handleEdit = (prompt: any) => {
|
||||
setEditingPrompt(prompt);
|
||||
setFormData({
|
||||
name: prompt.name,
|
||||
content: prompt.content
|
||||
});
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingPrompt(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
content: ''
|
||||
});
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim() || !formData.content.trim()) {
|
||||
toast.error('Nome e conteúdo são obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
await savePrompt({
|
||||
id: editingPrompt?.id,
|
||||
name: formData.name,
|
||||
content: formData.content
|
||||
});
|
||||
|
||||
setShowDialog(false);
|
||||
setFormData({ name: '', content: '' });
|
||||
setEditingPrompt(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowDialog(false);
|
||||
setFormData({ name: '', content: '' });
|
||||
setEditingPrompt(null);
|
||||
};
|
||||
|
||||
const handleDownload = (prompt: any) => {
|
||||
const filename = window.prompt('Nome do arquivo (sem extensão):', prompt.name.replace(/\s+/g, '_'));
|
||||
if (filename) {
|
||||
downloadPromptAsJson(prompt, `${filename}.json`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDefaultPrompts = async () => {
|
||||
for (const defaultPrompt of defaultPrompts) {
|
||||
await savePrompt(defaultPrompt);
|
||||
}
|
||||
await refreshPrompts();
|
||||
toast.success('Prompts padrão criados com sucesso!');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse">Carregando prompts...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-slate-700/50 border-slate-600">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white text-lg">Gerenciar Prompts</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleCreateDefaultPrompts}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-blue-600 hover:bg-blue-700 border-blue-500"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Criar Padrões
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Novo Prompt
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{prompts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-400 text-sm mb-4">
|
||||
Nenhum prompt cadastrado
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleCreateDefaultPrompts}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Criar Prompts Padrão
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{prompts.map((prompt) => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className={`p-4 rounded border cursor-pointer transition-colors ${
|
||||
selectedPrompt?.id === prompt.id
|
||||
? 'bg-blue-800/50 border-blue-500'
|
||||
: 'bg-slate-800/50 border-slate-600 hover:bg-slate-800/70'
|
||||
}`}
|
||||
onClick={() => setSelectedPrompt(prompt)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-medium">{prompt.name}</h4>
|
||||
<p className="text-slate-400 text-sm mt-1 line-clamp-2">
|
||||
{prompt.content.substring(0, 100)}...
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(prompt);
|
||||
}}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(prompt);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deletePrompt(prompt.id);
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPrompt && (
|
||||
<div className="mt-6 p-4 bg-slate-800/30 rounded border border-slate-600">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-white font-medium">Prompt Selecionado</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownload(selectedPrompt)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
Baixar JSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-slate-300 text-sm"><strong>Nome:</strong> {selectedPrompt.name}</p>
|
||||
<div>
|
||||
<p className="text-slate-300 text-sm mb-2"><strong>Conteúdo:</strong></p>
|
||||
<div className="bg-slate-900/50 p-3 rounded text-slate-300 text-sm max-h-40 overflow-y-auto whitespace-pre-wrap">
|
||||
{selectedPrompt.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={handleClose}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{editingPrompt ? 'Editar Prompt' : 'Novo Prompt'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Configure as instruções que serão utilizadas na geração dos arquivos CSV
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-white">Nome do Prompt</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ex: Conversão de Lista de Peças"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="content" className="text-white">Conteúdo do Prompt</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder="Insira as instruções detalhadas para a conversão dos dados..."
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white min-h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{editingPrompt ? 'Salvar' : 'Criar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptsManager;
|
||||
601
src/components/conversores/TecnometalConverter.tsx
Normal file
601
src/components/conversores/TecnometalConverter.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Upload, FileText, Download, Trash2, Plus, RefreshCw, Settings, CheckCircle, Clock } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { jsonCodeManager } from '@/utils/jsonCodeManager';
|
||||
import { apiKeyManager } from '@/utils/apiKeyManager';
|
||||
import { useWebhookConfigs } from '@/hooks/useWebhookConfigs';
|
||||
import WebhookConfigManager from './WebhookConfigManager';
|
||||
|
||||
interface ProcessedRow {
|
||||
of_number: string;
|
||||
etapa_fase: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
quantidade: string;
|
||||
peso_unitario: string;
|
||||
peso_total: string;
|
||||
tratamento_superficial: string;
|
||||
material: string;
|
||||
perfil_principal: string;
|
||||
tem_componentes: string;
|
||||
marca_componente: string;
|
||||
descricao_componente: string;
|
||||
perfil_componente: string;
|
||||
peso_unitario_componente: string;
|
||||
quantidade_por_peca: string;
|
||||
}
|
||||
|
||||
const TecnometalConverter: React.FC = () => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processedData, setProcessedData] = useState<ProcessedRow[]>([]);
|
||||
const [showTable, setShowTable] = useState(false);
|
||||
const [editingCell, setEditingCell] = useState<{ row: number; col: string } | null>(null);
|
||||
const [debugInfo, setDebugInfo] = useState<string>('');
|
||||
const [tecnometalConfig, setTecnometalConfig] = useState<any>(null);
|
||||
const [showWebhookConfig, setShowWebhookConfig] = useState(false);
|
||||
const [selectedWebhookConfig, setSelectedWebhookConfig] = useState<any>(null);
|
||||
const [currentProcessing, setCurrentProcessing] = useState<any>(null);
|
||||
const [isMonitoring, setIsMonitoring] = useState(false);
|
||||
|
||||
const { createFileProcessing, updateFileProcessing, fileProcessings } = useWebhookConfigs();
|
||||
|
||||
const columnHeaders = [
|
||||
'of_number', 'etapa_fase', 'marca', 'descricao', 'quantidade',
|
||||
'peso_unitario', 'peso_total', 'tratamento_superficial', 'material',
|
||||
'perfil_principal', 'tem_componentes', 'marca_componente', 'descricao_componente',
|
||||
'perfil_componente', 'peso_unitario_componente', 'quantidade_por_peca'
|
||||
];
|
||||
|
||||
const columnLabels = {
|
||||
of_number: 'OF',
|
||||
etapa_fase: 'Fase',
|
||||
marca: 'Marca',
|
||||
descricao: 'Descrição',
|
||||
quantidade: 'Qtd',
|
||||
peso_unitario: 'Peso Un.',
|
||||
peso_total: 'Peso Total',
|
||||
tratamento_superficial: 'Tratamento',
|
||||
material: 'Material',
|
||||
perfil_principal: 'Perfil Principal',
|
||||
tem_componentes: 'Tem Comp.',
|
||||
marca_componente: 'Marca Comp.',
|
||||
descricao_componente: 'Desc. Comp.',
|
||||
perfil_componente: 'Perfil Comp.',
|
||||
peso_unitario_componente: 'Peso Un. Comp.',
|
||||
quantidade_por_peca: 'Qtd por Peça'
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadTecnometalConfig = async () => {
|
||||
try {
|
||||
const config = await jsonCodeManager.getJsonCodeByName('Tecnometal');
|
||||
if (config) {
|
||||
setTecnometalConfig(config);
|
||||
setDebugInfo(prev => prev + 'Configuração Tecnometal carregada com sucesso!\n');
|
||||
} else {
|
||||
setDebugInfo(prev => prev + 'AVISO: Configuração "Tecnometal" não encontrada no sistema.\n');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar configuração Tecnometal:', error);
|
||||
setDebugInfo(prev => prev + `ERRO ao carregar configuração: ${error}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
loadTecnometalConfig();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const allowedTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
'application/pdf'
|
||||
];
|
||||
|
||||
if (allowedTypes.includes(file.type)) {
|
||||
setSelectedFile(file);
|
||||
setDebugInfo('');
|
||||
setShowTable(false);
|
||||
setProcessedData([]);
|
||||
setCurrentProcessing(null);
|
||||
toast.success('Arquivo selecionado com sucesso!');
|
||||
} else {
|
||||
toast.error('Tipo de arquivo não suportado. Use .xlsx, .xls ou .pdf');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendToWebhook = async (file: File, webhookConfig: any) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(webhookConfig.link_envio, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erro no webhook: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Erro ao enviar para webhook:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const checkWebhookStatus = async (webhookConfig: any, processingId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${webhookConfig.link_recebimento}/${processingId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erro ao verificar status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Erro ao verificar status:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const startMonitoring = async (processingId: string) => {
|
||||
if (!selectedWebhookConfig) return;
|
||||
|
||||
setIsMonitoring(true);
|
||||
|
||||
const monitorInterval = setInterval(async () => {
|
||||
const status = await checkWebhookStatus(selectedWebhookConfig, processingId);
|
||||
|
||||
if (status) {
|
||||
if (status.completed) {
|
||||
await updateFileProcessing(processingId, {
|
||||
status: 'concluido',
|
||||
completed_at: new Date().toISOString(),
|
||||
download_url: status.download_url
|
||||
});
|
||||
|
||||
setCurrentProcessing({
|
||||
...currentProcessing,
|
||||
status: 'concluido',
|
||||
download_url: status.download_url
|
||||
});
|
||||
|
||||
setIsMonitoring(false);
|
||||
clearInterval(monitorInterval);
|
||||
toast.success('Arquivo processado e disponível para download!');
|
||||
} else if (status.error) {
|
||||
await updateFileProcessing(processingId, {
|
||||
status: 'erro'
|
||||
});
|
||||
|
||||
setIsMonitoring(false);
|
||||
clearInterval(monitorInterval);
|
||||
toast.error('Erro no processamento do arquivo');
|
||||
}
|
||||
}
|
||||
}, 5000); // Verifica a cada 5 segundos
|
||||
|
||||
// Para o monitoramento após 10 minutos
|
||||
setTimeout(() => {
|
||||
clearInterval(monitorInterval);
|
||||
setIsMonitoring(false);
|
||||
}, 600000);
|
||||
};
|
||||
|
||||
const processFile = async () => {
|
||||
if (!selectedFile) {
|
||||
toast.error('Por favor, selecione um arquivo para processar.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedWebhookConfig) {
|
||||
toast.error('Por favor, selecione uma configuração de webhook.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setDebugInfo('Enviando arquivo para webhook...\n');
|
||||
|
||||
try {
|
||||
// Criar registro de processamento
|
||||
const processing = {
|
||||
webhook_config_id: selectedWebhookConfig.id,
|
||||
file_name: selectedFile.name,
|
||||
file_type: selectedFile.type,
|
||||
status: 'enviando'
|
||||
};
|
||||
|
||||
const success = await createFileProcessing(processing);
|
||||
if (!success) {
|
||||
throw new Error('Erro ao criar registro de processamento');
|
||||
}
|
||||
|
||||
// Enviar arquivo para webhook
|
||||
const webhookResponse = await sendToWebhook(selectedFile, selectedWebhookConfig);
|
||||
|
||||
setDebugInfo(prev => prev + 'Arquivo enviado com sucesso!\n');
|
||||
setDebugInfo(prev => prev + `ID do processamento: ${webhookResponse.processing_id}\n`);
|
||||
|
||||
// Atualizar status para enviado
|
||||
const currentFileProcessing = fileProcessings.find(fp =>
|
||||
fp.file_name === selectedFile.name && fp.status === 'enviando'
|
||||
);
|
||||
|
||||
if (currentFileProcessing) {
|
||||
await updateFileProcessing(currentFileProcessing.id, {
|
||||
status: 'processando'
|
||||
});
|
||||
|
||||
setCurrentProcessing({
|
||||
...currentFileProcessing,
|
||||
status: 'processando'
|
||||
});
|
||||
|
||||
// Iniciar monitoramento
|
||||
startMonitoring(currentFileProcessing.id);
|
||||
}
|
||||
|
||||
toast.success('Arquivo enviado para processamento!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar arquivo:', error);
|
||||
setDebugInfo(prev => prev + `ERRO: ${error}\n`);
|
||||
toast.error('Erro ao processar o arquivo. Verifique as configurações de webhook.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadProcessedFile = async () => {
|
||||
if (!currentProcessing?.download_url) {
|
||||
toast.error('URL de download não disponível.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(currentProcessing.download_url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Erro ao baixar arquivo');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `processed_${currentProcessing.file_name}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('Arquivo baixado com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao baixar arquivo:', error);
|
||||
toast.error('Erro ao baixar o arquivo processado.');
|
||||
}
|
||||
};
|
||||
|
||||
const processFileLocally = async (jsonData: any[], ofNumber: string, etapaFase: string) => {
|
||||
const linhasFinais: ProcessedRow[] = [];
|
||||
let dadosPecaMae: any = null;
|
||||
let componentesDoGrupo: any[] = [];
|
||||
|
||||
for (const row of jsonData) {
|
||||
const isEmptyRow = Object.values(row).every(val =>
|
||||
val === null || val === undefined || val === ''
|
||||
);
|
||||
|
||||
if (isEmptyRow) continue;
|
||||
|
||||
if (row['Marca']) {
|
||||
if (dadosPecaMae) {
|
||||
const pecaMaeFormatada = {
|
||||
of_number: ofNumber,
|
||||
etapa_fase: etapaFase,
|
||||
marca: formatarMarca(dadosPecaMae['Marca']),
|
||||
descricao: dadosPecaMae['Descrição'] || '',
|
||||
quantidade: String(dadosPecaMae['Qtde'] || ''),
|
||||
peso_unitario: formatarDecimal(dadosPecaMae['P.Un.'] || ''),
|
||||
peso_total: formatarDecimal(dadosPecaMae['P.Tot.'] || ''),
|
||||
tratamento_superficial: '-',
|
||||
material: componentesDoGrupo.length > 0 ?
|
||||
getMaterial(componentesDoGrupo[0]['Comp. Mat.'] || '') : '',
|
||||
perfil_principal: dadosPecaMae['Descrição'] || '',
|
||||
tem_componentes: componentesDoGrupo.length > 0 ? 'true' : 'false'
|
||||
};
|
||||
|
||||
if (componentesDoGrupo.length > 0) {
|
||||
for (const comp of componentesDoGrupo) {
|
||||
linhasFinais.push({
|
||||
...pecaMaeFormatada,
|
||||
marca_componente: formatarMarca(comp['Pos.'] || ''),
|
||||
descricao_componente: comp['Descrição'] || '',
|
||||
perfil_componente: comp['Descrição'] || '',
|
||||
peso_unitario_componente: formatarDecimal(comp['P.Un.'] || ''),
|
||||
quantidade_por_peca: String(comp['Qtde'] || '')
|
||||
});
|
||||
}
|
||||
} else {
|
||||
linhasFinais.push({
|
||||
...pecaMaeFormatada,
|
||||
marca_componente: '',
|
||||
descricao_componente: '',
|
||||
perfil_componente: '',
|
||||
peso_unitario_componente: '',
|
||||
quantidade_por_peca: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dadosPecaMae = row;
|
||||
componentesDoGrupo = [];
|
||||
} else if (row['Pos.'] && dadosPecaMae) {
|
||||
componentesDoGrupo.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return linhasFinais;
|
||||
};
|
||||
|
||||
const extractMetadataFromFilename = (filename: string) => {
|
||||
console.log('Extraindo metadados do nome do arquivo:', filename);
|
||||
const ofNumber = filename.split('-')[0];
|
||||
const faseMatch = filename.match(/FASE-(\d+)/i);
|
||||
const etapaFase = faseMatch ? faseMatch[1] : '1';
|
||||
|
||||
console.log('Metadados extraídos:', { ofNumber, etapaFase });
|
||||
return { ofNumber, etapaFase };
|
||||
};
|
||||
|
||||
const formatarMarca = (marca: string): string => {
|
||||
if (typeof marca === 'string' && marca.includes('-')) {
|
||||
return marca.split('-').pop() || marca;
|
||||
}
|
||||
return marca;
|
||||
};
|
||||
|
||||
const formatarDecimal = (numero: any): string => {
|
||||
try {
|
||||
return String(numero).replace(',', '.');
|
||||
} catch {
|
||||
return String(numero);
|
||||
}
|
||||
};
|
||||
|
||||
const getMaterial = (matStr: string): string => {
|
||||
if (typeof matStr === 'string') {
|
||||
const parts = matStr.split(' ');
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleCellEdit = (rowIndex: number, column: string, value: string) => {
|
||||
const newData = [...processedData];
|
||||
newData[rowIndex] = { ...newData[rowIndex], [column]: value };
|
||||
setProcessedData(newData);
|
||||
};
|
||||
|
||||
const deleteRow = (rowIndex: number) => {
|
||||
const newData = processedData.filter((_, index) => index !== rowIndex);
|
||||
setProcessedData(newData);
|
||||
toast.success('Linha excluída com sucesso!');
|
||||
};
|
||||
|
||||
const addNewRow = () => {
|
||||
const newRow: ProcessedRow = {
|
||||
of_number: '',
|
||||
etapa_fase: '',
|
||||
marca: '',
|
||||
descricao: '',
|
||||
quantidade: '',
|
||||
peso_unitario: '',
|
||||
peso_total: '',
|
||||
tratamento_superficial: '-',
|
||||
material: '',
|
||||
perfil_principal: '',
|
||||
tem_componentes: 'false',
|
||||
marca_componente: '',
|
||||
descricao_componente: '',
|
||||
perfil_componente: '',
|
||||
peso_unitario_componente: '',
|
||||
quantidade_por_peca: ''
|
||||
};
|
||||
|
||||
setProcessedData([...processedData, newRow]);
|
||||
toast.success('Nova linha adicionada!');
|
||||
};
|
||||
|
||||
const generateCSV = () => {
|
||||
if (processedData.length === 0) {
|
||||
toast.error('Nenhum dado para gerar o CSV.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const csvContent = [
|
||||
columnHeaders.join(','),
|
||||
...processedData.map(row =>
|
||||
columnHeaders.map(col => row[col as keyof ProcessedRow]).join(',')
|
||||
)
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
|
||||
const { ofNumber, etapaFase } = extractMetadataFromFilename(selectedFile?.name || 'arquivo');
|
||||
const fileName = `CSV_FINAL_${ofNumber}_FASE-${etapaFase}.csv`;
|
||||
|
||||
link.setAttribute('href', URL.createObjectURL(blob));
|
||||
link.setAttribute('download', fileName);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast.success(`Arquivo ${fileName} gerado com sucesso!`);
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar CSV:', error);
|
||||
toast.error('Erro ao gerar o arquivo CSV.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Processador Tecnometal com Webhook
|
||||
<Button
|
||||
onClick={() => setShowWebhookConfig(!showWebhookConfig)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ml-auto"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configurações
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{showWebhookConfig && (
|
||||
<WebhookConfigManager
|
||||
converterName="Tecnometal"
|
||||
onConfigSelect={(config) => {
|
||||
setSelectedWebhookConfig(config);
|
||||
setShowWebhookConfig(false);
|
||||
toast.success('Configuração selecionada!');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedWebhookConfig && (
|
||||
<Card className="bg-slate-700/30 border-slate-600">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">Configuração Ativa:</p>
|
||||
<p className="text-slate-400 text-xs">
|
||||
Envio: {selectedWebhookConfig.link_envio}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-white text-sm font-medium">
|
||||
Selecionar Arquivo (.pdf, .xlsx)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.xlsx,.xls"
|
||||
onChange={handleFileSelect}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full bg-slate-600 border-slate-500 text-white hover:bg-slate-500"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{selectedFile ? selectedFile.name : 'Selecionar Arquivo'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={processFile}
|
||||
disabled={!selectedFile || !selectedWebhookConfig || isProcessing}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Enviando para Webhook...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Enviar e Processar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{currentProcessing && (
|
||||
<Card className="bg-slate-700/30 border-slate-600">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">Status do Processamento:</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{currentProcessing.status === 'processando' ? (
|
||||
<>
|
||||
<Clock className="w-4 h-4 text-yellow-500 animate-pulse" />
|
||||
<span className="text-yellow-400 text-sm">Processando...</span>
|
||||
</>
|
||||
) : currentProcessing.status === 'concluido' ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-green-400 text-sm">Concluído</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-slate-400 text-sm">{currentProcessing.status}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentProcessing.status === 'concluido' && currentProcessing.download_url && (
|
||||
<Button
|
||||
onClick={downloadProcessedFile}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Baixar Arquivo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{debugInfo && (
|
||||
<div className="bg-slate-900 border border-slate-600 rounded p-3">
|
||||
<h4 className="text-white font-medium mb-2">Log de Processamento:</h4>
|
||||
<pre className="text-green-400 text-xs whitespace-pre-wrap overflow-x-auto">
|
||||
{debugInfo}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isMonitoring && (
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-blue-500" />
|
||||
<span className="text-white">Monitorando processamento...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TecnometalConverter;
|
||||
199
src/components/conversores/WebhookConfigManager.tsx
Normal file
199
src/components/conversores/WebhookConfigManager.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Plus, Edit, Trash2, Save, X } from 'lucide-react';
|
||||
import { useWebhookConfigs } from '@/hooks/useWebhookConfigs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface WebhookConfigManagerProps {
|
||||
converterName: string;
|
||||
onConfigSelect: (config: any) => void;
|
||||
}
|
||||
|
||||
const WebhookConfigManager: React.FC<WebhookConfigManagerProps> = ({
|
||||
converterName,
|
||||
onConfigSelect
|
||||
}) => {
|
||||
const { webhookConfigs, loading, saveWebhookConfig, deleteWebhookConfig } = useWebhookConfigs();
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
link_envio: '',
|
||||
link_recebimento: ''
|
||||
});
|
||||
|
||||
const configs = webhookConfigs.filter(config => config.converter_name === converterName);
|
||||
|
||||
const handleEdit = (config: any) => {
|
||||
setEditingConfig(config);
|
||||
setFormData({
|
||||
link_envio: config.link_envio,
|
||||
link_recebimento: config.link_recebimento
|
||||
});
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingConfig(null);
|
||||
setFormData({
|
||||
link_envio: '',
|
||||
link_recebimento: ''
|
||||
});
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.link_envio.trim() || !formData.link_recebimento.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await saveWebhookConfig({
|
||||
id: editingConfig?.id,
|
||||
converter_name: converterName,
|
||||
link_envio: formData.link_envio,
|
||||
link_recebimento: formData.link_recebimento
|
||||
});
|
||||
|
||||
setShowDialog(false);
|
||||
setFormData({ link_envio: '', link_recebimento: '' });
|
||||
setEditingConfig(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowDialog(false);
|
||||
setFormData({ link_envio: '', link_recebimento: '' });
|
||||
setEditingConfig(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse">Carregando configurações...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-slate-700/50 border-slate-600">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white text-sm">Configurações de Webhook</CardTitle>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{configs.length === 0 ? (
|
||||
<p className="text-slate-400 text-sm text-center py-4">
|
||||
Nenhuma configuração de webhook cadastrada
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-800/50 rounded border border-slate-600"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">
|
||||
Envio: {config.link_envio}
|
||||
</p>
|
||||
<p className="text-slate-400 text-xs">
|
||||
Recebimento: {config.link_recebimento}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onConfigSelect(config)}
|
||||
className="text-xs"
|
||||
>
|
||||
Usar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEdit(config)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteWebhookConfig(config.id)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={handleClose}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{editingConfig ? 'Editar Configuração' : 'Nova Configuração'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Configure os links de webhook para o conversor {converterName}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="link_envio" className="text-white">Link de Envio</Label>
|
||||
<Input
|
||||
id="link_envio"
|
||||
placeholder="https://api.exemplo.com/upload"
|
||||
value={formData.link_envio}
|
||||
onChange={(e) => setFormData({ ...formData, link_envio: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="link_recebimento" className="text-white">Link de Recebimento</Label>
|
||||
<Input
|
||||
id="link_recebimento"
|
||||
placeholder="https://api.exemplo.com/download"
|
||||
value={formData.link_recebimento}
|
||||
onChange={(e) => setFormData({ ...formData, link_recebimento: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{editingConfig ? 'Salvar' : 'Criar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebhookConfigManager;
|
||||
433
src/components/cronograma/CronogramaForm.tsx
Normal file
433
src/components/cronograma/CronogramaForm.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { CalendarIcon, Save, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CronogramaOf, ProcessoCronograma } from '@/types/cronograma';
|
||||
import { useCronogramaOperations } from '@/hooks/useCronogramaOperations';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CronogramaFormProps {
|
||||
cronograma?: CronogramaOf | null;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const processosDefault = [
|
||||
'Detalhamento',
|
||||
'Fabricação',
|
||||
'Pint/Galv',
|
||||
'Instalação',
|
||||
'Aceite/DB'
|
||||
];
|
||||
|
||||
export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onClose, isOpen }) => {
|
||||
const { saveCronograma, getCronogramaPorOf } = useCronogramaOperations();
|
||||
const [formData, setFormData] = useState({
|
||||
of_id: '',
|
||||
gestor_id: '',
|
||||
revisao: 1,
|
||||
processos: processosDefault.map((nome, index) => ({
|
||||
nome_processo: nome,
|
||||
data_inicio: '',
|
||||
data_fim: '',
|
||||
ordem: index + 1
|
||||
})) as ProcessoCronograma[]
|
||||
});
|
||||
|
||||
const [ofs, setOfs] = useState<any[]>([]);
|
||||
const [gestores, setGestores] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadOfs();
|
||||
loadGestores();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cronograma) {
|
||||
console.log('Carregando cronograma para edição:', cronograma);
|
||||
setFormData({
|
||||
of_id: cronograma.of_id,
|
||||
gestor_id: cronograma.gestor_id,
|
||||
revisao: cronograma.revisao,
|
||||
processos: cronograma.processos.length > 0 ? cronograma.processos.map(p => ({
|
||||
id: p.id,
|
||||
nome_processo: p.nome_processo,
|
||||
data_inicio: p.data_inicio,
|
||||
data_fim: p.data_fim,
|
||||
ordem: p.ordem
|
||||
})) : processosDefault.map((nome, index) => ({
|
||||
nome_processo: nome,
|
||||
data_inicio: '',
|
||||
data_fim: '',
|
||||
ordem: index + 1
|
||||
}))
|
||||
});
|
||||
}
|
||||
}, [cronograma]);
|
||||
|
||||
const loadOfs = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('ordens_fabricacao')
|
||||
.select('id, num_of, descritivo')
|
||||
.order('num_of');
|
||||
|
||||
if (error) throw error;
|
||||
setOfs(data || []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar OFs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadGestores = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, full_name, email')
|
||||
.not('full_name', 'is', null)
|
||||
.order('full_name');
|
||||
|
||||
if (error) throw error;
|
||||
setGestores(data || []);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar gestores:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOfChange = async (ofId: string) => {
|
||||
console.log('OF selecionada:', ofId);
|
||||
setFormData(prev => ({ ...prev, of_id: ofId }));
|
||||
|
||||
// Verificar se já existe cronograma para esta OF
|
||||
const cronogramaExistente = await getCronogramaPorOf(ofId);
|
||||
console.log('Cronograma existente encontrado:', cronogramaExistente);
|
||||
|
||||
if (cronogramaExistente) {
|
||||
// Carregar dados do cronograma existente
|
||||
setFormData({
|
||||
of_id: ofId,
|
||||
gestor_id: cronogramaExistente.gestor_id,
|
||||
revisao: cronogramaExistente.revisao,
|
||||
processos: cronogramaExistente.processos.length > 0 ? cronogramaExistente.processos.map(p => ({
|
||||
id: p.id,
|
||||
nome_processo: p.nome_processo,
|
||||
data_inicio: p.data_inicio,
|
||||
data_fim: p.data_fim,
|
||||
ordem: p.ordem
|
||||
})) : processosDefault.map((nome, index) => ({
|
||||
nome_processo: nome,
|
||||
data_inicio: '',
|
||||
data_fim: '',
|
||||
ordem: index + 1
|
||||
}))
|
||||
});
|
||||
toast.info('Cronograma existente carregado para edição');
|
||||
} else {
|
||||
// Resetar para valores padrão se não houver cronograma
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
gestor_id: '',
|
||||
revisao: 1,
|
||||
processos: processosDefault.map((nome, index) => ({
|
||||
nome_processo: nome,
|
||||
data_inicio: '',
|
||||
data_fim: '',
|
||||
ordem: index + 1
|
||||
}))
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcessoChange = (index: number, field: keyof ProcessoCronograma, value: string) => {
|
||||
const newProcessos = [...formData.processos];
|
||||
newProcessos[index] = { ...newProcessos[index], [field]: value };
|
||||
setFormData({ ...formData, processos: newProcessos });
|
||||
};
|
||||
|
||||
const addProcesso = () => {
|
||||
const newProcesso: ProcessoCronograma = {
|
||||
nome_processo: '',
|
||||
data_inicio: '',
|
||||
data_fim: '',
|
||||
ordem: formData.processos.length + 1
|
||||
};
|
||||
setFormData({
|
||||
...formData,
|
||||
processos: [...formData.processos, newProcesso]
|
||||
});
|
||||
};
|
||||
|
||||
const removeProcesso = (index: number) => {
|
||||
const newProcessos = formData.processos.filter((_, i) => i !== index);
|
||||
// Reordenar
|
||||
newProcessos.forEach((processo, i) => {
|
||||
processo.ordem = i + 1;
|
||||
});
|
||||
setFormData({ ...formData, processos: newProcessos });
|
||||
};
|
||||
|
||||
const moveProcessoUp = (index: number) => {
|
||||
if (index === 0) return;
|
||||
|
||||
const newProcessos = [...formData.processos];
|
||||
[newProcessos[index], newProcessos[index - 1]] = [newProcessos[index - 1], newProcessos[index]];
|
||||
|
||||
// Atualizar ordem
|
||||
newProcessos.forEach((processo, i) => {
|
||||
processo.ordem = i + 1;
|
||||
});
|
||||
|
||||
setFormData({ ...formData, processos: newProcessos });
|
||||
};
|
||||
|
||||
const moveProcessoDown = (index: number) => {
|
||||
if (index === formData.processos.length - 1) return;
|
||||
|
||||
const newProcessos = [...formData.processos];
|
||||
[newProcessos[index], newProcessos[index + 1]] = [newProcessos[index + 1], newProcessos[index]];
|
||||
|
||||
// Atualizar ordem
|
||||
newProcessos.forEach((processo, i) => {
|
||||
processo.ordem = i + 1;
|
||||
});
|
||||
|
||||
setFormData({ ...formData, processos: newProcessos });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.of_id || !formData.gestor_id) {
|
||||
toast.error('Por favor, selecione a OF e o gestor responsável');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.processos.some(p => !p.nome_processo || !p.data_inicio || !p.data_fim)) {
|
||||
toast.error('Por favor, preencha todos os campos dos processos');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const success = await saveCronograma({
|
||||
...formData,
|
||||
id: cronograma?.id
|
||||
});
|
||||
setLoading(false);
|
||||
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const DatePicker = ({ date, onDateChange, placeholder }: {
|
||||
date: string;
|
||||
onDateChange: (date: string) => void;
|
||||
placeholder: string;
|
||||
}) => {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
||||
date ? new Date(date + 'T12:00:00') : undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 text-gray-900 dark:text-gray-100",
|
||||
!selectedDate && "text-gray-500 dark:text-gray-400"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{selectedDate ? format(selectedDate, "dd/MM/yyyy", { locale: ptBR }) : placeholder}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
setSelectedDate(date);
|
||||
onDateChange(format(date, 'yyyy-MM-dd'));
|
||||
}
|
||||
}}
|
||||
initialFocus
|
||||
className="pointer-events-auto p-3"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{cronograma ? 'Editar Cronograma' : 'Novo Cronograma'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="of_id">Ordem de Fabricação *</Label>
|
||||
<Select
|
||||
value={formData.of_id}
|
||||
onValueChange={handleOfChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ofs.map((of) => (
|
||||
<SelectItem key={of.id} value={of.id}>
|
||||
{of.num_of} - {of.descritivo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gestor_id">Gestor Responsável *</Label>
|
||||
<Select
|
||||
value={formData.gestor_id}
|
||||
onValueChange={(value) => setFormData({ ...formData, gestor_id: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o gestor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gestores.map((gestor) => (
|
||||
<SelectItem key={gestor.id} value={gestor.id}>
|
||||
{gestor.full_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Processos do Cronograma</h3>
|
||||
<Button type="button" onClick={addProcesso} size="sm" variant="outline">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Adicionar Processo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{formData.processos.map((processo, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Controles de ordenação */}
|
||||
<div className="flex flex-col gap-1 mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => moveProcessoUp(index)}
|
||||
disabled={index === 0}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => moveProcessoDown(index)}
|
||||
disabled={index === formData.processos.length - 1}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Número de ordem */}
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full mt-6">
|
||||
<span className="text-sm font-semibold text-blue-600 dark:text-blue-300">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Campos do processo */}
|
||||
<div className="flex-1">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label>Nome do Processo *</Label>
|
||||
<Input
|
||||
value={processo.nome_processo}
|
||||
onChange={(e) => handleProcessoChange(index, 'nome_processo', e.target.value)}
|
||||
placeholder="Ex: Detalhamento"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Data de Início *</Label>
|
||||
<DatePicker
|
||||
date={processo.data_inicio}
|
||||
onDateChange={(date) => handleProcessoChange(index, 'data_inicio', date)}
|
||||
placeholder="Selecionar data"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Data de Fim *</Label>
|
||||
<DatePicker
|
||||
date={processo.data_fim}
|
||||
onDateChange={(date) => handleProcessoChange(index, 'data_fim', date)}
|
||||
placeholder="Selecionar data"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botão de remoção */}
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeProcesso(index)}
|
||||
disabled={formData.processos.length === 1}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{loading ? 'Salvando...' : 'Salvar Cronograma'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
185
src/components/cronograma/CronogramaGantt.tsx
Normal file
185
src/components/cronograma/CronogramaGantt.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X, Calendar, FileDown } from 'lucide-react';
|
||||
import { format, differenceInDays, parseISO } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { CronogramaOf } from '@/types/cronograma';
|
||||
|
||||
interface CronogramaGanttProps {
|
||||
cronograma: CronogramaOf;
|
||||
onClose: () => void;
|
||||
onExportPDF?: () => void;
|
||||
}
|
||||
|
||||
export const CronogramaGantt: React.FC<CronogramaGanttProps> = ({ cronograma, onClose, onExportPDF }) => {
|
||||
const cores = [
|
||||
'bg-blue-500 dark:bg-blue-600',
|
||||
'bg-green-500 dark:bg-green-600',
|
||||
'bg-yellow-500 dark:bg-yellow-600',
|
||||
'bg-purple-500 dark:bg-purple-600',
|
||||
'bg-red-500 dark:bg-red-600',
|
||||
'bg-indigo-500 dark:bg-indigo-600',
|
||||
'bg-pink-500 dark:bg-pink-600',
|
||||
'bg-gray-500 dark:bg-gray-600'
|
||||
];
|
||||
|
||||
// Calcular o período total do cronograma
|
||||
const todasAsDatas = cronograma.processos.flatMap(p => [p.data_inicio, p.data_fim]);
|
||||
const dataInicioTotal = new Date(Math.min(...todasAsDatas.map(d => new Date(d).getTime())));
|
||||
const dataFimTotal = new Date(Math.max(...todasAsDatas.map(d => new Date(d).getTime())));
|
||||
const duracaoTotal = differenceInDays(dataFimTotal, dataInicioTotal) + 1;
|
||||
|
||||
const calcularPosicaoELargura = (dataInicio: string, dataFim: string) => {
|
||||
const inicio = parseISO(dataInicio);
|
||||
const fim = parseISO(dataFim);
|
||||
|
||||
const diasDoInicio = differenceInDays(inicio, dataInicioTotal);
|
||||
const duracaoProcesso = differenceInDays(fim, inicio) + 1;
|
||||
|
||||
const left = (diasDoInicio / duracaoTotal) * 100;
|
||||
const width = (duracaoProcesso / duracaoTotal) * 100;
|
||||
|
||||
return { left: `${left}%`, width: `${width}%` };
|
||||
};
|
||||
|
||||
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
|
||||
return differenceInDays(parseISO(dataFim), parseISO(dataInicio)) + 1;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-6xl max-h-[90vh] overflow-auto bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
|
||||
<CardTitle className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Cronograma da OF: {cronograma.ordem_fabricacao?.num_of} - Rev. {cronograma.revisao}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{onExportPDF && (
|
||||
<Button onClick={onExportPDF} variant="outline" size="sm" className="border-gray-200 dark:border-gray-600">
|
||||
<FileDown className="w-4 h-4 mr-2" />
|
||||
Exportar PDF
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={onClose} className="hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 p-6">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Gestor Responsável:</strong> {cronograma.gestor_profile?.full_name}
|
||||
</div>
|
||||
|
||||
{/* Tabela de Prazos */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Prazos de Produção da OF</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-gray-300 dark:border-gray-600">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-gray-900 dark:text-gray-100">Processo</th>
|
||||
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-gray-900 dark:text-gray-100">Data de Início</th>
|
||||
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-gray-900 dark:text-gray-100">Data de Fim</th>
|
||||
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-gray-900 dark:text-gray-100">Dias Corridos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cronograma.processos
|
||||
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
|
||||
.map((processo, index) => (
|
||||
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-700'}>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
{processo.nome_processo}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-gray-900 dark:text-gray-100">
|
||||
{format(parseISO(processo.data_inicio), 'dd/MM/yyyy', { locale: ptBR })}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-gray-900 dark:text-gray-100">
|
||||
{format(parseISO(processo.data_fim), 'dd/MM/yyyy', { locale: ptBR })}
|
||||
</td>
|
||||
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-center text-gray-900 dark:text-gray-100">
|
||||
{calcularDiasCorridos(processo.data_inicio, processo.data_fim)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gráfico de Gantt */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Linha do Tempo dos Processos</h3>
|
||||
|
||||
{/* Cabeçalho com datas */}
|
||||
<div className="relative bg-gray-100 dark:bg-gray-700 h-8 rounded border border-gray-200 dark:border-gray-600">
|
||||
<div className="absolute left-0 top-1 text-xs font-medium px-2 text-gray-700 dark:text-gray-300">
|
||||
{format(dataInicioTotal, 'dd/MM/yyyy', { locale: ptBR })}
|
||||
</div>
|
||||
<div className="absolute right-0 top-1 text-xs font-medium px-2 text-gray-700 dark:text-gray-300">
|
||||
{format(dataFimTotal, 'dd/MM/yyyy', { locale: ptBR })}
|
||||
</div>
|
||||
<div className="absolute left-1/2 top-1 text-xs font-medium transform -translate-x-1/2 text-gray-700 dark:text-gray-300">
|
||||
{duracaoTotal} dias totais
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barras dos processos */}
|
||||
<div className="space-y-3">
|
||||
{cronograma.processos
|
||||
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
|
||||
.map((processo, index) => {
|
||||
const posicao = calcularPosicaoELargura(processo.data_inicio, processo.data_fim);
|
||||
const cor = cores[index % cores.length];
|
||||
const diasCorridos = calcularDiasCorridos(processo.data_inicio, processo.data_fim);
|
||||
const dataInicioFormatada = format(parseISO(processo.data_inicio), 'dd/MM', { locale: ptBR });
|
||||
const dataFimFormatada = format(parseISO(processo.data_fim), 'dd/MM', { locale: ptBR });
|
||||
|
||||
return (
|
||||
<div key={index} className="relative">
|
||||
<div className="flex items-center mb-1">
|
||||
<div className="w-32 text-sm font-medium truncate text-gray-900 dark:text-gray-100" title={processo.nome_processo}>
|
||||
{processo.nome_processo}
|
||||
</div>
|
||||
<div className="flex-1 relative h-8 bg-gray-200 dark:bg-gray-600 rounded ml-4">
|
||||
<div
|
||||
className={`absolute top-0 h-full ${cor} rounded flex items-center justify-between px-2 text-white text-xs font-medium`}
|
||||
style={posicao}
|
||||
title={`${processo.nome_processo}: ${dataInicioFormatada} - ${dataFimFormatada} (${diasCorridos} dias)`}
|
||||
>
|
||||
<span className="text-xs">{dataInicioFormatada}</span>
|
||||
<span className="text-xs font-bold">{diasCorridos}d</span>
|
||||
<span className="text-xs">{dataFimFormatada}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legenda */}
|
||||
<div className="flex flex-wrap gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Legenda:</div>
|
||||
{cronograma.processos
|
||||
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
|
||||
.map((processo, index) => {
|
||||
const cor = cores[index % cores.length];
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2 text-sm">
|
||||
<div className={`w-4 h-4 ${cor} rounded`}></div>
|
||||
<span className="text-gray-900 dark:text-gray-100">{processo.nome_processo}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
293
src/components/cronograma/CronogramaPDF.tsx
Normal file
293
src/components/cronograma/CronogramaPDF.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { CronogramaOf } from '@/hooks/useCronogramas';
|
||||
import { useBrandSettings } from '@/hooks/useBrandSettings';
|
||||
import jsPDF from 'jspdf';
|
||||
import { format, differenceInDays, parseISO } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
interface CronogramaPDFProps {
|
||||
cronograma: CronogramaOf;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComplete }) => {
|
||||
const { brandSettings } = useBrandSettings();
|
||||
|
||||
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
|
||||
return differenceInDays(parseISO(dataFim), parseISO(dataInicio)) + 1;
|
||||
};
|
||||
|
||||
const gerarPDF = async () => {
|
||||
const doc = new jsPDF('portrait', 'mm', 'a4');
|
||||
|
||||
// Configurações de cores (tons profissionais em cinza)
|
||||
const corCinzaClaro = [200, 200, 200]; // Cinza claro para cabeçalho
|
||||
const corCinzaMedio = [150, 150, 150]; // Cinza médio
|
||||
const corCinzaEscuro = [80, 80, 80]; // Cinza escuro para texto
|
||||
const corBranco = [255, 255, 255]; // Branco
|
||||
|
||||
const pageWidth = doc.internal.pageSize.width;
|
||||
const pageHeight = doc.internal.pageSize.height;
|
||||
const margin = 15;
|
||||
const usableWidth = pageWidth - (margin * 2);
|
||||
let yPosition = margin;
|
||||
|
||||
// CABEÇALHO COMPACTO
|
||||
// Fundo do cabeçalho - altura reduzida para 25px
|
||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
||||
doc.rect(0, 0, pageWidth, 25, 'F');
|
||||
|
||||
// Logo da empresa (tamanho reduzido)
|
||||
if (brandSettings.logo_url) {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = brandSettings.logo_url!;
|
||||
});
|
||||
|
||||
// Logo menor - 20x15
|
||||
const logoWidth = 20;
|
||||
const logoHeight = 15;
|
||||
doc.addImage(img, 'PNG', margin, 5, logoWidth, logoHeight);
|
||||
} catch (error) {
|
||||
console.log('Erro ao carregar logo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Nome da empresa e título
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(brandSettings.company_name, brandSettings.logo_url ? margin + 25 : margin, 12);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('CRONOGRAMA DE PRODUÇÃO', brandSettings.logo_url ? margin + 25 : margin, 18);
|
||||
|
||||
yPosition = 35;
|
||||
|
||||
// TÍTULO DO CRONOGRAMA
|
||||
doc.setFillColor(240, 240, 240);
|
||||
doc.rect(margin, yPosition - 3, usableWidth, 12, 'F');
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
const titulo = `OF: ${cronograma.ordem_fabricacao?.num_of} - Revisão: ${cronograma.revisao}`;
|
||||
doc.text(titulo, margin + 3, yPosition + 3);
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Gestor: ${cronograma.gestor_profile?.full_name || 'N/A'}`, margin + 3, yPosition + 8);
|
||||
|
||||
yPosition += 18;
|
||||
|
||||
// RESUMO EXECUTIVO COMPACTO
|
||||
const todasAsDatas = cronograma.processos.flatMap(p => [p.data_inicio, p.data_fim]);
|
||||
const dataInicioTotal = new Date(Math.min(...todasAsDatas.map(d => new Date(d).getTime())));
|
||||
const dataFimTotal = new Date(Math.max(...todasAsDatas.map(d => new Date(d).getTime())));
|
||||
const duracaoTotal = differenceInDays(dataFimTotal, dataInicioTotal) + 1;
|
||||
|
||||
// Boxes do resumo em linha
|
||||
const boxWidth = (usableWidth) / 3;
|
||||
|
||||
doc.setFillColor(corBranco[0], corBranco[1], corBranco[2]);
|
||||
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
|
||||
|
||||
// Box 1
|
||||
doc.rect(margin, yPosition, boxWidth - 2, 15, 'FD');
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${cronograma.processos.length} Processos`, margin + 3, yPosition + 6);
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Total', margin + 3, yPosition + 11);
|
||||
|
||||
// Box 2
|
||||
doc.rect(margin + boxWidth, yPosition, boxWidth - 2, 15, 'FD');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${duracaoTotal} dias`, margin + boxWidth + 3, yPosition + 6);
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Duração', margin + boxWidth + 3, yPosition + 11);
|
||||
|
||||
// Box 3
|
||||
doc.rect(margin + boxWidth * 2, yPosition, boxWidth - 2, 15, 'FD');
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${format(dataInicioTotal, 'dd/MM', { locale: ptBR })} - ${format(dataFimTotal, 'dd/MM', { locale: ptBR })}`, margin + boxWidth * 2 + 3, yPosition + 6);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Período', margin + boxWidth * 2 + 3, yPosition + 11);
|
||||
|
||||
yPosition += 22;
|
||||
|
||||
// TABELA COMPACTA
|
||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
||||
doc.rect(margin, yPosition, usableWidth, 8, 'F');
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('CRONOGRAMA DETALHADO', margin + 3, yPosition + 5);
|
||||
|
||||
yPosition += 10;
|
||||
|
||||
// Cabeçalho da tabela - altura reduzida
|
||||
const colWidths = [usableWidth * 0.4, usableWidth * 0.2, usableWidth * 0.2, usableWidth * 0.2];
|
||||
const headers = ['Processo', 'Início', 'Fim', 'Duração'];
|
||||
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(margin, yPosition, usableWidth, 7, 'F');
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
|
||||
let xPosition = margin;
|
||||
headers.forEach((header, index) => {
|
||||
doc.text(header, xPosition + 2, yPosition + 5);
|
||||
xPosition += colWidths[index];
|
||||
});
|
||||
|
||||
yPosition += 7;
|
||||
|
||||
// Linhas da tabela - altura reduzida em 40% (de 12 para 7.2)
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8);
|
||||
|
||||
cronograma.processos
|
||||
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
|
||||
.forEach((processo, index) => {
|
||||
// Alternância de cores
|
||||
if (index % 2 === 0) {
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.rect(margin, yPosition, usableWidth, 7, 'F');
|
||||
}
|
||||
|
||||
xPosition = margin;
|
||||
const rowData = [
|
||||
processo.nome_processo.length > 25 ? processo.nome_processo.substring(0, 25) + '...' : processo.nome_processo,
|
||||
format(parseISO(processo.data_inicio), 'dd/MM', { locale: ptBR }),
|
||||
format(parseISO(processo.data_fim), 'dd/MM', { locale: ptBR }),
|
||||
`${calcularDiasCorridos(processo.data_inicio, processo.data_fim)}d`
|
||||
];
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
rowData.forEach((data, colIndex) => {
|
||||
doc.text(data, xPosition + 2, yPosition + 5);
|
||||
xPosition += colWidths[colIndex];
|
||||
});
|
||||
|
||||
yPosition += 7;
|
||||
});
|
||||
|
||||
yPosition += 10;
|
||||
|
||||
// GRÁFICO DE GANTT VISUAL MELHORADO
|
||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
||||
doc.rect(margin, yPosition, usableWidth, 8, 'F');
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('LINHA DO TEMPO VISUAL', margin + 3, yPosition + 5);
|
||||
|
||||
yPosition += 12;
|
||||
|
||||
// Escala de tempo
|
||||
doc.setTextColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
|
||||
doc.setFontSize(8);
|
||||
doc.text(format(dataInicioTotal, 'dd/MM/yy', { locale: ptBR }), margin, yPosition - 2);
|
||||
doc.text(format(dataFimTotal, 'dd/MM/yy', { locale: ptBR }), margin + usableWidth - 20, yPosition - 2);
|
||||
doc.text(`${duracaoTotal} dias`, margin + usableWidth/2 - 10, yPosition - 2);
|
||||
|
||||
// Linha de base
|
||||
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
|
||||
doc.line(margin, yPosition, margin + usableWidth, yPosition);
|
||||
|
||||
yPosition += 3;
|
||||
|
||||
// Barras dos processos - área reservada para nomes maior
|
||||
const cores = [
|
||||
[52, 152, 219], // Azul
|
||||
[46, 204, 113], // Verde
|
||||
[241, 196, 15], // Amarelo
|
||||
[155, 89, 182], // Roxo
|
||||
[231, 76, 60], // Vermelho
|
||||
[230, 126, 34], // Laranja
|
||||
[26, 188, 156], // Turquesa
|
||||
[127, 140, 141] // Cinza
|
||||
];
|
||||
|
||||
const nomeAreaWidth = 60; // Área reservada para nomes dos processos
|
||||
const graficoWidth = usableWidth - nomeAreaWidth - 5;
|
||||
|
||||
cronograma.processos
|
||||
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
|
||||
.forEach((processo, index) => {
|
||||
const diasDoInicio = differenceInDays(parseISO(processo.data_inicio), dataInicioTotal);
|
||||
const duracaoProcesso = calcularDiasCorridos(processo.data_inicio, processo.data_fim);
|
||||
|
||||
const barraInicio = margin + nomeAreaWidth + (diasDoInicio / duracaoTotal) * graficoWidth;
|
||||
const barraLargura = Math.max(2, (duracaoProcesso / duracaoTotal) * graficoWidth);
|
||||
|
||||
// Nome do processo na área reservada
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
const nomeProcesso = processo.nome_processo.length > 20 ?
|
||||
processo.nome_processo.substring(0, 20) + '...' :
|
||||
processo.nome_processo;
|
||||
doc.text(nomeProcesso, margin, yPosition + 2);
|
||||
|
||||
// Barra colorida
|
||||
const cor = cores[index % cores.length];
|
||||
doc.setFillColor(cor[0], cor[1], cor[2]);
|
||||
doc.rect(barraInicio, yPosition - 1, barraLargura, 6, 'F');
|
||||
|
||||
// Duração na barra (se houver espaço)
|
||||
if (barraLargura > 8) {
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(6);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${duracaoProcesso}d`, barraInicio + barraLargura/2 - 2, yPosition + 2);
|
||||
}
|
||||
|
||||
yPosition += 8;
|
||||
});
|
||||
|
||||
// RODAPÉ COMPACTO
|
||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
||||
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`${brandSettings.company_name} - Sistema de Gestão`, margin, pageHeight - 8);
|
||||
doc.text(`${format(new Date(), 'dd/MM/yyyy HH:mm')}`, pageWidth - 35, pageHeight - 8);
|
||||
|
||||
// Download do PDF
|
||||
const nomeArquivo = `cronograma_${cronograma.ordem_fabricacao?.num_of}_rev${cronograma.revisao}_${format(new Date(), 'ddMMyyyy')}.pdf`;
|
||||
doc.save(nomeArquivo);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
gerarPDF();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [cronograma, onComplete, brandSettings]);
|
||||
|
||||
return null;
|
||||
};
|
||||
187
src/components/cronograma/CronogramaTable.tsx
Normal file
187
src/components/cronograma/CronogramaTable.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Edit, Trash2, BarChart3, FileText } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { CronogramaOf } from '@/types/cronograma';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface CronogramaTableProps {
|
||||
cronogramas: CronogramaOf[];
|
||||
onEdit: (cronograma: CronogramaOf) => void;
|
||||
onDelete: (cronogramaId: string) => void;
|
||||
onViewChart: (cronograma: CronogramaOf) => void;
|
||||
onViewPDF: (cronograma: CronogramaOf) => void;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
}
|
||||
|
||||
export const CronogramaTable: React.FC<CronogramaTableProps> = ({
|
||||
cronogramas,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewChart,
|
||||
onViewPDF,
|
||||
canEdit = true,
|
||||
canDelete = true,
|
||||
}) => {
|
||||
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
return diffDays;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-gray-200 dark:border-gray-700">
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">OF</TableHead>
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">Gestor</TableHead>
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">Revisão</TableHead>
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">Processos</TableHead>
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">Peso Total (kg)</TableHead>
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">Período Total</TableHead>
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">Dias Corridos</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cronogramas.map((cronograma) => {
|
||||
const primeiroProcesso = cronograma.processos[0];
|
||||
const ultimoProcesso = cronograma.processos[cronograma.processos.length - 1];
|
||||
const dataInicio = primeiroProcesso?.data_inicio;
|
||||
const dataFim = ultimoProcesso?.data_fim;
|
||||
const diasCorridos = dataInicio && dataFim ? calcularDiasCorridos(dataInicio, dataFim) : 0;
|
||||
|
||||
return (
|
||||
<TableRow key={cronograma.id} className="border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<TableCell className="font-medium">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100">{cronograma.ordem_fabricacao?.num_of}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{cronograma.ordem_fabricacao?.descritivo}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{cronograma.gestor_profile?.full_name || 'N/A'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100">Rev. {cronograma.revisao}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{cronograma.processos.length} processo{cronograma.processos.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">
|
||||
{cronograma.peso_total ? `${Number(cronograma.peso_total).toLocaleString('pt-BR')} kg` : 'N/A'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dataInicio && dataFim ? (
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-900 dark:text-gray-100">{format(new Date(dataInicio + 'T00:00:00'), 'dd/MM/yyyy', { locale: ptBR })}</div>
|
||||
<div className="text-gray-600 dark:text-gray-400">até</div>
|
||||
<div className="text-gray-900 dark:text-gray-100">{format(new Date(dataFim + 'T00:00:00'), 'dd/MM/yyyy', { locale: ptBR })}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100">{diasCorridos} dias</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewChart(cronograma)}
|
||||
title="Ver gráfico Gantt"
|
||||
className="border-blue-200 dark:border-blue-600 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-800/30 text-blue-700 dark:text-blue-300"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewPDF(cronograma)}
|
||||
title="Gerar PDF"
|
||||
className="border-green-200 dark:border-green-600 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-800/30 text-green-700 dark:text-green-300"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(cronograma)}
|
||||
title="Editar cronograma"
|
||||
className="border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<Edit className="w-4 h-4 text-gray-700 dark:text-gray-300" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title="Remover cronograma"
|
||||
className="border-red-200 dark:border-red-600 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-800/30 text-red-700 dark:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-gray-900 dark:text-gray-100">Remover Cronograma</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-600 dark:text-gray-400">
|
||||
Tem certeza que deseja remover o cronograma da OF {cronograma.ordem_fabricacao?.num_of}?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-gray-200 dark:border-gray-600 text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700">Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => cronograma.id && onDelete(cronograma.id)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
Remover
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
136
src/components/dashboard-producao/GraficoMestre.tsx
Normal file
136
src/components/dashboard-producao/GraficoMestre.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { format, parseISO, eachDayOfInterval } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
|
||||
|
||||
interface GraficoMestreProps {
|
||||
processos: DashboardProcesso[];
|
||||
onProcessoClick?: (processoNome: string) => void;
|
||||
processoSelecionado?: string | null;
|
||||
}
|
||||
|
||||
export const GraficoMestre: React.FC<GraficoMestreProps> = ({
|
||||
processos,
|
||||
onProcessoClick,
|
||||
processoSelecionado
|
||||
}) => {
|
||||
// Processar dados dos gráficos individuais para criar um gráfico sobreposto
|
||||
const dadosGraficoSobreposto = useMemo(() => {
|
||||
if (!processos || processos.length === 0) return [];
|
||||
|
||||
// Coletar todas as datas dos gráficos individuais
|
||||
const todasAsDatas = new Set<string>();
|
||||
processos.forEach(processo => {
|
||||
processo.dadosGrafico.forEach(ponto => {
|
||||
todasAsDatas.add(ponto.data);
|
||||
});
|
||||
});
|
||||
|
||||
// Ordenar as datas
|
||||
const datasOrdenadas = Array.from(todasAsDatas).sort();
|
||||
|
||||
// Construir dados do gráfico sobreposto
|
||||
return datasOrdenadas.map(data => {
|
||||
const pontoGrafico: any = {
|
||||
data: format(parseISO(data), 'dd/MM', { locale: ptBR }),
|
||||
dataCompleta: data
|
||||
};
|
||||
|
||||
// Para cada processo, buscar o valor realizado na data
|
||||
processos.forEach(processo => {
|
||||
const pontoProcesso = processo.dadosGrafico.find(ponto => ponto.data === data);
|
||||
// Converter para toneladas (dividir por 1000)
|
||||
pontoGrafico[processo.nome] = pontoProcesso ? Math.round(pontoProcesso.realizado / 1000 * 100) / 100 : 0;
|
||||
});
|
||||
|
||||
return pontoGrafico;
|
||||
});
|
||||
}, [processos]);
|
||||
|
||||
// Obter cores dos processos baseado no status
|
||||
const obterCorProcesso = (status: string) => {
|
||||
switch (status) {
|
||||
case 'verde':
|
||||
return '#10b981';
|
||||
case 'amarelo':
|
||||
return '#f59e0b';
|
||||
case 'vermelho':
|
||||
return '#ef4444';
|
||||
case 'azul':
|
||||
return '#3b82f6';
|
||||
default:
|
||||
return '#8884d8';
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcessoClick = (processoNome: string) => {
|
||||
if (onProcessoClick) {
|
||||
onProcessoClick(processoNome);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTooltipValue = (value: number, name: string) => [
|
||||
`${value.toFixed(2)} t`,
|
||||
name
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full h-96">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={dadosGraficoSobreposto}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="data"
|
||||
stroke="#9ca3af"
|
||||
fontSize={12}
|
||||
tick={{ fill: '#9ca3af' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#9ca3af"
|
||||
fontSize={12}
|
||||
tick={{ fill: '#9ca3af' }}
|
||||
label={{
|
||||
value: 'Peso Acumulado (t)',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { textAnchor: 'middle', fill: '#9ca3af' }
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1f2937',
|
||||
border: '1px solid #374151',
|
||||
borderRadius: '6px',
|
||||
color: '#f3f4f6'
|
||||
}}
|
||||
formatter={formatTooltipValue}
|
||||
labelFormatter={(label) => `Data: ${label}`}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ color: '#9ca3af' }}
|
||||
onClick={(e) => handleProcessoClick(e.value)}
|
||||
/>
|
||||
|
||||
{processos.map((processo) => (
|
||||
<Line
|
||||
key={processo.nome}
|
||||
type="monotone"
|
||||
dataKey={processo.nome}
|
||||
stroke={obterCorProcesso(processo.status)}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: obterCorProcesso(processo.status), strokeWidth: 2, r: 3 }}
|
||||
activeDot={{ r: 5, fill: obterCorProcesso(processo.status) }}
|
||||
opacity={processoSelecionado ? (processoSelecionado === processo.nome ? 1 : 0.3) : 1}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
119
src/components/dashboard-producao/GraficoProgressoIndividual.tsx
Normal file
119
src/components/dashboard-producao/GraficoProgressoIndividual.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
interface GraficoProgressoIndividualProps {
|
||||
processos: DashboardProcesso[];
|
||||
}
|
||||
|
||||
export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProps> = ({ processos }) => {
|
||||
const formatTooltipValue = (value: number, name: string) => [
|
||||
`${(value / 1000).toFixed(2)} t`,
|
||||
name === 'planejado' ? 'Planejado' : 'Realizado'
|
||||
];
|
||||
|
||||
const formatAxisValue = (value: number) => `${(value / 1000).toFixed(1)}t`;
|
||||
|
||||
const formatDateLabel = (tickItem: string) => {
|
||||
try {
|
||||
return format(new Date(tickItem), 'dd/MM', { locale: ptBR });
|
||||
} catch {
|
||||
return tickItem;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'verde': return '#10b981'; // emerald-500
|
||||
case 'amarelo': return '#f59e0b'; // amber-500
|
||||
case 'vermelho': return '#ef4444'; // red-500
|
||||
case 'azul': return '#3b82f6'; // blue-500
|
||||
default: return '#6b7280'; // gray-500
|
||||
}
|
||||
};
|
||||
|
||||
if (!processos.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||
<p>Nenhum processo disponível para exibir gráficos</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{processos.map((processo) => (
|
||||
<div key={processo.id} className="bg-card rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: getStatusColor(processo.status) }}
|
||||
/>
|
||||
<h3 className="text-lg font-semibold text-card-foreground">
|
||||
Progresso - {processo.nome}
|
||||
</h3>
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
{processo.progressoReal.toFixed(1)}% realizado
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={processo.dadosGrafico}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="data"
|
||||
tickFormatter={formatDateLabel}
|
||||
className="text-xs"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatAxisValue}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={formatTooltipValue}
|
||||
labelFormatter={(label) => `Data: ${formatDateLabel(label)}`}
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<defs>
|
||||
<linearGradient id={`colorPlanejado-${processo.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="#8884d8" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id={`colorRealizado-${processo.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={getStatusColor(processo.status)} stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor={getStatusColor(processo.status)} stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="planejado"
|
||||
stroke="#8884d8"
|
||||
fillOpacity={1}
|
||||
fill={`url(#colorPlanejado-${processo.id})`}
|
||||
name="planejado"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="realizado"
|
||||
stroke={getStatusColor(processo.status)}
|
||||
fillOpacity={1}
|
||||
fill={`url(#colorRealizado-${processo.id})`}
|
||||
name="realizado"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
interface GraficoProgressoProcessoProps {
|
||||
processo: DashboardProcesso;
|
||||
}
|
||||
|
||||
export const GraficoProgressoProcesso: React.FC<GraficoProgressoProcessoProps> = ({ processo }) => {
|
||||
const formatTooltipValue = (value: number, name: string) => [
|
||||
`${(value / 1000).toFixed(2)} t`,
|
||||
name === 'planejado' ? 'Planejado' : 'Realizado'
|
||||
];
|
||||
|
||||
const formatAxisValue = (value: number) => `${(value / 1000).toFixed(1)}t`;
|
||||
|
||||
const formatDateLabel = (tickItem: string) => {
|
||||
try {
|
||||
return format(new Date(tickItem), 'dd/MM', { locale: ptBR });
|
||||
} catch {
|
||||
return tickItem;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={processo.dadosGrafico}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="data"
|
||||
tickFormatter={formatDateLabel}
|
||||
className="text-xs"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatAxisValue}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={formatTooltipValue}
|
||||
labelFormatter={(label) => `Data: ${formatDateLabel(label)}`}
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<defs>
|
||||
<linearGradient id="colorPlanejado" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="#8884d8" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorRealizado" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="planejado"
|
||||
stroke="#8884d8"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorPlanejado)"
|
||||
name="planejado"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="realizado"
|
||||
stroke="#82ca9d"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorRealizado)"
|
||||
name="realizado"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
101
src/components/dashboard-producao/ResumoOF.tsx
Normal file
101
src/components/dashboard-producao/ResumoOF.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Package, TrendingUp, Weight, Calendar } from 'lucide-react';
|
||||
import { DashboardDataOtimizado } from '@/hooks/useDashboardProducaoOtimizado';
|
||||
|
||||
interface ResumoOFProps {
|
||||
of: string;
|
||||
data: DashboardDataOtimizado | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const ResumoOF: React.FC<ResumoOFProps> = ({ of, data, loading }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i} className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
|
||||
<div className="h-8 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-card-foreground">
|
||||
Progresso Geral da OF
|
||||
</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-card-foreground mb-2">
|
||||
{data.progressoGeral.toFixed(1)}%
|
||||
</div>
|
||||
<Progress value={data.progressoGeral} className="h-2 mb-2" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
OF: {data.of}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-card-foreground">
|
||||
Peso Total Fabricado
|
||||
</CardTitle>
|
||||
<Weight className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-card-foreground">
|
||||
{(data.pesoTotalFabricado / 1000).toFixed(2)} t
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
de {(data.tonelagem / 1000).toFixed(2)} t contratadas
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-card-foreground">
|
||||
Status dos Processos
|
||||
</CardTitle>
|
||||
<Package className="h-4 w-4 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Badge variant="default" className="bg-blue-500">
|
||||
{data.processos.filter(p => p.status === 'azul').length}
|
||||
</Badge>
|
||||
<Badge variant="default" className="bg-green-500">
|
||||
{data.processos.filter(p => p.status === 'verde').length}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-yellow-500">
|
||||
{data.processos.filter(p => p.status === 'amarelo').length}
|
||||
</Badge>
|
||||
<Badge variant="destructive">
|
||||
{data.processos.filter(p => p.status === 'vermelho').length}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Adiantado / No Prazo / Atenção / Atrasado
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
94
src/components/dashboard-producao/TabelaResumoProcessos.tsx
Normal file
94
src/components/dashboard-producao/TabelaResumoProcessos.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { DashboardDataOtimizado } from '@/hooks/useDashboardProducaoOtimizado';
|
||||
|
||||
|
||||
interface TabelaResumoProcessosProps {
|
||||
data: DashboardDataOtimizado | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const TabelaResumoProcessos: React.FC<TabelaResumoProcessosProps> = ({ data, loading }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-full"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data.processos.length) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nenhum processo encontrado para esta OF
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'azul':
|
||||
return <Badge className="bg-blue-500">Adiantado</Badge>;
|
||||
case 'verde':
|
||||
return <Badge className="bg-green-500">No Prazo</Badge>;
|
||||
case 'amarelo':
|
||||
return <Badge className="bg-yellow-500">Atenção</Badge>;
|
||||
case 'vermelho':
|
||||
return <Badge variant="destructive">Atrasado</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">Indefinido</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table disableOverflow>
|
||||
<TableHeader>
|
||||
<TableRow className="h-10">
|
||||
<TableHead>Processo</TableHead>
|
||||
<TableHead>Progresso Real</TableHead>
|
||||
<TableHead>Progresso Esperado</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Peso Processado</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.processos.map((processo) => (
|
||||
<TableRow key={processo.id} className="h-12">
|
||||
<TableCell className="font-medium py-2">{processo.nome}</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{processo.progressoReal.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={processo.progressoReal} className="h-2" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{processo.progressoEsperado.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={processo.progressoEsperado} className="h-2 opacity-50" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
{getStatusBadge(processo.status)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-2">
|
||||
{(processo.pesoFabricado / 1000).toFixed(3)} t
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
src/components/dashboard/CalendarClock.tsx
Normal file
44
src/components/dashboard/CalendarClock.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { getCurrentSaoPauloTime } from '@/utils/dateTimeUtils';
|
||||
|
||||
export function CalendarClock() {
|
||||
const [currentTime, setCurrentTime] = useState(getCurrentSaoPauloTime());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(getCurrentSaoPauloTime());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Sao_Paulo'
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/Sao_Paulo'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-bold text-blue-500">
|
||||
{formatTime(currentTime)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(currentTime)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/components/dashboard/NotificationsSugestoes.tsx
Normal file
83
src/components/dashboard/NotificationsSugestoes.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Bell, CheckCircle, XCircle, X } from 'lucide-react';
|
||||
import { useSugestoes, SugestaoNotification } from '@/hooks/useSugestoes';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
const NotificationsSugestoes: React.FC = () => {
|
||||
const { notifications, markNotificationAsRead } = useSugestoes();
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getNotificationIcon = (message: string) => {
|
||||
if (message.includes('implementada')) {
|
||||
return <CheckCircle className="h-4 w-4 text-green-400" />;
|
||||
}
|
||||
if (message.includes('rejeitada')) {
|
||||
return <XCircle className="h-4 w-4 text-red-400" />;
|
||||
}
|
||||
return <Bell className="h-4 w-4 text-blue-400" />;
|
||||
};
|
||||
|
||||
const getNotificationColor = (message: string) => {
|
||||
if (message.includes('implementada')) {
|
||||
return 'bg-green-900/10 border-green-400/20';
|
||||
}
|
||||
if (message.includes('rejeitada')) {
|
||||
return 'bg-red-900/10 border-red-400/20';
|
||||
}
|
||||
return 'bg-blue-900/10 border-blue-400/20';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white flex items-center gap-2 text-lg">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notificações de Sugestões
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{notifications.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-3 rounded-lg border ${getNotificationColor(notification.message)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{getNotificationIcon(notification.message)}
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
{format(parseISO(notification.created_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => markNotificationAsRead(notification.id)}
|
||||
className="h-6 w-6 p-0 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsSugestoes;
|
||||
54
src/components/dashboard/OnlineUsers.tsx
Normal file
54
src/components/dashboard/OnlineUsers.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useSessionLogsSimple } from '@/hooks/useSessionLogsSimple';
|
||||
|
||||
export function OnlineUsers() {
|
||||
const { onlineUsers, fetchOnlineUsers } = useSessionLogsSimple();
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchOnlineUsers();
|
||||
const interval = setInterval(fetchOnlineUsers, 30000); // Atualizar a cada 30 segundos
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchOnlineUsers]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Usuários Online ({onlineUsers.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{onlineUsers.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Nenhum usuário online</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{onlineUsers.map((user) => (
|
||||
<div key={user.user_id} className="flex items-center space-x-2">
|
||||
<UserAvatar
|
||||
imageUrl={user.avatar_url || undefined}
|
||||
name={user.full_name || user.email}
|
||||
email={user.email}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-foreground truncate">
|
||||
{user.full_name || 'Usuário'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
50
src/components/dashboard/TaskStatusChart.tsx
Normal file
50
src/components/dashboard/TaskStatusChart.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
|
||||
const data = [
|
||||
{ name: 'Pendente', value: 0, color: '#f59e0b' },
|
||||
{ name: 'Em Progresso', value: 0, color: '#3b82f6' },
|
||||
{ name: 'Concluída', value: 0, color: '#10b981' },
|
||||
{ name: 'Cancelada', value: 0, color: '#ef4444' },
|
||||
];
|
||||
|
||||
export function TaskStatusChart() {
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Status das Tarefas</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Total: {total}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] flex items-center justify-center">
|
||||
{total === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Nenhuma tarefa encontrada</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
169
src/components/dashboard/TaskTypeChart.tsx
Normal file
169
src/components/dashboard/TaskTypeChart.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { useTasksEnhanced } from '@/hooks/useTasksEnhanced';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useSmartPolling } from '@/hooks/useSmartPolling';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface TaskTypeData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
a_fazer: 'A Fazer',
|
||||
em_andamento: 'Em Andamento',
|
||||
revisao: 'Revisão',
|
||||
pendente: 'Pendente',
|
||||
bloqueado: 'Bloqueado',
|
||||
concluido: 'Concluída',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
a_fazer: '#c084fc',
|
||||
em_andamento: '#3b82f6',
|
||||
revisao: '#eab308',
|
||||
pendente: '#6b7280',
|
||||
bloqueado: '#ef4444',
|
||||
concluido: '#22c55e',
|
||||
};
|
||||
|
||||
export const TaskTypeChart = memo(() => {
|
||||
const { user } = useAuth();
|
||||
const { fetchMyTasks, fetchAssignedTasks } = useTasksEnhanced();
|
||||
|
||||
const { data: taskTypeData = [], isLoading } = useQuery({
|
||||
queryKey: ['dashboard-task-type'],
|
||||
queryFn: async (): Promise<TaskTypeData[]> => {
|
||||
if (!user) return [];
|
||||
|
||||
try {
|
||||
logger.info('Fetching task type data for dashboard');
|
||||
|
||||
const [myTasks, assignedTasks] = await Promise.all([
|
||||
fetchMyTasks({}),
|
||||
fetchAssignedTasks({})
|
||||
]);
|
||||
|
||||
const allTasks = [...myTasks, ...assignedTasks];
|
||||
|
||||
// Contar por status funcional
|
||||
const statusCount: Record<string, number> = {
|
||||
a_fazer: 0,
|
||||
em_andamento: 0,
|
||||
revisao: 0,
|
||||
pendente: 0,
|
||||
bloqueado: 0,
|
||||
concluido: 0,
|
||||
};
|
||||
|
||||
allTasks.forEach(task => {
|
||||
const status = task.status || 'a_fazer';
|
||||
if (statusCount.hasOwnProperty(status)) {
|
||||
statusCount[status]++;
|
||||
}
|
||||
});
|
||||
|
||||
const result = Object.entries(statusCount)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([status, count]) => ({
|
||||
name: statusLabels[status as keyof typeof statusLabels],
|
||||
value: count,
|
||||
color: statusColors[status as keyof typeof statusColors],
|
||||
}));
|
||||
|
||||
logger.success('Task type data processed successfully');
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task type data', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: 60000, // 1 minuto
|
||||
gcTime: 300000, // 5 minutos
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
// Polling inteligente apenas quando necessário
|
||||
useSmartPolling({
|
||||
queryKey: ['dashboard-task-type'],
|
||||
interval: 120000, // 2 minutos ao invés de 30 segundos
|
||||
enabled: !!user,
|
||||
onError: (error) => logger.error('Task polling error', error)
|
||||
});
|
||||
|
||||
const totalTasks = taskTypeData.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Distribuição por Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-center py-4 text-sm">Carregando dados...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Distribuição por Status</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Total: {totalTasks}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] flex items-center justify-center">
|
||||
{taskTypeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={taskTypeData}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 10,
|
||||
left: 5,
|
||||
bottom: 25,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground))" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={10}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={10} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[2, 2, 0, 0]}>
|
||||
{taskTypeData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8 text-sm">Nenhuma tarefa encontrada</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
TaskTypeChart.displayName = 'TaskTypeChart';
|
||||
174
src/components/dashboard/UserInfo.tsx
Normal file
174
src/components/dashboard/UserInfo.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { LogoutButton } from '@/components/auth/LogoutButton';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
|
||||
interface UserProfile {
|
||||
full_name: string | null;
|
||||
email: string | null;
|
||||
profile_image_url: string | null;
|
||||
}
|
||||
|
||||
const SAO_PAULO_TIMEZONE = 'America/Sao_Paulo';
|
||||
|
||||
export function UserInfo() {
|
||||
const { user } = useAuth();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loginTime, setLoginTime] = useState<string>('00:00:00');
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const loginStartTimeRef = useRef<number>(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchUserProfile();
|
||||
initializeLoginTime(); // Inicializar timer baseado no localStorage
|
||||
} else {
|
||||
// Limpar timer quando não há usuário
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Listener para eventos de autenticação do Supabase
|
||||
useEffect(() => {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'SIGNED_IN' && session?.user) {
|
||||
// Reset timer apenas em login real
|
||||
resetLoginTime();
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Inicializar tempo de login baseado no localStorage (preserva tempo entre navegações)
|
||||
const initializeLoginTime = () => {
|
||||
const savedLoginTime = localStorage.getItem('userLoginTime');
|
||||
|
||||
if (savedLoginTime) {
|
||||
// Usar tempo salvo do localStorage
|
||||
loginStartTimeRef.current = parseInt(savedLoginTime);
|
||||
} else {
|
||||
// Se não há tempo salvo, definir tempo atual (primeira vez)
|
||||
const currentTime = Date.now();
|
||||
loginStartTimeRef.current = currentTime;
|
||||
localStorage.setItem('userLoginTime', currentTime.toString());
|
||||
}
|
||||
|
||||
// Limpar interval anterior se existir
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// Iniciar cronômetro
|
||||
intervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - loginStartTimeRef.current;
|
||||
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
||||
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
|
||||
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
|
||||
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Resetar tempo de login apenas em eventos reais de login
|
||||
const resetLoginTime = () => {
|
||||
// Limpar interval anterior se existir
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// Definir novo tempo de início
|
||||
const currentTime = Date.now();
|
||||
loginStartTimeRef.current = currentTime;
|
||||
localStorage.setItem('userLoginTime', currentTime.toString());
|
||||
|
||||
// Iniciar novo cronômetro
|
||||
intervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - loginStartTimeRef.current;
|
||||
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
||||
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
|
||||
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
|
||||
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Limpar tempo de login quando usuário sair
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
localStorage.removeItem('userLoginTime');
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, email, profile_image_url')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!error && data) {
|
||||
setProfile(data);
|
||||
} else {
|
||||
// Fallback para dados do usuário auth
|
||||
setProfile({
|
||||
full_name: user.user_metadata?.full_name || null,
|
||||
email: user.email || null,
|
||||
profile_image_url: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!user || !profile) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 rounded-lg bg-green-100 text-gray-800 dark:bg-teal-900 dark:text-white">
|
||||
<div className="flex items-center space-x-3">
|
||||
<UserAvatar
|
||||
imageUrl={profile.profile_image_url || undefined}
|
||||
name={profile.full_name || profile.email || 'Usuário'}
|
||||
email={profile.email || ''}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 dark:text-white truncate text-sm">
|
||||
{profile.full_name || 'TrackSteel User'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-white/80 truncate">
|
||||
{profile.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs text-gray-600 dark:text-white/80">
|
||||
<p>
|
||||
<span>Logado há:</span> {loginTime}
|
||||
</p>
|
||||
<LogoutButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
showText={true}
|
||||
className="w-full text-xs hover:bg-sidebar-accent p-1 h-auto justify-start"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/components/debug/PermissionDebug.tsx
Normal file
71
src/components/debug/PermissionDebug.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useUserPermissions } from '@/hooks/useUserPermissions';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export function PermissionDebug() {
|
||||
const { user } = useAuth();
|
||||
const { isAdmin } = useUserRole();
|
||||
const {
|
||||
userPermissions,
|
||||
resourcePermissions,
|
||||
hasAccess,
|
||||
getResourcePermission,
|
||||
loading
|
||||
} = useUserPermissions();
|
||||
|
||||
if (!import.meta.env.DEV) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const equipamentosAccess = hasAccess('equipamentos');
|
||||
const equipamentosPermission = getResourcePermission('equipamentos' as any);
|
||||
|
||||
return (
|
||||
<Card className="fixed top-4 right-4 w-96 max-h-[80vh] overflow-y-auto z-50 bg-background border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Debug - Permissões</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-xs">
|
||||
<div>
|
||||
<strong>Usuário:</strong> {user?.email || 'Não logado'}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Loading:</strong> {loading ? 'Sim' : 'Não'}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Is Admin:</strong> {isAdmin ? 'Sim' : 'Não'}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Permissões Funcionais:</strong>
|
||||
<pre className="mt-1 p-2 bg-muted rounded text-xs">
|
||||
{JSON.stringify(userPermissions, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Permissões de Recursos:</strong>
|
||||
<pre className="mt-1 p-2 bg-muted rounded text-xs">
|
||||
{JSON.stringify(resourcePermissions, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<strong>Teste Equipamentos:</strong>
|
||||
<div>Acesso: {equipamentosAccess ? '✅ Sim' : '❌ Não'}</div>
|
||||
<div>Permissão: {equipamentosPermission}</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<strong>Acesso Geral:</strong>
|
||||
<div>hasAccess(): {hasAccess() ? '✅ Sim' : '❌ Não'}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
413
src/components/equipamentos/EquipamentoLoanControl.tsx
Normal file
413
src/components/equipamentos/EquipamentoLoanControl.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Trash2, Edit, Plus } from 'lucide-react';
|
||||
import { useOFs } from '@/hooks/useOFs';
|
||||
import { useUserManagement } from '@/hooks/useUserManagement';
|
||||
import { type Equipamento } from '@/hooks/useEquipamentos';
|
||||
|
||||
interface LoanRecord {
|
||||
id: string;
|
||||
equipamento_id: string;
|
||||
of_number?: string;
|
||||
destino_outro?: string;
|
||||
data_saida: string;
|
||||
retirado_por: string;
|
||||
data_retorno?: string;
|
||||
devolvido_por?: string;
|
||||
status: 'emprestado' | 'devolvido';
|
||||
}
|
||||
|
||||
interface LoanFormData {
|
||||
of_number: string;
|
||||
destino_outro: string;
|
||||
data_saida: string;
|
||||
retirado_por: string;
|
||||
data_retorno: string;
|
||||
devolvido_por: string;
|
||||
}
|
||||
|
||||
interface EquipamentoLoanControlProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
equipamento: Equipamento;
|
||||
}
|
||||
|
||||
export const EquipamentoLoanControl: React.FC<EquipamentoLoanControlProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
equipamento
|
||||
}) => {
|
||||
const { ofs } = useOFs();
|
||||
const { users } = useUserManagement();
|
||||
const [loans, setLoans] = useState<LoanRecord[]>([]);
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingLoan, setEditingLoan] = useState<LoanRecord | null>(null);
|
||||
const [formData, setFormData] = useState<LoanFormData>({
|
||||
of_number: '',
|
||||
destino_outro: '',
|
||||
data_saida: '',
|
||||
retirado_por: '',
|
||||
data_retorno: '',
|
||||
devolvido_por: ''
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState<{[key: string]: string}>({});
|
||||
|
||||
// Função para converter data DD/MM/AA para formato input (YYYY-MM-DD)
|
||||
const formatDateToInput = (dateStr: string): string => {
|
||||
if (!dateStr) return '';
|
||||
const parts = dateStr.split('/');
|
||||
if (parts.length === 3) {
|
||||
const [day, month, year] = parts;
|
||||
const fullYear = year.length === 2 ? `20${year}` : year;
|
||||
return `${fullYear}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||||
}
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
// Função para converter data do input (YYYY-MM-DD) para DD/MM/AA
|
||||
const formatDateFromInput = (dateStr: string): string => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear().toString().slice(-2);
|
||||
return `${day}/${month}/${year}`;
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: {[key: string]: string} = {};
|
||||
|
||||
// Validação: OF de destino OU Outro Destino obrigatório
|
||||
if (!formData.of_number && !formData.destino_outro) {
|
||||
errors.destination = 'Informe a OF de destino OU o outro destino';
|
||||
}
|
||||
|
||||
if (!formData.data_saida) {
|
||||
errors.data_saida = 'Data de saída é obrigatória';
|
||||
}
|
||||
|
||||
if (!formData.retirado_por) {
|
||||
errors.retirado_por = 'Retirado por é obrigatório';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
const loanData: LoanRecord = {
|
||||
id: editingLoan?.id || Date.now().toString(),
|
||||
equipamento_id: equipamento.id,
|
||||
of_number: formData.of_number || undefined,
|
||||
destino_outro: formData.destino_outro || undefined,
|
||||
data_saida: formatDateFromInput(formData.data_saida),
|
||||
retirado_por: formData.retirado_por,
|
||||
data_retorno: formData.data_retorno ? formatDateFromInput(formData.data_retorno) : undefined,
|
||||
devolvido_por: formData.devolvido_por || undefined,
|
||||
status: formData.data_retorno ? 'devolvido' : 'emprestado'
|
||||
};
|
||||
|
||||
if (editingLoan) {
|
||||
setLoans(prev => prev.map(loan => loan.id === editingLoan.id ? loanData : loan));
|
||||
} else {
|
||||
setLoans(prev => [...prev, loanData]);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar empréstimo:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
of_number: '',
|
||||
destino_outro: '',
|
||||
data_saida: '',
|
||||
retirado_por: '',
|
||||
data_retorno: '',
|
||||
devolvido_por: ''
|
||||
});
|
||||
setFormErrors({});
|
||||
setEditingLoan(null);
|
||||
setIsFormOpen(false);
|
||||
};
|
||||
|
||||
const handleEdit = (loan: LoanRecord) => {
|
||||
setFormData({
|
||||
of_number: loan.of_number || '',
|
||||
destino_outro: loan.destino_outro || '',
|
||||
data_saida: formatDateToInput(loan.data_saida),
|
||||
retirado_por: loan.retirado_por,
|
||||
data_retorno: loan.data_retorno ? formatDateToInput(loan.data_retorno) : '',
|
||||
devolvido_por: loan.devolvido_por || ''
|
||||
});
|
||||
setEditingLoan(loan);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (loanId: string) => {
|
||||
if (confirm('Tem certeza que deseja excluir este registro de empréstimo?')) {
|
||||
setLoans(prev => prev.filter(loan => loan.id !== loanId));
|
||||
}
|
||||
};
|
||||
|
||||
const getUserName = (userId: string) => {
|
||||
const user = users.find(u => u.id === userId);
|
||||
return user?.full_name || user?.email || 'Usuário não encontrado';
|
||||
};
|
||||
|
||||
const getOFDescription = (ofNumber: string) => {
|
||||
const of = ofs.find(o => o.num_of === ofNumber);
|
||||
return of ? `${of.num_of} - ${of.descritivo || 'Sem descrição'}` : ofNumber;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
Controle de Empréstimo - {equipamento.codigo} ({equipamento.descricao})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Botão para adicionar novo empréstimo */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Novo Empréstimo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabela de empréstimos */}
|
||||
<div className="border border-slate-600 rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-600">
|
||||
<TableHead className="text-slate-300">Destino</TableHead>
|
||||
<TableHead className="text-slate-300">Data Saída</TableHead>
|
||||
<TableHead className="text-slate-300">Retirado Por</TableHead>
|
||||
<TableHead className="text-slate-300">Data Retorno</TableHead>
|
||||
<TableHead className="text-slate-300">Devolvido Por</TableHead>
|
||||
<TableHead className="text-slate-300">Status</TableHead>
|
||||
<TableHead className="text-slate-300">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loans.map((loan) => (
|
||||
<TableRow key={loan.id} className="border-slate-600">
|
||||
<TableCell className="text-white">
|
||||
{loan.of_number ? getOFDescription(loan.of_number) : loan.destino_outro}
|
||||
</TableCell>
|
||||
<TableCell className="text-white">{loan.data_saida}</TableCell>
|
||||
<TableCell className="text-white">{getUserName(loan.retirado_por)}</TableCell>
|
||||
<TableCell className="text-white">{loan.data_retorno || '-'}</TableCell>
|
||||
<TableCell className="text-white">{loan.devolvido_por ? getUserName(loan.devolvido_por) : '-'}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
loan.status === 'emprestado'
|
||||
? 'bg-yellow-600 text-yellow-100'
|
||||
: 'bg-green-600 text-green-100'
|
||||
}`}>
|
||||
{loan.status === 'emprestado' ? 'Emprestado' : 'Devolvido'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(loan)}
|
||||
className="border-slate-600 text-slate-300 hover:bg-slate-700"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDelete(loan.id)}
|
||||
className="border-red-600 text-red-400 hover:bg-red-900"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{loans.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-slate-400 py-8">
|
||||
Nenhum registro de empréstimo encontrado
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal do formulário */}
|
||||
<Dialog open={isFormOpen} onOpenChange={() => resetForm()}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{editingLoan ? 'Editar Empréstimo' : 'Novo Empréstimo'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Validação de destino */}
|
||||
{formErrors.destination && (
|
||||
<div className="text-red-400 text-sm bg-red-900/20 p-2 rounded">
|
||||
{formErrors.destination}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="of_number" className="text-slate-300">OF de Destino</Label>
|
||||
<Select
|
||||
value={formData.of_number}
|
||||
onValueChange={(value) => setFormData(prev => ({
|
||||
...prev,
|
||||
of_number: value === 'none' ? '' : value,
|
||||
destino_outro: value !== 'none' && value ? '' : prev.destino_outro
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-700 border-slate-600 text-white">
|
||||
<SelectValue placeholder="Selecione uma OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-700 border-slate-600">
|
||||
<SelectItem value="none" className="text-white hover:bg-slate-600">Nenhuma</SelectItem>
|
||||
{ofs.map((of) => (
|
||||
<SelectItem key={of.id} value={of.num_of} className="text-white hover:bg-slate-600">
|
||||
{of.num_of} - {of.descritivo || 'Sem descrição'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="destino_outro" className="text-slate-300">Outro Destino</Label>
|
||||
<Input
|
||||
id="destino_outro"
|
||||
value={formData.destino_outro}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
destino_outro: e.target.value,
|
||||
of_number: e.target.value ? '' : prev.of_number
|
||||
}))}
|
||||
disabled={!!formData.of_number}
|
||||
className="bg-slate-700 border-slate-600 text-white disabled:opacity-50"
|
||||
placeholder="Informe o destino alternativo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data_saida" className="text-slate-300">Data de Saída *</Label>
|
||||
<Input
|
||||
id="data_saida"
|
||||
type="date"
|
||||
value={formData.data_saida}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, data_saida: e.target.value }))}
|
||||
required
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
{formErrors.data_saida && (
|
||||
<span className="text-red-400 text-xs">{formErrors.data_saida}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retirado_por" className="text-slate-300">Retirado Por *</Label>
|
||||
<Select
|
||||
value={formData.retirado_por}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, retirado_por: value === 'none' ? '' : value }))}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-700 border-slate-600 text-white">
|
||||
<SelectValue placeholder="Selecione um usuário" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-700 border-slate-600">
|
||||
<SelectItem value="none" className="text-white hover:bg-slate-600">Nenhum</SelectItem>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id} className="text-white hover:bg-slate-600">
|
||||
{user.full_name || user.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formErrors.retirado_por && (
|
||||
<span className="text-red-400 text-xs">{formErrors.retirado_por}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data_retorno" className="text-slate-300">Data de Retorno</Label>
|
||||
<Input
|
||||
id="data_retorno"
|
||||
type="date"
|
||||
value={formData.data_retorno}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, data_retorno: e.target.value }))}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="devolvido_por" className="text-slate-300">Devolvido Por</Label>
|
||||
<Select
|
||||
value={formData.devolvido_por}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, devolvido_por: value === 'none' ? '' : value }))}
|
||||
disabled={!formData.data_retorno}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-700 border-slate-600 text-white disabled:opacity-50">
|
||||
<SelectValue placeholder="Selecione um usuário" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-700 border-slate-600">
|
||||
<SelectItem value="none" className="text-white hover:bg-slate-600">Nenhum</SelectItem>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id} className="text-white hover:bg-slate-600">
|
||||
{user.full_name || user.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={resetForm} className="border-slate-600 text-slate-300 hover:bg-slate-700">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
{editingLoan ? 'Atualizar' : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Botão fechar */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onClose} className="bg-slate-600 hover:bg-slate-700 text-white">
|
||||
Fechar
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
278
src/components/equipamentos/EquipamentoModal.tsx
Normal file
278
src/components/equipamentos/EquipamentoModal.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useEquipamentos, type Equipamento, type EquipamentoFormData } from '@/hooks/useEquipamentos';
|
||||
|
||||
interface EquipamentoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
equipamento?: Equipamento | null;
|
||||
onSave?: (data: EquipamentoFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const EquipamentoModal: React.FC<EquipamentoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
equipamento,
|
||||
onSave
|
||||
}) => {
|
||||
const { createEquipamento, updateEquipamento } = useEquipamentos();
|
||||
|
||||
const [formData, setFormData] = useState<EquipamentoFormData>({
|
||||
codigo: '',
|
||||
descricao: '',
|
||||
capacidade: '',
|
||||
quantidade: 1,
|
||||
local_estoque: '',
|
||||
propriedade: 'proprio',
|
||||
validade_calibracao: '',
|
||||
certificado_calibracao: '',
|
||||
periodicidade_calibracao: undefined,
|
||||
observacoes: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (equipamento) {
|
||||
setFormData({
|
||||
codigo: equipamento.codigo,
|
||||
descricao: equipamento.descricao,
|
||||
capacidade: equipamento.capacidade || '',
|
||||
quantidade: equipamento.quantidade,
|
||||
local_estoque: equipamento.local_estoque,
|
||||
propriedade: equipamento.propriedade,
|
||||
validade_calibracao: equipamento.validade_calibracao ? formatDateToInput(equipamento.validade_calibracao) : '',
|
||||
certificado_calibracao: equipamento.certificado_calibracao || '',
|
||||
periodicidade_calibracao: equipamento.periodicidade_calibracao,
|
||||
observacoes: equipamento.observacoes || ''
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
codigo: '',
|
||||
descricao: '',
|
||||
capacidade: '',
|
||||
quantidade: 1,
|
||||
local_estoque: '',
|
||||
propriedade: 'proprio',
|
||||
validade_calibracao: '',
|
||||
certificado_calibracao: '',
|
||||
periodicidade_calibracao: undefined,
|
||||
observacoes: ''
|
||||
});
|
||||
}
|
||||
}, [equipamento]);
|
||||
|
||||
// Função para converter data DD/MM/AA para formato input (YYYY-MM-DD)
|
||||
const formatDateToInput = (dateStr: string): string => {
|
||||
if (!dateStr) return '';
|
||||
const parts = dateStr.split('/');
|
||||
if (parts.length === 3) {
|
||||
const [day, month, year] = parts;
|
||||
const fullYear = year.length === 2 ? `20${year}` : year;
|
||||
return `${fullYear}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||||
}
|
||||
return dateStr;
|
||||
};
|
||||
|
||||
// Função para converter data do input (YYYY-MM-DD) para DD/MM/AA
|
||||
const formatDateFromInput = (dateStr: string): string => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear().toString().slice(-2);
|
||||
return `${day}/${month}/${year}`;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
// Converter data antes de enviar
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
validade_calibracao: formData.validade_calibracao ? formatDateFromInput(formData.validade_calibracao) : ''
|
||||
};
|
||||
|
||||
if (onSave) {
|
||||
await onSave(dataToSave);
|
||||
} else {
|
||||
if (equipamento) {
|
||||
await updateEquipamento.mutateAsync({ id: equipamento.id, data: dataToSave });
|
||||
} else {
|
||||
await createEquipamento.mutateAsync(dataToSave);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar equipamento:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof EquipamentoFormData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{equipamento ? 'Editar Equipamento' : 'Novo Equipamento'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Informações Básicas */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codigo" className="text-slate-300">Código *</Label>
|
||||
<Input
|
||||
id="codigo"
|
||||
value={formData.codigo}
|
||||
onChange={(e) => handleInputChange('codigo', e.target.value)}
|
||||
required
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="descricao" className="text-slate-300">Descrição *</Label>
|
||||
<Input
|
||||
id="descricao"
|
||||
value={formData.descricao}
|
||||
onChange={(e) => handleInputChange('descricao', e.target.value)}
|
||||
required
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capacidade" className="text-slate-300">Capacidade</Label>
|
||||
<Input
|
||||
id="capacidade"
|
||||
value={formData.capacidade}
|
||||
onChange={(e) => handleInputChange('capacidade', e.target.value)}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantidade" className="text-slate-300">Quantidade *</Label>
|
||||
<Input
|
||||
id="quantidade"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.quantidade}
|
||||
onChange={(e) => handleInputChange('quantidade', parseInt(e.target.value) || 1)}
|
||||
required
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="local_estoque" className="text-slate-300">Local de Estoque *</Label>
|
||||
<Input
|
||||
id="local_estoque"
|
||||
value={formData.local_estoque}
|
||||
onChange={(e) => handleInputChange('local_estoque', e.target.value)}
|
||||
required
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="propriedade" className="text-slate-300">Propriedade *</Label>
|
||||
<Select
|
||||
value={formData.propriedade}
|
||||
onValueChange={(value) => handleInputChange('propriedade', value)}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-700 border-slate-600 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-700 border-slate-600">
|
||||
<SelectItem value="proprio" className="text-white hover:bg-slate-600">Próprio</SelectItem>
|
||||
<SelectItem value="terceiros" className="text-white hover:bg-slate-600">Terceiros</SelectItem>
|
||||
<SelectItem value="alugado" className="text-white hover:bg-slate-600">Alugado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calibração */}
|
||||
<div className="border-t border-slate-600 pt-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-slate-200">Calibração</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validade_calibracao" className="text-slate-300">Validade da Calibração (DD/MM/AA)</Label>
|
||||
<Input
|
||||
id="validade_calibracao"
|
||||
type="date"
|
||||
value={formData.validade_calibracao}
|
||||
onChange={(e) => handleInputChange('validade_calibracao', e.target.value)}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificado_calibracao" className="text-slate-300">Certificado de Calibração</Label>
|
||||
<Input
|
||||
id="certificado_calibracao"
|
||||
value={formData.certificado_calibracao}
|
||||
onChange={(e) => handleInputChange('certificado_calibracao', e.target.value)}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="periodicidade_calibracao" className="text-slate-300">Periodicidade (dias)</Label>
|
||||
<Input
|
||||
id="periodicidade_calibracao"
|
||||
type="number"
|
||||
value={formData.periodicidade_calibracao || ''}
|
||||
onChange={(e) => handleInputChange('periodicidade_calibracao', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Observações */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="observacoes" className="text-slate-300">Observações</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => handleInputChange('observacoes', e.target.value)}
|
||||
rows={3}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Botões */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={onClose} className="border-slate-600 text-slate-300 hover:bg-slate-700">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createEquipamento.isPending || updateEquipamento.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{createEquipamento.isPending || updateEquipamento.isPending
|
||||
? 'Salvando...'
|
||||
: equipamento
|
||||
? 'Atualizar'
|
||||
: 'Criar'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
73
src/components/equipamentos/EquipamentosFilters.tsx
Normal file
73
src/components/equipamentos/EquipamentosFilters.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface EquipamentosFiltersProps {
|
||||
searchTerm: string;
|
||||
statusFilter: string;
|
||||
propriedadeFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onPropriedadeChange: (value: string) => void;
|
||||
onResetFilters: () => void;
|
||||
}
|
||||
|
||||
export function EquipamentosFilters({
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
propriedadeFilter,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
onPropriedadeChange,
|
||||
onResetFilters,
|
||||
}: EquipamentosFiltersProps) {
|
||||
return (
|
||||
<Card className="mb-6 no-print bg-card border-border">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Input
|
||||
placeholder="Buscar por código ou descrição..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="bg-background border-border text-foreground"
|
||||
/>
|
||||
|
||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||
<SelectTrigger className="bg-background border-border text-foreground">
|
||||
<SelectValue placeholder="Todos os Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os Status</SelectItem>
|
||||
<SelectItem value="disponivel">Disponível</SelectItem>
|
||||
<SelectItem value="em_uso">Em Uso</SelectItem>
|
||||
<SelectItem value="calibracao_vencida">Calibração Vencida</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={propriedadeFilter} onValueChange={onPropriedadeChange}>
|
||||
<SelectTrigger className="bg-background border-border text-foreground">
|
||||
<SelectValue placeholder="Todas as Propriedades" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as Propriedades</SelectItem>
|
||||
<SelectItem value="proprio">Próprio</SelectItem>
|
||||
<SelectItem value="terceiros">Terceiros</SelectItem>
|
||||
<SelectItem value="alugado">Alugado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onResetFilters}
|
||||
className="border-border hover:bg-accent"
|
||||
>
|
||||
Limpar Filtros
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
46
src/components/equipamentos/EquipamentosStats.tsx
Normal file
46
src/components/equipamentos/EquipamentosStats.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface EquipamentosStatsProps {
|
||||
stats: {
|
||||
total: number;
|
||||
disponiveis: number;
|
||||
emUso: number;
|
||||
calibracaoVencida: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function EquipamentosStats({ stats }: EquipamentosStatsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-5">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Total de Ativos</h3>
|
||||
<p className="text-2xl font-semibold text-card-foreground mt-1">{stats.total}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-5">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Disponíveis</h3>
|
||||
<p className="text-2xl font-semibold text-green-400 mt-1">{stats.disponiveis}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-5">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Em Uso (Emprestados)</h3>
|
||||
<p className="text-2xl font-semibold text-yellow-400 mt-1">{stats.emUso}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-5">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Calibração Vencida/Vencendo</h3>
|
||||
<p className="text-2xl font-semibold text-red-400 mt-1">{stats.calibracaoVencida}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/components/equipamentos/EquipamentosTable.tsx
Normal file
106
src/components/equipamentos/EquipamentosTable.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Edit, Trash2 } from 'lucide-react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { Equipamento, useEquipamentos } from '@/hooks/useEquipamentos';
|
||||
|
||||
interface EquipamentosTableProps {
|
||||
equipamentos: Equipamento[];
|
||||
onEdit: (equipamento: Equipamento) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onLoanControl: (equipamento: Equipamento) => void; // Adicionar esta prop
|
||||
canModify?: boolean;
|
||||
}
|
||||
|
||||
export function EquipamentosTable({ equipamentos, onEdit, onDelete, onLoanControl, canModify = true }: EquipamentosTableProps) {
|
||||
const { getEquipamentoStatus } = useEquipamentos();
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString + 'T00:00:00');
|
||||
return date.toLocaleDateString('pt-BR');
|
||||
};
|
||||
|
||||
if (equipamentos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nenhum equipamento encontrado com os filtros aplicados.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="font-medium text-muted-foreground">Código</TableHead>
|
||||
<TableHead className="font-medium text-muted-foreground">Descrição</TableHead>
|
||||
<TableHead className="font-medium text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="font-medium text-muted-foreground">Local de Estoque</TableHead>
|
||||
<TableHead className="font-medium text-muted-foreground">Validade Calibração</TableHead>
|
||||
<TableHead className="font-medium text-muted-foreground">OF Empréstimo</TableHead>
|
||||
{canModify && (
|
||||
<TableHead className="font-medium text-muted-foreground no-print">Ações</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{equipamentos.map((equipamento) => {
|
||||
const status = getEquipamentoStatus(equipamento);
|
||||
return (
|
||||
<TableRow key={equipamento.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-medium text-card-foreground">{equipamento.codigo}</TableCell>
|
||||
<TableCell className="text-card-foreground">{equipamento.descricao}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${status.class} border`}>
|
||||
{status.text}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-card-foreground">{equipamento.local_estoque}</TableCell>
|
||||
<TableCell className="text-card-foreground">{formatDate(equipamento.validade_calibracao)}</TableCell>
|
||||
<TableCell className="text-card-foreground">{equipamento.of_number || 'N/A'}</TableCell>
|
||||
{canModify && (
|
||||
<TableCell className="no-print">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(equipamento)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:bg-blue-400/10"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(equipamento.id)}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onLoanControl(equipamento)}
|
||||
className="border-orange-600 text-orange-400 hover:bg-orange-900"
|
||||
title="Controle de Empréstimo"
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
src/components/estoque/CSVImportModal.tsx
Normal file
166
src/components/estoque/CSVImportModal.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Upload, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { parseCSV } from '@/utils/csvUtils';
|
||||
import { useCriarMaterial } from '@/hooks/useEstoque';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CSVImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CSVImportModal: React.FC<CSVImportModalProps> = ({ isOpen, onClose }) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [csvData, setCsvData] = useState<any[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{ success: number; errors: string[] } | null>(null);
|
||||
|
||||
const criarMaterial = useCriarMaterial();
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
if (selectedFile && selectedFile.type === 'text/csv') {
|
||||
setFile(selectedFile);
|
||||
setImportResult(null);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const csvText = e.target?.result as string;
|
||||
const parsed = parseCSV(csvText);
|
||||
setCsvData(parsed);
|
||||
};
|
||||
reader.readAsText(selectedFile);
|
||||
} else {
|
||||
toast.error('Por favor, selecione um arquivo CSV válido');
|
||||
}
|
||||
};
|
||||
|
||||
const processImport = async () => {
|
||||
if (csvData.length === 0) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
const errors: string[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (const row of csvData) {
|
||||
try {
|
||||
const materialData = {
|
||||
codigo: row.codigo || '',
|
||||
descricao: row.descricao || '',
|
||||
tipo_material_id: row.tipo_material_id || null,
|
||||
unidade: row.unidade || 'PC',
|
||||
peso_unitario: parseFloat(row.peso_unitario) || 0,
|
||||
quantidade_total: parseFloat(row.quantidade_total) || 0,
|
||||
quantidade_disponivel: parseFloat(row.quantidade_disponivel) || 0,
|
||||
quantidade_empenhada: parseFloat(row.quantidade_empenhada) || 0,
|
||||
quantidade_minima: parseFloat(row.quantidade_minima) || 0,
|
||||
quantidade_maxima: row.quantidade_maxima ? parseFloat(row.quantidade_maxima) : null,
|
||||
lote_atual: row.lote_atual || null,
|
||||
fornecedor: row.fornecedor || null,
|
||||
localizacao: row.localizacao || null,
|
||||
status: row.status || 'Normal',
|
||||
certificado: row.certificado || null,
|
||||
observacoes: row.observacoes || null
|
||||
};
|
||||
|
||||
await criarMaterial.mutateAsync(materialData);
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
errors.push(`Erro na linha ${csvData.indexOf(row) + 2}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
setImportResult({ success: successCount, errors });
|
||||
setIsProcessing(false);
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount} materiais importados com sucesso!`);
|
||||
}
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setFile(null);
|
||||
setCsvData([]);
|
||||
setImportResult(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={resetModal}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Importar Matérias-Primas via CSV</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
O arquivo CSV deve conter as seguintes colunas: codigo, descricao, unidade, peso_unitario,
|
||||
quantidade_total, quantidade_disponivel, quantidade_empenhada, quantidade_minima,
|
||||
quantidade_maxima, lote_atual, fornecedor, localizacao, status, certificado, observacoes
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="csv-file">Selecionar arquivo CSV</Label>
|
||||
<Input
|
||||
id="csv-file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="p-3 bg-muted rounded">
|
||||
<p className="text-sm">
|
||||
<strong>Arquivo:</strong> {file.name} ({csvData.length} registros encontrados)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<Alert className={importResult.errors.length > 0 ? "border-yellow-500" : "border-green-500"}>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p><strong>Resultado da Importação:</strong></p>
|
||||
<p>✅ {importResult.success} materiais importados com sucesso</p>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p>❌ {importResult.errors.length} erros encontrados:</p>
|
||||
<ul className="text-xs mt-1 max-h-20 overflow-y-auto">
|
||||
{importResult.errors.map((error, index) => (
|
||||
<li key={index}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={resetModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={processImport}
|
||||
disabled={csvData.length === 0 || isProcessing}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{isProcessing ? 'Importando...' : 'Importar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
67
src/components/estoque/CrudModalsManager.tsx
Normal file
67
src/components/estoque/CrudModalsManager.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Settings } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TiposMateriaModal } from './TiposMateriaModal';
|
||||
import { UnidadesMedidaModal } from './UnidadesMedidaModal';
|
||||
import { LocalizacaoModal } from './LocalizacaoModal';
|
||||
import { QualidadeAcoModal } from './QualidadeAcoModal';
|
||||
|
||||
export const CrudModalsManager: React.FC = () => {
|
||||
const [tiposModalOpen, setTiposModalOpen] = useState(false);
|
||||
const [unidadesModalOpen, setUnidadesModalOpen] = useState(false);
|
||||
const [localizacaoModalOpen, setLocalizacaoModalOpen] = useState(false);
|
||||
const [qualidadeModalOpen, setQualidadeModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Gerenciar
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTiposModalOpen(true)}>
|
||||
Tipos de Material
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setUnidadesModalOpen(true)}>
|
||||
Unidades de Medida
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLocalizacaoModalOpen(true)}>
|
||||
Localizações
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setQualidadeModalOpen(true)}>
|
||||
Qualidade do Aço
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<TiposMateriaModal
|
||||
isOpen={tiposModalOpen}
|
||||
onClose={() => setTiposModalOpen(false)}
|
||||
/>
|
||||
|
||||
<UnidadesMedidaModal
|
||||
isOpen={unidadesModalOpen}
|
||||
onClose={() => setUnidadesModalOpen(false)}
|
||||
/>
|
||||
|
||||
<LocalizacaoModal
|
||||
isOpen={localizacaoModalOpen}
|
||||
onClose={() => setLocalizacaoModalOpen(false)}
|
||||
/>
|
||||
|
||||
<QualidadeAcoModal
|
||||
isOpen={qualidadeModalOpen}
|
||||
onClose={() => setQualidadeModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
272
src/components/estoque/EmpenhosMaterialComponent.tsx
Normal file
272
src/components/estoque/EmpenhosMaterialComponent.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Package, AlertTriangle, CheckCircle, XCircle, Trash2 } from 'lucide-react';
|
||||
import { useEmpenhosMaterial, useOFsComEmpenhos, useCancelarEmpenho } from '@/hooks/useEmpenhosMaterial';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export const EmpenhosMaterialComponent: React.FC = () => {
|
||||
const [selectedOF, setSelectedOF] = useState<string>('all');
|
||||
|
||||
const { data: ofsComEmpenhos = [], isLoading: loadingOFs } = useOFsComEmpenhos();
|
||||
const { data: empenhos = [], isLoading: loadingEmpenhos } = useEmpenhosMaterial(selectedOF === 'all' ? undefined : selectedOF);
|
||||
const cancelarEmpenho = useCancelarEmpenho();
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Empenhado':
|
||||
return 'bg-yellow-500';
|
||||
case 'Finalizado':
|
||||
return 'bg-green-500';
|
||||
case 'Cancelado':
|
||||
return 'bg-red-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Empenhado':
|
||||
return <AlertTriangle className="h-3 w-3" />;
|
||||
case 'Finalizado':
|
||||
return <CheckCircle className="h-3 w-3" />;
|
||||
case 'Cancelado':
|
||||
return <XCircle className="h-3 w-3" />;
|
||||
default:
|
||||
return <Package className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelarEmpenho = async (empenhoId: string) => {
|
||||
try {
|
||||
await cancelarEmpenho.mutateAsync(empenhoId);
|
||||
} catch (error) {
|
||||
console.error('Erro ao cancelar empenho:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calcular totais
|
||||
const totais = empenhos.reduce((acc, empenho) => {
|
||||
acc.quantidadeEmpenhada += empenho.quantidade_empenhada;
|
||||
acc.quantidadeUtilizada += empenho.quantidade_utilizada;
|
||||
acc.valorTotal += (empenho.quantidade_empenhada * (empenho.estoque_materiais?.valor_unitario || 0));
|
||||
|
||||
if (empenho.status === 'Empenhado') acc.empenhosAtivos++;
|
||||
else if (empenho.status === 'Finalizado') acc.empenhosFinalizados++;
|
||||
else if (empenho.status === 'Cancelado') acc.empenhosCancelados++;
|
||||
|
||||
return acc;
|
||||
}, {
|
||||
quantidadeEmpenhada: 0,
|
||||
quantidadeUtilizada: 0,
|
||||
valorTotal: 0,
|
||||
empenhosAtivos: 0,
|
||||
empenhosFinalizados: 0,
|
||||
empenhosCancelados: 0
|
||||
});
|
||||
|
||||
if (loadingOFs) {
|
||||
return <Skeleton className="w-full h-96" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Gestão de Empenhos de Material
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Seletor de OF */}
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-2 block">Ordem de Fabricação</label>
|
||||
<Select value={selectedOF} onValueChange={setSelectedOF}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma OF para ver os empenhos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as OFs</SelectItem>
|
||||
{ofsComEmpenhos.map((of) => (
|
||||
<SelectItem key={of} value={of}>
|
||||
{of}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards de Resumo */}
|
||||
{selectedOF && selectedOF !== 'all' && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-blue-50 dark:bg-blue-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">{totais.empenhosAtivos}</div>
|
||||
<div className="text-sm text-blue-600">Empenhos Ativos</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-green-50 dark:bg-green-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{totais.empenhosFinalizados}</div>
|
||||
<div className="text-sm text-green-600">Finalizados</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-yellow-50 dark:bg-yellow-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{totais.quantidadeEmpenhada.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-yellow-600">Qtd Empenhada</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-purple-50 dark:bg-purple-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
R$ {totais.valorTotal.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Valor Total</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabela de Empenhos */}
|
||||
{loadingEmpenhos ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="w-full h-12" />
|
||||
))}
|
||||
</div>
|
||||
) : empenhos.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Material</TableHead>
|
||||
<TableHead>OF</TableHead>
|
||||
<TableHead>Lote</TableHead>
|
||||
<TableHead>Qtd Empenhada</TableHead>
|
||||
<TableHead>Qtd Utilizada</TableHead>
|
||||
<TableHead>Restante</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{empenhos.map((empenho) => {
|
||||
const qtdRestante = empenho.quantidade_empenhada - empenho.quantidade_utilizada;
|
||||
|
||||
return (
|
||||
<TableRow key={empenho.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{empenho.estoque_materiais?.descricao}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{empenho.estoque_materiais?.codigo}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{empenho.of_number}</TableCell>
|
||||
<TableCell>{empenho.lote || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{empenho.quantidade_empenhada.toFixed(2)} {empenho.estoque_materiais?.unidade}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{empenho.quantidade_utilizada.toFixed(2)} {empenho.estoque_materiais?.unidade}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={qtdRestante > 0 ? 'text-yellow-600' : 'text-green-600'}>
|
||||
{qtdRestante.toFixed(2)} {empenho.estoque_materiais?.unidade}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getStatusColor(empenho.status)} text-white border-none`}
|
||||
>
|
||||
{getStatusIcon(empenho.status)}
|
||||
<span className="ml-1">{empenho.status}</span>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(empenho.data_empenho).toLocaleDateString('pt-BR')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{empenho.status === 'Empenhado' && empenho.quantidade_utilizada === 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
disabled={cancelarEmpenho.isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancelar Empenho</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja cancelar este empenho? Esta ação irá:
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
<li>Reverter a quantidade empenhada para disponível</li>
|
||||
<li>Cancelar a movimentação de empenho relacionada</li>
|
||||
<li>Marcar o empenho como cancelado</li>
|
||||
</ul>
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleCancelarEmpenho(empenho.id)}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Confirmar Cancelamento
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{selectedOF && selectedOF !== 'all' ? (
|
||||
<div>
|
||||
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||
<p>Nenhum empenho encontrado para a OF {selectedOF}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||
<p>Selecione uma OF para visualizar os empenhos de material</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
262
src/components/estoque/EmpenhosMaterialSimplificado.tsx
Normal file
262
src/components/estoque/EmpenhosMaterialSimplificado.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Package, AlertTriangle, CheckCircle, XCircle, Info } from 'lucide-react';
|
||||
import { useEmpenhosMaterial, useOFsComEmpenhos } from '@/hooks/useEmpenhosMaterialSimplificado';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export const EmpenhosMaterialSimplificado: React.FC = () => {
|
||||
const [selectedOF, setSelectedOF] = useState<string>('all');
|
||||
|
||||
const { data: ofsComEmpenhos = [], isLoading: loadingOFs } = useOFsComEmpenhos();
|
||||
const { data: empenhos = [], isLoading: loadingEmpenhos } = useEmpenhosMaterial(selectedOF === 'all' ? undefined : selectedOF);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Empenhado':
|
||||
return 'bg-yellow-500';
|
||||
case 'Finalizado':
|
||||
return 'bg-green-500';
|
||||
case 'Cancelado':
|
||||
return 'bg-red-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Empenhado':
|
||||
return <AlertTriangle className="h-3 w-3" />;
|
||||
case 'Finalizado':
|
||||
return <CheckCircle className="h-3 w-3" />;
|
||||
case 'Cancelado':
|
||||
return <XCircle className="h-3 w-3" />;
|
||||
default:
|
||||
return <Package className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Calcular totais
|
||||
const totais = empenhos.reduce((acc, empenho) => {
|
||||
acc.quantidadeEmpenhada += empenho.quantidade_empenhada;
|
||||
acc.quantidadeUtilizada += empenho.quantidade_utilizada;
|
||||
acc.valorTotal += (empenho.quantidade_empenhada * (empenho.estoque_materiais?.valor_unitario || 0));
|
||||
|
||||
if (empenho.status === 'Empenhado') acc.empenhosAtivos++;
|
||||
else if (empenho.status === 'Finalizado') acc.empenhosFinalizados++;
|
||||
else if (empenho.status === 'Cancelado') acc.empenhosCancelados++;
|
||||
|
||||
return acc;
|
||||
}, {
|
||||
quantidadeEmpenhada: 0,
|
||||
quantidadeUtilizada: 0,
|
||||
valorTotal: 0,
|
||||
empenhosAtivos: 0,
|
||||
empenhosFinalizados: 0,
|
||||
empenhosCancelados: 0
|
||||
});
|
||||
|
||||
if (loadingOFs) {
|
||||
return <Skeleton className="w-full h-96" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Visualização de Empenhos de Material
|
||||
</CardTitle>
|
||||
<div className="bg-blue-50 dark:bg-blue-950 p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-600" />
|
||||
<p className="text-sm text-blue-600">
|
||||
Os empenhos são gerados automaticamente através das movimentações.
|
||||
Para cancelar um empenho, exclua a movimentação correspondente na aba "Movimentação".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Seletor de OF */}
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-2 block">Ordem de Fabricação</label>
|
||||
<Select value={selectedOF} onValueChange={setSelectedOF}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma OF para ver os empenhos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as OFs</SelectItem>
|
||||
{ofsComEmpenhos.map((of) => (
|
||||
<SelectItem key={of} value={of}>
|
||||
{of}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards de Resumo */}
|
||||
{selectedOF && selectedOF !== 'all' && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-blue-50 dark:bg-blue-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">{totais.empenhosAtivos}</div>
|
||||
<div className="text-sm text-blue-600">Empenhos Ativos</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-green-50 dark:bg-green-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{totais.empenhosFinalizados}</div>
|
||||
<div className="text-sm text-green-600">Finalizados</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-yellow-50 dark:bg-yellow-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{totais.quantidadeEmpenhada.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-yellow-600">Qtd Empenhada</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-purple-50 dark:bg-purple-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
R$ {totais.valorTotal.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Valor Total</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabela de Empenhos */}
|
||||
{loadingEmpenhos ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="w-full h-12" />
|
||||
))}
|
||||
</div>
|
||||
) : empenhos.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Material</TableHead>
|
||||
<TableHead>OF</TableHead>
|
||||
<TableHead>Lote</TableHead>
|
||||
<TableHead>Qtd Empenhada</TableHead>
|
||||
<TableHead>Qtd Utilizada</TableHead>
|
||||
<TableHead>Restante</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
<TableHead>Informações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{empenhos.map((empenho) => {
|
||||
const qtdRestante = empenho.quantidade_empenhada - empenho.quantidade_utilizada;
|
||||
|
||||
return (
|
||||
<TableRow key={empenho.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{empenho.estoque_materiais?.descricao}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{empenho.estoque_materiais?.codigo}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{empenho.of_number}</TableCell>
|
||||
<TableCell>{empenho.lote || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{empenho.quantidade_empenhada.toFixed(2)} {empenho.estoque_materiais?.unidade}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{empenho.quantidade_utilizada.toFixed(2)} {empenho.estoque_materiais?.unidade}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={qtdRestante > 0 ? 'text-yellow-600' : 'text-green-600'}>
|
||||
{qtdRestante.toFixed(2)} {empenho.estoque_materiais?.unidade}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getStatusColor(empenho.status)} text-white border-none`}
|
||||
>
|
||||
{getStatusIcon(empenho.status)}
|
||||
<span className="ml-1">{empenho.status}</span>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(empenho.data_empenho).toLocaleDateString('pt-BR')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<Info className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Informações do Empenho</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<div><strong>Material:</strong> {empenho.estoque_materiais?.codigo} - {empenho.estoque_materiais?.descricao}</div>
|
||||
<div><strong>OF:</strong> {empenho.of_number}</div>
|
||||
<div><strong>Quantidade Empenhada:</strong> {empenho.quantidade_empenhada.toFixed(2)} {empenho.estoque_materiais?.unidade}</div>
|
||||
<div><strong>Quantidade Utilizada:</strong> {empenho.quantidade_utilizada.toFixed(2)} {empenho.estoque_materiais?.unidade}</div>
|
||||
<div><strong>Status:</strong> {empenho.status}</div>
|
||||
<div><strong>Data:</strong> {new Date(empenho.data_empenho).toLocaleDateString('pt-BR')}</div>
|
||||
{empenho.lote && <div><strong>Lote:</strong> {empenho.lote}</div>}
|
||||
{empenho.observacoes && <div><strong>Observações:</strong> {empenho.observacoes}</div>}
|
||||
<div className="mt-4 p-2 bg-blue-50 border border-blue-200 rounded">
|
||||
<strong>Para cancelar este empenho:</strong>
|
||||
<br />
|
||||
Acesse a aba "Movimentação" e exclua a movimentação de empenho correspondente.
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{selectedOF && selectedOF !== 'all' ? (
|
||||
<div>
|
||||
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||
<p>Nenhum empenho encontrado para a OF {selectedOF}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||
<p>Selecione uma OF para visualizar os empenhos de material</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
143
src/components/estoque/EstoqueBatchActions.tsx
Normal file
143
src/components/estoque/EstoqueBatchActions.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Trash2, Edit, Package } from 'lucide-react';
|
||||
import { EstoqueMaterial, useExcluirMateriais } from '@/hooks/useEstoque';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface EstoqueBatchActionsProps {
|
||||
selectedMaterials: EstoqueMaterial[];
|
||||
onClearSelection: () => void;
|
||||
onShowMovimentacao: () => void;
|
||||
onShowBatchEdit: () => void;
|
||||
}
|
||||
|
||||
export function EstoqueBatchActions({
|
||||
selectedMaterials,
|
||||
onClearSelection,
|
||||
onShowMovimentacao,
|
||||
onShowBatchEdit
|
||||
}: EstoqueBatchActionsProps) {
|
||||
const excluirMateriais = useExcluirMateriais();
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedMaterials.length === 0) {
|
||||
console.log('Nenhum material selecionado para exclusão');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const materialIds = selectedMaterials.map(m => m.id);
|
||||
console.log('Iniciando exclusão de materiais:', {
|
||||
count: selectedMaterials.length,
|
||||
ids: materialIds,
|
||||
materials: selectedMaterials.map(m => ({ id: m.id, codigo: m.codigo, descricao: m.descricao }))
|
||||
});
|
||||
|
||||
await excluirMateriais.mutateAsync(materialIds);
|
||||
console.log('Exclusão concluída com sucesso');
|
||||
onClearSelection();
|
||||
} catch (error) {
|
||||
console.error('Erro na exclusão de materiais:', error);
|
||||
// Error is handled by the mutation hook via toast
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedMaterials.length === 0) return null;
|
||||
|
||||
const confirmMessage = selectedMaterials.length === 1
|
||||
? `Tem certeza que deseja excluir o material "${selectedMaterials[0].descricao}"?`
|
||||
: `Tem certeza que deseja excluir ${selectedMaterials.length} materiais selecionados?`;
|
||||
|
||||
return (
|
||||
<Card className="mb-4 bg-blue-50 border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
{selectedMaterials.length} material(is) selecionado(s)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onShowBatchEdit}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onShowMovimentacao}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Movimentar
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={excluirMateriais.isPending}
|
||||
className="text-red-600 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{excluirMateriais.isPending ? 'Excluindo...' : 'Excluir'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="bg-slate-800 border-slate-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">Confirmar Exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-slate-400">
|
||||
{confirmMessage}
|
||||
<br /><br />
|
||||
<strong>Esta ação não pode ser desfeita.</strong>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
className="bg-slate-700 border-slate-600 text-white hover:bg-slate-600"
|
||||
disabled={excluirMateriais.isPending}
|
||||
>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleBatchDelete}
|
||||
disabled={excluirMateriais.isPending}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
{excluirMateriais.isPending ? 'Excluindo...' : 'Excluir'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearSelection}
|
||||
className="text-gray-500"
|
||||
>
|
||||
Limpar Seleção
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
264
src/components/estoque/EstoqueBatchEditModal.tsx
Normal file
264
src/components/estoque/EstoqueBatchEditModal.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { EstoqueMaterial, useTiposMateriaPrima, useAtualizarMaterial } from '@/hooks/useEstoque';
|
||||
import { useUnidadesMedida, useLocalizacoesEstoque, useQualidadesAco } from '@/hooks/useEstoqueCRUD';
|
||||
|
||||
interface EstoqueBatchEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedMaterials: EstoqueMaterial[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const EstoqueBatchEditModal: React.FC<EstoqueBatchEditModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedMaterials,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
tipo_material_id: '',
|
||||
unidade: '',
|
||||
quantidade_minima: '',
|
||||
quantidade_maxima: '',
|
||||
peso_unitario: '',
|
||||
valor_unitario: '',
|
||||
lote_atual: '',
|
||||
fornecedor: '',
|
||||
localizacao: '',
|
||||
qualidade_aco: ''
|
||||
});
|
||||
|
||||
const { data: tiposMaterial } = useTiposMateriaPrima();
|
||||
const { data: unidadesMedida } = useUnidadesMedida();
|
||||
const { data: localizacoes } = useLocalizacoesEstoque();
|
||||
const { data: qualidadesAco } = useQualidadesAco();
|
||||
const atualizarMaterial = useAtualizarMaterial();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize form data with common values from selected materials
|
||||
if (selectedMaterials.length > 0) {
|
||||
setFormData({
|
||||
tipo_material_id: '',
|
||||
unidade: '',
|
||||
quantidade_minima: '',
|
||||
quantidade_maxima: '',
|
||||
peso_unitario: '',
|
||||
valor_unitario: '',
|
||||
lote_atual: '',
|
||||
fornecedor: '',
|
||||
localizacao: '',
|
||||
qualidade_aco: ''
|
||||
});
|
||||
}
|
||||
}, [selectedMaterials]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedMaterials.map(material => {
|
||||
const updates: Partial<EstoqueMaterial> = {};
|
||||
if (formData.tipo_material_id !== '' && formData.tipo_material_id !== 'none') updates.tipo_material_id = formData.tipo_material_id;
|
||||
if (formData.unidade !== '' && formData.unidade !== 'none') updates.unidade = formData.unidade;
|
||||
if (formData.quantidade_minima !== '') updates.quantidade_minima = parseFloat(formData.quantidade_minima);
|
||||
if (formData.quantidade_maxima !== '') updates.quantidade_maxima = parseFloat(formData.quantidade_maxima);
|
||||
if (formData.peso_unitario !== '') updates.peso_unitario = parseFloat(formData.peso_unitario);
|
||||
if (formData.valor_unitario !== '') updates.valor_unitario = parseFloat(formData.valor_unitario);
|
||||
if (formData.lote_atual !== '') updates.lote_atual = formData.lote_atual;
|
||||
if (formData.fornecedor !== '') updates.fornecedor = formData.fornecedor;
|
||||
if (formData.localizacao !== '' && formData.localizacao !== 'none') updates.localizacao = formData.localizacao;
|
||||
if (formData.qualidade_aco !== '' && formData.qualidade_aco !== 'none') {
|
||||
updates.qualidade_aco = formData.qualidade_aco === "remove" ? null : formData.qualidade_aco;
|
||||
}
|
||||
|
||||
return atualizarMaterial.mutateAsync({ id: material.id, ...updates });
|
||||
})
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Erro ao atualizar materiais em lote:", error);
|
||||
alert("Erro ao atualizar materiais em lote.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar {selectedMaterials.length} Material(is) em Lote</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="tipo_material_id">Tipo de Material</Label>
|
||||
<Select value={formData.tipo_material_id} onValueChange={(value) => handleInputChange('tipo_material_id', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Não alterar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Não alterar</SelectItem>
|
||||
{tiposMaterial?.map((tipo) => (
|
||||
<SelectItem key={tipo.id} value={tipo.id}>
|
||||
{tipo.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="unidade">Unidade</Label>
|
||||
<Select value={formData.unidade} onValueChange={(value) => handleInputChange('unidade', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Não alterar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Não alterar</SelectItem>
|
||||
{unidadesMedida?.map((unidade) => (
|
||||
<SelectItem key={unidade.id} value={unidade.abreviacao}>
|
||||
{unidade.abreviacao} - {unidade.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="quantidade_minima">Quantidade Mínima</Label>
|
||||
<Input
|
||||
id="quantidade_minima"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.quantidade_minima}
|
||||
onChange={(e) => handleInputChange('quantidade_minima', e.target.value)}
|
||||
placeholder="Não alterar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quantidade_maxima">Quantidade Máxima</Label>
|
||||
<Input
|
||||
id="quantidade_maxima"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.quantidade_maxima}
|
||||
onChange={(e) => handleInputChange('quantidade_maxima', e.target.value)}
|
||||
placeholder="Não alterar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="peso_unitario">Peso Unitário</Label>
|
||||
<Input
|
||||
id="peso_unitario"
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={formData.peso_unitario}
|
||||
onChange={(e) => handleInputChange('peso_unitario', e.target.value)}
|
||||
placeholder="Não alterar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="valor_unitario">Valor Unitário</Label>
|
||||
<Input
|
||||
id="valor_unitario"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.valor_unitario}
|
||||
onChange={(e) => handleInputChange('valor_unitario', e.target.value)}
|
||||
placeholder="Não alterar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="lote_atual">Lote Atual</Label>
|
||||
<Input
|
||||
id="lote_atual"
|
||||
value={formData.lote_atual}
|
||||
onChange={(e) => handleInputChange('lote_atual', e.target.value)}
|
||||
placeholder="Não alterar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fornecedor">Fornecedor</Label>
|
||||
<Input
|
||||
id="fornecedor"
|
||||
value={formData.fornecedor}
|
||||
onChange={(e) => handleInputChange('fornecedor', e.target.value)}
|
||||
placeholder="Não alterar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="localizacao">Localização</Label>
|
||||
<Select value={formData.localizacao} onValueChange={(value) => handleInputChange('localizacao', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Não alterar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Não alterar</SelectItem>
|
||||
{localizacoes?.map((localizacao) => (
|
||||
<SelectItem key={localizacao.id} value={localizacao.nome}>
|
||||
{localizacao.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="qualidade_aco">Qualidade do Aço</Label>
|
||||
<Select value={formData.qualidade_aco} onValueChange={(value) => handleInputChange('qualidade_aco', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Não alterar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Não alterar</SelectItem>
|
||||
<SelectItem value="remove">Remover qualidade</SelectItem>
|
||||
{qualidadesAco?.map((qualidade) => (
|
||||
<SelectItem key={qualidade.id} value={qualidade.nome}>
|
||||
{qualidade.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={atualizarMaterial.isPending}>
|
||||
{atualizarMaterial.isPending ? 'Atualizando...' : 'Salvar Alterações'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
180
src/components/estoque/EstoqueBatchMovementModal.tsx
Normal file
180
src/components/estoque/EstoqueBatchMovementModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCriarMovimentacao } from '@/hooks/useEstoqueMovimentacoes';
|
||||
import { EstoqueMaterial } from '@/hooks/useEstoque';
|
||||
import { useOFsAtivas } from '@/hooks/useOFsAtivas';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface EstoqueBatchMovementModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedMaterials: EstoqueMaterial[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function EstoqueBatchMovementModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedMaterials,
|
||||
onSuccess
|
||||
}: EstoqueBatchMovementModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
tipo_movimentacao: 'entrada' as 'entrada' | 'saida' | 'transferencia' | 'ajuste' | 'empenho' | 'desempenho',
|
||||
quantidade: '',
|
||||
of_vinculada: '',
|
||||
observacoes: '',
|
||||
data_movimentacao: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
const criarMovimentacao = useCriarMovimentacao();
|
||||
const { data: ofsAtivas = [] } = useOFsAtivas();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.quantidade || !formData.tipo_movimentacao) {
|
||||
toast.error('Preencha os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar se OF é obrigatória para empenho e desempenho
|
||||
if ((formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho') && !formData.of_vinculada) {
|
||||
toast.error('OF vinculada é obrigatória para movimentações de empenho e desempenho');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const quantidade = parseFloat(formData.quantidade);
|
||||
|
||||
// Create movement for each selected material
|
||||
for (const material of selectedMaterials) {
|
||||
await criarMovimentacao.mutateAsync({
|
||||
material_id: material.id,
|
||||
tipo_movimentacao: formData.tipo_movimentacao,
|
||||
quantidade,
|
||||
of_vinculada: formData.of_vinculada || undefined,
|
||||
observacoes: formData.observacoes || undefined,
|
||||
data_movimentacao: formData.data_movimentacao
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(`Movimentação criada para ${selectedMaterials.length} material(is)!`);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar movimentação em lote:', error);
|
||||
toast.error('Erro ao criar movimentação em lote');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const requiresOF = formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Movimentação em Lote</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Criará a mesma movimentação para {selectedMaterials.length} material(is) selecionado(s)
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Tipo de Movimentação *</Label>
|
||||
<Select value={formData.tipo_movimentacao} onValueChange={(value: any) => handleInputChange('tipo_movimentacao', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="entrada">Entrada</SelectItem>
|
||||
<SelectItem value="saida">Saída</SelectItem>
|
||||
<SelectItem value="transferencia">Transferência</SelectItem>
|
||||
<SelectItem value="ajuste">Ajuste</SelectItem>
|
||||
<SelectItem value="empenho">Empenho</SelectItem>
|
||||
<SelectItem value="desempenho">Desempenho</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Quantidade *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.quantidade}
|
||||
onChange={(e) => handleInputChange('quantidade', e.target.value)}
|
||||
placeholder="Quantidade a movimentar"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Data da Movimentação</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.data_movimentacao}
|
||||
onChange={(e) => handleInputChange('data_movimentacao', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{requiresOF ? (
|
||||
<div className="space-y-2">
|
||||
<Label>OF Vinculada *</Label>
|
||||
<Select value={formData.of_vinculada} onValueChange={(value) => handleInputChange('of_vinculada', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ofsAtivas.map((of) => (
|
||||
<SelectItem key={of.of_number} value={of.of_number}>
|
||||
{of.of_number} - {of.cliente || 'Cliente não informado'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>OF Vinculada</Label>
|
||||
<Input
|
||||
value={formData.of_vinculada}
|
||||
onChange={(e) => handleInputChange('of_vinculada', e.target.value)}
|
||||
placeholder="Número da OF (opcional)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Observações</Label>
|
||||
<Textarea
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => handleInputChange('observacoes', e.target.value)}
|
||||
placeholder="Observações sobre a movimentação"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={criarMovimentacao.isPending}
|
||||
>
|
||||
{criarMovimentacao.isPending ? 'Criando...' : 'Criar Movimentação'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
428
src/components/estoque/EstoqueCSVImportModal.tsx
Normal file
428
src/components/estoque/EstoqueCSVImportModal.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Upload, FileText, AlertTriangle, CheckCircle, X } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useCriarMaterial } from '@/hooks/useEstoque';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface EstoqueCSVImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CSVRow {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface ValidationError {
|
||||
row: number;
|
||||
field: string;
|
||||
value: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ProcessedMaterial {
|
||||
codigo: string;
|
||||
descricao: string;
|
||||
tipo_material_id?: string;
|
||||
unidade: string;
|
||||
quantidade_total: number;
|
||||
quantidade_disponivel: number;
|
||||
quantidade_empenhada: number;
|
||||
quantidade_minima: number;
|
||||
quantidade_maxima?: number;
|
||||
peso_unitario: number;
|
||||
valor_unitario?: number;
|
||||
lote_atual?: string;
|
||||
fornecedor?: string;
|
||||
localizacao?: string;
|
||||
status: 'Normal' | 'Crítico' | 'Excesso';
|
||||
certificado?: string;
|
||||
observacoes?: string;
|
||||
comprimento?: number;
|
||||
largura?: number;
|
||||
espessura?: number;
|
||||
qualidade_aco?: string;
|
||||
kg_por_metro?: number;
|
||||
}
|
||||
|
||||
const REQUIRED_FIELDS = ['descricao', 'unidade'];
|
||||
const VALID_STATUS = ['Normal', 'Crítico', 'Excesso'] as const;
|
||||
const VALID_UNITS = ['PC', 'KG', 'M', 'M2', 'M3', 'L', 'UN'];
|
||||
|
||||
export function EstoqueCSVImportModal({ isOpen, onClose }: EstoqueCSVImportModalProps) {
|
||||
const [csvData, setCsvData] = useState<CSVRow[]>([]);
|
||||
const [processedData, setProcessedData] = useState<ProcessedMaterial[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [fileName, setFileName] = useState('');
|
||||
|
||||
const criarMaterial = useCriarMaterial();
|
||||
|
||||
// Function to generate a unique codigo
|
||||
const generateCodigo = (descricao: string, index: number): string => {
|
||||
const prefix = descricao.substring(0, 3).toUpperCase().replace(/[^A-Z]/g, 'X');
|
||||
const timestamp = Date.now().toString().slice(-6);
|
||||
const indexStr = index.toString().padStart(3, '0');
|
||||
return `${prefix}${timestamp}${indexStr}`;
|
||||
};
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
if (!file) return;
|
||||
|
||||
setFileName(file.name);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
parseCSV(text);
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'text/csv': ['.csv'],
|
||||
'application/vnd.ms-excel': ['.csv']
|
||||
},
|
||||
maxFiles: 1
|
||||
});
|
||||
|
||||
const parseCSV = (text: string) => {
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
if (lines.length < 2) {
|
||||
toast.error('Arquivo CSV deve ter pelo menos uma linha de cabeçalho e uma linha de dados');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
const rows: CSVRow[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
||||
const row: CSVRow = {};
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
setCsvData(rows);
|
||||
processAndValidateData(rows);
|
||||
};
|
||||
|
||||
const processAndValidateData = (data: CSVRow[]) => {
|
||||
setIsProcessing(true);
|
||||
const errors: ValidationError[] = [];
|
||||
const processed: ProcessedMaterial[] = [];
|
||||
|
||||
data.forEach((row, index) => {
|
||||
// Validate and normalize status
|
||||
let normalizedStatus: 'Normal' | 'Crítico' | 'Excesso' = 'Normal';
|
||||
if (row.status && row.status.trim()) {
|
||||
const statusValue = row.status.trim();
|
||||
if (VALID_STATUS.includes(statusValue as any)) {
|
||||
normalizedStatus = statusValue as 'Normal' | 'Crítico' | 'Excesso';
|
||||
} else {
|
||||
errors.push({
|
||||
row: index + 1,
|
||||
field: 'status',
|
||||
value: statusValue,
|
||||
message: `Status inválido. Use: ${VALID_STATUS.join(', ')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate codigo if not provided
|
||||
const codigo = row.codigo?.trim() || generateCodigo(row.descricao || 'MATERIAL', index);
|
||||
|
||||
const material: ProcessedMaterial = {
|
||||
codigo,
|
||||
descricao: row.descricao?.trim() || '',
|
||||
unidade: row.unidade?.trim() || 'PC',
|
||||
quantidade_total: parseFloat(row.quantidade_total) || 0,
|
||||
quantidade_disponivel: parseFloat(row.quantidade_disponivel) || 0,
|
||||
quantidade_empenhada: parseFloat(row.quantidade_empenhada) || 0,
|
||||
quantidade_minima: parseFloat(row.quantidade_minima) || 0,
|
||||
quantidade_maxima: row.quantidade_maxima ? parseFloat(row.quantidade_maxima) : undefined,
|
||||
peso_unitario: parseFloat(row.peso_unitario) || 0,
|
||||
valor_unitario: row.valor_unitario ? parseFloat(row.valor_unitario) : undefined,
|
||||
lote_atual: row.lote_atual?.trim() || undefined,
|
||||
fornecedor: row.fornecedor?.trim() || undefined,
|
||||
localizacao: row.localizacao?.trim() || undefined,
|
||||
status: normalizedStatus,
|
||||
certificado: row.certificado?.trim() || undefined,
|
||||
observacoes: row.observacoes?.trim() || undefined,
|
||||
comprimento: row.comprimento ? parseFloat(row.comprimento) : undefined,
|
||||
largura: row.largura ? parseFloat(row.largura) : undefined,
|
||||
espessura: row.espessura ? parseFloat(row.espessura) : undefined,
|
||||
qualidade_aco: row.qualidade_aco?.trim() || undefined,
|
||||
kg_por_metro: row.kg_por_metro ? parseFloat(row.kg_por_metro) : undefined,
|
||||
};
|
||||
|
||||
// Validações
|
||||
REQUIRED_FIELDS.forEach(field => {
|
||||
if (!material[field as keyof ProcessedMaterial]) {
|
||||
errors.push({
|
||||
row: index + 1,
|
||||
field,
|
||||
value: row[field] || '',
|
||||
message: `Campo obrigatório não informado`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Validar unidade
|
||||
if (material.unidade && !VALID_UNITS.includes(material.unidade)) {
|
||||
errors.push({
|
||||
row: index + 1,
|
||||
field: 'unidade',
|
||||
value: material.unidade,
|
||||
message: `Unidade inválida. Use: ${VALID_UNITS.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// Validar quantidades negativas
|
||||
const quantityFields = ['quantidade_total', 'quantidade_disponivel', 'quantidade_empenhada', 'quantidade_minima'];
|
||||
quantityFields.forEach(field => {
|
||||
const value = material[field as keyof ProcessedMaterial] as number;
|
||||
if (value < 0) {
|
||||
errors.push({
|
||||
row: index + 1,
|
||||
field,
|
||||
value: value.toString(),
|
||||
message: 'Quantidade não pode ser negativa'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
processed.push(material);
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
setProcessedData(processed);
|
||||
setShowPreview(true);
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (validationErrors.length > 0) {
|
||||
toast.error('Corrija os erros de validação antes de continuar');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const material of processedData) {
|
||||
try {
|
||||
await criarMaterial.mutateAsync(material);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error('Erro ao importar material:', error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount} material(is) importado(s) com sucesso!`);
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
toast.error(`${errorCount} material(is) falharam na importação`);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setCsvData([]);
|
||||
setProcessedData([]);
|
||||
setValidationErrors([]);
|
||||
setShowPreview(false);
|
||||
setFileName('');
|
||||
setIsProcessing(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getErrorsForRow = (rowIndex: number) => {
|
||||
return validationErrors.filter(error => error.row === rowIndex + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Importar Materiais CSV
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{!showPreview ? (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
|
||||
${isDragActive ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
{isDragActive ? (
|
||||
<p className="text-blue-600">Solte o arquivo aqui...</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-lg font-medium text-gray-900 mb-2">
|
||||
Arraste um arquivo CSV ou clique para selecionar
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Arquivos .csv são aceitos
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Formato esperado do CSV:</h3>
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p><strong>Campos obrigatórios:</strong> descricao, unidade</p>
|
||||
<p><strong>Campos opcionais:</strong> codigo, quantidade_total, quantidade_disponivel, quantidade_empenhada, quantidade_minima, quantidade_maxima, peso_unitario, valor_unitario, lote_atual, fornecedor, localizacao, status, certificado, observacoes, comprimento, largura, espessura, qualidade_aco, kg_por_metro</p>
|
||||
<p><strong>Status válidos:</strong> Normal, Crítico, Excesso</p>
|
||||
<p><strong>Unidades válidas:</strong> PC, KG, M, M2, M3, L, UN</p>
|
||||
<p><strong>Nota:</strong> Se o campo 'codigo' não for informado, será gerado automaticamente pelo sistema.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{fileName}</span>
|
||||
</div>
|
||||
<Badge variant={validationErrors.length > 0 ? "destructive" : "default"}>
|
||||
{processedData.length} registros
|
||||
</Badge>
|
||||
{validationErrors.length > 0 && (
|
||||
<Badge variant="destructive">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
{validationErrors.length} erros
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationErrors.length > 0 && (
|
||||
<Card className="border-red-200">
|
||||
<CardContent className="p-4">
|
||||
<h4 className="font-medium text-red-800 mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Erros de Validação
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{validationErrors.map((error, index) => (
|
||||
<div key={index} className="text-sm text-red-700 bg-red-50 p-2 rounded">
|
||||
<strong>Linha {error.row}:</strong> {error.field} - {error.message}
|
||||
{error.value && <span className="ml-2 text-red-600">("{error.value}")</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full overflow-auto border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Linha</TableHead>
|
||||
<TableHead>Código</TableHead>
|
||||
<TableHead>Descrição</TableHead>
|
||||
<TableHead>Unidade</TableHead>
|
||||
<TableHead>Qtd Total</TableHead>
|
||||
<TableHead>Qtd Disp.</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Erros</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processedData.map((material, index) => {
|
||||
const errors = getErrorsForRow(index);
|
||||
return (
|
||||
<TableRow key={index} className={errors.length > 0 ? 'bg-red-50' : ''}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell className="max-w-24 truncate">{material.codigo}</TableCell>
|
||||
<TableCell className="max-w-48 truncate">{material.descricao}</TableCell>
|
||||
<TableCell>{material.unidade}</TableCell>
|
||||
<TableCell>{material.quantidade_total}</TableCell>
|
||||
<TableCell>{material.quantidade_disponivel}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={material.status === 'Normal' ? 'default' : 'secondary'}>
|
||||
{material.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{errors.length > 0 ? (
|
||||
<Badge variant="destructive">{errors.length}</Badge>
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
{showPreview ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setShowPreview(false)} disabled={isProcessing}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={isProcessing || validationErrors.length > 0}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isProcessing ? 'Importando...' : `Importar ${processedData.length} Material(is)`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
292
src/components/estoque/EstoqueCriticoModal.tsx
Normal file
292
src/components/estoque/EstoqueCriticoModal.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { AlertTriangle, ShoppingCart, Clock } from 'lucide-react';
|
||||
import { useMateriaisCriticos } from '@/hooks/useMateriaisCriticos';
|
||||
import { useMateriaisEmSC } from '@/hooks/useMateriaisEmSC';
|
||||
import { useSolicitacoesCompra } from '@/hooks/useSolicitacoesCompra';
|
||||
import { EstoqueMaterial } from '@/hooks/useEstoque';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface EstoqueCriticoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EstoqueCriticoModal({ isOpen, onClose }: EstoqueCriticoModalProps) {
|
||||
const { materiaisCriticos, loading } = useMateriaisCriticos();
|
||||
const { data: materiaisEmSCMap, isLoading: loadingMateriaisEmSC } = useMateriaisEmSC();
|
||||
const { createSolicitacao, isCreating } = useSolicitacoesCompra();
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<EstoqueMaterial[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Separar materiais críticos em dois grupos
|
||||
const { materiaisDisponiveis, materiaisEmSC } = useMemo(() => {
|
||||
if (!materiaisEmSCMap) {
|
||||
return {
|
||||
materiaisDisponiveis: materiaisCriticos,
|
||||
materiaisEmSC: []
|
||||
};
|
||||
}
|
||||
|
||||
const disponiveis: EstoqueMaterial[] = [];
|
||||
const emSC: Array<EstoqueMaterial & { solicitacoes: any[] }> = [];
|
||||
|
||||
materiaisCriticos.forEach(material => {
|
||||
if (materiaisEmSCMap.has(material.id)) {
|
||||
const scInfo = materiaisEmSCMap.get(material.id);
|
||||
emSC.push({
|
||||
...material,
|
||||
solicitacoes: scInfo.solicitacoes
|
||||
});
|
||||
} else {
|
||||
disponiveis.push(material);
|
||||
}
|
||||
});
|
||||
|
||||
return { materiaisDisponiveis: disponiveis, materiaisEmSC: emSC };
|
||||
}, [materiaisCriticos, materiaisEmSCMap]);
|
||||
|
||||
const handleSelectMaterial = (material: EstoqueMaterial, isSelected: boolean) => {
|
||||
if (isSelected) {
|
||||
setSelectedMaterials(prev => [...prev, material]);
|
||||
} else {
|
||||
setSelectedMaterials(prev => prev.filter(m => m.id !== material.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedMaterials(materiaisDisponiveis);
|
||||
} else {
|
||||
setSelectedMaterials([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGerarSC = async () => {
|
||||
if (selectedMaterials.length === 0) {
|
||||
toast.error('Selecione pelo menos um material para gerar a SC');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hoje = new Date();
|
||||
const prazoRecebimento = new Date();
|
||||
prazoRecebimento.setDate(hoje.getDate() + 3);
|
||||
|
||||
const itens = selectedMaterials.map(material => ({
|
||||
material_id: material.id,
|
||||
quantidade: Math.ceil((material.quantidade_minima || 1) * 3),
|
||||
prazo_recebimento: prazoRecebimento.toISOString().split('T')[0]
|
||||
}));
|
||||
|
||||
const solicitacaoData = {
|
||||
data_solicitacao: hoje.toISOString().split('T')[0],
|
||||
objetivo: 'Geração de SC automática',
|
||||
justificativa: `SC gerada automaticamente para reposição de ${selectedMaterials.length} material(is) crítico(s) em estoque.`,
|
||||
itens,
|
||||
anexos_urls: []
|
||||
};
|
||||
|
||||
await createSolicitacao(solicitacaoData);
|
||||
|
||||
toast.success('Solicitação de compra gerada com sucesso!');
|
||||
setSelectedMaterials([]);
|
||||
onClose();
|
||||
|
||||
navigate('/solicitacao-compras');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar SC:', error);
|
||||
toast.error('Erro ao gerar solicitação de compra');
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = () => {
|
||||
return materiaisDisponiveis.length > 0 && selectedMaterials.length === materiaisDisponiveis.length;
|
||||
};
|
||||
|
||||
if (loading || loadingMateriaisEmSC) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-7xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Materiais com Estoque Crítico
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
{materiaisCriticos.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-green-500 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
Nenhum material crítico encontrado
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Todos os materiais estão com quantidades adequadas em estoque.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Materiais já em SC */}
|
||||
{materiaisEmSC.length > 0 && (
|
||||
<Card className="border-orange-200 bg-orange-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-orange-700">
|
||||
<Clock className="h-5 w-5" />
|
||||
Materiais já em Solicitação de Compra ({materiaisEmSC.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{materiaisEmSC.map((material) => (
|
||||
<div key={material.id} className="bg-white p-4 rounded-lg border border-orange-200">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">{material.descricao}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Código: {material.codigo} | Disponível: {material.quantidade_disponivel} {material.unidade}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-orange-600 bg-orange-100 border-orange-300">
|
||||
Em SC
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{material.solicitacoes.map((sc, index) => (
|
||||
<div key={index} className="text-xs bg-orange-100 px-2 py-1 rounded">
|
||||
<span className="font-medium">{sc.numero_sc}</span> -
|
||||
Qtd: {sc.quantidade} -
|
||||
Status: <span className="font-medium">{sc.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Materiais disponíveis para nova SC */}
|
||||
{materiaisDisponiveis.length > 0 && (
|
||||
<Card className="border-red-200 bg-red-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-700">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Materiais Disponíveis para Nova SC ({materiaisDisponiveis.length})
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{selectedMaterials.length > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{selectedMaterials.length} selecionado(s)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleGerarSC}
|
||||
disabled={selectedMaterials.length === 0 || isCreating}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
{isCreating ? 'Gerando SC...' : 'Gerar SC Automática'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected()}
|
||||
onChange={handleSelectAll}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Descrição</TableHead>
|
||||
<TableHead>Código</TableHead>
|
||||
<TableHead>Lote</TableHead>
|
||||
<TableHead>Unidade</TableHead>
|
||||
<TableHead>Qtd. Disponível</TableHead>
|
||||
<TableHead>Qtd. Mínima</TableHead>
|
||||
<TableHead>Qtd. Sugerida</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{materiaisDisponiveis.map((material) => {
|
||||
const quantidadeSugerida = Math.ceil((material.quantidade_minima || 1) * 3);
|
||||
|
||||
return (
|
||||
<TableRow key={material.id}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.some(m => m.id === material.id)}
|
||||
onChange={(e) => handleSelectMaterial(material, e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{material.descricao}
|
||||
</TableCell>
|
||||
<TableCell>{material.codigo}</TableCell>
|
||||
<TableCell>{material.lote_atual || '-'}</TableCell>
|
||||
<TableCell>{material.unidade}</TableCell>
|
||||
<TableCell className="text-red-600 font-medium">
|
||||
{material.quantidade_disponivel}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{material.quantidade_minima}
|
||||
</TableCell>
|
||||
<TableCell className="text-green-600 font-medium">
|
||||
{quantidadeSugerida}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-red-600 bg-red-100 border-red-300">
|
||||
{material.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Fechar
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
222
src/components/estoque/EstoqueDashboard.tsx
Normal file
222
src/components/estoque/EstoqueDashboard.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TrendingUp, TrendingDown, RotateCcw, Package, ArrowRightLeft, AlertTriangle } from 'lucide-react';
|
||||
import { useMovimentacoesEstoque } from '@/hooks/useEstoqueMovimentacoes';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
interface MovimentacaoEstoque {
|
||||
id: string;
|
||||
material_id: string;
|
||||
tipo_movimentacao: 'entrada' | 'saida' | 'transferencia' | 'ajuste' | 'empenho' | 'desempenho';
|
||||
quantidade: number;
|
||||
lote?: string;
|
||||
fornecedor?: string;
|
||||
of_vinculada?: string;
|
||||
observacoes?: string;
|
||||
data_movimentacao?: string;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
estoque_materiais?: {
|
||||
codigo: string;
|
||||
descricao: string;
|
||||
};
|
||||
}
|
||||
|
||||
const getMovementIcon = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'entrada':
|
||||
return <TrendingUp className="h-4 w-4 text-green-600" />;
|
||||
case 'saida':
|
||||
return <TrendingDown className="h-4 w-4 text-red-600" />;
|
||||
case 'ajuste':
|
||||
return <RotateCcw className="h-4 w-4 text-blue-600" />;
|
||||
case 'transferencia':
|
||||
return <ArrowRightLeft className="h-4 w-4 text-purple-600" />;
|
||||
case 'empenho':
|
||||
return <Package className="h-4 w-4 text-orange-600" />;
|
||||
case 'desempenho':
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-600" />;
|
||||
default:
|
||||
return <Package className="h-4 w-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getMovementColor = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'entrada':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'saida':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'ajuste':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'transferencia':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
case 'empenho':
|
||||
return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||
case 'desempenho':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getMovementTitle = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'entrada':
|
||||
return 'Entradas';
|
||||
case 'saida':
|
||||
return 'Saídas';
|
||||
case 'ajuste':
|
||||
return 'Ajustes';
|
||||
case 'transferencia':
|
||||
return 'Transferências';
|
||||
case 'empenho':
|
||||
return 'Empenhos';
|
||||
case 'desempenho':
|
||||
return 'Desempenhos';
|
||||
default:
|
||||
return 'Outras';
|
||||
}
|
||||
};
|
||||
|
||||
const MovementPanel: React.FC<{
|
||||
tipo: string;
|
||||
movimentacoes: MovimentacaoEstoque[];
|
||||
icon: React.ReactNode;
|
||||
}> = ({ tipo, movimentacoes, icon }) => {
|
||||
const recentMovements = movimentacoes
|
||||
.filter(mov => mov.tipo_movimentacao === tipo)
|
||||
.slice(0, 5); // Mostrar apenas as 5 mais recentes
|
||||
|
||||
if (recentMovements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
{icon}
|
||||
{getMovementTitle(tipo)}
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{recentMovements.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recentMovements.map((movimento) => (
|
||||
<div key={movimento.id} className="border-l-4 border-l-gray-200 pl-3 py-2 hover:bg-gray-50 rounded-r">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge className={`text-xs ${getMovementColor(movimento.tipo_movimentacao)}`}>
|
||||
{movimento.tipo_movimentacao.toUpperCase()}
|
||||
</Badge>
|
||||
<span className="text-xs text-gray-500">
|
||||
{format(new Date(movimento.created_at), 'dd/MM HH:mm', { locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 mb-1">
|
||||
{movimento.estoque_materiais?.descricao || 'Material não identificado'}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>Qtd: <strong>{movimento.quantidade}</strong></span>
|
||||
{movimento.lote && (
|
||||
<span>Lote: <strong>{movimento.lote}</strong></span>
|
||||
)}
|
||||
</div>
|
||||
{movimento.of_vinculada && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
OF: {movimento.of_vinculada}
|
||||
</div>
|
||||
)}
|
||||
{movimento.fornecedor && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Fornecedor: {movimento.fornecedor}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const EstoqueDashboard: React.FC = () => {
|
||||
const { data: movimentacoes = [], isLoading } = useMovimentacoesEstoque();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Card key={index} className="h-80">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tiposMovimentacao = ['entrada', 'saida', 'ajuste', 'transferencia', 'empenho', 'desempenho'];
|
||||
|
||||
// Filtrar apenas movimentações dos últimos 30 dias
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const recentMovements = movimentacoes.filter(mov =>
|
||||
new Date(mov.created_at) >= thirtyDaysAgo
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Resumo Geral */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Resumo de Movimentações (Últimos 30 dias)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{tiposMovimentacao.map(tipo => {
|
||||
const count = recentMovements.filter(mov => mov.tipo_movimentacao === tipo).length;
|
||||
return (
|
||||
<div key={tipo} className="text-center">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
{getMovementIcon(tipo)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{count}</div>
|
||||
<div className="text-xs text-gray-500 capitalize">{tipo}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Painéis de Movimentações */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{tiposMovimentacao.map(tipo => (
|
||||
<MovementPanel
|
||||
key={tipo}
|
||||
tipo={tipo}
|
||||
movimentacoes={recentMovements}
|
||||
icon={getMovementIcon(tipo)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
93
src/components/estoque/EstoqueDashboardCards.tsx
Normal file
93
src/components/estoque/EstoqueDashboardCards.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Package, AlertTriangle, TrendingUp, Box } from 'lucide-react';
|
||||
import { EstoqueMaterial } from '@/hooks/useEstoque';
|
||||
|
||||
interface EstoqueDashboardCardsProps {
|
||||
materiais: EstoqueMaterial[];
|
||||
}
|
||||
|
||||
export const EstoqueDashboardCards: React.FC<EstoqueDashboardCardsProps> = ({ materiais }) => {
|
||||
// Calcular Total (kg)
|
||||
const totalKg = materiais.reduce((acc, material) => {
|
||||
const kgPorMetro = material.kg_por_metro || 0;
|
||||
const comprimento = material.comprimento || 0;
|
||||
const quantidadeTotal = material.quantidade_total || 0;
|
||||
|
||||
// Fórmula: (Kg por metro * comprimento / 1000) * quantidade total
|
||||
const kgItem = (kgPorMetro * comprimento / 1000) * quantidadeTotal;
|
||||
return acc + kgItem;
|
||||
}, 0);
|
||||
|
||||
// Calcular Total (unid)
|
||||
const totalUnidades = materiais.reduce((acc, material) => {
|
||||
return acc + (material.quantidade_total || 0);
|
||||
}, 0);
|
||||
|
||||
// Calcular Total Disponível (unid)
|
||||
const totalDisponiveis = materiais.reduce((acc, material) => {
|
||||
return acc + (material.quantidade_disponivel || 0);
|
||||
}, 0);
|
||||
|
||||
// Calcular Total Empenhado (unid)
|
||||
const totalEmpenhadas = materiais.reduce((acc, material) => {
|
||||
return acc + (material.quantidade_empenhada || 0);
|
||||
}, 0);
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total (kg)',
|
||||
value: totalKg.toFixed(2),
|
||||
icon: Package,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50'
|
||||
},
|
||||
{
|
||||
title: 'Total (unid)',
|
||||
value: totalUnidades.toString(),
|
||||
icon: Box,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50'
|
||||
},
|
||||
{
|
||||
title: 'Total Disp. (unid)',
|
||||
value: totalDisponiveis.toString(),
|
||||
icon: TrendingUp,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50'
|
||||
},
|
||||
{
|
||||
title: 'Total Emp. (unid)',
|
||||
value: totalEmpenhadas.toString(),
|
||||
icon: AlertTriangle,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
{cards.map((card, index) => {
|
||||
const IconComponent = card.icon;
|
||||
return (
|
||||
<Card key={index} className={`${card.bgColor} border-none shadow-sm`}>
|
||||
<CardHeader className="pb-2 px-3 sm:px-4 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<IconComponent className={`h-4 w-4 ${card.color}`} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 sm:px-4 pb-3">
|
||||
<div className={`text-lg sm:text-2xl font-bold ${card.color}`}>
|
||||
{card.value}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
210
src/components/estoque/EstoqueMaterialModal.tsx
Normal file
210
src/components/estoque/EstoqueMaterialModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EstoqueMaterial, useTiposMateriaPrima, useCriarMaterial, useAtualizarMaterial } from '@/hooks/useEstoque';
|
||||
import { useRastreabilidadeMateriais } from '@/hooks/useRastreabilidadeMateriais';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { MaterialBasicInfo } from './material-form/MaterialBasicInfo';
|
||||
import { MaterialTechnicalSpecs } from './material-form/MaterialTechnicalSpecs';
|
||||
import { MaterialQuantitiesValues } from './material-form/MaterialQuantitiesValues';
|
||||
import { MaterialAdditionalInfo } from './material-form/MaterialAdditionalInfo';
|
||||
|
||||
interface EstoqueMaterialModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
material?: EstoqueMaterial | null;
|
||||
}
|
||||
|
||||
export const EstoqueMaterialModal: React.FC<EstoqueMaterialModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
material
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [formData, setFormData] = useState<Partial<EstoqueMaterial>>({
|
||||
codigo: '',
|
||||
descricao: '',
|
||||
tipo_material_id: '',
|
||||
unidade: 'PC',
|
||||
quantidade_total: 0,
|
||||
quantidade_disponivel: 0,
|
||||
quantidade_empenhada: 0,
|
||||
quantidade_minima: 0,
|
||||
quantidade_maxima: null,
|
||||
peso_unitario: 0,
|
||||
lote_atual: '',
|
||||
localizacao: '',
|
||||
status: 'Normal',
|
||||
observacoes: '',
|
||||
comprimento: null,
|
||||
largura: null,
|
||||
espessura: null,
|
||||
qualidade_aco: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (material) {
|
||||
setFormData({
|
||||
codigo: material.codigo || '',
|
||||
descricao: material.descricao || '',
|
||||
tipo_material_id: material.tipo_material_id || '',
|
||||
unidade: material.unidade || 'PC',
|
||||
quantidade_total: material.quantidade_total || 0,
|
||||
quantidade_disponivel: material.quantidade_disponivel || 0,
|
||||
quantidade_empenhada: material.quantidade_empenhada || 0,
|
||||
quantidade_minima: material.quantidade_minima || 0,
|
||||
quantidade_maxima: material.quantidade_maxima || null,
|
||||
peso_unitario: material.peso_unitario || 0,
|
||||
lote_atual: material.lote_atual || '',
|
||||
localizacao: material.localizacao || '',
|
||||
status: material.status || 'Normal',
|
||||
observacoes: material.observacoes || '',
|
||||
comprimento: material.comprimento || null,
|
||||
largura: material.largura || null,
|
||||
espessura: material.espessura || null,
|
||||
qualidade_aco: material.qualidade_aco || ''
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
codigo: '',
|
||||
descricao: '',
|
||||
tipo_material_id: '',
|
||||
unidade: 'PC',
|
||||
quantidade_total: 0,
|
||||
quantidade_disponivel: 0,
|
||||
quantidade_empenhada: 0,
|
||||
quantidade_minima: 0,
|
||||
quantidade_maxima: null,
|
||||
peso_unitario: 0,
|
||||
lote_atual: '',
|
||||
localizacao: '',
|
||||
status: 'Normal',
|
||||
observacoes: '',
|
||||
comprimento: null,
|
||||
largura: null,
|
||||
espessura: null,
|
||||
qualidade_aco: ''
|
||||
});
|
||||
}
|
||||
}, [material]);
|
||||
|
||||
const { data: tiposMaterial } = useTiposMateriaPrima();
|
||||
const { data: lotes } = useRastreabilidadeMateriais();
|
||||
const criarMaterial = useCriarMaterial();
|
||||
const atualizarMaterial = useAtualizarMaterial();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.descricao?.trim()) {
|
||||
alert('Por favor, preencha a descrição do material.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.codigo?.trim()) {
|
||||
alert('Por favor, preencha o código do material.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Preparar as observações com log de usuário e data
|
||||
const currentDate = new Date().toLocaleString('pt-BR');
|
||||
const userEmail = user?.email || 'Usuário não identificado';
|
||||
const actionType = material ? 'atualização' : 'criação';
|
||||
|
||||
const logEntry = `\n[${currentDate}] ${actionType.charAt(0).toUpperCase() + actionType.slice(1)} realizada por: ${userEmail}`;
|
||||
const observacoesComLog = (formData.observacoes || '') + logEntry;
|
||||
|
||||
const dataToSubmit = {
|
||||
codigo: formData.codigo!,
|
||||
descricao: formData.descricao!,
|
||||
tipo_material_id: formData.tipo_material_id || '',
|
||||
unidade: formData.unidade || 'PC',
|
||||
quantidade_total: formData.quantidade_total || 0,
|
||||
quantidade_disponivel: formData.quantidade_disponivel || 0,
|
||||
quantidade_empenhada: formData.quantidade_empenhada || 0,
|
||||
quantidade_minima: formData.quantidade_minima || 0,
|
||||
quantidade_maxima: formData.quantidade_maxima,
|
||||
peso_unitario: formData.peso_unitario || 0,
|
||||
valor_unitario: formData.valor_unitario,
|
||||
lote_atual: formData.lote_atual,
|
||||
fornecedor: formData.fornecedor,
|
||||
localizacao: formData.localizacao,
|
||||
status: formData.status || 'Normal',
|
||||
certificado: formData.certificado,
|
||||
observacoes: observacoesComLog,
|
||||
comprimento: formData.comprimento,
|
||||
largura: formData.largura,
|
||||
espessura: formData.espessura,
|
||||
qualidade_aco: formData.qualidade_aco,
|
||||
kg_por_metro: formData.kg_por_metro,
|
||||
created_by: user?.id
|
||||
};
|
||||
|
||||
if (material) {
|
||||
if (material.id) {
|
||||
await atualizarMaterial.mutateAsync({ id: material.id, ...dataToSubmit });
|
||||
} else {
|
||||
console.error("ID do material não encontrado para atualização.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await criarMaterial.mutateAsync(dataToSubmit);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar material:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{material ? 'Editar Material' : 'Novo Material'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<MaterialBasicInfo
|
||||
formData={formData}
|
||||
onInputChange={handleInputChange}
|
||||
tiposMaterial={tiposMaterial}
|
||||
lotes={lotes}
|
||||
isEditing={!!material}
|
||||
/>
|
||||
|
||||
<MaterialTechnicalSpecs
|
||||
formData={formData}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<MaterialQuantitiesValues
|
||||
formData={formData}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<MaterialAdditionalInfo
|
||||
formData={formData}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={criarMaterial.isPending || atualizarMaterial.isPending}>
|
||||
{criarMaterial.isPending || atualizarMaterial.isPending ? 'Salvando...' : 'Salvar Material'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
269
src/components/estoque/EstoqueReports.tsx
Normal file
269
src/components/estoque/EstoqueReports.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { FileText, Download, Filter, X } from 'lucide-react';
|
||||
import { PosicaoEstoqueReport } from './reports/PosicaoEstoqueReport';
|
||||
import { MovimentacoesReport } from './reports/MovimentacoesReport';
|
||||
import { MateriaisCriticosReport } from './reports/MateriaisCriticosReport';
|
||||
import { EmpenhosPorOFReport } from './reports/EmpenhosPorOFReport';
|
||||
import { ReportPreviewModal } from './reports/ReportPreviewModal';
|
||||
import { useOFsComEmpenhos } from '@/hooks/useEmpenhosMaterial';
|
||||
|
||||
const tiposRelatorio = [
|
||||
{ value: 'posicao', label: 'Posição de Estoque', icon: FileText },
|
||||
{ value: 'movimentacoes', label: 'Movimentações', icon: FileText },
|
||||
{ value: 'criticos', label: 'Materiais Críticos', icon: FileText },
|
||||
{ value: 'empenhos', label: 'Empenhos por OF', icon: FileText }
|
||||
];
|
||||
|
||||
export const EstoqueReports: React.FC = () => {
|
||||
const [tipoRelatorio, setTipoRelatorio] = useState('');
|
||||
const [filters, setFilters] = useState({
|
||||
data_inicio: '',
|
||||
data_fim: '',
|
||||
descricao_material: '',
|
||||
status: 'todos',
|
||||
of_vinculada: 'todos',
|
||||
status_empenho: 'todos',
|
||||
title: ''
|
||||
});
|
||||
|
||||
// Buscar OFs com empenhos usando o novo hook
|
||||
const { data: ofsComEmpenhos = [] } = useOFsComEmpenhos();
|
||||
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
|
||||
const handleFilterChange = (field: string, value: string) => {
|
||||
setFilters(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
data_inicio: '',
|
||||
data_fim: '',
|
||||
descricao_material: '',
|
||||
status: 'todos',
|
||||
of_vinculada: 'todos',
|
||||
status_empenho: 'todos',
|
||||
title: ''
|
||||
});
|
||||
};
|
||||
|
||||
const renderReport = () => {
|
||||
// Ajustar os filtros para o relatório
|
||||
const reportFilters = {
|
||||
...filters,
|
||||
of_vinculada: filters.of_vinculada === 'todos' ? '' : filters.of_vinculada
|
||||
};
|
||||
|
||||
switch (tipoRelatorio) {
|
||||
case 'posicao':
|
||||
return <PosicaoEstoqueReport filters={reportFilters} />;
|
||||
case 'movimentacoes':
|
||||
return <MovimentacoesReport filters={reportFilters} />;
|
||||
case 'criticos':
|
||||
return <MateriaisCriticosReport filters={reportFilters} />;
|
||||
case 'empenhos':
|
||||
return <EmpenhosPorOFReport filters={reportFilters} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters = Object.values(filters).some(value =>
|
||||
value !== '' && value !== 'todos'
|
||||
);
|
||||
|
||||
const shouldShowFilters = tipoRelatorio !== '';
|
||||
|
||||
const getReportId = () => {
|
||||
return `relatorio-${tipoRelatorio}-${Date.now()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Relatórios de Estoque
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Seleção do tipo de relatório */}
|
||||
<div>
|
||||
<Label htmlFor="tipo-relatorio">Tipo de Relatório</Label>
|
||||
<Select value={tipoRelatorio} onValueChange={setTipoRelatorio}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o tipo de relatório" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tiposRelatorio.map((tipo) => {
|
||||
const Icon = tipo.icon;
|
||||
return (
|
||||
<SelectItem key={tipo.value} value={tipo.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{tipo.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
{shouldShowFilters && (
|
||||
<div className="space-y-4 p-4 border rounded-lg bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtros
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-8 flex items-center gap-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Filtros de data */}
|
||||
{(tipoRelatorio === 'movimentacoes' || tipoRelatorio === 'empenhos') && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="data-inicio">Data Início</Label>
|
||||
<Input
|
||||
id="data-inicio"
|
||||
type="date"
|
||||
value={filters.data_inicio}
|
||||
onChange={(e) => handleFilterChange('data_inicio', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="data-fim">Data Fim</Label>
|
||||
<Input
|
||||
id="data-fim"
|
||||
type="date"
|
||||
value={filters.data_fim}
|
||||
onChange={(e) => handleFilterChange('data_fim', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Filtro de material */}
|
||||
{tipoRelatorio !== 'empenhos' && (
|
||||
<div>
|
||||
<Label htmlFor="descricao-material">Material (descrição)</Label>
|
||||
<Input
|
||||
id="descricao-material"
|
||||
placeholder="Digite parte da descrição..."
|
||||
value={filters.descricao_material}
|
||||
onChange={(e) => handleFilterChange('descricao_material', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filtro de status para posição */}
|
||||
{tipoRelatorio === 'posicao' && (
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select value={filters.status} onValueChange={(value) => handleFilterChange('status', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
<SelectItem value="Normal">Normal</SelectItem>
|
||||
<SelectItem value="Crítico">Crítico</SelectItem>
|
||||
<SelectItem value="Excesso">Excesso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filtros específicos para empenhos */}
|
||||
{tipoRelatorio === 'empenhos' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="of-empenhos">OF com Empenhos</Label>
|
||||
<Select value={filters.of_vinculada} onValueChange={(value) => handleFilterChange('of_vinculada', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todas as OFs</SelectItem>
|
||||
{ofsComEmpenhos.map((of) => (
|
||||
<SelectItem key={of} value={of}>
|
||||
{of}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status-empenho">Status do Empenho</Label>
|
||||
<Select value={filters.status_empenho} onValueChange={(value) => handleFilterChange('status_empenho', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
<SelectItem value="Empenhado">Empenhado</SelectItem>
|
||||
<SelectItem value="Finalizado">Finalizado</SelectItem>
|
||||
<SelectItem value="Cancelado">Cancelado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ações */}
|
||||
{tipoRelatorio && (
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setIsPreviewOpen(true)}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Gerar Relatório
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview do relatório */}
|
||||
{tipoRelatorio && !isPreviewOpen && (
|
||||
<div className="border rounded-lg p-4 bg-background">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{renderReport()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ReportPreviewModal
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={() => setIsPreviewOpen(false)}
|
||||
title={`Relatório: ${tiposRelatorio.find(t => t.value === tipoRelatorio)?.label || ''}`}
|
||||
reportId={getReportId()}
|
||||
filters={filters}
|
||||
>
|
||||
{renderReport()}
|
||||
</ReportPreviewModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
417
src/components/estoque/EstoqueTable.tsx
Normal file
417
src/components/estoque/EstoqueTable.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { StandardCard } from '@/components/layout/StandardCard';
|
||||
import { Package, Search, Download, Upload, Settings, Users, Plus, Database, AlertTriangle } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useEstoque } from '@/hooks/useEstoque';
|
||||
import { EstoqueDashboardCards } from './EstoqueDashboardCards';
|
||||
import { EstoqueMaterialModal } from './EstoqueMaterialModal';
|
||||
import { TiposFiltroButtonsOtimizado } from './TiposFiltroButtonsOtimizado';
|
||||
import { EstoqueBatchActions } from './EstoqueBatchActions';
|
||||
import { EstoqueBatchEditModal } from './EstoqueBatchEditModal';
|
||||
import { MovimentacaoModalSimplificada } from './MovimentacaoModalSimplificada';
|
||||
import { EstoqueDesktopTable } from './table/EstoqueDesktopTable';
|
||||
import { EstoqueMobileView } from './table/EstoqueMobileView';
|
||||
import { CrudModalsManager } from './CrudModalsManager';
|
||||
import { RastreabilidadeMP } from './RastreabilidadeMP';
|
||||
import { EstoqueCriticoModal } from './EstoqueCriticoModal';
|
||||
import { EstoqueMaterial } from '@/hooks/useEstoque';
|
||||
import { toast } from 'sonner';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
export function EstoqueTable() {
|
||||
const { materiais, loading } = useEstoque();
|
||||
const [tipoSelecionado, setTipoSelecionado] = useState('all');
|
||||
const [categoriaFilter, setCategoriaFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('Normal');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [descricaoFilter, setDescricaoFilter] = useState('');
|
||||
const [loteFilter, setLoteFilter] = useState('');
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<EstoqueMaterial[]>([]);
|
||||
const [sortField, setSortField] = useState('descricao');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [editingMaterial, setEditingMaterial] = useState<EstoqueMaterial | null>(null);
|
||||
const [showMaterialModal, setShowMaterialModal] = useState(false);
|
||||
const [showMovimentacaoModal, setShowMovimentacaoModal] = useState(false);
|
||||
const [showBatchEditModal, setShowBatchEditModal] = useState(false);
|
||||
const [showRastreabilidadeModal, setShowRastreabilidadeModal] = useState(false);
|
||||
const [showEstoqueCriticoModal, setShowEstoqueCriticoModal] = useState(false);
|
||||
|
||||
const materiaisFiltrados = useMemo(() => {
|
||||
let filtered = materiais;
|
||||
|
||||
// Filtro por termo de busca geral
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(material =>
|
||||
material.descricao.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.lote_atual?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
material.codigo.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Filtro específico por descrição
|
||||
if (descricaoFilter) {
|
||||
filtered = filtered.filter(material =>
|
||||
material.descricao.toLowerCase().includes(descricaoFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Filtro específico por lote
|
||||
if (loteFilter) {
|
||||
filtered = filtered.filter(material =>
|
||||
material.lote_atual?.toLowerCase().includes(loteFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Filtro por status
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(material => material.status === statusFilter);
|
||||
}
|
||||
|
||||
// Filtro por categoria (direto/indireto)
|
||||
if (categoriaFilter !== 'all') {
|
||||
filtered = filtered.filter(material => material.tipos_materia_prima?.categoria === categoriaFilter);
|
||||
}
|
||||
|
||||
// Filtro por tipo específico (botões dos grupos)
|
||||
if (tipoSelecionado !== 'all') {
|
||||
filtered = filtered.filter(material => material.tipos_materia_prima?.nome === tipoSelecionado);
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let aValue, bValue;
|
||||
|
||||
switch (sortField) {
|
||||
case 'descricao':
|
||||
aValue = a.descricao;
|
||||
bValue = b.descricao;
|
||||
break;
|
||||
case 'tipo':
|
||||
aValue = a.tipos_materia_prima?.nome || '';
|
||||
bValue = b.tipos_materia_prima?.nome || '';
|
||||
break;
|
||||
case 'quantidade_total':
|
||||
aValue = a.quantidade_total;
|
||||
bValue = b.quantidade_total;
|
||||
break;
|
||||
case 'quantidade_disponivel':
|
||||
aValue = a.quantidade_disponivel;
|
||||
bValue = b.quantidade_disponivel;
|
||||
break;
|
||||
case 'quantidade_empenhada':
|
||||
aValue = a.quantidade_empenhada;
|
||||
bValue = b.quantidade_empenhada;
|
||||
break;
|
||||
case 'status':
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
break;
|
||||
default:
|
||||
aValue = a.descricao;
|
||||
bValue = b.descricao;
|
||||
}
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
return sortOrder === 'asc'
|
||||
? aValue.localeCompare(bValue)
|
||||
: bValue.localeCompare(aValue);
|
||||
}
|
||||
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [materiais, searchTerm, descricaoFilter, loteFilter, statusFilter, categoriaFilter, tipoSelecionado, sortField, sortOrder]);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectMaterial = (material: EstoqueMaterial, isSelected: boolean) => {
|
||||
if (isSelected) {
|
||||
setSelectedMaterials(prev => [...prev, material]);
|
||||
} else {
|
||||
setSelectedMaterials(prev => prev.filter(m => m.id !== material.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedMaterials(materiaisFiltrados);
|
||||
} else {
|
||||
setSelectedMaterials([]);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = () => {
|
||||
return materiaisFiltrados.length > 0 && selectedMaterials.length === materiaisFiltrados.length;
|
||||
};
|
||||
|
||||
const handleEditMaterial = (material: EstoqueMaterial) => {
|
||||
setEditingMaterial(material);
|
||||
setShowMaterialModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowMaterialModal(false);
|
||||
setEditingMaterial(null);
|
||||
};
|
||||
|
||||
const handleBatchEditSuccess = () => {
|
||||
setSelectedMaterials([]);
|
||||
setShowBatchEditModal(false);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Crítico':
|
||||
return 'text-red-600 bg-red-100 border-red-300';
|
||||
case 'Normal':
|
||||
return 'text-green-600 bg-green-100 border-green-300';
|
||||
case 'Excesso':
|
||||
return 'text-yellow-600 bg-yellow-100 border-yellow-300';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100 border-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setDescricaoFilter('');
|
||||
setLoteFilter('');
|
||||
setStatusFilter('Normal');
|
||||
setCategoriaFilter('all');
|
||||
setTipoSelecionado('all');
|
||||
setSelectedMaterials([]);
|
||||
toast.success('Filtros limpos');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Dashboard Cards */}
|
||||
<EstoqueDashboardCards materiais={materiais} />
|
||||
|
||||
<StandardCard title="Materiais em Estoque" icon={Package}>
|
||||
<div className="space-y-4">
|
||||
{/* Header com botões de ação */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">Controle de Materiais</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => setShowMaterialModal(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Novo Material
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowEstoqueCriticoModal(true)}
|
||||
variant="outline"
|
||||
className="bg-red-50 border-red-200 text-red-700 hover:bg-red-100"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
Estoque Crítico
|
||||
</Button>
|
||||
<CrudModalsManager />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowRastreabilidadeModal(true)}
|
||||
className="bg-slate-700 border-slate-600 text-white hover:bg-slate-600"
|
||||
>
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
Rastreabilidade
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros de Tipos */}
|
||||
<TiposFiltroButtonsOtimizado
|
||||
tipoSelecionado={tipoSelecionado}
|
||||
onTipoChange={setTipoSelecionado}
|
||||
categoriaFilter={categoriaFilter}
|
||||
onCategoriaChange={setCategoriaFilter}
|
||||
/>
|
||||
|
||||
{/* Filtros superiores com novos filtros específicos */}
|
||||
<div className="space-y-4">
|
||||
{/* Primeira linha - filtros principais */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar por descrição ou lote..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os Status</SelectItem>
|
||||
<SelectItem value="Normal">Normal</SelectItem>
|
||||
<SelectItem value="Crítico">Crítico</SelectItem>
|
||||
<SelectItem value="Excesso">Excesso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" onClick={handleClearFilters}>
|
||||
Limpar Filtros
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Segunda linha - filtros específicos para Descrição e Lote */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center bg-muted/30 p-3 rounded-lg border">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Filtrar por descrição..."
|
||||
value={descricaoFilter}
|
||||
onChange={(e) => setDescricaoFilter(e.target.value)}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Filtrar por lote..."
|
||||
value={loteFilter}
|
||||
onChange={(e) => setLoteFilter(e.target.value)}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informações dos filtros */}
|
||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
<span>Total: {materiaisFiltrados.length} materiais</span>
|
||||
{searchTerm && (
|
||||
<Badge variant="secondary">
|
||||
Busca: {searchTerm}
|
||||
</Badge>
|
||||
)}
|
||||
{descricaoFilter && (
|
||||
<Badge variant="secondary">
|
||||
Descrição: {descricaoFilter}
|
||||
</Badge>
|
||||
)}
|
||||
{loteFilter && (
|
||||
<Badge variant="secondary">
|
||||
Lote: {loteFilter}
|
||||
</Badge>
|
||||
)}
|
||||
{statusFilter !== 'all' && (
|
||||
<Badge variant="secondary">
|
||||
Status: {statusFilter}
|
||||
</Badge>
|
||||
)}
|
||||
{categoriaFilter !== 'all' && (
|
||||
<Badge variant="secondary">
|
||||
Categoria: {categoriaFilter === 'direto' ? 'Diretos' : 'Indiretos'}
|
||||
</Badge>
|
||||
)}
|
||||
{tipoSelecionado !== 'all' && (
|
||||
<Badge variant="secondary">
|
||||
Tipo: {tipoSelecionado}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ações em lote */}
|
||||
{selectedMaterials.length > 0 && (
|
||||
<EstoqueBatchActions
|
||||
selectedMaterials={selectedMaterials}
|
||||
onClearSelection={() => setSelectedMaterials([])}
|
||||
onShowMovimentacao={() => setShowMovimentacaoModal(true)}
|
||||
onShowBatchEdit={() => setShowBatchEditModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabela Desktop */}
|
||||
<EstoqueDesktopTable
|
||||
materiais={materiaisFiltrados}
|
||||
selectedMaterials={selectedMaterials}
|
||||
onSelectMaterial={handleSelectMaterial}
|
||||
onEditMaterial={handleEditMaterial}
|
||||
onSelectAll={handleSelectAll}
|
||||
isAllSelected={isAllSelected}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
getStatusColor={getStatusColor}
|
||||
/>
|
||||
|
||||
{/* Vista Mobile */}
|
||||
<EstoqueMobileView
|
||||
materiais={materiaisFiltrados}
|
||||
selectedMaterials={selectedMaterials}
|
||||
onSelectMaterial={handleSelectMaterial}
|
||||
onEditMaterial={handleEditMaterial}
|
||||
getStatusColor={getStatusColor}
|
||||
/>
|
||||
</div>
|
||||
</StandardCard>
|
||||
|
||||
{/* Modal de Material */}
|
||||
<EstoqueMaterialModal
|
||||
isOpen={showMaterialModal}
|
||||
onClose={handleCloseModal}
|
||||
material={editingMaterial}
|
||||
/>
|
||||
|
||||
{/* Modal de Edição em Lote */}
|
||||
<EstoqueBatchEditModal
|
||||
isOpen={showBatchEditModal}
|
||||
onClose={() => setShowBatchEditModal(false)}
|
||||
selectedMaterials={selectedMaterials}
|
||||
onSuccess={handleBatchEditSuccess}
|
||||
/>
|
||||
|
||||
{/* Modal de Movimentação */}
|
||||
<MovimentacaoModalSimplificada
|
||||
isOpen={showMovimentacaoModal}
|
||||
onClose={() => setShowMovimentacaoModal(false)}
|
||||
selectedMaterials={selectedMaterials}
|
||||
/>
|
||||
|
||||
{/* Modal de Estoque Crítico */}
|
||||
<EstoqueCriticoModal
|
||||
isOpen={showEstoqueCriticoModal}
|
||||
onClose={() => setShowEstoqueCriticoModal(false)}
|
||||
/>
|
||||
|
||||
{/* Modal de Rastreabilidade */}
|
||||
<Dialog open={showRastreabilidadeModal} onOpenChange={setShowRastreabilidadeModal}>
|
||||
<DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rastreabilidade de Matéria Prima</DialogTitle>
|
||||
</DialogHeader>
|
||||
<RastreabilidadeMP />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/components/estoque/EstoqueTableHeader.tsx
Normal file
110
src/components/estoque/EstoqueTableHeader.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
import React from 'react';
|
||||
import { TableHead, TableRow } from '@/components/ui/table';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface EstoqueTableHeaderProps {
|
||||
sortField: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onSort: (field: string) => void;
|
||||
isAllSelected: boolean;
|
||||
onSelectAll: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const EstoqueTableHeader: React.FC<EstoqueTableHeaderProps> = ({
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSort,
|
||||
isAllSelected,
|
||||
onSelectAll
|
||||
}) => {
|
||||
const getSortIcon = (field: string) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="h-3 w-3" />;
|
||||
return sortOrder === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableHead className="w-8 px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-3 h-3"
|
||||
checked={isAllSelected}
|
||||
onChange={onSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-80 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSort('descricao')}
|
||||
className="h-6 px-1 text-xs font-medium"
|
||||
>
|
||||
Descrição
|
||||
{getSortIcon('descricao')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-24 px-2 text-xs font-medium">Lote</TableHead>
|
||||
<TableHead className="w-32 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSort('tipo')}
|
||||
className="h-6 px-1 text-xs font-medium"
|
||||
>
|
||||
Tipo
|
||||
{getSortIcon('tipo')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-16 px-2 text-xs font-medium">Un.</TableHead>
|
||||
<TableHead className="w-16 px-2 text-xs font-medium">Comp.</TableHead>
|
||||
<TableHead className="w-20 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSort('quantidade_total')}
|
||||
className="h-6 px-1 text-xs font-medium"
|
||||
>
|
||||
Total
|
||||
{getSortIcon('quantidade_total')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-20 px-2 text-xs font-medium">Peso</TableHead>
|
||||
<TableHead className="w-20 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSort('quantidade_disponivel')}
|
||||
className="h-6 px-1 text-xs font-medium"
|
||||
>
|
||||
Disp.
|
||||
{getSortIcon('quantidade_disponivel')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-20 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSort('quantidade_empenhada')}
|
||||
className="h-6 px-1 text-xs font-medium"
|
||||
>
|
||||
Emp.
|
||||
{getSortIcon('quantidade_empenhada')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-24 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSort('status')}
|
||||
className="h-6 px-1 text-xs font-medium"
|
||||
>
|
||||
Status
|
||||
{getSortIcon('status')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-24 px-2 text-xs font-medium">Ações</TableHead>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
177
src/components/estoque/LocalizacaoModal.tsx
Normal file
177
src/components/estoque/LocalizacaoModal.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Plus, Edit, Trash2 } from 'lucide-react';
|
||||
import { useLocalizacoesEstoque, useCriarLocalizacao, useAtualizarLocalizacao, useExcluirLocalizacao } from '@/hooks/useEstoqueCRUD';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface LocalizacaoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const LocalizacaoModal: React.FC<LocalizacaoModalProps> = ({ isOpen, onClose }) => {
|
||||
const { data: localizacoes = [], isLoading } = useLocalizacoesEstoque();
|
||||
const criarLocalizacao = useCriarLocalizacao();
|
||||
const atualizarLocalizacao = useAtualizarLocalizacao();
|
||||
const excluirLocalizacao = useExcluirLocalizacao();
|
||||
|
||||
const [novaLocalizacao, setNovaLocalizacao] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (novaLocalizacao.trim() && !localizacoes.some(loc => loc.nome === novaLocalizacao.trim())) {
|
||||
try {
|
||||
await criarLocalizacao.mutateAsync({
|
||||
nome: novaLocalizacao.trim(),
|
||||
codigo: novaLocalizacao.trim().substring(0, 10).toUpperCase(),
|
||||
descricao: `Localização ${novaLocalizacao.trim()}`,
|
||||
ativo: true
|
||||
});
|
||||
setNovaLocalizacao('');
|
||||
toast.success('Localização criada com sucesso!');
|
||||
} catch (error) {
|
||||
toast.error('Erro ao criar localização');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (localizacao: any) => {
|
||||
setEditingId(localizacao.id);
|
||||
setEditValue(localizacao.nome);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (editValue.trim() && editingId && !localizacoes.some(loc => loc.nome === editValue.trim() && loc.id !== editingId)) {
|
||||
try {
|
||||
const localizacao = localizacoes.find(loc => loc.id === editingId);
|
||||
if (localizacao) {
|
||||
await atualizarLocalizacao.mutateAsync({
|
||||
...localizacao,
|
||||
nome: editValue.trim(),
|
||||
codigo: editValue.trim().substring(0, 10).toUpperCase(),
|
||||
descricao: `Localização ${editValue.trim()}`
|
||||
});
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
toast.success('Localização atualizada com sucesso!');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao atualizar localização');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Tem certeza que deseja excluir esta localização?')) {
|
||||
try {
|
||||
await excluirLocalizacao.mutateAsync(id);
|
||||
toast.success('Localização excluída com sucesso!');
|
||||
} catch (error) {
|
||||
toast.error('Erro ao excluir localização');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gerenciar Localizações</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="nova-localizacao">Nova Localização</Label>
|
||||
<Input
|
||||
id="nova-localizacao"
|
||||
value={novaLocalizacao}
|
||||
onChange={(e) => setNovaLocalizacao(e.target.value)}
|
||||
placeholder="Ex: Estoque A1, Galpão Principal"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button onClick={handleAdd} disabled={!novaLocalizacao.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Localizações Cadastradas ({localizacoes.length})</h3>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-8">
|
||||
<TableHead className="py-2">Localização</TableHead>
|
||||
<TableHead className="w-24 py-2">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{localizacoes.map((localizacao) => (
|
||||
<TableRow key={localizacao.id} className="h-8">
|
||||
<TableCell className="py-1">
|
||||
{editingId === localizacao.id ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()}
|
||||
className="h-7"
|
||||
/>
|
||||
<Button size="sm" onClick={handleSaveEdit} className="h-7">
|
||||
Salvar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleCancelEdit} className="h-7">
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-medium">{localizacao.nome}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
{editingId !== localizacao.id && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(localizacao)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(localizacao.id)}
|
||||
className="h-6 w-6 p-0 text-red-500"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
335
src/components/estoque/MovimentacaoEstoque.tsx
Normal file
335
src/components/estoque/MovimentacaoEstoque.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Plus, TrendingUp, Trash2, Package } from 'lucide-react';
|
||||
import { useMovimentacoesEstoque } from '@/hooks/useEstoque';
|
||||
import { MovimentacaoModal } from './MovimentacaoModal';
|
||||
import { MovimentacaoTableHeader } from './MovimentacaoTableHeader';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const MovimentacaoEstoque = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [sortConfig, setSortConfig] = useState<{
|
||||
key: string;
|
||||
direction: 'asc' | 'desc';
|
||||
}>({ key: 'created_at', direction: 'desc' });
|
||||
|
||||
const { data: movimentacoes = [], isLoading } = useMovimentacoesEstoque();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteMovimentacao = useMutation({
|
||||
mutationFn: async (movimentacaoId: string) => {
|
||||
console.log('🗑️ Tentando excluir movimentação:', movimentacaoId);
|
||||
|
||||
// Buscar detalhes da movimentação primeiro
|
||||
const { data: movimentacao, error: fetchError } = await supabase
|
||||
.from('movimentacoes_estoque')
|
||||
.select('*, estoque_materiais(codigo, descricao)')
|
||||
.eq('id', movimentacaoId)
|
||||
.single();
|
||||
|
||||
if (fetchError) {
|
||||
console.error('Erro ao buscar movimentação:', fetchError);
|
||||
throw new Error('Movimentação não encontrada');
|
||||
}
|
||||
|
||||
console.log('📋 Movimentação encontrada:', movimentacao);
|
||||
|
||||
// Se for movimentação de empenho, precisa cancelar o empenho vinculado primeiro
|
||||
if (movimentacao.tipo_movimentacao === 'empenho') {
|
||||
console.log('🔗 Removendo empenho vinculado...');
|
||||
|
||||
// Buscar empenho vinculado a esta movimentação
|
||||
const { data: empenhoVinculado, error: empenhoError } = await supabase
|
||||
.from('empenhos_material')
|
||||
.select('id')
|
||||
.eq('movimentacao_empenho_id', movimentacaoId)
|
||||
.maybeSingle();
|
||||
|
||||
if (empenhoError) {
|
||||
console.error('Erro ao buscar empenho vinculado:', empenhoError);
|
||||
}
|
||||
|
||||
if (empenhoVinculado) {
|
||||
// Remover referência da movimentação no empenho
|
||||
const { error: updateEmpenhoError } = await supabase
|
||||
.from('empenhos_material')
|
||||
.update({
|
||||
movimentacao_empenho_id: null,
|
||||
status: 'Cancelado'
|
||||
})
|
||||
.eq('id', empenhoVinculado.id);
|
||||
|
||||
if (updateEmpenhoError) {
|
||||
console.error('Erro ao cancelar empenho:', updateEmpenhoError);
|
||||
throw new Error('Erro ao cancelar empenho vinculado');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Agora excluir a movimentação
|
||||
const { error: deleteError } = await supabase
|
||||
.from('movimentacoes_estoque')
|
||||
.delete()
|
||||
.eq('id', movimentacaoId);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Erro ao excluir movimentação:', deleteError);
|
||||
throw deleteError;
|
||||
}
|
||||
|
||||
console.log('✅ Movimentação excluída com sucesso');
|
||||
return movimentacao;
|
||||
},
|
||||
onSuccess: (movimentacao) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['movimentacoes-estoque'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['estoque-materiais'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['empenhos-material'] });
|
||||
|
||||
const materialInfo = movimentacao.estoque_materiais;
|
||||
const materialDesc = materialInfo ? `${materialInfo.codigo} - ${materialInfo.descricao}` : 'Material';
|
||||
|
||||
toast.success(`Movimentação de ${movimentacao.tipo_movimentacao} para ${materialDesc} excluída com sucesso!`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Erro ao excluir movimentação:', error);
|
||||
const errorMessage = error?.message || 'Erro ao excluir movimentação';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
const sortData = (data: any[], key: string) => {
|
||||
return data.sort((a: any, b: any) => {
|
||||
if (a[key] < b[key]) {
|
||||
return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
}
|
||||
if (a[key] > b[key]) {
|
||||
return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
let direction: 'asc' | 'desc' = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const sortedMovimentacoes = React.useMemo(() => {
|
||||
if (!movimentacoes) return [];
|
||||
|
||||
return [...movimentacoes].sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sortConfig.key) {
|
||||
case 'material':
|
||||
aValue = a.estoque_materiais?.codigo || '';
|
||||
bValue = b.estoque_materiais?.codigo || '';
|
||||
break;
|
||||
case 'data_movimentacao':
|
||||
aValue = new Date(a.data_movimentacao);
|
||||
bValue = new Date(b.data_movimentacao);
|
||||
break;
|
||||
case 'created_at':
|
||||
aValue = new Date(a.created_at);
|
||||
bValue = new Date(b.created_at);
|
||||
break;
|
||||
default:
|
||||
aValue = a[sortConfig.key as keyof typeof a];
|
||||
bValue = b[sortConfig.key as keyof typeof b];
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [movimentacoes, sortConfig]);
|
||||
|
||||
const getStatusColor = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'entrada':
|
||||
return 'bg-green-500';
|
||||
case 'saida':
|
||||
return 'bg-red-500';
|
||||
case 'empenho':
|
||||
return 'bg-yellow-500';
|
||||
case 'desempenho':
|
||||
return 'bg-blue-500';
|
||||
case 'ajuste':
|
||||
return 'bg-purple-500';
|
||||
case 'transferencia':
|
||||
return 'bg-orange-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="w-full h-12" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Histórico de Movimentações
|
||||
</CardTitle>
|
||||
<Button onClick={() => setIsModalOpen(true)} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nova Movimentação
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{sortedMovimentacoes.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<MovimentacaoTableHeader
|
||||
label="Material"
|
||||
sortKey="material"
|
||||
currentSort={sortConfig}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<MovimentacaoTableHeader
|
||||
label="Tipo"
|
||||
sortKey="tipo_movimentacao"
|
||||
currentSort={sortConfig}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<MovimentacaoTableHeader
|
||||
label="Quantidade"
|
||||
sortKey="quantidade"
|
||||
currentSort={sortConfig}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<TableHead>OF Vinculada</TableHead>
|
||||
<MovimentacaoTableHeader
|
||||
label="Data Movim."
|
||||
sortKey="data_movimentacao"
|
||||
currentSort={sortConfig}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<TableHead>Lote</TableHead>
|
||||
<TableHead>Observações</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedMovimentacoes.map((movimentacao) => (
|
||||
<TableRow key={movimentacao.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{movimentacao.estoque_materiais?.codigo}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{movimentacao.estoque_materiais?.descricao}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getStatusColor(movimentacao.tipo_movimentacao)} text-white border-none`}
|
||||
>
|
||||
{movimentacao.tipo_movimentacao}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{movimentacao.quantidade.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{movimentacao.of_vinculada || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(movimentacao.data_movimentacao).toLocaleDateString('pt-BR')}
|
||||
</TableCell>
|
||||
<TableCell>{movimentacao.lote || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-xs truncate" title={movimentacao.observacoes || ''}>
|
||||
{movimentacao.observacoes || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
disabled={deleteMovimentacao.isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir Movimentação</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir esta movimentação de{' '}
|
||||
<strong>{movimentacao.tipo_movimentacao}</strong> para{' '}
|
||||
<strong>{movimentacao.estoque_materiais?.codigo}</strong>?
|
||||
{movimentacao.tipo_movimentacao === 'empenho' && (
|
||||
<div className="mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<strong>Atenção:</strong> Esta ação também cancelará o empenho vinculado e reverterá as quantidades no estoque.
|
||||
</div>
|
||||
)}
|
||||
<br />
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteMovimentacao.mutate(movimentacao.id)}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||
<p>Nenhuma movimentação registrada</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<MovimentacaoModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
240
src/components/estoque/MovimentacaoEstoqueSimplificada.tsx
Normal file
240
src/components/estoque/MovimentacaoEstoqueSimplificada.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Plus, TrendingUp, Trash2, Package } from 'lucide-react';
|
||||
import { useMovimentacoesEstoque, useExcluirMovimentacao } from '@/hooks/useEstoqueMovimentacoes';
|
||||
import { MovimentacaoModal } from './MovimentacaoModal';
|
||||
import { MovimentacaoTableHeader } from './MovimentacaoTableHeader';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export const MovimentacaoEstoqueSimplificada = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [sortConfig, setSortConfig] = useState<{
|
||||
key: string;
|
||||
direction: 'asc' | 'desc';
|
||||
}>({ key: 'created_at', direction: 'desc' });
|
||||
|
||||
const { data: movimentacoes = [], isLoading } = useMovimentacoesEstoque();
|
||||
const excluirMovimentacao = useExcluirMovimentacao();
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
let direction: 'asc' | 'desc' = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const sortedMovimentacoes = React.useMemo(() => {
|
||||
if (!movimentacoes) return [];
|
||||
|
||||
return [...movimentacoes].sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sortConfig.key) {
|
||||
case 'material':
|
||||
aValue = a.estoque_materiais?.codigo || '';
|
||||
bValue = b.estoque_materiais?.codigo || '';
|
||||
break;
|
||||
case 'data_movimentacao':
|
||||
aValue = new Date(a.data_movimentacao);
|
||||
bValue = new Date(b.data_movimentacao);
|
||||
break;
|
||||
case 'created_at':
|
||||
aValue = new Date(a.created_at);
|
||||
bValue = new Date(b.created_at);
|
||||
break;
|
||||
default:
|
||||
aValue = a[sortConfig.key as keyof typeof a];
|
||||
bValue = b[sortConfig.key as keyof typeof b];
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [movimentacoes, sortConfig]);
|
||||
|
||||
const getStatusColor = (tipo: string) => {
|
||||
switch (tipo) {
|
||||
case 'entrada':
|
||||
return 'bg-green-500';
|
||||
case 'saida':
|
||||
return 'bg-red-500';
|
||||
case 'empenho':
|
||||
return 'bg-yellow-500';
|
||||
case 'desempenho':
|
||||
return 'bg-blue-500';
|
||||
case 'ajuste':
|
||||
return 'bg-purple-500';
|
||||
case 'transferencia':
|
||||
return 'bg-orange-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="w-full h-12" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Histórico de Movimentações
|
||||
</CardTitle>
|
||||
<Button onClick={() => setIsModalOpen(true)} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nova Movimentação
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{sortedMovimentacoes.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<MovimentacaoTableHeader
|
||||
label="Material"
|
||||
sortKey="material"
|
||||
currentSort={sortConfig}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<MovimentacaoTableHeader
|
||||
label="Tipo"
|
||||
sortKey="tipo_movimentacao"
|
||||
currentSort={sortConfig}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<MovimentacaoTableHeader
|
||||
label="Quantidade"
|
||||
sortKey="quantidade"
|
||||
currentSort={sortConfig}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<TableHead>OF Vinculada</TableHead>
|
||||
<MovimentacaoTableHeader
|
||||
label="Data Movim."
|
||||
sortKey="data_movimentacao"
|
||||
currentSort={sortConfig}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<TableHead>Lote</TableHead>
|
||||
<TableHead>Observações</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedMovimentacoes.map((movimentacao) => (
|
||||
<TableRow key={movimentacao.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{movimentacao.estoque_materiais?.codigo}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{movimentacao.estoque_materiais?.descricao}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getStatusColor(movimentacao.tipo_movimentacao)} text-white border-none`}
|
||||
>
|
||||
{movimentacao.tipo_movimentacao}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{movimentacao.quantidade.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{movimentacao.of_vinculada || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(movimentacao.data_movimentacao).toLocaleDateString('pt-BR')}
|
||||
</TableCell>
|
||||
<TableCell>{movimentacao.lote || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-xs truncate" title={movimentacao.observacoes || ''}>
|
||||
{movimentacao.observacoes || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
disabled={excluirMovimentacao.isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir Movimentação</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir esta movimentação de{' '}
|
||||
<strong>{movimentacao.tipo_movimentacao}</strong> para{' '}
|
||||
<strong>{movimentacao.estoque_materiais?.codigo}</strong>?
|
||||
{movimentacao.tipo_movimentacao === 'empenho' && (
|
||||
<div className="mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<strong>Atenção:</strong> Esta ação também cancelará o empenho vinculado e reverterá as quantidades no estoque.
|
||||
</div>
|
||||
)}
|
||||
<br />
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => excluirMovimentacao.mutate(movimentacao.id)}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||
<p>Nenhuma movimentação registrada</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<MovimentacaoModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
356
src/components/estoque/MovimentacaoModal.tsx
Normal file
356
src/components/estoque/MovimentacaoModal.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useEstoqueMateriais } from '@/hooks/useEstoque';
|
||||
import { useRastreabilidadeMateriais } from '@/hooks/useRastreabilidadeMateriais';
|
||||
import { useOFs } from '@/hooks/useOFs';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { useCriarMovimentacao } from '@/hooks/useEstoqueMovimentacoes';
|
||||
import { useEmpenhosAtivosPorMaterial } from '@/hooks/useEmpenhosMaterial';
|
||||
|
||||
interface MovimentacaoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const localizacoes = ['Galpão A', 'Galpão B', 'Pátio', 'Estoque Externo', 'Almoxarifado'];
|
||||
|
||||
export const MovimentacaoModal: React.FC<MovimentacaoModalProps> = ({
|
||||
isOpen,
|
||||
onClose
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
material_id: '',
|
||||
tipo_movimentacao: 'entrada' as 'entrada' | 'saida' | 'transferencia' | 'ajuste' | 'empenho' | 'desempenho',
|
||||
quantidade: 0,
|
||||
lote: '',
|
||||
fornecedor: '',
|
||||
nota_fiscal: '',
|
||||
of_vinculada: '',
|
||||
observacoes: '',
|
||||
nova_localizacao: '',
|
||||
data_movimentacao: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
const [quantidadeMaximaDisponivel, setQuantidadeMaximaDisponivel] = useState(0);
|
||||
const [empenhosAtivos, setEmpenhosAtivos] = useState<any[]>([]);
|
||||
|
||||
const { data: materiais } = useEstoqueMateriais();
|
||||
const { data: lotes } = useRastreabilidadeMateriais();
|
||||
const { data: ofs } = useOFs();
|
||||
const { user } = useAuth();
|
||||
const criarMovimentacao = useCriarMovimentacao();
|
||||
|
||||
// Buscar empenhos ativos quando material for selecionado e tipo for desempenho
|
||||
useEffect(() => {
|
||||
const fetchEmpenhosAtivos = async () => {
|
||||
if (formData.material_id && formData.tipo_movimentacao === 'desempenho') {
|
||||
const { data, error } = await supabase
|
||||
.from('empenhos_material')
|
||||
.select('*')
|
||||
.eq('material_id', formData.material_id)
|
||||
.eq('status', 'Empenhado');
|
||||
|
||||
if (!error && data) {
|
||||
setEmpenhosAtivos(data);
|
||||
}
|
||||
} else {
|
||||
setEmpenhosAtivos([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEmpenhosAtivos();
|
||||
}, [formData.material_id, formData.tipo_movimentacao]);
|
||||
|
||||
// Buscar informações do material e definir quantidade máxima
|
||||
useEffect(() => {
|
||||
if (formData.material_id && materiais) {
|
||||
const materialSelecionado = materiais.find(m => m.id === formData.material_id);
|
||||
|
||||
if (materialSelecionado) {
|
||||
// Definir quantidade máxima baseada no tipo de movimentação
|
||||
if (formData.tipo_movimentacao === 'saida' || formData.tipo_movimentacao === 'empenho') {
|
||||
setQuantidadeMaximaDisponivel(materialSelecionado.quantidade_disponivel || 0);
|
||||
} else if (formData.tipo_movimentacao === 'desempenho') {
|
||||
// Para desempenho, limitar pela quantidade empenhada disponível
|
||||
const totalEmpenhado = empenhosAtivos.reduce((sum, emp) =>
|
||||
sum + ((emp.quantidade_empenhada || 0) - (emp.quantidade_utilizada || 0)), 0);
|
||||
setQuantidadeMaximaDisponivel(totalEmpenhado);
|
||||
} else {
|
||||
setQuantidadeMaximaDisponivel(99999); // Sem limite para entrada, ajuste, transferência
|
||||
}
|
||||
|
||||
// Preencher lote automaticamente se disponível
|
||||
if (materialSelecionado.lote_atual) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
lote: materialSelecionado.lote_atual || ''
|
||||
}));
|
||||
|
||||
// Buscar informações do lote
|
||||
if (lotes) {
|
||||
const loteVinculado = lotes.find(l => l.lote === materialSelecionado.lote_atual);
|
||||
if (loteVinculado) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
lote: loteVinculado.lote,
|
||||
fornecedor: loteVinculado.fornecedor || '',
|
||||
nota_fiscal: loteVinculado.nota_fiscal || ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [formData.material_id, formData.tipo_movimentacao, materiais, lotes, empenhosAtivos]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validações básicas
|
||||
if (!formData.material_id) {
|
||||
toast.error('Material é obrigatório');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.quantidade <= 0) {
|
||||
toast.error('Quantidade deve ser maior que zero');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação para empenho e desempenho
|
||||
if (formData.tipo_movimentacao === 'empenho' && !formData.of_vinculada) {
|
||||
toast.error('OF vinculada é obrigatória para movimentações de empenho');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.tipo_movimentacao === 'desempenho' && !formData.of_vinculada) {
|
||||
toast.error('OF vinculada é obrigatória para movimentações de desempenho');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentDate = new Date().toLocaleDateString('pt-BR');
|
||||
const userName = user?.user_metadata?.full_name || user?.email || 'Usuário não identificado';
|
||||
const observacoesComplementadas = `Movimentação realizada em ${currentDate} por ${userName}${formData.observacoes ? ' - ' + formData.observacoes : ''}`;
|
||||
|
||||
const dadosMovimentacao = {
|
||||
material_id: formData.material_id,
|
||||
tipo_movimentacao: formData.tipo_movimentacao,
|
||||
quantidade: formData.quantidade,
|
||||
lote: formData.lote,
|
||||
fornecedor: formData.fornecedor,
|
||||
nota_fiscal: formData.nota_fiscal,
|
||||
of_vinculada: formData.of_vinculada,
|
||||
observacoes: observacoesComplementadas,
|
||||
data_movimentacao: formData.data_movimentacao,
|
||||
user_name: userName
|
||||
};
|
||||
|
||||
await criarMovimentacao.mutateAsync(dadosMovimentacao);
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
material_id: '',
|
||||
tipo_movimentacao: 'entrada',
|
||||
quantidade: 0,
|
||||
lote: '',
|
||||
fornecedor: '',
|
||||
nota_fiscal: '',
|
||||
of_vinculada: '',
|
||||
observacoes: '',
|
||||
nova_localizacao: '',
|
||||
data_movimentacao: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
toast.success('Movimentação criada com sucesso!');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido';
|
||||
toast.error(`Erro ao criar movimentação: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Filtrar materiais que possuem lote atual
|
||||
const materiaisComLote = materiais?.filter(material => material.lote_atual) || [];
|
||||
const materialSelecionado = materiais?.find(m => m.id === formData.material_id);
|
||||
|
||||
const requiresOF = formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho';
|
||||
const isTransferencia = formData.tipo_movimentacao === 'transferencia';
|
||||
const showLoteInfo = formData.lote || materialSelecionado?.lote_atual;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova Movimentação de Estoque</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="material_id">Material (com Lote) *</Label>
|
||||
<Select value={formData.material_id} onValueChange={(value) => handleInputChange('material_id', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o material" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{materiaisComLote?.map((material) => (
|
||||
<SelectItem key={material.id} value={material.id}>
|
||||
{material.descricao} (Lote: {material.lote_atual})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tipo_movimentacao">Tipo de Movimentação *</Label>
|
||||
<Select value={formData.tipo_movimentacao} onValueChange={(value) => handleInputChange('tipo_movimentacao', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="entrada">Entrada</SelectItem>
|
||||
<SelectItem value="saida">Saída</SelectItem>
|
||||
<SelectItem value="transferencia">Transferência</SelectItem>
|
||||
<SelectItem value="ajuste">Ajuste</SelectItem>
|
||||
<SelectItem value="empenho">Empenho</SelectItem>
|
||||
<SelectItem value="desempenho">Desempenho</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="quantidade">
|
||||
Quantidade *
|
||||
{quantidadeMaximaDisponivel > 0 && (
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
(Máx: {quantidadeMaximaDisponivel})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="quantidade"
|
||||
type="number"
|
||||
step="0.01"
|
||||
max={quantidadeMaximaDisponivel > 0 ? quantidadeMaximaDisponivel : undefined}
|
||||
value={formData.quantidade}
|
||||
onChange={(e) => handleInputChange('quantidade', parseFloat(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="data_movimentacao">Data da Movimentação</Label>
|
||||
<Input
|
||||
id="data_movimentacao"
|
||||
type="date"
|
||||
value={formData.data_movimentacao}
|
||||
onChange={(e) => handleInputChange('data_movimentacao', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLoteInfo && (
|
||||
<div className="bg-muted p-4 rounded-lg space-y-2">
|
||||
<h4 className="font-medium">Informações do Lote</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Lote:</span> {formData.lote}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Fornecedor:</span> {formData.fornecedor || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">NF:</span> {formData.nota_fiscal || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiresOF && (
|
||||
<div>
|
||||
<Label htmlFor="of_vinculada">OF Vinculada *</Label>
|
||||
<Select value={formData.of_vinculada} onValueChange={(value) => handleInputChange('of_vinculada', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ofs?.map((of) => (
|
||||
<SelectItem key={of.id} value={of.num_of}>
|
||||
{of.num_of} - {of.descritivo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.tipo_movimentacao === 'desempenho' && empenhosAtivos.length === 0 && formData.material_id && (
|
||||
<p className="text-sm text-yellow-600 mt-1">
|
||||
⚠️ Este material não possui empenhos ativos. Verifique se há empenho para a OF selecionada.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTransferencia && (
|
||||
<div>
|
||||
<Label htmlFor="nova_localizacao">Nova Localização</Label>
|
||||
<Select value={formData.nova_localizacao} onValueChange={(value) => handleInputChange('nova_localizacao', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a nova localização" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localizacoes.map((localizacao) => (
|
||||
<SelectItem key={localizacao} value={localizacao}>
|
||||
{localizacao}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="observacoes">Observações Adicionais</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => handleInputChange('observacoes', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Observações adicionais (data e usuário serão adicionados automaticamente)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
A data e o usuário da movimentação serão adicionados automaticamente às observações.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={criarMovimentacao.isPending}
|
||||
className="bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{criarMovimentacao.isPending ? 'Criando...' : 'Criar Movimentação'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
350
src/components/estoque/MovimentacaoModalSimplificada.tsx
Normal file
350
src/components/estoque/MovimentacaoModalSimplificada.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useEstoqueMateriais } from '@/hooks/useEstoqueSimplificado';
|
||||
import { useCriarMovimentacao } from '@/hooks/useEstoqueMovimentacoes';
|
||||
import { useRastreabilidadeMateriais } from '@/hooks/useRastreabilidadeMateriais';
|
||||
import { useOFs } from '@/hooks/useOFs';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { EstoqueMaterial } from '@/hooks/useEstoque';
|
||||
|
||||
interface MovimentacaoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedMaterials?: EstoqueMaterial[];
|
||||
}
|
||||
|
||||
const localizacoes = ['Galpão A', 'Galpão B', 'Pátio', 'Estoque Externo', 'Almoxarifado'];
|
||||
|
||||
export const MovimentacaoModalSimplificada: React.FC<MovimentacaoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedMaterials = []
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
material_id: '',
|
||||
tipo_movimentacao: 'entrada' as 'entrada' | 'saida' | 'transferencia' | 'ajuste' | 'empenho' | 'desempenho',
|
||||
quantidade: 0,
|
||||
lote: '',
|
||||
fornecedor: '',
|
||||
nota_fiscal: '',
|
||||
of_vinculada: '',
|
||||
observacoes: '',
|
||||
nova_localizacao: '',
|
||||
data_movimentacao: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
const [quantidadeMaximaDisponivel, setQuantidadeMaximaDisponivel] = useState(0);
|
||||
|
||||
const { data: materiais } = useEstoqueMateriais();
|
||||
const { data: lotes } = useRastreabilidadeMateriais();
|
||||
const { data: ofs } = useOFs();
|
||||
const { user } = useAuth();
|
||||
const criarMovimentacao = useCriarMovimentacao();
|
||||
|
||||
// Se há materiais selecionados, mostrar título diferente e permitir movimentação em lote
|
||||
const isBatchMode = selectedMaterials.length > 0;
|
||||
|
||||
// Buscar informações do material e definir quantidade máxima
|
||||
useEffect(() => {
|
||||
if (formData.material_id && materiais) {
|
||||
const materialSelecionado = materiais.find(m => m.id === formData.material_id);
|
||||
|
||||
if (materialSelecionado) {
|
||||
// Definir quantidade máxima baseada no tipo de movimentação
|
||||
if (formData.tipo_movimentacao === 'saida' || formData.tipo_movimentacao === 'empenho') {
|
||||
setQuantidadeMaximaDisponivel(materialSelecionado.quantidade_disponivel || 0);
|
||||
} else {
|
||||
setQuantidadeMaximaDisponivel(99999); // Sem limite para entrada, ajuste, transferência
|
||||
}
|
||||
|
||||
// Preencher lote automaticamente se disponível
|
||||
if (materialSelecionado.lote_atual) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
lote: materialSelecionado.lote_atual || ''
|
||||
}));
|
||||
|
||||
// Buscar informações do lote
|
||||
if (lotes) {
|
||||
const loteVinculado = lotes.find(l => l.lote === materialSelecionado.lote_atual);
|
||||
if (loteVinculado) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
lote: loteVinculado.lote,
|
||||
fornecedor: loteVinculado.fornecedor || '',
|
||||
nota_fiscal: loteVinculado.nota_fiscal || ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [formData.material_id, formData.tipo_movimentacao, materiais, lotes]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validações específicas
|
||||
if ((formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho') && !formData.of_vinculada) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.quantidade <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.quantidade > quantidadeMaximaDisponivel && quantidadeMaximaDisponivel > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentDate = new Date().toLocaleDateString('pt-BR');
|
||||
const userName = user?.user_metadata?.full_name || user?.email || 'Usuário não identificado';
|
||||
const observacoesComplementadas = `Movimentação realizada em ${currentDate} por ${userName}${formData.observacoes ? ' - ' + formData.observacoes : ''}`;
|
||||
|
||||
if (isBatchMode) {
|
||||
// Movimentação em lote para materiais selecionados
|
||||
for (const material of selectedMaterials) {
|
||||
await criarMovimentacao.mutateAsync({
|
||||
material_id: material.id,
|
||||
tipo_movimentacao: formData.tipo_movimentacao,
|
||||
quantidade: formData.quantidade,
|
||||
lote: material.lote_atual || '',
|
||||
fornecedor: formData.fornecedor,
|
||||
nota_fiscal: formData.nota_fiscal,
|
||||
of_vinculada: formData.of_vinculada,
|
||||
observacoes: observacoesComplementadas,
|
||||
data_movimentacao: formData.data_movimentacao,
|
||||
user_name: userName
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Movimentação individual
|
||||
await criarMovimentacao.mutateAsync({
|
||||
material_id: formData.material_id,
|
||||
tipo_movimentacao: formData.tipo_movimentacao,
|
||||
quantidade: formData.quantidade,
|
||||
lote: formData.lote,
|
||||
fornecedor: formData.fornecedor,
|
||||
nota_fiscal: formData.nota_fiscal,
|
||||
of_vinculada: formData.of_vinculada,
|
||||
observacoes: observacoesComplementadas,
|
||||
data_movimentacao: formData.data_movimentacao,
|
||||
user_name: userName
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
material_id: '',
|
||||
tipo_movimentacao: 'entrada',
|
||||
quantidade: 0,
|
||||
lote: '',
|
||||
fornecedor: '',
|
||||
nota_fiscal: '',
|
||||
of_vinculada: '',
|
||||
observacoes: '',
|
||||
nova_localizacao: '',
|
||||
data_movimentacao: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar movimentação:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Filtrar materiais que possuem lote atual
|
||||
const materiaisComLote = materiais?.filter(material => material.lote_atual) || [];
|
||||
const materialSelecionado = materiais?.find(m => m.id === formData.material_id);
|
||||
|
||||
const requiresOF = formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho';
|
||||
const isTransferencia = formData.tipo_movimentacao === 'transferencia';
|
||||
const showLoteInfo = formData.lote || materialSelecionado?.lote_atual;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isBatchMode
|
||||
? `Nova Movimentação em Lote (${selectedMaterials.length} materiais)`
|
||||
: 'Nova Movimentação de Estoque'
|
||||
}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isBatchMode ? (
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<h4 className="font-medium mb-2">Materiais Selecionados:</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
{selectedMaterials.slice(0, 3).map((material) => (
|
||||
<div key={material.id}>
|
||||
• {material.descricao} - {material.lote_atual || 'Sem lote'}
|
||||
</div>
|
||||
))}
|
||||
{selectedMaterials.length > 3 && (
|
||||
<div>... e mais {selectedMaterials.length - 3} materiais</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="material_id">Material (com Lote) *</Label>
|
||||
<Select value={formData.material_id} onValueChange={(value) => handleInputChange('material_id', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o material" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{materiaisComLote?.map((material) => (
|
||||
<SelectItem key={material.id} value={material.id}>
|
||||
{material.descricao} (Lote: {material.lote_atual})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="tipo_movimentacao">Tipo de Movimentação *</Label>
|
||||
<Select value={formData.tipo_movimentacao} onValueChange={(value) => handleInputChange('tipo_movimentacao', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="entrada">Entrada</SelectItem>
|
||||
<SelectItem value="saida">Saída</SelectItem>
|
||||
<SelectItem value="transferencia">Transferência</SelectItem>
|
||||
<SelectItem value="ajuste">Ajuste</SelectItem>
|
||||
<SelectItem value="empenho">Empenho</SelectItem>
|
||||
<SelectItem value="desempenho">Desempenho</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quantidade">
|
||||
Quantidade *
|
||||
{quantidadeMaximaDisponivel > 0 && !isBatchMode && (
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
(Máx: {quantidadeMaximaDisponivel})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="quantidade"
|
||||
type="number"
|
||||
step="0.01"
|
||||
max={quantidadeMaximaDisponivel > 0 && !isBatchMode ? quantidadeMaximaDisponivel : undefined}
|
||||
value={formData.quantidade}
|
||||
onChange={(e) => handleInputChange('quantidade', parseFloat(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="data_movimentacao">Data da Movimentação</Label>
|
||||
<Input
|
||||
id="data_movimentacao"
|
||||
type="date"
|
||||
value={formData.data_movimentacao}
|
||||
onChange={(e) => handleInputChange('data_movimentacao', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isBatchMode && showLoteInfo && (
|
||||
<div className="bg-muted p-4 rounded-lg space-y-2">
|
||||
<h4 className="font-medium">Informações do Lote</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Lote:</span> {formData.lote}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Fornecedor:</span> {formData.fornecedor || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">NF:</span> {formData.nota_fiscal || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiresOF && (
|
||||
<div>
|
||||
<Label htmlFor="of_vinculada">OF Vinculada *</Label>
|
||||
<Select value={formData.of_vinculada} onValueChange={(value) => handleInputChange('of_vinculada', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ofs?.map((of) => (
|
||||
<SelectItem key={of.id} value={of.num_of}>
|
||||
{of.num_of} - {of.descritivo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTransferencia && (
|
||||
<div>
|
||||
<Label htmlFor="nova_localizacao">Nova Localização</Label>
|
||||
<Select value={formData.nova_localizacao} onValueChange={(value) => handleInputChange('nova_localizacao', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a nova localização" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localizacoes.map((localizacao) => (
|
||||
<SelectItem key={localizacao} value={localizacao}>
|
||||
{localizacao}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="observacoes">Observações Adicionais</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => handleInputChange('observacoes', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Observações adicionais (data e usuário serão adicionados automaticamente)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
A data e o usuário da movimentação serão adicionados automaticamente às observações.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={criarMovimentacao.isPending}>
|
||||
{criarMovimentacao.isPending ? 'Criando...' : 'Criar Movimentação'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
42
src/components/estoque/MovimentacaoTableHeader.tsx
Normal file
42
src/components/estoque/MovimentacaoTableHeader.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
import React from 'react';
|
||||
import { TableHead } from '@/components/ui/table';
|
||||
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface MovimentacaoTableHeaderProps {
|
||||
label: string;
|
||||
sortKey: string;
|
||||
currentSort: {
|
||||
key: string;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
onSort: (key: string) => void;
|
||||
}
|
||||
|
||||
export function MovimentacaoTableHeader({
|
||||
label,
|
||||
sortKey,
|
||||
currentSort,
|
||||
onSort
|
||||
}: MovimentacaoTableHeaderProps) {
|
||||
const getSortIcon = () => {
|
||||
if (currentSort.key !== sortKey) {
|
||||
return <ChevronUp className="h-3 w-3 opacity-30" />;
|
||||
}
|
||||
return currentSort.direction === 'asc' ?
|
||||
<ChevronUp className="h-3 w-3" /> :
|
||||
<ChevronDown className="h-3 w-3" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 select-none text-xs py-1 px-2"
|
||||
onClick={() => onSort(sortKey)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{label}
|
||||
{getSortIcon()}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
175
src/components/estoque/QualidadeAcoModal.tsx
Normal file
175
src/components/estoque/QualidadeAcoModal.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Plus, Edit, Trash2 } from 'lucide-react';
|
||||
import { useQualidadesAco, useCriarQualidadeAco, useAtualizarQualidadeAco, useExcluirQualidadeAco } from '@/hooks/useEstoqueCRUD';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface QualidadeAcoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const QualidadeAcoModal: React.FC<QualidadeAcoModalProps> = ({ isOpen, onClose }) => {
|
||||
const { data: qualidades = [], isLoading } = useQualidadesAco();
|
||||
const criarQualidade = useCriarQualidadeAco();
|
||||
const atualizarQualidade = useAtualizarQualidadeAco();
|
||||
const excluirQualidade = useExcluirQualidadeAco();
|
||||
|
||||
const [novaQualidade, setNovaQualidade] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (novaQualidade.trim() && !qualidades.some(qual => qual.nome === novaQualidade.trim())) {
|
||||
try {
|
||||
await criarQualidade.mutateAsync({
|
||||
nome: novaQualidade.trim(),
|
||||
descricao: `Qualidade ${novaQualidade.trim()}`,
|
||||
ativo: true
|
||||
});
|
||||
setNovaQualidade('');
|
||||
toast.success('Qualidade criada com sucesso!');
|
||||
} catch (error) {
|
||||
toast.error('Erro ao criar qualidade');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (qualidade: any) => {
|
||||
setEditingId(qualidade.id);
|
||||
setEditValue(qualidade.nome);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (editValue.trim() && editingId && !qualidades.some(qual => qual.nome === editValue.trim() && qual.id !== editingId)) {
|
||||
try {
|
||||
const qualidade = qualidades.find(qual => qual.id === editingId);
|
||||
if (qualidade) {
|
||||
await atualizarQualidade.mutateAsync({
|
||||
...qualidade,
|
||||
nome: editValue.trim(),
|
||||
descricao: `Qualidade ${editValue.trim()}`
|
||||
});
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
toast.success('Qualidade atualizada com sucesso!');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao atualizar qualidade');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Tem certeza que deseja excluir esta qualidade?')) {
|
||||
try {
|
||||
await excluirQualidade.mutateAsync(id);
|
||||
toast.success('Qualidade excluída com sucesso!');
|
||||
} catch (error) {
|
||||
toast.error('Erro ao excluir qualidade');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gerenciar Qualidades do Material</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="nova-qualidade">Nova Qualidade</Label>
|
||||
<Input
|
||||
id="nova-qualidade"
|
||||
value={novaQualidade}
|
||||
onChange={(e) => setNovaQualidade(e.target.value)}
|
||||
placeholder="Ex: A572G50"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button onClick={handleAdd} disabled={!novaQualidade.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Qualidades Cadastradas ({qualidades.length})</h3>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-8">
|
||||
<TableHead className="py-2">Qualidade</TableHead>
|
||||
<TableHead className="w-24 py-2">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{qualidades.map((qualidade) => (
|
||||
<TableRow key={qualidade.id} className="h-8">
|
||||
<TableCell className="py-1">
|
||||
{editingId === qualidade.id ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()}
|
||||
className="h-7"
|
||||
/>
|
||||
<Button size="sm" onClick={handleSaveEdit} className="h-7">
|
||||
Salvar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleCancelEdit} className="h-7">
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-medium">{qualidade.nome}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
{editingId !== qualidade.id && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(qualidade)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(qualidade.id)}
|
||||
className="h-6 w-6 p-0 text-red-500"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
21
src/components/estoque/QualidadeAcoOptions.tsx
Normal file
21
src/components/estoque/QualidadeAcoOptions.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
export const QUALIDADES_ACO = [
|
||||
'ASTM A36',
|
||||
'ASTM A572 Gr 50',
|
||||
'ASTM A588',
|
||||
'ASTM A992',
|
||||
'SAE 1020',
|
||||
'SAE 1045',
|
||||
'AISI 304',
|
||||
'AISI 316',
|
||||
'AISI 430',
|
||||
'ABNT NBR 7007',
|
||||
'ABNT NBR 8800',
|
||||
'DIN 17100 St37',
|
||||
'DIN 17100 St52',
|
||||
'EN 10025 S235',
|
||||
'EN 10025 S275',
|
||||
'EN 10025 S355'
|
||||
];
|
||||
|
||||
export const getQualidadeAcoOptions = () => QUALIDADES_ACO;
|
||||
268
src/components/estoque/RastreabilidadeLoteModal.tsx
Normal file
268
src/components/estoque/RastreabilidadeLoteModal.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useEstoqueMateriais, useCriarRastreabilidade, useAtualizarRastreabilidade } from '@/hooks/useRastreabilidadeMateriais';
|
||||
|
||||
interface RastreabilidadeLoteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
lote?: any | null;
|
||||
}
|
||||
|
||||
export const RastreabilidadeLoteModal: React.FC<RastreabilidadeLoteModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
lote
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
lote: '',
|
||||
material_id: '',
|
||||
quantidade: '',
|
||||
data_entrada: '',
|
||||
fornecedor: '',
|
||||
certificado: '',
|
||||
corrida: '',
|
||||
data_validade: '',
|
||||
nota_fiscal: '',
|
||||
status: 'Ativo'
|
||||
});
|
||||
|
||||
const [materialFilter, setMaterialFilter] = useState('');
|
||||
|
||||
const { data: materiais } = useEstoqueMateriais();
|
||||
const criarRastreabilidade = useCriarRastreabilidade();
|
||||
const atualizarRastreabilidade = useAtualizarRastreabilidade();
|
||||
|
||||
// Filtrar materiais baseado na busca
|
||||
const materiaisFiltrados = materiais?.filter(material =>
|
||||
material.descricao.toLowerCase().includes(materialFilter.toLowerCase()) ||
|
||||
material.codigo.toLowerCase().includes(materialFilter.toLowerCase())
|
||||
) || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (lote) {
|
||||
setFormData({
|
||||
lote: lote.lote || '',
|
||||
material_id: lote.material_id || '',
|
||||
quantidade: lote.quantidade?.toString() || '',
|
||||
data_entrada: lote.data_entrada || '',
|
||||
fornecedor: lote.fornecedor || '',
|
||||
certificado: lote.certificado || '',
|
||||
corrida: lote.corrida || '',
|
||||
data_validade: lote.data_validade || '',
|
||||
nota_fiscal: lote.nota_fiscal || '',
|
||||
status: lote.status || 'Ativo'
|
||||
});
|
||||
} else {
|
||||
// Resetar formulário para novo lote
|
||||
const hoje = new Date();
|
||||
const dataFormatada = hoje.toISOString().split('T')[0];
|
||||
|
||||
setFormData({
|
||||
lote: '',
|
||||
material_id: '',
|
||||
quantidade: '',
|
||||
data_entrada: dataFormatada,
|
||||
fornecedor: '',
|
||||
certificado: '',
|
||||
corrida: '',
|
||||
data_validade: '',
|
||||
nota_fiscal: '',
|
||||
status: 'Ativo'
|
||||
});
|
||||
}
|
||||
// Limpar filtro ao abrir/fechar modal
|
||||
setMaterialFilter('');
|
||||
}, [lote, isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.material_id || !formData.quantidade || !formData.nota_fiscal) {
|
||||
alert('Por favor, preencha os campos obrigatórios: Material, Quantidade e Nota Fiscal.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
quantidade: parseFloat(formData.quantidade),
|
||||
lote: formData.lote.trim() || null,
|
||||
data_validade: formData.data_validade.trim() || null
|
||||
};
|
||||
|
||||
if (lote) {
|
||||
await atualizarRastreabilidade.mutateAsync({ id: lote.id, ...dataToSubmit });
|
||||
} else {
|
||||
await criarRastreabilidade.mutateAsync(dataToSubmit);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar rastreabilidade:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{lote ? 'Editar Lote' : 'Novo Lote'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="lote">Lote</Label>
|
||||
<Input
|
||||
id="lote"
|
||||
value={formData.lote}
|
||||
onChange={(e) => handleInputChange('lote', e.target.value)}
|
||||
placeholder="Deixe vazio para gerar automaticamente"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="material_id">Material *</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="filtro: Digite para buscar material..."
|
||||
value={materialFilter}
|
||||
onChange={(e) => setMaterialFilter(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={formData.material_id} onValueChange={(value) => handleInputChange('material_id', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o material" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{materiaisFiltrados?.map((material) => (
|
||||
<SelectItem key={material.id} value={material.id}>
|
||||
{material.descricao} ({material.comprimento ? `${material.comprimento}mm` : 'S/ compr.'})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="quantidade">Quantidade *</Label>
|
||||
<Input
|
||||
id="quantidade"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.quantidade}
|
||||
onChange={(e) => handleInputChange('quantidade', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="nota_fiscal">Nota Fiscal *</Label>
|
||||
<Input
|
||||
id="nota_fiscal"
|
||||
value={formData.nota_fiscal}
|
||||
onChange={(e) => handleInputChange('nota_fiscal', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="data_entrada">Data de Entrada</Label>
|
||||
<Input
|
||||
id="data_entrada"
|
||||
type="date"
|
||||
value={formData.data_entrada}
|
||||
onChange={(e) => handleInputChange('data_entrada', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fornecedor">Fornecedor</Label>
|
||||
<Input
|
||||
id="fornecedor"
|
||||
value={formData.fornecedor}
|
||||
onChange={(e) => handleInputChange('fornecedor', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="certificado">Certificado</Label>
|
||||
<Input
|
||||
id="certificado"
|
||||
value={formData.certificado}
|
||||
onChange={(e) => handleInputChange('certificado', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="corrida">Corrida</Label>
|
||||
<Input
|
||||
id="corrida"
|
||||
value={formData.corrida}
|
||||
onChange={(e) => handleInputChange('corrida', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="data_validade">Data de Validade (Opcional)</Label>
|
||||
<Input
|
||||
id="data_validade"
|
||||
type="date"
|
||||
value={formData.data_validade}
|
||||
onChange={(e) => handleInputChange('data_validade', e.target.value)}
|
||||
placeholder="Campo opcional - pode ficar vazio"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Este campo é opcional e pode ser deixado vazio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select value={formData.status} onValueChange={(value) => handleInputChange('status', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Ativo">Ativo</SelectItem>
|
||||
<SelectItem value="Inativo">Inativo</SelectItem>
|
||||
<SelectItem value="Vencido">Vencido</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={criarRastreabilidade.isPending || atualizarRastreabilidade.isPending}>
|
||||
{criarRastreabilidade.isPending || atualizarRastreabilidade.isPending ? 'Salvando...' : 'Salvar Lote'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
175
src/components/estoque/RastreabilidadeMP.tsx
Normal file
175
src/components/estoque/RastreabilidadeMP.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Search, Plus, Edit, Trash2 } from 'lucide-react';
|
||||
import { useRastreabilidadeMateriais, useExcluirRastreabilidade } from '@/hooks/useRastreabilidadeMateriais';
|
||||
import { RastreabilidadeLoteModal } from './RastreabilidadeLoteModal';
|
||||
|
||||
export const RastreabilidadeMP: React.FC = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedLote, setSelectedLote] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { data: lotes, isLoading } = useRastreabilidadeMateriais();
|
||||
const excluirRastreabilidade = useExcluirRastreabilidade();
|
||||
|
||||
const filteredLotes = lotes?.filter(lote =>
|
||||
lote.lote.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(lote.fornecedor && lote.fornecedor.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(lote.certificado && lote.certificado.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
) || [];
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setSelectedLote(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditLote = (lote: any) => {
|
||||
setSelectedLote(lote);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteLote = async (lote: any) => {
|
||||
if (window.confirm(`Tem certeza que deseja excluir o lote ${lote.lote}?`)) {
|
||||
try {
|
||||
await excluirRastreabilidade.mutateAsync(lote.id);
|
||||
} catch (error) {
|
||||
console.error('Erro ao excluir lote:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('pt-BR');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-muted-foreground text-sm">Carregando...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="pb-3 sm:pb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<CardTitle className="text-card-foreground text-sm sm:text-lg lg:text-xl">
|
||||
Rastreabilidade do MP
|
||||
</CardTitle>
|
||||
<Button onClick={handleOpenModal} className="bg-primary hover:bg-primary/90 text-xs sm:text-sm px-3 py-2" size="sm">
|
||||
<Plus className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
|
||||
Novo Lote
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-2 sm:px-6">
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-2 sm:left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<Input
|
||||
placeholder="Buscar por lote, fornecedor ou certificado..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8 sm:pl-10 text-xs sm:text-sm h-8 sm:h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto -mx-2 sm:mx-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-8">
|
||||
<TableHead className="text-xs py-1 px-2">Lote</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2">Material</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2">Quantidade</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2">Fornecedor</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2">Nota Fiscal</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2">Certificado</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2">Corrida</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2">Data Entrada</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2">Data Validade</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2">Status</TableHead>
|
||||
<TableHead className="text-xs py-1 px-2 w-20">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLotes.map((lote) => (
|
||||
<TableRow key={lote.id} className="h-6">
|
||||
<TableCell className="text-xs py-1 px-2 font-medium">{lote.lote}</TableCell>
|
||||
<TableCell className="text-xs py-1 px-2">
|
||||
{lote.estoque_materiais?.descricao || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-1 px-2">{lote.quantidade}</TableCell>
|
||||
<TableCell className="text-xs py-1 px-2">{lote.fornecedor || '-'}</TableCell>
|
||||
<TableCell className="text-xs py-1 px-2">{lote.nota_fiscal || '-'}</TableCell>
|
||||
<TableCell className="text-xs py-1 px-2">{lote.certificado || '-'}</TableCell>
|
||||
<TableCell className="text-xs py-1 px-2">{lote.corrida || '-'}</TableCell>
|
||||
<TableCell className="text-xs py-1 px-2">
|
||||
{lote.data_entrada ? formatDate(lote.data_entrada) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-1 px-2">
|
||||
{lote.data_validade ? formatDate(lote.data_validade) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-1 px-2">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
lote.status === 'Ativo' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{lote.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-1 px-2">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditLote(lote)}
|
||||
className="h-6 w-6 p-1"
|
||||
title="Editar lote"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteLote(lote)}
|
||||
className="h-6 w-6 p-1 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Excluir lote"
|
||||
disabled={excluirRastreabilidade.isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{filteredLotes.length === 0 && (
|
||||
<div className="text-center py-6 sm:py-8 text-muted-foreground text-sm">
|
||||
Nenhum lote encontrado
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RastreabilidadeLoteModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
lote={selectedLote}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
70
src/components/estoque/TiposFiltroButtons.tsx
Normal file
70
src/components/estoque/TiposFiltroButtons.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTiposMateriaPrima } from '@/hooks/useEstoque';
|
||||
|
||||
interface TiposFiltroButtonsProps {
|
||||
tipoSelecionado: string;
|
||||
onTipoChange: (tipo: string) => void;
|
||||
}
|
||||
|
||||
export const TiposFiltroButtons: React.FC<TiposFiltroButtonsProps> = ({
|
||||
tipoSelecionado,
|
||||
onTipoChange
|
||||
}) => {
|
||||
const { data: tipos, isLoading } = useTiposMateriaPrima();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-4 p-3 bg-muted/30 rounded-lg border">
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(18%_-_0.5rem)] min-w-[120px]"></div>
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(18%_-_0.5rem)] min-w-[120px]"></div>
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(18%_-_0.5rem)] min-w-[120px]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Criar uma estrutura unificada para todos os tipos
|
||||
const allTiposItem = { id: 'all', nome: 'Todos os Tipos', isSpecial: true };
|
||||
const tipoItems = tipos?.map(tipo => ({ ...tipo, isSpecial: false })) || [];
|
||||
const allItems = [allTiposItem, ...tipoItems];
|
||||
|
||||
// Dividir os tipos em duas linhas
|
||||
const firstLine = allItems.slice(0, Math.ceil(allItems.length / 2));
|
||||
const secondLine = allItems.slice(Math.ceil(allItems.length / 2));
|
||||
|
||||
return (
|
||||
<div className="mb-4 p-3 bg-muted/30 rounded-lg border space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{firstLine.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={tipoSelecionado === (item.isSpecial ? 'all' : item.nome) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onTipoChange(item.isSpecial ? 'all' : item.nome)}
|
||||
className="h-8 text-xs flex-[0_0_calc(18%_-_0.5rem)] min-w-[180px] max-w-none"
|
||||
title={item.nome}
|
||||
>
|
||||
{item.nome}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{secondLine.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{secondLine.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={tipoSelecionado === item.nome ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onTipoChange(item.nome)}
|
||||
className="h-8 text-xs flex-[0_0_calc(18%_-_0.5rem)] min-w-[180px] max-w-none"
|
||||
title={item.nome}
|
||||
>
|
||||
{item.nome}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
98
src/components/estoque/TiposFiltroButtonsOtimizado.tsx
Normal file
98
src/components/estoque/TiposFiltroButtonsOtimizado.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTiposMateriaPrima } from '@/hooks/useEstoque';
|
||||
|
||||
interface TiposFiltroButtonsOtimizadoProps {
|
||||
tipoSelecionado: string;
|
||||
onTipoChange: (tipo: string) => void;
|
||||
categoriaFilter: string;
|
||||
onCategoriaChange: (categoria: string) => void;
|
||||
}
|
||||
|
||||
export const TiposFiltroButtonsOtimizado: React.FC<TiposFiltroButtonsOtimizadoProps> = ({
|
||||
tipoSelecionado,
|
||||
onTipoChange,
|
||||
categoriaFilter,
|
||||
onCategoriaChange
|
||||
}) => {
|
||||
const { data: tipos, isLoading } = useTiposMateriaPrima();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 mb-4 p-3 bg-muted/30 rounded-lg border">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(25%_-_0.5rem)] min-w-[120px]"></div>
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(25%_-_0.5rem)] min-w-[120px]"></div>
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(25%_-_0.5rem)] min-w-[120px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Separar tipos por categoria
|
||||
const tiposDiretos = tipos?.filter(tipo => tipo.categoria === 'direto') || [];
|
||||
const tiposIndiretos = tipos?.filter(tipo => tipo.categoria === 'indireto') || [];
|
||||
|
||||
const renderCategoriaSection = (titulo: string, tiposList: any[], categoria: string) => {
|
||||
if (tiposList.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{titulo}</h4>
|
||||
<div className="flex-1 h-px bg-border"></div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tiposList.map((tipo) => (
|
||||
<Button
|
||||
key={tipo.id}
|
||||
variant={tipoSelecionado === tipo.nome ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onTipoChange(tipo.nome)}
|
||||
className="h-8 text-xs min-w-[120px]"
|
||||
title={tipo.nome}
|
||||
>
|
||||
{tipo.nome}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 mb-4 p-3 bg-muted/30 rounded-lg border">
|
||||
{/* Filtros de categoria e "Todos" */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={tipoSelecionado === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onTipoChange('all')}
|
||||
className="h-8 text-xs min-w-[120px]"
|
||||
>
|
||||
Todos os Tipos
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<span className="text-sm text-muted-foreground">Filtrar por:</span>
|
||||
<select
|
||||
value={categoriaFilter}
|
||||
onChange={(e) => onCategoriaChange(e.target.value)}
|
||||
className="h-8 text-xs border border-input bg-background rounded px-2"
|
||||
>
|
||||
<option value="all">Todas as Categorias</option>
|
||||
<option value="direto">Material Direto</option>
|
||||
<option value="indireto">Material Indireto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seções por categoria */}
|
||||
{(categoriaFilter === 'all' || categoriaFilter === 'direto') &&
|
||||
renderCategoriaSection('Materiais Diretos', tiposDiretos, 'direto')}
|
||||
|
||||
{(categoriaFilter === 'all' || categoriaFilter === 'indireto') &&
|
||||
renderCategoriaSection('Materiais Indiretos', tiposIndiretos, 'indireto')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
253
src/components/estoque/TiposMateriaModal.tsx
Normal file
253
src/components/estoque/TiposMateriaModal.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Edit, Trash } from 'lucide-react';
|
||||
import {
|
||||
useTiposMateriaPrima,
|
||||
useCriarTipoMaterial,
|
||||
useAtualizarTipoMaterial,
|
||||
useExcluirTipoMaterial
|
||||
} from '@/hooks/useEstoque';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface TiposMateriaModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const TiposMateriaModal: React.FC<TiposMateriaModalProps> = ({ isOpen, onClose }) => {
|
||||
const [nome, setNome] = useState('');
|
||||
const [descricao, setDescricao] = useState('');
|
||||
const [categoria, setCategoria] = useState<'direto' | 'indireto'>('direto');
|
||||
const [gestaoEstoqueCritico, setGestaoEstoqueCritico] = useState(true);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const { data: tipos, isLoading } = useTiposMateriaPrima();
|
||||
const criarTipo = useCriarTipoMaterial();
|
||||
const atualizarTipo = useAtualizarTipoMaterial();
|
||||
const excluirTipo = useExcluirTipoMaterial();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!nome.trim()) return;
|
||||
|
||||
try {
|
||||
const tipoData = {
|
||||
nome: nome.trim(),
|
||||
descricao: descricao.trim(),
|
||||
categoria,
|
||||
gestao_estoque_critico: gestaoEstoqueCritico,
|
||||
caracteristicas: {},
|
||||
controles: {},
|
||||
ativo: true
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
await atualizarTipo.mutateAsync({
|
||||
id: editingId,
|
||||
...tipoData
|
||||
});
|
||||
} else {
|
||||
await criarTipo.mutateAsync(tipoData);
|
||||
}
|
||||
|
||||
setNome('');
|
||||
setDescricao('');
|
||||
setCategoria('direto');
|
||||
setGestaoEstoqueCritico(true);
|
||||
setEditingId(null);
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar tipo de material:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (tipo: any) => {
|
||||
setNome(tipo.nome);
|
||||
setDescricao(tipo.descricao || '');
|
||||
setCategoria(tipo.categoria || 'direto');
|
||||
setGestaoEstoqueCritico(tipo.gestao_estoque_critico ?? true);
|
||||
setEditingId(tipo.id);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Tem certeza que deseja excluir este tipo de material?')) {
|
||||
await excluirTipo.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setNome('');
|
||||
setDescricao('');
|
||||
setCategoria('direto');
|
||||
setGestaoEstoqueCritico(true);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleToggleGestaoEstoque = async (tipo: any) => {
|
||||
try {
|
||||
const novoValor = !tipo.gestao_estoque_critico;
|
||||
|
||||
await atualizarTipo.mutateAsync({
|
||||
id: tipo.id,
|
||||
nome: tipo.nome,
|
||||
descricao: tipo.descricao || '',
|
||||
categoria: tipo.categoria || 'direto',
|
||||
caracteristicas: tipo.caracteristicas || {},
|
||||
controles: tipo.controles || {},
|
||||
ativo: tipo.ativo ?? true,
|
||||
gestao_estoque_critico: novoValor
|
||||
});
|
||||
|
||||
toast.success(`Gestão de estoque crítico ${novoValor ? 'ativada' : 'desativada'} para ${tipo.nome}`);
|
||||
} catch (error) {
|
||||
console.error('Erro ao alterar gestão de estoque crítico:', error);
|
||||
toast.error('Erro ao alterar configuração');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gerenciar Tipos de Material</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-4 border rounded-lg">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="nome">Nome do Tipo *</Label>
|
||||
<Input
|
||||
id="nome"
|
||||
value={nome}
|
||||
onChange={(e) => setNome(e.target.value)}
|
||||
placeholder="Ex: Perfis Laminados"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="categoria">Categoria *</Label>
|
||||
<Select value={categoria} onValueChange={(value: 'direto' | 'indireto') => setCategoria(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="direto">Material Direto</SelectItem>
|
||||
<SelectItem value="indireto">Material Indireto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-6">
|
||||
<Checkbox
|
||||
id="gestaoEstoqueCritico"
|
||||
checked={gestaoEstoqueCritico}
|
||||
onCheckedChange={(checked) => setGestaoEstoqueCritico(!!checked)}
|
||||
/>
|
||||
<Label htmlFor="gestaoEstoqueCritico" className="text-sm font-medium">
|
||||
Gestão Estoque Crítico
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="descricao">Descrição</Label>
|
||||
<Textarea
|
||||
id="descricao"
|
||||
value={descricao}
|
||||
onChange={(e) => setDescricao(e.target.value)}
|
||||
placeholder="Descrição do tipo de material"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={criarTipo.isPending || atualizarTipo.isPending}>
|
||||
{editingId ? 'Atualizar' : 'Adicionar'}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Categoria</TableHead>
|
||||
<TableHead>Gestão Est. Crítico (S/N)</TableHead>
|
||||
<TableHead>Descrição</TableHead>
|
||||
<TableHead className="w-24">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center">Carregando...</TableCell>
|
||||
</TableRow>
|
||||
) : tipos?.length ? (
|
||||
tipos.map((tipo) => (
|
||||
<TableRow key={tipo.id}>
|
||||
<TableCell className="font-medium">{tipo.nome}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={tipo.categoria === 'direto' ? 'default' : 'secondary'}>
|
||||
{tipo.categoria === 'direto' ? 'Direto' : 'Indireto'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={(tipo as any).gestao_estoque_critico !== false ? 'default' : 'outline'}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity select-none"
|
||||
onDoubleClick={() => handleToggleGestaoEstoque(tipo)}
|
||||
title="Duplo clique para alterar"
|
||||
>
|
||||
{(tipo as any).gestao_estoque_critico !== false ? 'SIM' : 'NÃO'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{tipo.descricao || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(tipo)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tipo.id)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center">Nenhum tipo encontrado</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
196
src/components/estoque/UnidadesMedidaModal.tsx
Normal file
196
src/components/estoque/UnidadesMedidaModal.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Edit, Trash } from 'lucide-react';
|
||||
import {
|
||||
useUnidadesMedida,
|
||||
useCriarUnidadeMedida,
|
||||
useAtualizarUnidadeMedida,
|
||||
useExcluirUnidadeMedida
|
||||
} from '@/hooks/useEstoqueCRUD';
|
||||
|
||||
interface UnidadesMedidaModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UnidadesMedidaModal: React.FC<UnidadesMedidaModalProps> = ({ isOpen, onClose }) => {
|
||||
const [nome, setNome] = useState('');
|
||||
const [abreviacao, setAbreviacao] = useState('');
|
||||
const [descricao, setDescricao] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const { data: unidades, isLoading } = useUnidadesMedida();
|
||||
const criarUnidade = useCriarUnidadeMedida();
|
||||
const atualizarUnidade = useAtualizarUnidadeMedida();
|
||||
const excluirUnidade = useExcluirUnidadeMedida();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!nome.trim() || !abreviacao.trim()) return;
|
||||
|
||||
try {
|
||||
const unidadeData = {
|
||||
nome: nome.trim(),
|
||||
abreviacao: abreviacao.trim().toUpperCase(),
|
||||
descricao: descricao.trim(),
|
||||
ativo: true
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
await atualizarUnidade.mutateAsync({
|
||||
id: editingId,
|
||||
...unidadeData
|
||||
});
|
||||
} else {
|
||||
await criarUnidade.mutateAsync(unidadeData);
|
||||
}
|
||||
|
||||
setNome('');
|
||||
setAbreviacao('');
|
||||
setDescricao('');
|
||||
setEditingId(null);
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar unidade de medida:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (unidade: any) => {
|
||||
setNome(unidade.nome);
|
||||
setAbreviacao(unidade.abreviacao);
|
||||
setDescricao(unidade.descricao || '');
|
||||
setEditingId(unidade.id);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Tem certeza que deseja excluir esta unidade de medida?')) {
|
||||
await excluirUnidade.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setNome('');
|
||||
setAbreviacao('');
|
||||
setDescricao('');
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gerenciar Unidades de Medida</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-4 border rounded-lg">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="nome">Nome da Unidade *</Label>
|
||||
<Input
|
||||
id="nome"
|
||||
value={nome}
|
||||
onChange={(e) => setNome(e.target.value)}
|
||||
placeholder="Ex: Quilograma"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="abreviacao">Abreviação *</Label>
|
||||
<Input
|
||||
id="abreviacao"
|
||||
value={abreviacao}
|
||||
onChange={(e) => setAbreviacao(e.target.value)}
|
||||
placeholder="Ex: KG"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="descricao">Descrição</Label>
|
||||
<Textarea
|
||||
id="descricao"
|
||||
value={descricao}
|
||||
onChange={(e) => setDescricao(e.target.value)}
|
||||
placeholder="Descrição da unidade de medida"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={criarUnidade.isPending || atualizarUnidade.isPending}>
|
||||
{editingId ? 'Atualizar' : 'Adicionar'}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Abreviação</TableHead>
|
||||
<TableHead>Descrição</TableHead>
|
||||
<TableHead className="w-24">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">Carregando...</TableCell>
|
||||
</TableRow>
|
||||
) : unidades?.length ? (
|
||||
unidades.map((unidade) => (
|
||||
<TableRow key={unidade.id}>
|
||||
<TableCell className="font-medium">{unidade.nome}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono bg-muted px-2 py-1 rounded text-sm">
|
||||
{unidade.abreviacao}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{unidade.descricao || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(unidade)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(unidade.id)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">Nenhuma unidade encontrada</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { EstoqueMaterial } from '@/hooks/useEstoque';
|
||||
|
||||
interface MaterialAdditionalInfoProps {
|
||||
formData: Partial<EstoqueMaterial>;
|
||||
onInputChange: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
export const MaterialAdditionalInfo: React.FC<MaterialAdditionalInfoProps> = ({
|
||||
formData,
|
||||
onInputChange
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Outras Informações</h3>
|
||||
<div>
|
||||
<Label htmlFor="observacoes">Observações</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
value={formData.observacoes || ''}
|
||||
onChange={(e) => onInputChange('observacoes', e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
187
src/components/estoque/material-form/MaterialBasicInfo.tsx
Normal file
187
src/components/estoque/material-form/MaterialBasicInfo.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { EstoqueMaterial } from '@/hooks/useEstoque';
|
||||
import { useUnidadesMedida, useLocalizacoesEstoque } from '@/hooks/useEstoqueCRUD';
|
||||
|
||||
interface MaterialBasicInfoProps {
|
||||
formData: Partial<EstoqueMaterial>;
|
||||
onInputChange: (field: string, value: any) => void;
|
||||
tiposMaterial?: any[];
|
||||
lotes?: any[];
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export const MaterialBasicInfo: React.FC<MaterialBasicInfoProps> = ({
|
||||
formData,
|
||||
onInputChange,
|
||||
tiposMaterial,
|
||||
lotes,
|
||||
isEditing = false
|
||||
}) => {
|
||||
// Hooks para buscar dados das tabelas relacionadas
|
||||
const { data: unidadesMedida, isLoading: loadingUnidades } = useUnidadesMedida();
|
||||
const { data: localizacoes, isLoading: loadingLocalizacoes } = useLocalizacoesEstoque();
|
||||
|
||||
// Filtrar lotes disponíveis baseado na descrição e qualidade do aço
|
||||
const lotesDisponiveis = React.useMemo(() => {
|
||||
if (!lotes || !formData.descricao) return [];
|
||||
|
||||
return lotes.filter(lote => {
|
||||
if (lote.status !== 'Ativo') return false;
|
||||
|
||||
// Se o material tem descrição e qualidade do aço, filtrar por ambos
|
||||
if (formData.descricao && formData.qualidade_aco) {
|
||||
return lote.estoque_materiais?.descricao === formData.descricao &&
|
||||
lote.estoque_materiais?.qualidade_aco === formData.qualidade_aco;
|
||||
}
|
||||
|
||||
// Se tem apenas descrição, filtrar só por descrição
|
||||
if (formData.descricao) {
|
||||
return lote.estoque_materiais?.descricao === formData.descricao;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [lotes, formData.descricao, formData.qualidade_aco]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Informações Básicas</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="descricao">Descrição *</Label>
|
||||
<Input
|
||||
id="descricao"
|
||||
value={formData.descricao || ''}
|
||||
onChange={(e) => onInputChange('descricao', e.target.value)}
|
||||
required={!isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tipo_material_id">Tipo de Material</Label>
|
||||
<Select value={formData.tipo_material_id || ''} onValueChange={(value) => onInputChange('tipo_material_id', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tiposMaterial?.map((tipo) => (
|
||||
<SelectItem key={tipo.id} value={tipo.id}>
|
||||
{tipo.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="unidade">Unidade</Label>
|
||||
<Select value={formData.unidade || ''} onValueChange={(value) => onInputChange('unidade', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a unidade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingUnidades ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Carregando unidades...
|
||||
</SelectItem>
|
||||
) : unidadesMedida && unidadesMedida.length > 0 ? (
|
||||
unidadesMedida.map((unidade) => (
|
||||
<SelectItem key={unidade.id} value={unidade.abreviacao}>
|
||||
{unidade.abreviacao} - {unidade.nome}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-units" disabled>
|
||||
Nenhuma unidade cadastrada
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="lote_atual">Lote Atual (Opcional)</Label>
|
||||
<Select value={formData.lote_atual || ''} onValueChange={(value) => onInputChange('lote_atual', value === 'none' ? '' : value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o lote (opcional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Nenhum lote</SelectItem>
|
||||
{lotesDisponiveis.length > 0 ? (
|
||||
lotesDisponiveis.map((lote) => (
|
||||
<SelectItem key={lote.id} value={lote.lote}>
|
||||
{lote.lote} - {lote.estoque_materiais?.descricao || 'Sem descrição'}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-lots" disabled>
|
||||
Nenhum lote disponível para este material
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="localizacao">Localização</Label>
|
||||
<Select value={formData.localizacao || ''} onValueChange={(value) => onInputChange('localizacao', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a localização" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingLocalizacoes ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Carregando localizações...
|
||||
</SelectItem>
|
||||
) : localizacoes && localizacoes.length > 0 ? (
|
||||
localizacoes.map((localizacao) => (
|
||||
<SelectItem key={localizacao.id} value={localizacao.nome}>
|
||||
{localizacao.nome}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-locations" disabled>
|
||||
Nenhuma localização cadastrada
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="kg_por_metro">Kg/m ou Kg/m2</Label>
|
||||
<Input
|
||||
id="kg_por_metro"
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={formData.kg_por_metro || ''}
|
||||
onChange={(e) => onInputChange('kg_por_metro', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="Peso por metro/metro quadrado"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select value={formData.status || 'Normal'} onValueChange={(value) => onInputChange('status', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Normal">Normal</SelectItem>
|
||||
<SelectItem value="Crítico">Crítico</SelectItem>
|
||||
<SelectItem value="Excesso">Excesso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { EstoqueMaterial } from '@/hooks/useEstoque';
|
||||
|
||||
interface MaterialQuantitiesValuesProps {
|
||||
formData: Partial<EstoqueMaterial>;
|
||||
onInputChange: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
export const MaterialQuantitiesValues = ({ formData, onInputChange }: MaterialQuantitiesValuesProps) => {
|
||||
// Calcular quantidade disponível automaticamente
|
||||
const quantidadeTotal = formData.quantidade_total || 0;
|
||||
const quantidadeEmpenhada = formData.quantidade_empenhada || 0;
|
||||
const quantidadeDisponivel = quantidadeTotal - quantidadeEmpenhada;
|
||||
|
||||
// Atualizar quantidade disponível sempre que total ou empenhada mudarem
|
||||
React.useEffect(() => {
|
||||
onInputChange('quantidade_disponivel', quantidadeDisponivel);
|
||||
}, [quantidadeTotal, quantidadeEmpenhada, quantidadeDisponivel, onInputChange]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Quantidades e Valores</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="quantidade_total">Quantidade Total *</Label>
|
||||
<Input
|
||||
id="quantidade_total"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={formData.quantidade_total || ''}
|
||||
onChange={(e) => onInputChange('quantidade_total', e.target.value ? Math.floor(Number(e.target.value)) : 0)}
|
||||
placeholder="Quantidade total"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quantidade_empenhada">Quantidade Empenhada</Label>
|
||||
<Input
|
||||
id="quantidade_empenhada"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={formData.quantidade_empenhada || ''}
|
||||
onChange={(e) => onInputChange('quantidade_empenhada', e.target.value ? Math.floor(Number(e.target.value)) : 0)}
|
||||
placeholder="Quantidade empenhada"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quantidade_disponivel">Quantidade Disponível</Label>
|
||||
<Input
|
||||
id="quantidade_disponivel"
|
||||
type="number"
|
||||
value={quantidadeDisponivel}
|
||||
readOnly
|
||||
className="bg-gray-100 cursor-not-allowed"
|
||||
placeholder="Calculado automaticamente"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quantidade_minima">Quantidade Mínima</Label>
|
||||
<Input
|
||||
id="quantidade_minima"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={formData.quantidade_minima || ''}
|
||||
onChange={(e) => onInputChange('quantidade_minima', e.target.value ? Math.floor(Number(e.target.value)) : null)}
|
||||
placeholder="Estoque mínimo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quantidade_maxima">Quantidade Máxima</Label>
|
||||
<Input
|
||||
id="quantidade_maxima"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={formData.quantidade_maxima || ''}
|
||||
onChange={(e) => onInputChange('quantidade_maxima', e.target.value ? Math.floor(Number(e.target.value)) : null)}
|
||||
placeholder="Estoque máximo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="valor_unitario">Preço Unitário (R$)</Label>
|
||||
<Input
|
||||
id="valor_unitario"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.valor_unitario || ''}
|
||||
onChange={(e) => onInputChange('valor_unitario', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { EstoqueMaterial } from '@/hooks/useEstoque';
|
||||
|
||||
interface MaterialTechnicalSpecsProps {
|
||||
formData: Partial<EstoqueMaterial>;
|
||||
onInputChange: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
export const MaterialTechnicalSpecs = ({ formData, onInputChange }: MaterialTechnicalSpecsProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Especificações Técnicas</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="comprimento">Comprimento (mm)</Label>
|
||||
<Input
|
||||
id="comprimento"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={formData.comprimento || ''}
|
||||
onChange={(e) => onInputChange('comprimento', e.target.value ? Math.floor(Number(e.target.value)) : null)}
|
||||
placeholder="Comprimento em mm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="largura">Largura (mm)</Label>
|
||||
<Input
|
||||
id="largura"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={formData.largura || ''}
|
||||
onChange={(e) => onInputChange('largura', e.target.value ? Math.floor(Number(e.target.value)) : null)}
|
||||
placeholder="Largura em mm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="espessura">Espessura (mm)</Label>
|
||||
<Input
|
||||
id="espessura"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={formData.espessura || ''}
|
||||
onChange={(e) => onInputChange('espessura', e.target.value ? Math.floor(Number(e.target.value)) : null)}
|
||||
placeholder="Espessura em mm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="qualidade_aco">Qualidade do Aço</Label>
|
||||
<Input
|
||||
id="qualidade_aco"
|
||||
value={formData.qualidade_aco || ''}
|
||||
onChange={(e) => onInputChange('qualidade_aco', e.target.value)}
|
||||
placeholder="Ex: SAE 1020, ASTM A36, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="peso_unitario">Peso Unitário (kg)</Label>
|
||||
<Input
|
||||
id="peso_unitario"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
value={formData.peso_unitario || ''}
|
||||
onChange={(e) => onInputChange('peso_unitario', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="Peso unitário em kg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user