🚀 Initial commit: Versão atual do TrackSteel APP

This commit is contained in:
2026-03-18 21:17:53 +00:00
commit bde410c9ad
633 changed files with 108150 additions and 0 deletions

42
src/App.css Normal file
View 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;
}

View 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>
);
}

View 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
View 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>
);
};

View 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}</>;
};

View 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}</>;
};

View 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}</>;
};

View 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>
);
}

View 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>
</>
);
};

View 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>
);
};

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

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

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

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 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}
/>
</>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
};

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

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

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

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

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

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

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

View 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>
);
};

View 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>
);
};

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

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

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

View 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>
);
};

View 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>
);
};

View 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>
);
}

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

View 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>
);
}

View 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>
);
}

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

View 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 :</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>
);
}

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
};

View 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)}
/>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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)}
/>
</>
);
};

View 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)}
/>
</>
);
};

View 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 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
};

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

View 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>
);
};

View 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}
/>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

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

View 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>
);
};

View File

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

View File

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