feat: adicionar gestao de revisoes simples e criticas de pecas com integracao a apontamentos
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Table, TableBody, TableHeader } from '@/components/ui/table';
|
import { Table, TableBody, TableHeader } from '@/components/ui/table';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Package } from 'lucide-react';
|
import { Package, FileText, AlertTriangle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Peca } from '@/hooks/usePecas';
|
import { Peca } from '@/hooks/usePecas';
|
||||||
import { usePecasTable } from '@/hooks/usePecasTable';
|
import { usePecasTable } from '@/hooks/usePecasTable';
|
||||||
import { TableFilters } from './table/TableFilters';
|
import { TableFilters } from './table/TableFilters';
|
||||||
@@ -22,6 +23,9 @@ interface PecasTableProps {
|
|||||||
onEditPeca?: (peca: Peca) => void;
|
onEditPeca?: (peca: Peca) => void;
|
||||||
onDeleteLastImport?: () => void;
|
onDeleteLastImport?: () => void;
|
||||||
onBatchUpdatePecas?: (pecaIds: string[], updates: any) => Promise<void>;
|
onBatchUpdatePecas?: (pecaIds: string[], updates: any) => Promise<void>;
|
||||||
|
onOpenRevisaoPeca?: (peca: Peca) => void;
|
||||||
|
onOpenPainelRevisoes?: () => void;
|
||||||
|
totalRevisoesOF?: number;
|
||||||
hasRecentImport: boolean;
|
hasRecentImport: boolean;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
@@ -36,6 +40,9 @@ export function PecasTable({
|
|||||||
onEditPeca,
|
onEditPeca,
|
||||||
onDeleteLastImport,
|
onDeleteLastImport,
|
||||||
onBatchUpdatePecas,
|
onBatchUpdatePecas,
|
||||||
|
onOpenRevisaoPeca,
|
||||||
|
onOpenPainelRevisoes,
|
||||||
|
totalRevisoesOF,
|
||||||
hasRecentImport,
|
hasRecentImport,
|
||||||
canEdit = true,
|
canEdit = true,
|
||||||
canDelete = true,
|
canDelete = true,
|
||||||
@@ -169,6 +176,25 @@ export function PecasTable({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{onOpenPainelRevisoes && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenPainelRevisoes}
|
||||||
|
className="h-8 border-red-500/60 bg-red-950/20 text-red-400 hover:bg-red-900/40 hover:text-red-200 font-bold flex items-center gap-1.5 transition-all shadow-sm"
|
||||||
|
title="Abrir Registro e Gestão de Revisões da OF"
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 text-red-400" />
|
||||||
|
<span>REG.REVISOES</span>
|
||||||
|
{totalRevisoesOF !== undefined && totalRevisoesOF > 0 && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-red-500 text-white font-black">
|
||||||
|
{totalRevisoesOF}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{canEdit && onBatchUpdatePecas && (
|
{canEdit && onBatchUpdatePecas && (
|
||||||
<BatchEditButton
|
<BatchEditButton
|
||||||
selectedPecas={selectedPecas}
|
selectedPecas={selectedPecas}
|
||||||
@@ -209,6 +235,7 @@ export function PecasTable({
|
|||||||
onEdit={() => handleEditPeca(peca)}
|
onEdit={() => handleEditPeca(peca)}
|
||||||
onDelete={() => handleDeletePeca(peca.id)}
|
onDelete={() => handleDeletePeca(peca.id)}
|
||||||
onOpenComponentPopup={() => onOpenComponentPopup(peca.id)}
|
onOpenComponentPopup={() => onOpenComponentPopup(peca.id)}
|
||||||
|
onOpenRevisao={() => onOpenRevisaoPeca?.(peca)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
Clock,
|
||||||
|
Printer,
|
||||||
|
ExternalLink,
|
||||||
|
Filter,
|
||||||
|
CheckCircle2,
|
||||||
|
RefreshCw,
|
||||||
|
Layers,
|
||||||
|
ArrowRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useRevisoes, Revisao } from '@/hooks/useRevisoes';
|
||||||
|
import { Peca } from '@/hooks/usePecas';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ptBR } from 'date-fns/locale';
|
||||||
|
|
||||||
|
interface PainelRevisoesOFModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
ofNumber: string;
|
||||||
|
pecas: Peca[];
|
||||||
|
onOpenRevisaoPeca: (peca: Peca) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PainelRevisoesOFModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
ofNumber,
|
||||||
|
pecas,
|
||||||
|
onOpenRevisaoPeca
|
||||||
|
}: PainelRevisoesOFModalProps) {
|
||||||
|
const { revisoes, loading, carregarRevisoesOF } = useRevisoes(ofNumber);
|
||||||
|
const [busca, setBusca] = useState('');
|
||||||
|
const [filtroTipo, setFiltroTipo] = useState<'todos' | 'critica' | 'simples'>('todos');
|
||||||
|
const [visualizandoRelatorio, setVisualizandoRelatorio] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && ofNumber) {
|
||||||
|
carregarRevisoesOF(ofNumber);
|
||||||
|
setVisualizandoRelatorio(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, ofNumber, carregarRevisoesOF]);
|
||||||
|
|
||||||
|
// Estatísticas
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = revisoes.length;
|
||||||
|
const criticas = revisoes.filter((r) => r.tipo_revisao === 'critica').length;
|
||||||
|
const simples = revisoes.filter((r) => r.tipo_revisao === 'simples').length;
|
||||||
|
const pecasUnicas = new Set(revisoes.map((r) => r.peca_id)).size;
|
||||||
|
return { total, criticas, simples, pecasUnicas };
|
||||||
|
}, [revisoes]);
|
||||||
|
|
||||||
|
// Filtro
|
||||||
|
const revisoesFiltradas = useMemo(() => {
|
||||||
|
return revisoes.filter((rev) => {
|
||||||
|
// Tipo
|
||||||
|
if (filtroTipo !== 'todos' && rev.tipo_revisao !== filtroTipo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Busca texto
|
||||||
|
if (busca.trim()) {
|
||||||
|
const termo = busca.toLowerCase();
|
||||||
|
const marca = (rev.peca?.marca || '').toLowerCase();
|
||||||
|
const fase = (rev.peca?.etapa_fase || '').toLowerCase();
|
||||||
|
const motivo = (rev.descricao_motivo || '').toLowerCase();
|
||||||
|
const numero = (rev.numero_revisao || '').toLowerCase();
|
||||||
|
const autor = (rev.usuario_nome || '').toLowerCase();
|
||||||
|
return (
|
||||||
|
marca.includes(termo) ||
|
||||||
|
fase.includes(termo) ||
|
||||||
|
motivo.includes(termo) ||
|
||||||
|
numero.includes(termo) ||
|
||||||
|
autor.includes(termo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [revisoes, filtroTipo, busca]);
|
||||||
|
|
||||||
|
const handleImprimirRelatorio = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto bg-slate-950 border-slate-800 text-slate-100 p-6">
|
||||||
|
<DialogHeader className="border-b border-slate-800 pb-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-red-500/10 border border-red-500/30 flex items-center justify-center text-red-500">
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<span>Registro Geral de Revisões da OF</span>
|
||||||
|
<Badge variant="outline" className="font-mono text-amber-400 border-amber-400/40 bg-amber-500/10 text-sm">
|
||||||
|
{ofNumber || 'Geral'}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400 text-xs mt-0.5">
|
||||||
|
Rastreabilidade técnica, histórico de modificações simples e críticas nas peças desta Ordem de Fabricação.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setVisualizandoRelatorio(!visualizandoRelatorio)}
|
||||||
|
className="border-slate-700 text-slate-200 hover:bg-slate-800 flex items-center gap-1.5 h-8 text-xs"
|
||||||
|
>
|
||||||
|
<Printer className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
{visualizandoRelatorio ? 'Voltar para Tabela' : 'Gerar Relatório'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* MODO RELATÓRIO IMPRESSÃO */}
|
||||||
|
{visualizandoRelatorio ? (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between bg-slate-900 p-3 rounded border border-slate-800">
|
||||||
|
<span className="text-xs text-slate-300">
|
||||||
|
Pré-visualização do Relatório Técnico para impressão ou exportação.
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleImprimirRelatorio}
|
||||||
|
className="bg-blue-600 hover:bg-blue-500 text-white text-xs h-7 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Printer className="w-3.5 h-3.5" />
|
||||||
|
Imprimir / Salvar PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documento A4 Formato Relatório */}
|
||||||
|
<div className="p-6 bg-white text-slate-900 rounded-lg shadow-lg text-xs space-y-4 font-sans">
|
||||||
|
{/* Header do Relatório */}
|
||||||
|
<div className="border-b-2 border-slate-800 pb-3 flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-black tracking-wider text-slate-900">TRACKSTEEL INDUSTRIAL</h2>
|
||||||
|
<p className="text-[11px] text-slate-600">Sistema Integrado de Gestão & Rastreabilidade de Estruturas</p>
|
||||||
|
<p className="text-[11px] font-bold text-slate-800 mt-1">RELATÓRIO TÉCNICO DE GESTÃO DE REVISÕES</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-[11px] text-slate-700">
|
||||||
|
<p><strong>OF:</strong> {ofNumber}</p>
|
||||||
|
<p><strong>Emissão:</strong> {format(new Date(), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}</p>
|
||||||
|
<p><strong>Total de Revisões:</strong> {stats.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabela do Relatório */}
|
||||||
|
<table className="w-full border-collapse border border-slate-300 text-[10px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-100 text-slate-800">
|
||||||
|
<th className="border border-slate-300 p-1.5 text-left">Data/Hora</th>
|
||||||
|
<th className="border border-slate-300 p-1.5 text-left">Peça</th>
|
||||||
|
<th className="border border-slate-300 p-1.5 text-center">Fase</th>
|
||||||
|
<th className="border border-slate-300 p-1.5 text-center">Revisão</th>
|
||||||
|
<th className="border border-slate-300 p-1.5 text-center">Tipo</th>
|
||||||
|
<th className="border border-slate-300 p-1.5 text-left">Motivo / Descrição</th>
|
||||||
|
<th className="border border-slate-300 p-1.5 text-left">Campos Alterados</th>
|
||||||
|
<th className="border border-slate-300 p-1.5 text-left">Responsável</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{revisoes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="p-4 text-center text-slate-500">
|
||||||
|
Nenhum registro de revisão para esta OF.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
revisoes.map((r) => (
|
||||||
|
<tr key={r.id} className="hover:bg-slate-50 border-b border-slate-200">
|
||||||
|
<td className="border border-slate-300 p-1.5 whitespace-nowrap">
|
||||||
|
{r.data_revisao ? format(new Date(r.data_revisao), 'dd/MM/yy HH:mm') : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="border border-slate-300 p-1.5 font-bold">
|
||||||
|
{r.peca?.marca || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="border border-slate-300 p-1.5 text-center">
|
||||||
|
{r.peca?.etapa_fase || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="border border-slate-300 p-1.5 text-center font-bold">
|
||||||
|
{r.numero_revisao}
|
||||||
|
</td>
|
||||||
|
<td className="border border-slate-300 p-1.5 text-center font-semibold">
|
||||||
|
<span className={r.tipo_revisao === 'critica' ? 'text-red-600' : 'text-blue-600'}>
|
||||||
|
{r.tipo_revisao === 'critica' ? 'CRÍTICA' : 'SIMPLES'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="border border-slate-300 p-1.5">
|
||||||
|
{r.descricao_motivo}
|
||||||
|
{r.observacoes && <span className="block text-slate-500 italic mt-0.5">Obs: {r.observacoes}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="border border-slate-300 p-1.5 font-mono text-[9px]">
|
||||||
|
{r.campos_alterados?.join(', ') || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="border border-slate-300 p-1.5">
|
||||||
|
{r.usuario_nome || r.usuario_email || 'Sistema'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Rodapé e Assinaturas */}
|
||||||
|
<div className="pt-8 border-t border-slate-300 grid grid-cols-2 gap-8 text-center text-[10px] text-slate-600">
|
||||||
|
<div>
|
||||||
|
<div className="border-t border-slate-400 w-48 mx-auto mb-1"></div>
|
||||||
|
<p className="font-bold">Engenharia / PCP</p>
|
||||||
|
<p>Responsável pelo Lançamento</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="border-t border-slate-400 w-48 mx-auto mb-1"></div>
|
||||||
|
<p className="font-bold">Supervisão de Produção</p>
|
||||||
|
<p>Aceite de Modificação</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* MODO PAINEL / TABELA */
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{/* CARDS DE MÉTRICAS */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400 block">
|
||||||
|
Total de Revisões
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-black text-slate-100 mt-1 block">
|
||||||
|
{stats.total}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-500">histórico completo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-red-950/20 border border-red-500/30">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-red-400 block">
|
||||||
|
Revisões Críticas
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-black text-red-300 mt-1 block">
|
||||||
|
{stats.criticas}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-red-400/80">alteraram marca, qtd, peso</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-blue-950/20 border border-blue-500/30">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-400 block">
|
||||||
|
Revisões Simples
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-black text-blue-300 mt-1 block">
|
||||||
|
{stats.simples}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-blue-400/80">dados informativos</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-amber-950/20 border border-amber-500/30">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-amber-400 block">
|
||||||
|
Peças Afetadas
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-black text-amber-300 mt-1 block">
|
||||||
|
{stats.pecasUnicas}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-amber-400/80">peças distintas</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FILTROS E BUSCA */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-[240px]">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
value={busca}
|
||||||
|
onChange={(e) => setBusca(e.target.value)}
|
||||||
|
placeholder="Filtrar por peça, fase, motivo ou autor..."
|
||||||
|
className="pl-8 bg-slate-900 border-slate-800 text-xs h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiltroTipo('todos')}
|
||||||
|
className={`px-2.5 py-1 rounded text-xs transition-colors ${
|
||||||
|
filtroTipo === 'todos' ? 'bg-slate-700 text-white font-bold' : 'text-slate-400 hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todas ({stats.total})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiltroTipo('critica')}
|
||||||
|
className={`px-2.5 py-1 rounded text-xs transition-colors ${
|
||||||
|
filtroTipo === 'critica'
|
||||||
|
? 'bg-red-600 text-white font-bold'
|
||||||
|
: 'text-red-400 hover:bg-red-950/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Críticas ({stats.criticas})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiltroTipo('simples')}
|
||||||
|
className={`px-2.5 py-1 rounded text-xs transition-colors ${
|
||||||
|
filtroTipo === 'simples'
|
||||||
|
? 'bg-blue-600 text-white font-bold'
|
||||||
|
: 'text-blue-400 hover:bg-blue-950/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Simples ({stats.simples})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => carregarRevisoesOF(ofNumber)}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-8 w-8 p-0 text-slate-400 hover:text-white"
|
||||||
|
title="Atualizar lista"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TABELA DE REVISÕES */}
|
||||||
|
<div className="rounded-md border border-slate-800 bg-slate-900/60 overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-slate-900">
|
||||||
|
<TableRow className="border-slate-800 hover:bg-slate-900">
|
||||||
|
<TableHead className="text-slate-300 text-xs w-28">Data</TableHead>
|
||||||
|
<TableHead className="text-slate-300 text-xs w-20">Peça</TableHead>
|
||||||
|
<TableHead className="text-slate-300 text-xs w-16 text-center">Fase</TableHead>
|
||||||
|
<TableHead className="text-slate-300 text-xs w-20 text-center">Revisão</TableHead>
|
||||||
|
<TableHead className="text-slate-300 text-xs w-20 text-center">Tipo</TableHead>
|
||||||
|
<TableHead className="text-slate-300 text-xs">Motivo da Revisão</TableHead>
|
||||||
|
<TableHead className="text-slate-300 text-xs">Campos Alterados</TableHead>
|
||||||
|
<TableHead className="text-slate-300 text-xs w-28">Autor</TableHead>
|
||||||
|
<TableHead className="text-slate-300 text-xs w-16 text-right">Ação</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-center py-8 text-slate-400 text-xs">
|
||||||
|
Carregando revisões da OF...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : revisoesFiltradas.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-center py-8 text-slate-400 text-xs">
|
||||||
|
{revisoes.length === 0
|
||||||
|
? 'Nenhuma revisão cadastrada nesta OF.'
|
||||||
|
: 'Nenhuma revisão encontrada para os filtros selecionados.'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
revisoesFiltradas.map((r) => {
|
||||||
|
const pecaCorrespondente = pecas.find((p) => p.id === r.peca_id);
|
||||||
|
return (
|
||||||
|
<TableRow key={r.id} className="border-slate-800 hover:bg-slate-800/50 text-xs">
|
||||||
|
<TableCell className="font-mono text-slate-400 whitespace-nowrap">
|
||||||
|
{r.data_revisao ? format(new Date(r.data_revisao), 'dd/MM/yy HH:mm') : '-'}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="font-mono font-bold text-slate-100">
|
||||||
|
{r.peca?.marca || '-'}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-center text-slate-300 font-mono">
|
||||||
|
{r.peca?.etapa_fase || '-'}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono text-[11px] font-bold bg-amber-500/10 text-amber-400 border-amber-400/40"
|
||||||
|
>
|
||||||
|
{r.numero_revisao}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge
|
||||||
|
className={`text-[10px] font-bold ${
|
||||||
|
r.tipo_revisao === 'critica'
|
||||||
|
? 'bg-red-500/20 text-red-400 border border-red-500/40'
|
||||||
|
: 'bg-blue-500/20 text-blue-400 border border-blue-500/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.tipo_revisao === 'critica' ? 'Crítica' : 'Simples'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-slate-200">
|
||||||
|
<span className="font-medium block">{r.descricao_motivo}</span>
|
||||||
|
{r.observacoes && (
|
||||||
|
<span className="text-[10px] text-slate-400 italic block mt-0.5">
|
||||||
|
{r.observacoes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{r.campos_alterados?.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c}
|
||||||
|
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700"
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-slate-300 truncate max-w-[120px]">
|
||||||
|
{r.usuario_nome || r.usuario_email || 'Sistema'}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{pecaCorrespondente ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
onOpenRevisaoPeca(pecaCorrespondente);
|
||||||
|
}}
|
||||||
|
title="Abrir CRUD / Histórico da Peça"
|
||||||
|
className="h-6 w-6 p-0 text-amber-400 hover:text-amber-300 hover:bg-amber-950/40"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600 text-[10px]">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
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 { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
History,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowRight,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Layers,
|
||||||
|
Save,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
Info,
|
||||||
|
ShieldAlert,
|
||||||
|
Edit3
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Peca } from '@/hooks/usePecas';
|
||||||
|
import { useRevisoes, Revisao, TipoRevisao } from '@/hooks/useRevisoes';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ptBR } from 'date-fns/locale';
|
||||||
|
|
||||||
|
interface RevisaoPecaModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
peca: Peca | null;
|
||||||
|
onPecaAtualizada?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevisaoPecaModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
peca,
|
||||||
|
onPecaAtualizada
|
||||||
|
}: RevisaoPecaModalProps) {
|
||||||
|
const { carregarRevisoesPeca, detectarTipoRevisao, criarRevisao, loading } = useRevisoes();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'nova' | 'historico'>('nova');
|
||||||
|
const [historico, setHistorico] = useState<Revisao[]>([]);
|
||||||
|
const [loadingHistorico, setLoadingHistorico] = useState(false);
|
||||||
|
|
||||||
|
// Campos editáveis do formulário
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
marca: '',
|
||||||
|
quantidade: 0,
|
||||||
|
peso_unitario: 0,
|
||||||
|
peso_total: 0,
|
||||||
|
descricao: '',
|
||||||
|
etapa_fase: '',
|
||||||
|
material: '',
|
||||||
|
perfil_principal: '',
|
||||||
|
tratamento_superficial: '',
|
||||||
|
comprimento: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Campos de controle da revisão
|
||||||
|
const [numeroRevisao, setNumeroRevisao] = useState('Rev 01');
|
||||||
|
const [motivo, setMotivo] = useState('');
|
||||||
|
const [observacoes, setObservacoes] = useState('');
|
||||||
|
const [tipoManual, setTipoManual] = useState<TipoRevisao | null>(null);
|
||||||
|
|
||||||
|
// Inicializa os dados quando o modal abre
|
||||||
|
useEffect(() => {
|
||||||
|
if (peca && isOpen) {
|
||||||
|
setFormData({
|
||||||
|
marca: peca.marca || '',
|
||||||
|
quantidade: Number(peca.quantidade) || 0,
|
||||||
|
peso_unitario: Number(peca.peso_unitario) || 0,
|
||||||
|
peso_total: Number(peca.peso_total) || 0,
|
||||||
|
descricao: peca.descricao || '',
|
||||||
|
etapa_fase: peca.etapa_fase || '',
|
||||||
|
material: peca.material || '',
|
||||||
|
perfil_principal: peca.perfil_principal || '',
|
||||||
|
tratamento_superficial: peca.tratamento_superficial || '',
|
||||||
|
comprimento: Number(peca.comprimento) || 0
|
||||||
|
});
|
||||||
|
setMotivo('');
|
||||||
|
setObservacoes('');
|
||||||
|
setTipoManual(null);
|
||||||
|
|
||||||
|
// Carregar histórico
|
||||||
|
setLoadingHistorico(true);
|
||||||
|
carregarRevisoesPeca(peca.id).then((revs) => {
|
||||||
|
setHistorico(revs);
|
||||||
|
setLoadingHistorico(false);
|
||||||
|
// Sugere o próximo número de revisão
|
||||||
|
const proximoNum = revs.length + 1;
|
||||||
|
setNumeroRevisao(`Rev ${String(proximoNum).padStart(2, '0')}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [peca, isOpen, carregarRevisoesPeca]);
|
||||||
|
|
||||||
|
if (!peca) return null;
|
||||||
|
|
||||||
|
// Analisa alterações em tempo real
|
||||||
|
const analise = detectarTipoRevisao(peca, formData);
|
||||||
|
const tipoEfetivo = tipoManual || analise.tipo;
|
||||||
|
|
||||||
|
const diffQtd = Number(formData.quantidade) - Number(peca.quantidade || 0);
|
||||||
|
const diffPeso = Number(formData.peso_unitario) - Number(peca.peso_unitario || 0);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: any) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const next = { ...prev, [field]: value };
|
||||||
|
if (field === 'quantidade' || field === 'peso_unitario') {
|
||||||
|
const q = field === 'quantidade' ? Number(value) : prev.quantidade;
|
||||||
|
const p = field === 'peso_unitario' ? Number(value) : prev.peso_unitario;
|
||||||
|
next.peso_total = Number((q * p).toFixed(3));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSalvarRevisao = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!motivo.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sucesso = await criarRevisao({
|
||||||
|
peca,
|
||||||
|
numeroRevisao,
|
||||||
|
tipoRevisao: tipoEfetivo,
|
||||||
|
descricaoMotivo: motivo,
|
||||||
|
observacoes,
|
||||||
|
novosDados: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sucesso) {
|
||||||
|
if (onPecaAtualizada) {
|
||||||
|
onPecaAtualizada();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-slate-950 border-slate-800 text-slate-100 p-6">
|
||||||
|
<DialogHeader className="border-b border-slate-800 pb-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-500">
|
||||||
|
<Edit3 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<span>Gestão de Revisões da Peça</span>
|
||||||
|
<Badge variant="outline" className="font-mono text-amber-400 border-amber-400/40 bg-amber-500/10 text-sm">
|
||||||
|
{peca.marca}
|
||||||
|
</Badge>
|
||||||
|
{historico.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="bg-slate-800 text-slate-300 text-xs">
|
||||||
|
{historico.length} {historico.length === 1 ? 'revisão gravada' : 'revisões gravadas'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400 text-xs mt-0.5">
|
||||||
|
OF: <strong className="text-slate-200">{peca.of_number}</strong> • Fase: <strong className="text-slate-200">{peca.etapa_fase || 'N/A'}</strong> • Qtd Atual: <strong className="text-slate-200">{peca.quantidade} un</strong> • Peso Atual: <strong className="text-slate-200">{peca.peso_total || (peca.quantidade * peca.peso_unitario)} kg</strong>
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={tipoEfetivo === 'critica' ? 'destructive' : 'secondary'}
|
||||||
|
className={`text-xs px-2.5 py-1 font-semibold ${
|
||||||
|
tipoEfetivo === 'critica'
|
||||||
|
? 'bg-red-500/20 text-red-400 border border-red-500/40'
|
||||||
|
: 'bg-blue-500/20 text-blue-400 border border-blue-500/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tipoEfetivo === 'critica' ? '⚠️ Revisão Crítica' : 'ℹ️ Revisão Simples'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="mt-4">
|
||||||
|
<TabsList className="grid grid-cols-2 bg-slate-900 border border-slate-800">
|
||||||
|
<TabsTrigger value="nova" className="data-[state=active]:bg-amber-600 data-[state=active]:text-white">
|
||||||
|
<Edit3 className="w-4 h-4 mr-2" />
|
||||||
|
Nova Revisão (Editar Peça)
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="historico" className="data-[state=active]:bg-slate-800 data-[state=active]:text-white">
|
||||||
|
<History className="w-4 h-4 mr-2" />
|
||||||
|
Histórico de Revisões ({historico.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ABA 1: NOVA REVISÃO */}
|
||||||
|
<TabsContent value="nova" className="mt-4 space-y-4">
|
||||||
|
<form onSubmit={handleSalvarRevisao} className="space-y-4">
|
||||||
|
{/* Metadados da Revisão */}
|
||||||
|
<div className="p-4 rounded-lg bg-slate-900/80 border border-slate-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center gap-2">
|
||||||
|
<Info className="w-3.5 h-3.5 text-amber-500" />
|
||||||
|
Identificação da Revisão
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-slate-400">Classificação:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTipoManual('simples')}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
|
tipoEfetivo === 'simples'
|
||||||
|
? 'bg-blue-600 text-white font-bold'
|
||||||
|
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Simples
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTipoManual('critica')}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
||||||
|
tipoEfetivo === 'critica'
|
||||||
|
? 'bg-red-600 text-white font-bold'
|
||||||
|
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Crítica
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300">Número da Revisão *</Label>
|
||||||
|
<Input
|
||||||
|
value={numeroRevisao}
|
||||||
|
onChange={(e) => setNumeroRevisao(e.target.value)}
|
||||||
|
placeholder="Ex: Rev 01, Rev 02, Rev A"
|
||||||
|
className="bg-slate-950 border-slate-700 text-slate-100 font-mono text-sm mt-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label className="text-xs text-slate-300">Motivo / Justificativa da Revisão *</Label>
|
||||||
|
<Input
|
||||||
|
value={motivo}
|
||||||
|
onChange={(e) => setMotivo(e.target.value)}
|
||||||
|
placeholder="Ex: Ajuste de projeto, alteração de quantidade pelo cliente..."
|
||||||
|
className="bg-slate-950 border-slate-700 text-slate-100 text-sm mt-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300">Observações Adicionais (Opcional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={observacoes}
|
||||||
|
onChange={(e) => setObservacoes(e.target.value)}
|
||||||
|
placeholder="Detalhes adicionais, aprovações de engenharia, notas de fábrica..."
|
||||||
|
rows={2}
|
||||||
|
className="bg-slate-950 border-slate-700 text-slate-100 text-xs mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulário CRUD da Peça */}
|
||||||
|
<div className="p-4 rounded-lg bg-slate-900/60 border border-slate-800 space-y-4">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center gap-2">
|
||||||
|
<Edit3 className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
Campos da Peça para Revisão
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{/* Marca */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300 flex items-center justify-between">
|
||||||
|
<span>Marca / Número da Peça *</span>
|
||||||
|
{formData.marca !== peca.marca && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-bold">Alterado!</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.marca}
|
||||||
|
onChange={(e) => handleInputChange('marca', e.target.value)}
|
||||||
|
className={`font-mono text-sm mt-1 bg-slate-950 border-slate-700 ${
|
||||||
|
formData.marca !== peca.marca ? 'border-amber-500 bg-amber-950/20 text-amber-200' : ''
|
||||||
|
}`}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantidade */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300 flex items-center justify-between">
|
||||||
|
<span>Quantidade *</span>
|
||||||
|
{diffQtd !== 0 && (
|
||||||
|
<span className={`text-[10px] font-bold ${diffQtd > 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{diffQtd > 0 ? `+${diffQtd}` : `${diffQtd}`} un
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step="1"
|
||||||
|
value={formData.quantidade}
|
||||||
|
onChange={(e) => handleInputChange('quantidade', Number(e.target.value))}
|
||||||
|
className={`font-mono text-sm mt-1 bg-slate-950 border-slate-700 ${
|
||||||
|
diffQtd !== 0
|
||||||
|
? diffQtd > 0
|
||||||
|
? 'border-green-500 bg-green-950/20 text-green-200'
|
||||||
|
: 'border-red-500 bg-red-950/20 text-red-200'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Peso Unitário */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300 flex items-center justify-between">
|
||||||
|
<span>Peso Unitário (kg) *</span>
|
||||||
|
{diffPeso !== 0 && (
|
||||||
|
<span className="text-[10px] text-amber-400 font-bold">Alterado</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
value={formData.peso_unitario}
|
||||||
|
onChange={(e) => handleInputChange('peso_unitario', Number(e.target.value))}
|
||||||
|
className="font-mono text-sm mt-1 bg-slate-950 border-slate-700"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Peso Total (calculado) */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300">Peso Total (kg)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.peso_total}
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
className="font-mono text-sm mt-1 bg-slate-900 border-slate-800 text-slate-400 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{/* Descrição */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label className="text-xs text-slate-300">Descrição da Peça</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.descricao}
|
||||||
|
onChange={(e) => handleInputChange('descricao', e.target.value)}
|
||||||
|
placeholder="Ex: Viga I, Pilar Tubular, Perfil Soldado..."
|
||||||
|
className="text-sm mt-1 bg-slate-950 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fase / Etapa */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300">Fase / Etapa</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.etapa_fase}
|
||||||
|
onChange={(e) => handleInputChange('etapa_fase', e.target.value)}
|
||||||
|
placeholder="Ex: 1, 2, Cobertura..."
|
||||||
|
className="text-sm mt-1 bg-slate-950 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comprimento */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300">Comprimento (mm)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.comprimento}
|
||||||
|
onChange={(e) => handleInputChange('comprimento', Number(e.target.value))}
|
||||||
|
placeholder="Ex: 6000"
|
||||||
|
className="text-sm mt-1 bg-slate-950 border-slate-700 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300">Material / Aço</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.material}
|
||||||
|
onChange={(e) => handleInputChange('material', e.target.value)}
|
||||||
|
placeholder="Ex: ASTM A36, A572 GR50"
|
||||||
|
className="text-sm mt-1 bg-slate-950 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300">Perfil Principal</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.perfil_principal}
|
||||||
|
onChange={(e) => handleInputChange('perfil_principal', e.target.value)}
|
||||||
|
placeholder="Ex: W250x32.7, RHS150x6.3"
|
||||||
|
className="text-sm mt-1 bg-slate-950 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-slate-300">Tratamento Superficial</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.tratamento_superficial}
|
||||||
|
onChange={(e) => handleInputChange('tratamento_superficial', e.target.value)}
|
||||||
|
placeholder="Ex: Pintura Epóxi, Galvanizado"
|
||||||
|
className="text-sm mt-1 bg-slate-950 border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CARD DE IMPACTO OPERACIONAL / REGRAS DE NEGÓCIO */}
|
||||||
|
{analise.camposAlterados.length > 0 && (
|
||||||
|
<div className={`p-4 rounded-lg border ${
|
||||||
|
tipoEfetivo === 'critica'
|
||||||
|
? 'bg-red-950/20 border-red-500/40 text-red-200'
|
||||||
|
: 'bg-blue-950/20 border-blue-500/40 text-blue-200'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ShieldAlert className={`w-5 h-5 mt-0.5 shrink-0 ${
|
||||||
|
tipoEfetivo === 'critica' ? 'text-red-400' : 'text-blue-400'
|
||||||
|
}`} />
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="font-bold uppercase tracking-wider">
|
||||||
|
{tipoEfetivo === 'critica'
|
||||||
|
? 'Impacto Crítico na Linha de Produção & Apontamentos'
|
||||||
|
: 'Revisão Informativa (Campos Subjetivos)'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{diffQtd > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-emerald-400 font-medium">
|
||||||
|
<TrendingUp className="w-4 h-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>Aumento de quantidade (+{diffQtd} un):</strong> A diferença excedente de {diffQtd} un nascerá automaticamente no 1º processo de fabricação (Corte/Detalhamento) para novos apontamentos.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diffQtd < 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-rose-400 font-medium">
|
||||||
|
<TrendingDown className="w-4 h-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>Redução de quantidade ({diffQtd} un):</strong> O novo total será de {formData.quantidade} un. Os processos que possuírem apontamentos superiores a {formData.quantidade} un serão revisados automaticamente para o novo teto.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.marca !== peca.marca && (
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-300 font-medium">
|
||||||
|
<ArrowRight className="w-4 h-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>Alteração de marca/código ({peca.marca} ➔ {formData.marca}):</strong> A rastreabilidade da peça será atualizada e preservada em apontamentos e romaneios de expedição.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analise.detalhes.map((det, idx) => (
|
||||||
|
<div key={idx} className="text-slate-300">
|
||||||
|
• {det}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botões de Ação */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !motivo.trim() || analise.camposAlterados.length === 0}
|
||||||
|
className="bg-amber-600 hover:bg-amber-500 text-white font-semibold flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{loading ? 'Processando Revisão...' : 'Salvar Revisão e Atualizar Peça'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ABA 2: HISTÓRICO DE REVISÕES */}
|
||||||
|
<TabsContent value="historico" className="mt-4 space-y-3">
|
||||||
|
{loadingHistorico ? (
|
||||||
|
<div className="p-8 text-center text-slate-400">
|
||||||
|
Carregando histórico de revisões...
|
||||||
|
</div>
|
||||||
|
) : historico.length === 0 ? (
|
||||||
|
<div className="p-8 text-center border border-dashed border-slate-800 rounded-lg text-slate-400">
|
||||||
|
<History className="w-8 h-8 mx-auto mb-2 text-slate-600" />
|
||||||
|
<p className="font-medium">Nenhuma revisão registrada para esta peça até o momento.</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Ao realizar a primeira alteração pela aba "Nova Revisão", o histórico completo será guardado aqui.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{historico.map((rev) => (
|
||||||
|
<div
|
||||||
|
key={rev.id}
|
||||||
|
className="p-4 rounded-lg bg-slate-900 border border-slate-800 hover:border-slate-700 transition-colors space-y-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono font-bold bg-amber-500/10 text-amber-400 border-amber-400/40">
|
||||||
|
{rev.numero_revisao}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
className={`text-xs ${
|
||||||
|
rev.tipo_revisao === 'critica'
|
||||||
|
? 'bg-red-500/20 text-red-300 border-red-500/30'
|
||||||
|
: 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rev.tipo_revisao === 'critica' ? 'Crítica' : 'Simples'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-slate-400 flex items-center gap-1">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
{rev.data_revisao
|
||||||
|
? format(new Date(rev.data_revisao), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
|
||||||
|
: 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||||
|
<User className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
<span>{rev.usuario_nome || rev.usuario_email || 'Usuário'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Motivo */}
|
||||||
|
<div className="text-sm font-medium text-slate-200">
|
||||||
|
<strong>Motivo:</strong> {rev.descricao_motivo}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rev.observacoes && (
|
||||||
|
<div className="text-xs text-slate-400 italic">
|
||||||
|
<strong>Obs:</strong> {rev.observacoes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comparativo de Campos Alterados */}
|
||||||
|
{rev.campos_alterados && rev.campos_alterados.length > 0 && (
|
||||||
|
<div className="p-2.5 rounded bg-slate-950/80 border border-slate-850 text-xs space-y-1">
|
||||||
|
<div className="font-semibold text-slate-400 text-[11px] uppercase tracking-wider mb-1">
|
||||||
|
Modificações Realizadas:
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{rev.campos_alterados.map((campo) => {
|
||||||
|
const ant = rev.dados_anteriores?.[campo];
|
||||||
|
const nov = rev.dados_novos?.[campo];
|
||||||
|
return (
|
||||||
|
<div key={campo} className="p-1.5 rounded bg-slate-900 border border-slate-800">
|
||||||
|
<span className="text-slate-400 font-mono text-[10px] block uppercase">
|
||||||
|
{campo}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-mono mt-0.5">
|
||||||
|
<span className="text-rose-400 line-through truncate max-w-[80px]">
|
||||||
|
{String(ant ?? '-')}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="w-3 h-3 text-slate-500 shrink-0" />
|
||||||
|
<span className="text-emerald-400 font-bold truncate max-w-[80px]">
|
||||||
|
{String(nov ?? '-')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Impacto em Apontamentos */}
|
||||||
|
{rev.impacto_processos && rev.impacto_processos.resumo && rev.impacto_processos.resumo.length > 0 && (
|
||||||
|
<div className="p-2 rounded bg-amber-950/20 border border-amber-900/40 text-[11px] text-amber-300/90 space-y-0.5">
|
||||||
|
<div className="font-bold flex items-center gap-1 text-amber-400">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Ajustes de Produção Aplicados:
|
||||||
|
</div>
|
||||||
|
{rev.impacto_processos.resumo.map((res: string, i: number) => (
|
||||||
|
<div key={i}>• {res}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Loader2,
|
Loader2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Minus
|
Minus,
|
||||||
|
AlertTriangle,
|
||||||
|
FileText
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Peca } from '@/hooks/usePecas';
|
import { Peca } from '@/hooks/usePecas';
|
||||||
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
|
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
|
||||||
@@ -28,6 +30,7 @@ interface TableRowProps {
|
|||||||
onOpenComponentPopup: () => void;
|
onOpenComponentPopup: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
onOpenRevisao?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableRow({
|
export function TableRow({
|
||||||
@@ -36,7 +39,8 @@ export function TableRow({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onOpenComponentPopup,
|
onOpenComponentPopup,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete
|
onDelete,
|
||||||
|
onOpenRevisao
|
||||||
}: TableRowProps) {
|
}: TableRowProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const { labels } = useAppLabels();
|
const { labels } = useAppLabels();
|
||||||
@@ -91,6 +95,20 @@ export function TableRow({
|
|||||||
<TableCell className="w-14 px-2 text-foreground text-xs font-bold font-mono">
|
<TableCell className="w-14 px-2 text-foreground text-xs font-bold font-mono">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span>{peca.marca}</span>
|
<span>{peca.marca}</span>
|
||||||
|
{Boolean(peca.tem_revisao || (peca.qtd_revisoes && peca.qtd_revisoes > 0)) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenRevisao?.();
|
||||||
|
}}
|
||||||
|
className="cursor-pointer inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/40 px-1.5 py-0.5 rounded-full font-bold transition-all shadow-sm animate-pulse"
|
||||||
|
title={`Peça possui ${peca.qtd_revisoes || 1} revisão(ões) registrada(s) (${peca.revisao_atual || 'Rev'}). Clique para consultar.`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-2.5 w-2.5 text-amber-400" />
|
||||||
|
<span>{peca.revisao_atual || '!'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{hasSubComponents && (
|
{hasSubComponents && (
|
||||||
<span
|
<span
|
||||||
onClick={toggleExpand}
|
onClick={toggleExpand}
|
||||||
@@ -168,6 +186,18 @@ export function TableRow({
|
|||||||
<Package className="h-3.5 w-3.5" />
|
<Package className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{onOpenRevisao && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenRevisao}
|
||||||
|
title="Gestão e Histórico de Revisões da Peça"
|
||||||
|
className="h-6 w-6 p-0 text-amber-500 hover:text-amber-400 hover:bg-amber-950/40"
|
||||||
|
>
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export interface Peca {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
prioridade: string;
|
prioridade: string;
|
||||||
quantidadeDisponivel?: number;
|
quantidadeDisponivel?: number;
|
||||||
|
revisao_atual?: string;
|
||||||
|
tem_revisao?: boolean;
|
||||||
|
qtd_revisoes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PecaPrioridade {
|
interface PecaPrioridade {
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { Peca } from '@/hooks/usePecas';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export type TipoRevisao = 'simples' | 'critica';
|
||||||
|
|
||||||
|
export interface Revisao {
|
||||||
|
id: string;
|
||||||
|
of_number: string;
|
||||||
|
peca_id: string;
|
||||||
|
numero_revisao: string;
|
||||||
|
tipo_revisao: TipoRevisao;
|
||||||
|
descricao_motivo: string;
|
||||||
|
observacoes: string | null;
|
||||||
|
usuario_id: string | null;
|
||||||
|
usuario_nome: string | null;
|
||||||
|
usuario_email: string | null;
|
||||||
|
data_revisao: string;
|
||||||
|
dados_anteriores: Record<string, any>;
|
||||||
|
dados_novos: Record<string, any>;
|
||||||
|
campos_alterados: string[];
|
||||||
|
afeta_apontamentos: boolean;
|
||||||
|
impacto_processos: Record<string, any> | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
peca?: {
|
||||||
|
marca: string;
|
||||||
|
etapa_fase: string;
|
||||||
|
descricao: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CriarRevisaoDTO {
|
||||||
|
peca: Peca;
|
||||||
|
numeroRevisao: string;
|
||||||
|
tipoRevisao?: TipoRevisao;
|
||||||
|
descricaoMotivo: string;
|
||||||
|
observacoes?: string;
|
||||||
|
novosDados: Partial<Peca>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevisoes(ofNumber?: string) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [revisoes, setRevisoes] = useState<Revisao[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Carrega todas as revisões de uma OF
|
||||||
|
const carregarRevisoesOF = useCallback(async (of?: string) => {
|
||||||
|
const ofAlvo = of || ofNumber;
|
||||||
|
if (!ofAlvo) return [];
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('revisoes' as any)
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
peca:pecas(marca, etapa_fase, descricao)
|
||||||
|
`)
|
||||||
|
.eq('of_number', ofAlvo)
|
||||||
|
.order('data_revisao', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Erro ao buscar revisões da OF:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lista = (data || []) as unknown as Revisao[];
|
||||||
|
setRevisoes(lista);
|
||||||
|
return lista;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Erro ao carregar revisões:', err);
|
||||||
|
toast.error('Erro ao carregar registros de revisões');
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [ofNumber]);
|
||||||
|
|
||||||
|
// Carrega histórico de revisões de uma peça específica
|
||||||
|
const carregarRevisoesPeca = useCallback(async (pecaId: string): Promise<Revisao[]> => {
|
||||||
|
if (!pecaId) return [];
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('revisoes' as any)
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
peca:pecas(marca, etapa_fase, descricao)
|
||||||
|
`)
|
||||||
|
.eq('peca_id', pecaId)
|
||||||
|
.order('data_revisao', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Erro ao buscar revisões da peça:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data || []) as unknown as Revisao[];
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Erro ao carregar revisões da peça:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Analisa automaticamente se uma alteração é Crítica ou Simples
|
||||||
|
const detectarTipoRevisao = (pecaAtual: Peca, novosDados: Partial<Peca>): {
|
||||||
|
tipo: TipoRevisao;
|
||||||
|
camposAlterados: string[];
|
||||||
|
afetaApontamentos: boolean;
|
||||||
|
detalhes: string[];
|
||||||
|
} => {
|
||||||
|
const camposCriticos = ['marca', 'quantidade', 'peso_unitario', 'peso_total', 'tem_componentes'];
|
||||||
|
const camposAlterados: string[] = [];
|
||||||
|
const detalhes: string[] = [];
|
||||||
|
let isCritica = false;
|
||||||
|
let afetaApontamentos = false;
|
||||||
|
|
||||||
|
// Verificar cada campo relevante
|
||||||
|
const chavesParaChecar = [
|
||||||
|
'marca',
|
||||||
|
'quantidade',
|
||||||
|
'peso_unitario',
|
||||||
|
'peso_total',
|
||||||
|
'descricao',
|
||||||
|
'etapa_fase',
|
||||||
|
'material',
|
||||||
|
'perfil_principal',
|
||||||
|
'tratamento_superficial',
|
||||||
|
'comprimento',
|
||||||
|
'tem_componentes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const chave of chavesParaChecar) {
|
||||||
|
const valorAntigo = (pecaAtual as any)[chave];
|
||||||
|
const valorNovo = (novosDados as any)[chave];
|
||||||
|
|
||||||
|
if (valorNovo !== undefined && valorNovo !== null && valorNovo !== valorAntigo) {
|
||||||
|
// Para números, checar diferença de valor
|
||||||
|
if (typeof valorAntigo === 'number' && typeof valorNovo === 'number') {
|
||||||
|
if (Math.abs(valorAntigo - valorNovo) > 0.0001) {
|
||||||
|
camposAlterados.push(chave);
|
||||||
|
if (camposCriticos.includes(chave)) {
|
||||||
|
isCritica = true;
|
||||||
|
if (chave === 'marca' || chave === 'quantidade') {
|
||||||
|
afetaApontamentos = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
camposAlterados.push(chave);
|
||||||
|
if (camposCriticos.includes(chave)) {
|
||||||
|
isCritica = true;
|
||||||
|
if (chave === 'marca') {
|
||||||
|
afetaApontamentos = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (camposAlterados.includes('marca')) {
|
||||||
|
detalhes.push(`Alteração no código/marca da peça (${pecaAtual.marca} ➔ ${novosDados.marca})`);
|
||||||
|
}
|
||||||
|
if (camposAlterados.includes('quantidade')) {
|
||||||
|
const diff = Number(novosDados.quantidade || 0) - Number(pecaAtual.quantidade || 0);
|
||||||
|
detalhes.push(`Alteração na quantidade (${pecaAtual.quantidade} ➔ ${novosDados.quantidade}, dif: ${diff > 0 ? '+' : ''}${diff})`);
|
||||||
|
}
|
||||||
|
if (camposAlterados.includes('peso_unitario') || camposAlterados.includes('peso_total')) {
|
||||||
|
detalhes.push(`Alteração dimensional/peso`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tipo: isCritica ? 'critica' : 'simples',
|
||||||
|
camposAlterados,
|
||||||
|
afetaApontamentos,
|
||||||
|
detalhes
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cria e aplica a revisão
|
||||||
|
const criarRevisao = async (dto: CriarRevisaoDTO): Promise<boolean> => {
|
||||||
|
const { peca, numeroRevisao, descricaoMotivo, observacoes, novosDados } = dto;
|
||||||
|
|
||||||
|
if (!dto.descricaoMotivo?.trim()) {
|
||||||
|
toast.error('Informe o motivo ou observações da revisão');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analise = detectarTipoRevisao(peca, novosDados);
|
||||||
|
const tipoFinal = dto.tipoRevisao || analise.tipo;
|
||||||
|
const camposAlterados = analise.camposAlterados;
|
||||||
|
|
||||||
|
if (camposAlterados.length === 0) {
|
||||||
|
toast.error('Nenhum campo foi alterado nesta peça.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const dadosAnteriores: Record<string, any> = {};
|
||||||
|
const dadosNovos: Record<string, any> = {};
|
||||||
|
|
||||||
|
camposAlterados.forEach(c => {
|
||||||
|
dadosAnteriores[c] = (peca as any)[c];
|
||||||
|
dadosNovos[c] = (novosDados as any)[c];
|
||||||
|
});
|
||||||
|
|
||||||
|
const impactoProcessos: Record<string, any> = {
|
||||||
|
resumo: [],
|
||||||
|
ajustes_apontamentos: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// REGRA DE NEGÓCIO DE APONTAMENTOS / PROCESSOS:
|
||||||
|
// Caso 1: A quantidade foi reduzida (pra menos)
|
||||||
|
const novaQtd = Number(novosDados.quantidade !== undefined ? novosDados.quantidade : peca.quantidade);
|
||||||
|
const qtdAntiga = Number(peca.quantidade || 0);
|
||||||
|
|
||||||
|
if (camposAlterados.includes('quantidade') && novaQtd < qtdAntiga) {
|
||||||
|
// Buscar todos os apontamentos da peça agrupados por processo
|
||||||
|
const { data: apontamentos, error: apErr } = await supabase
|
||||||
|
.from('apontamentos_producao')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
processo_id,
|
||||||
|
quantidade_produzida,
|
||||||
|
data_apontamento,
|
||||||
|
created_at,
|
||||||
|
processo:processos_fabricacao(id, nome, ordem)
|
||||||
|
`)
|
||||||
|
.eq('peca_id', peca.id)
|
||||||
|
.eq('tipo_apontamento', 'peca')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (!apErr && apontamentos && apontamentos.length > 0) {
|
||||||
|
// Agrupar apontamentos por processo
|
||||||
|
const porProcesso: Record<string, typeof apontamentos> = {};
|
||||||
|
apontamentos.forEach(a => {
|
||||||
|
if (!porProcesso[a.processo_id]) porProcesso[a.processo_id] = [];
|
||||||
|
porProcesso[a.processo_id].push(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [procId, listaAps] of Object.entries(porProcesso)) {
|
||||||
|
const totalProduzido = listaAps.reduce((sum, a) => sum + Number(a.quantidade_produzida || 0), 0);
|
||||||
|
const nomeProc = (listaAps[0].processo as any)?.nome || procId;
|
||||||
|
|
||||||
|
if (totalProduzido > novaQtd) {
|
||||||
|
let excesso = totalProduzido - novaQtd;
|
||||||
|
impactoProcessos.resumo.push(
|
||||||
|
`Processo "${nomeProc}": Produção de ${totalProduzido} un reduzida para o novo teto de ${novaQtd} un (corte de ${excesso} un).`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reduzir dos apontamentos mais recentes para os mais antigos
|
||||||
|
for (const ap of listaAps) {
|
||||||
|
if (excesso <= 0) break;
|
||||||
|
const apQtd = Number(ap.quantidade_produzida || 0);
|
||||||
|
|
||||||
|
if (apQtd <= excesso) {
|
||||||
|
// Apontamento inteiro é absorvido/removido
|
||||||
|
await supabase
|
||||||
|
.from('apontamentos_producao')
|
||||||
|
.delete()
|
||||||
|
.eq('id', ap.id);
|
||||||
|
|
||||||
|
excesso -= apQtd;
|
||||||
|
impactoProcessos.ajustes_apontamentos.push({
|
||||||
|
apontamento_id: ap.id,
|
||||||
|
processo: nomeProc,
|
||||||
|
acao: 'removido',
|
||||||
|
quantidade_anterior: apQtd,
|
||||||
|
quantidade_nova: 0
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Apontamento parcial
|
||||||
|
const novaQtdAp = apQtd - excesso;
|
||||||
|
await supabase
|
||||||
|
.from('apontamentos_producao')
|
||||||
|
.update({
|
||||||
|
quantidade_produzida: novaQtdAp,
|
||||||
|
observacoes: `${ap.data_apontamento || ''} [Revisado por ${numeroRevisao} - Qtd ajustada de ${apQtd} para ${novaQtdAp}]`
|
||||||
|
})
|
||||||
|
.eq('id', ap.id);
|
||||||
|
|
||||||
|
impactoProcessos.ajustes_apontamentos.push({
|
||||||
|
apontamento_id: ap.id,
|
||||||
|
processo: nomeProc,
|
||||||
|
acao: 'reduzido',
|
||||||
|
quantidade_anterior: apQtd,
|
||||||
|
quantidade_nova: novaQtdAp
|
||||||
|
});
|
||||||
|
excesso = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
impactoProcessos.resumo.push(
|
||||||
|
`Processo "${nomeProc}": Mantido (${totalProduzido} un já apontadas estão dentro do novo limite de ${novaQtd} un).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (camposAlterados.includes('quantidade') && novaQtd > qtdAntiga) {
|
||||||
|
// Caso 2: A quantidade foi aumentada (pra mais)
|
||||||
|
const diferenca = novaQtd - qtdAntiga;
|
||||||
|
impactoProcessos.resumo.push(
|
||||||
|
`Aumento de quantidade (+${diferenca} un): O saldo excedente nasce automaticamente no 1º processo da linha de fabricação para novos apontamentos.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso 3: Marca alterada
|
||||||
|
if (camposAlterados.includes('marca')) {
|
||||||
|
const novaMarca = String(novosDados.marca || '');
|
||||||
|
impactoProcessos.resumo.push(
|
||||||
|
`Código da peça alterado de "${peca.marca}" para "${novaMarca}". Vínculos sincronizados.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Atualizar romaneios que desnormalizam a marca
|
||||||
|
await supabase
|
||||||
|
.from('itens_romaneio_pecas')
|
||||||
|
.update({ marca: novaMarca })
|
||||||
|
.eq('peca_id', peca.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Atualizar a peça no banco de dados
|
||||||
|
const payloadUpdate: any = {
|
||||||
|
...novosDados,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recalcular peso total se peso_unitario ou quantidade mudaram
|
||||||
|
if (novosDados.quantidade !== undefined || novosDados.peso_unitario !== undefined) {
|
||||||
|
const qtdFinal = Number(novosDados.quantidade !== undefined ? novosDados.quantidade : peca.quantidade);
|
||||||
|
const pesoUnitFinal = Number(novosDados.peso_unitario !== undefined ? novosDados.peso_unitario : peca.peso_unitario);
|
||||||
|
payloadUpdate.peso_total = Number((qtdFinal * pesoUnitFinal).toFixed(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateErr } = await supabase
|
||||||
|
.from('pecas')
|
||||||
|
.update(payloadUpdate)
|
||||||
|
.eq('id', peca.id);
|
||||||
|
|
||||||
|
if (updateErr) {
|
||||||
|
console.error('Erro ao atualizar peça na revisão:', updateErr);
|
||||||
|
throw updateErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Inserir na tabela revisoes
|
||||||
|
const { error: revErr } = await supabase
|
||||||
|
.from('revisoes' as any)
|
||||||
|
.insert({
|
||||||
|
of_number: peca.of_number,
|
||||||
|
peca_id: peca.id,
|
||||||
|
numero_revisao: numeroRevisao.trim(),
|
||||||
|
tipo_revisao: tipoFinal,
|
||||||
|
descricao_motivo: descricaoMotivo.trim(),
|
||||||
|
observacoes: observacoes?.trim() || null,
|
||||||
|
usuario_id: user?.id || null,
|
||||||
|
usuario_nome: user?.name || user?.email || 'Administrador',
|
||||||
|
usuario_email: user?.email || null,
|
||||||
|
dados_anteriores: dadosAnteriores,
|
||||||
|
dados_novos: dadosNovos,
|
||||||
|
campos_alterados: camposAlterados,
|
||||||
|
afeta_apontamentos: analise.afetaApontamentos,
|
||||||
|
impacto_processos: impactoProcessos,
|
||||||
|
data_revisao: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (revErr) {
|
||||||
|
console.error('Erro ao registrar revisão:', revErr);
|
||||||
|
throw revErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Revisão ${numeroRevisao} (${tipoFinal === 'critica' ? 'Crítica' : 'Simples'}) registrada com sucesso!`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recarregar revisões da OF
|
||||||
|
await carregarRevisoesOF(peca.of_number);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Falha ao processar revisão:', err);
|
||||||
|
toast.error('Erro ao processar revisão: ' + (err.message || 'Erro desconhecido'));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
revisoes,
|
||||||
|
loading,
|
||||||
|
carregarRevisoesOF,
|
||||||
|
carregarRevisoesPeca,
|
||||||
|
detectarTipoRevisao,
|
||||||
|
criarRevisao
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { usePecasP4Listener } from '@/hooks/usePecasP4Listener';
|
|||||||
import { PecaForm } from '@/components/pecas/PecaForm';
|
import { PecaForm } from '@/components/pecas/PecaForm';
|
||||||
import { PecasTable } from '@/components/pecas/PecasTable';
|
import { PecasTable } from '@/components/pecas/PecasTable';
|
||||||
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
|
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
|
||||||
|
import { RevisaoPecaModal } from '@/components/pecas/revisoes/RevisaoPecaModal';
|
||||||
|
import { PainelRevisoesOFModal } from '@/components/pecas/revisoes/PainelRevisoesOFModal';
|
||||||
|
import { useRevisoes } from '@/hooks/useRevisoes';
|
||||||
import { Package, Plus, RefreshCw } from 'lucide-react';
|
import { Package, Plus, RefreshCw } from 'lucide-react';
|
||||||
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
||||||
|
|
||||||
@@ -39,6 +42,8 @@ export default function CadastroPecas() {
|
|||||||
const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
|
const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
|
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
|
||||||
|
const [pecaParaRevisao, setPecaParaRevisao] = useState<Peca | null>(null);
|
||||||
|
const [showPainelRevisoes, setShowPainelRevisoes] = useState(false);
|
||||||
|
|
||||||
const handleSave = async (formData: any) => {
|
const handleSave = async (formData: any) => {
|
||||||
if (!canCreate()) return false;
|
if (!canCreate()) return false;
|
||||||
@@ -242,6 +247,8 @@ export default function CadastroPecas() {
|
|||||||
onDeletePecasBatch={canDelete() ? handleDeleteBatch : undefined}
|
onDeletePecasBatch={canDelete() ? handleDeleteBatch : undefined}
|
||||||
onEditPeca={canEdit() ? handleEdit : undefined}
|
onEditPeca={canEdit() ? handleEdit : undefined}
|
||||||
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
|
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
|
||||||
|
onOpenRevisaoPeca={(p) => setPecaParaRevisao(p)}
|
||||||
|
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
|
||||||
hasRecentImport={hasRecentImport}
|
hasRecentImport={hasRecentImport}
|
||||||
canEdit={canEdit()}
|
canEdit={canEdit()}
|
||||||
canDelete={canDelete()}
|
canDelete={canDelete()}
|
||||||
@@ -260,6 +267,29 @@ export default function CadastroPecas() {
|
|||||||
peca={selectedPecaForPopup}
|
peca={selectedPecaForPopup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal de Gestão de Revisão da Peça */}
|
||||||
|
{pecaParaRevisao && (
|
||||||
|
<RevisaoPecaModal
|
||||||
|
isOpen={!!pecaParaRevisao}
|
||||||
|
onClose={() => setPecaParaRevisao(null)}
|
||||||
|
peca={pecaParaRevisao}
|
||||||
|
onPecaAtualizada={() => {
|
||||||
|
loadPecas();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Painel Geral de Revisões */}
|
||||||
|
{showPainelRevisoes && (
|
||||||
|
<PainelRevisoesOFModal
|
||||||
|
isOpen={showPainelRevisoes}
|
||||||
|
onClose={() => setShowPainelRevisoes(false)}
|
||||||
|
ofNumber={pecas[0]?.of_number || ''}
|
||||||
|
pecas={pecas}
|
||||||
|
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { ArrowLeft, Plus, Package } from 'lucide-react';
|
|||||||
import { PecaForm } from '@/components/pecas/PecaForm';
|
import { PecaForm } from '@/components/pecas/PecaForm';
|
||||||
import { PecasTable } from '@/components/pecas/PecasTable';
|
import { PecasTable } from '@/components/pecas/PecasTable';
|
||||||
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
|
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
|
||||||
|
import { RevisaoPecaModal } from '@/components/pecas/revisoes/RevisaoPecaModal';
|
||||||
|
import { PainelRevisoesOFModal } from '@/components/pecas/revisoes/PainelRevisoesOFModal';
|
||||||
import { usePecas, Peca } from '@/hooks/usePecas';
|
import { usePecas, Peca } from '@/hooks/usePecas';
|
||||||
|
import { useRevisoes } from '@/hooks/useRevisoes';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
||||||
import { StandardPageLayout } from '@/components/layout/StandardPageLayout';
|
import { StandardPageLayout } from '@/components/layout/StandardPageLayout';
|
||||||
@@ -35,10 +38,14 @@ export default function CadastroPecasFiltrado() {
|
|||||||
loadPecas
|
loadPecas
|
||||||
} = usePecas();
|
} = usePecas();
|
||||||
|
|
||||||
|
const { revisoes: revisoesOF, carregarRevisoesOF } = useRevisoes(ofSelecionada);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
|
const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
|
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
|
||||||
|
const [pecaParaRevisao, setPecaParaRevisao] = useState<Peca | null>(null);
|
||||||
|
const [showPainelRevisoes, setShowPainelRevisoes] = useState(false);
|
||||||
|
|
||||||
// Filtrar peças pela OF selecionada
|
// Filtrar peças pela OF selecionada
|
||||||
const pecasFiltradas = pecas.filter(peca => peca.of_number === ofSelecionada);
|
const pecasFiltradas = pecas.filter(peca => peca.of_number === ofSelecionada);
|
||||||
@@ -170,8 +177,10 @@ export default function CadastroPecasFiltrado() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ofSelecionada) {
|
if (!ofSelecionada) {
|
||||||
navigate('/seletor-of');
|
navigate('/seletor-of');
|
||||||
|
} else {
|
||||||
|
carregarRevisoesOF(ofSelecionada);
|
||||||
}
|
}
|
||||||
}, [ofSelecionada, navigate]);
|
}, [ofSelecionada, navigate, carregarRevisoesOF]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -269,6 +278,9 @@ export default function CadastroPecasFiltrado() {
|
|||||||
onEditPeca={canEdit() ? handleEdit : undefined}
|
onEditPeca={canEdit() ? handleEdit : undefined}
|
||||||
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
|
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
|
||||||
onBatchUpdatePecas={canEdit() ? handleBatchUpdatePecas : undefined}
|
onBatchUpdatePecas={canEdit() ? handleBatchUpdatePecas : undefined}
|
||||||
|
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
|
||||||
|
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
|
||||||
|
totalRevisoesOF={revisoesOF.length}
|
||||||
hasRecentImport={hasRecentImport}
|
hasRecentImport={hasRecentImport}
|
||||||
canEdit={canEdit()}
|
canEdit={canEdit()}
|
||||||
canDelete={canDelete()}
|
canDelete={canDelete()}
|
||||||
@@ -288,6 +300,30 @@ export default function CadastroPecasFiltrado() {
|
|||||||
peca={selectedPecaForPopup}
|
peca={selectedPecaForPopup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal de Gestão de Revisão da Peça */}
|
||||||
|
{pecaParaRevisao && (
|
||||||
|
<RevisaoPecaModal
|
||||||
|
isOpen={!!pecaParaRevisao}
|
||||||
|
onClose={() => setPecaParaRevisao(null)}
|
||||||
|
peca={pecaParaRevisao}
|
||||||
|
onPecaAtualizada={() => {
|
||||||
|
loadPecas();
|
||||||
|
carregarRevisoesOF(ofSelecionada);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Painel Geral de Revisões da OF */}
|
||||||
|
{showPainelRevisoes && (
|
||||||
|
<PainelRevisoesOFModal
|
||||||
|
isOpen={showPainelRevisoes}
|
||||||
|
onClose={() => setShowPainelRevisoes(false)}
|
||||||
|
ofNumber={ofSelecionada}
|
||||||
|
pecas={pecasFiltradas}
|
||||||
|
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</StandardPageLayout>
|
</StandardPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- ---------------------------------------------------------
|
||||||
|
-- TRACKSTEEL ERP: TABELA DE REVISÕES DE PEÇAS POR OF
|
||||||
|
-- ---------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.revisoes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
of_number TEXT NOT NULL,
|
||||||
|
peca_id UUID NOT NULL REFERENCES public.pecas(id) ON DELETE CASCADE,
|
||||||
|
numero_revisao TEXT NOT NULL,
|
||||||
|
tipo_revisao TEXT NOT NULL CHECK (tipo_revisao IN ('simples', 'critica')),
|
||||||
|
descricao_motivo TEXT,
|
||||||
|
observacoes TEXT,
|
||||||
|
usuario_id TEXT,
|
||||||
|
usuario_nome TEXT,
|
||||||
|
usuario_email TEXT,
|
||||||
|
data_revisao TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
dados_anteriores JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
dados_novos JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
campos_alterados TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
afeta_apontamentos BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
impacto_processos JSONB DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Índices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_revisoes_of_number ON public.revisoes(of_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_revisoes_peca_id ON public.revisoes(peca_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_revisoes_data ON public.revisoes(data_revisao DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_revisoes_tipo ON public.revisoes(tipo_revisao);
|
||||||
|
|
||||||
|
-- Colunas auxiliares na tabela pecas
|
||||||
|
ALTER TABLE public.pecas
|
||||||
|
ADD COLUMN IF NOT EXISTS revisao_atual TEXT DEFAULT 'Rev 00',
|
||||||
|
ADD COLUMN IF NOT EXISTS tem_revisao BOOLEAN DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS qtd_revisoes INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Trigger para sincronizar campos de revisão na tabela pecas
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_sync_peca_revisoes()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_peca_id UUID;
|
||||||
|
v_count INTEGER;
|
||||||
|
v_last_rev TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
v_peca_id := OLD.peca_id;
|
||||||
|
ELSE
|
||||||
|
v_peca_id := NEW.peca_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT count(*), COALESCE((SELECT numero_revisao FROM public.revisoes WHERE peca_id = v_peca_id ORDER BY data_revisao DESC, created_at DESC LIMIT 1), 'Rev 00')
|
||||||
|
INTO v_count, v_last_rev
|
||||||
|
FROM public.revisoes
|
||||||
|
WHERE peca_id = v_peca_id;
|
||||||
|
|
||||||
|
UPDATE public.pecas
|
||||||
|
SET tem_revisao = (v_count > 0),
|
||||||
|
qtd_revisoes = v_count,
|
||||||
|
revisao_atual = v_last_rev,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_peca_id;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_sync_peca_revisoes ON public.revisoes;
|
||||||
|
CREATE TRIGGER trg_sync_peca_revisoes
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON public.revisoes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.fn_sync_peca_revisoes();
|
||||||
|
|
||||||
|
-- Políticas RLS
|
||||||
|
ALTER TABLE public.revisoes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "revisoes_select_policy" ON public.revisoes;
|
||||||
|
CREATE POLICY "revisoes_select_policy" ON public.revisoes FOR SELECT USING (true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "revisoes_insert_policy" ON public.revisoes;
|
||||||
|
CREATE POLICY "revisoes_insert_policy" ON public.revisoes FOR INSERT WITH CHECK (true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "revisoes_update_policy" ON public.revisoes;
|
||||||
|
CREATE POLICY "revisoes_update_policy" ON public.revisoes FOR UPDATE USING (true) WITH CHECK (true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "revisoes_delete_policy" ON public.revisoes;
|
||||||
|
CREATE POLICY "revisoes_delete_policy" ON public.revisoes FOR DELETE USING (true);
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE public.revisoes TO postgres, anon, authenticated, service_role;
|
||||||
Reference in New Issue
Block a user