feat: Novo módulo Analisador 3D BIM & QA/QC (Auditoria de Furação e Comparador de Reviseeeees)
This commit is contained in:
@@ -59,6 +59,8 @@ export function AppSidebar() {
|
||||
'estoque': '#8b5cf6', // roxo
|
||||
'ofs': '#eab308', // amarelo
|
||||
'producao': '#ef4444', // vermelho
|
||||
'visualizador-3d': '#8b5cf6', // roxo
|
||||
'analisador-3d': '#6366f1', // indigo
|
||||
'painel-industrial': '#06b6d4', // ciano
|
||||
'expedicao': '#ec4899', // rosa
|
||||
'obra': '#a3a3a3', // marrom
|
||||
@@ -112,6 +114,7 @@ export function AppSidebar() {
|
||||
'painel-industrial': 'producao-dashboard',
|
||||
// Engenharia, 3D & OFs
|
||||
'visualizador-3d': 'visualizador-3d',
|
||||
'analisador-3d': 'analisador-3d',
|
||||
'ordens-fabricacao': 'ofs-lista',
|
||||
'ofs-concluidas': 'ofs-lista',
|
||||
'ficha-tecnica-of': 'cadastro-of',
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
UserCog,
|
||||
Cog
|
||||
Cog,
|
||||
ScanSearch
|
||||
} from "lucide-react";
|
||||
import { MenuItem, MenuGroup } from './types';
|
||||
|
||||
@@ -156,6 +157,12 @@ export const menuGroups: MenuGroup[] = [
|
||||
url: "/visualizador-3d",
|
||||
icon: Box,
|
||||
},
|
||||
{
|
||||
key: "analisador-3d",
|
||||
title: "Analisador 3D",
|
||||
url: "/analisador-3d",
|
||||
icon: ScanSearch,
|
||||
},
|
||||
{
|
||||
key: "painel-industrial",
|
||||
title: "Painel Industrial",
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three-stdlib';
|
||||
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
|
||||
import { DefectItem, ColorizationMode } from '@/types/analisador3d';
|
||||
import { ViewCube } from './ViewCube';
|
||||
import {
|
||||
RotateCcw,
|
||||
Eye,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Box,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Search,
|
||||
Crosshair,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
HelpCircle,
|
||||
Cpu
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AnalyzerViewer3DProps {
|
||||
modelData: LoadedIFCResult | null;
|
||||
defects: DefectItem[];
|
||||
selectedDefect: DefectItem | null;
|
||||
selectedPieceMark: string | null;
|
||||
colorizationMode: ColorizationMode;
|
||||
onSelectDefect?: (defect: DefectItem | null) => void;
|
||||
onSelectPiece?: (pieceMark: string | null) => void;
|
||||
isGhostMode?: boolean;
|
||||
onToggleGhostMode?: () => void;
|
||||
isFullscreen?: boolean;
|
||||
onToggleFullscreen?: () => void;
|
||||
theme?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
|
||||
modelData,
|
||||
defects,
|
||||
selectedDefect,
|
||||
selectedPieceMark,
|
||||
colorizationMode,
|
||||
onSelectDefect,
|
||||
onSelectPiece,
|
||||
isGhostMode = false,
|
||||
onToggleGhostMode,
|
||||
isFullscreen = false,
|
||||
onToggleFullscreen,
|
||||
theme = 'dark',
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const modelGroupRef = useRef<THREE.Group | null>(null);
|
||||
const gridRef = useRef<THREE.GridHelper | null>(null);
|
||||
|
||||
const [wireframe, setWireframe] = useState<boolean>(false);
|
||||
const [hoveredDefect, setHoveredDefect] = useState<DefectItem | null>(null);
|
||||
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Mapeamentos rápidos para busca e colorização
|
||||
const defectByPieceMap = useRef<Map<string, DefectItem>>(new Map());
|
||||
useEffect(() => {
|
||||
const map = new Map<string, DefectItem>();
|
||||
defects.forEach((d) => {
|
||||
map.set(d.pecaMarca.toUpperCase(), d);
|
||||
if (d.pecaConectadaMarca) {
|
||||
map.set(d.pecaConectadaMarca.toUpperCase(), d);
|
||||
}
|
||||
});
|
||||
defectByPieceMap.current = map;
|
||||
}, [defects]);
|
||||
|
||||
// 1. Inicializa o Three.js
|
||||
useEffect(() => {
|
||||
if (!canvasContainerRef.current) return;
|
||||
|
||||
const width = canvasContainerRef.current.clientWidth || 800;
|
||||
const height = canvasContainerRef.current.clientHeight || 500;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
sceneRef.current = scene;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
|
||||
camera.position.set(40, 35, 50);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.1;
|
||||
rendererRef.current = renderer;
|
||||
|
||||
canvasContainerRef.current.innerHTML = '';
|
||||
canvasContainerRef.current.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.maxDistance = 1500;
|
||||
controls.minDistance = 0.2;
|
||||
controlsRef.current = controls;
|
||||
|
||||
// Iluminação balanceada industrial
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.85);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
dirLight1.position.set(80, 120, 80);
|
||||
scene.add(dirLight1);
|
||||
|
||||
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
|
||||
dirLight2.position.set(-80, -40, -80);
|
||||
scene.add(dirLight2);
|
||||
|
||||
// Grid no piso
|
||||
const isDark = theme === 'dark' || document.documentElement.classList.contains('dark');
|
||||
const gridColor1 = isDark ? 0x475569 : 0x94a3b8;
|
||||
const gridColor2 = isDark ? 0x1e293b : 0xe2e8f0;
|
||||
const grid = new THREE.GridHelper(100, 50, gridColor1, gridColor2);
|
||||
grid.position.y = -0.01;
|
||||
scene.add(grid);
|
||||
gridRef.current = grid;
|
||||
|
||||
let animId: number;
|
||||
const animate = () => {
|
||||
animId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
const handleResize = () => {
|
||||
if (!canvasContainerRef.current || !renderer || !camera) return;
|
||||
const w = canvasContainerRef.current.clientWidth;
|
||||
const h = canvasContainerRef.current.clientHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
const observer = new ResizeObserver(handleResize);
|
||||
observer.observe(canvasContainerRef.current);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
observer.disconnect();
|
||||
renderer.dispose();
|
||||
controls.dispose();
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
// 2. Carrega o modelo na cena
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current;
|
||||
if (!scene) return;
|
||||
|
||||
if (modelGroupRef.current) {
|
||||
scene.remove(modelGroupRef.current);
|
||||
modelGroupRef.current = null;
|
||||
}
|
||||
|
||||
if (!modelData || !modelData.sceneGroup) return;
|
||||
|
||||
const group = modelData.sceneGroup.clone();
|
||||
modelGroupRef.current = group;
|
||||
scene.add(group);
|
||||
|
||||
// Ajusta piso do grid com base no tamanho do modelo
|
||||
const bbox = new THREE.Box3().setFromObject(group);
|
||||
if (!bbox.isEmpty()) {
|
||||
const center = new THREE.Vector3();
|
||||
bbox.getCenter(center);
|
||||
const size = new THREE.Vector3();
|
||||
bbox.getSize(size);
|
||||
|
||||
// Centraliza grupo na origem
|
||||
group.position.x = -center.x;
|
||||
group.position.y = -bbox.min.y;
|
||||
group.position.z = -center.z;
|
||||
|
||||
if (gridRef.current) {
|
||||
gridRef.current.position.y = 0;
|
||||
}
|
||||
|
||||
// Ajusta câmera
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
if (cameraRef.current && controlsRef.current) {
|
||||
cameraRef.current.position.set(maxDim * 1.3, maxDim * 1.1, maxDim * 1.5);
|
||||
controlsRef.current.target.set(0, size.y / 2, 0);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
}
|
||||
}, [modelData]);
|
||||
|
||||
// 3. Aplica Colorização Dinâmica com base no modo selecionado e na peça/defeito ativo
|
||||
useEffect(() => {
|
||||
if (!modelGroupRef.current) return;
|
||||
|
||||
const group = modelGroupRef.current;
|
||||
const targetMark = selectedPieceMark?.toUpperCase() || selectedDefect?.pecaMarca.toUpperCase();
|
||||
|
||||
group.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
// Tenta achar a marca associada na userData
|
||||
const pieceInfo = obj.userData?.pieceInfo as PieceInfo | undefined;
|
||||
const mark = (pieceInfo?.pieceMark || obj.userData?.pieceMark || '').toUpperCase();
|
||||
const defect = defectByPieceMap.current.get(mark);
|
||||
|
||||
const isSelected = targetMark && (mark === targetMark || (defect && defect.pecaConectadaMarca?.toUpperCase() === targetMark));
|
||||
|
||||
let color = '#94a3b8'; // neutro default
|
||||
let emissiveColor = '#000000';
|
||||
let emissiveIntensity = 0;
|
||||
|
||||
if (colorizationMode === 'status_defeito') {
|
||||
if (defect) {
|
||||
if (defect.gravidade === 'critico') color = '#ef4444'; // vermelho
|
||||
else if (defect.gravidade === 'alerta') color = '#f59e0b'; // laranja/amarelo
|
||||
else color = '#3b82f6'; // azul
|
||||
} else {
|
||||
color = '#10b981'; // verde conforme
|
||||
}
|
||||
} else if (colorizationMode === 'diff_revisao') {
|
||||
if (defect?.tipo === 'peca_nova') color = '#3b82f6'; // azul novo
|
||||
else if (defect?.tipo === 'peca_excluida') color = '#ef4444'; // vermelho excluído
|
||||
else if (defect?.tipo === 'peca_modificada' || defect?.tipo === 'deslocamento_furo' || defect?.tipo === 'furo_extra_revisao') {
|
||||
color = '#f59e0b'; // amarelo modificado
|
||||
} else {
|
||||
color = '#10b981'; // verde inalterado
|
||||
}
|
||||
} else if (colorizationMode === 'risco_fabril') {
|
||||
if (defect) {
|
||||
if (defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto') color = '#dc2626';
|
||||
else if (defect.riscoFabril === 'medio') color = '#f59e0b';
|
||||
else color = '#10b981';
|
||||
} else {
|
||||
color = '#64748b';
|
||||
}
|
||||
} else if (colorizationMode === 'processo') {
|
||||
color = defect?.processoCor || '#3b82f6';
|
||||
}
|
||||
|
||||
// Se estiver em modo fantasma e houver seleção: atenua peças não selecionadas
|
||||
let opacity = 1.0;
|
||||
let transparent = false;
|
||||
|
||||
if (isGhostMode && targetMark) {
|
||||
if (!isSelected) {
|
||||
opacity = 0.12;
|
||||
transparent = true;
|
||||
} else {
|
||||
opacity = 1.0;
|
||||
emissiveColor = color;
|
||||
emissiveIntensity = 0.5;
|
||||
}
|
||||
} else if (isSelected) {
|
||||
emissiveColor = '#ffffff';
|
||||
emissiveIntensity = 0.35;
|
||||
}
|
||||
|
||||
// Aplica o material atualizado
|
||||
obj.material = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(color),
|
||||
emissive: new THREE.Color(emissiveColor),
|
||||
emissiveIntensity,
|
||||
roughness: 0.35,
|
||||
metalness: 0.2,
|
||||
wireframe,
|
||||
transparent,
|
||||
opacity,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [colorizationMode, selectedDefect, selectedPieceMark, isGhostMode, wireframe, modelData]);
|
||||
|
||||
// 4. Foco na Câmera ao selecionar defeito/peça
|
||||
const focusOnTarget = useCallback((coords?: { x: number; y: number; z: number }) => {
|
||||
if (!coords || !cameraRef.current || !controlsRef.current) return;
|
||||
const target = new THREE.Vector3(coords.x, coords.y, coords.z);
|
||||
|
||||
controlsRef.current.target.copy(target);
|
||||
cameraRef.current.position.set(target.x + 3, target.y + 3, target.z + 4);
|
||||
controlsRef.current.update();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDefect && selectedDefect.coordenadas3D) {
|
||||
focusOnTarget(selectedDefect.coordenadas3D);
|
||||
}
|
||||
}, [selectedDefect, focusOnTarget]);
|
||||
|
||||
// 5. Clique no 3D (Raycasting)
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!canvasContainerRef.current || !cameraRef.current || !modelGroupRef.current) return;
|
||||
|
||||
const rect = canvasContainerRef.current.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(new THREE.Vector2(x, y), cameraRef.current);
|
||||
|
||||
const intersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
|
||||
if (intersects.length > 0) {
|
||||
const hit = intersects[0].object as THREE.Mesh;
|
||||
const pieceInfo = hit.userData?.pieceInfo as PieceInfo | undefined;
|
||||
const mark = pieceInfo?.pieceMark || hit.userData?.pieceMark;
|
||||
|
||||
if (mark) {
|
||||
onSelectPiece?.(mark);
|
||||
const def = defectByPieceMap.current.get(mark.toUpperCase());
|
||||
onSelectDefect?.(def || null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Reset de Câmera
|
||||
const handleResetCamera = () => {
|
||||
if (!modelGroupRef.current || !cameraRef.current || !controlsRef.current) return;
|
||||
const bbox = new THREE.Box3().setFromObject(modelGroupRef.current);
|
||||
const size = new THREE.Vector3();
|
||||
bbox.getSize(size);
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
cameraRef.current.position.set(maxDim * 1.3, maxDim * 1.1, maxDim * 1.5);
|
||||
controlsRef.current.target.set(0, size.y / 2, 0);
|
||||
controlsRef.current.update();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative w-full h-full select-none overflow-hidden rounded-xl border ${
|
||||
theme === 'dark'
|
||||
? 'bg-slate-950/80 border-slate-800'
|
||||
: 'bg-slate-900 border-slate-700'
|
||||
}`}
|
||||
>
|
||||
{/* Container Three.js */}
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
className="w-full h-full cursor-grab active:cursor-grabbing"
|
||||
onPointerDown={handlePointerDown}
|
||||
/>
|
||||
|
||||
{/* ViewCube Superior Direito */}
|
||||
{cameraRef.current && controlsRef.current && (
|
||||
<ViewCube camera={cameraRef.current} controls={controlsRef.current} />
|
||||
)}
|
||||
|
||||
{/* Barra de Ferramentas Flutuante Superior */}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1.5 p-1.5 bg-slate-900/90 backdrop-blur-md border border-slate-700/80 rounded-lg shadow-xl z-20">
|
||||
<button
|
||||
onClick={handleResetCamera}
|
||||
title="Resetar Câmera"
|
||||
className="p-1.5 text-slate-300 hover:text-white hover:bg-slate-800 rounded transition"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-[1px] h-4 bg-slate-700 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={onToggleGhostMode}
|
||||
title={isGhostMode ? 'Desativar Modo Raio-X / Foco' : 'Ativar Modo Raio-X / Foco'}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded transition ${
|
||||
isGhostMode
|
||||
? 'bg-indigo-600 text-white shadow'
|
||||
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Foco / Raio-X</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setWireframe(!wireframe)}
|
||||
title="Alternar Estrutura de Arame (Wireframe)"
|
||||
className={`p-1.5 rounded transition ${
|
||||
wireframe
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Box className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{onToggleFullscreen && (
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
title={isFullscreen ? 'Sair da Tela Cheia' : 'Tela Cheia'}
|
||||
className="p-1.5 text-slate-300 hover:text-white hover:bg-slate-800 rounded transition"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legenda de Cores Flutuante Inferior */}
|
||||
<div className="absolute bottom-3 left-3 flex flex-wrap items-center gap-3 px-3 py-2 bg-slate-900/90 backdrop-blur-md border border-slate-700/80 rounded-lg shadow-xl text-xs z-20">
|
||||
<span className="text-slate-400 font-medium">Legenda:</span>
|
||||
{colorizationMode === 'status_defeito' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-sm" />
|
||||
<span className="text-slate-300">Conforme</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 shadow-sm" />
|
||||
<span className="text-slate-300">Alerta (Deslocado)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-red-500 shadow-sm" />
|
||||
<span className="text-slate-300">Crítico (Furo Cego/Colisão)</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{colorizationMode === 'diff_revisao' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-slate-300">Inalterada</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
|
||||
<span className="text-slate-300">Modificada</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-blue-500" />
|
||||
<span className="text-slate-300">Nova</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
|
||||
<span className="text-slate-300">Excluída</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{colorizationMode === 'risco_fabril' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-slate-300">Baixo Risco (Sem Corte)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
|
||||
<span className="text-slate-300">Médio (Em Corte)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-red-600 animate-pulse" />
|
||||
<span className="text-slate-300">Crítico (Soldada/Expedida)</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerta de elemento selecionado flutuante */}
|
||||
{selectedPieceMark && (
|
||||
<div className="absolute top-3 right-16 px-3 py-1.5 bg-indigo-950/90 border border-indigo-700/80 rounded-lg shadow-xl text-xs flex items-center gap-2 z-20">
|
||||
<Crosshair className="w-3.5 h-3.5 text-indigo-400 animate-spin" style={{ animationDuration: '6s' }} />
|
||||
<span className="text-indigo-200">
|
||||
Foco Ativo: <strong className="text-white font-mono">{selectedPieceMark}</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSelectPiece?.(null);
|
||||
onSelectDefect?.(null);
|
||||
}}
|
||||
className="ml-1 text-indigo-400 hover:text-white"
|
||||
title="Limpar seleção"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { DefectItem } from '@/types/analisador3d';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Crosshair,
|
||||
Layers,
|
||||
Wrench,
|
||||
Factory,
|
||||
ShieldAlert,
|
||||
ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface PieceAuditDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
defect: DefectItem | null;
|
||||
onFocus3D?: (coords?: { x: number; y: number; z: number }) => void;
|
||||
}
|
||||
|
||||
export const PieceAuditDetailModal: React.FC<PieceAuditDetailModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
defect,
|
||||
onFocus3D,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
if (!defect) return null;
|
||||
|
||||
const handleCopy = () => {
|
||||
const text = `[AUDITORIA 3D TRACKSTEEL]\nPeça: ${defect.pecaMarca}\nConjunto: ${defect.conjuntoMarca || 'N/A'}\nTipo: ${defect.tipo}\nGravidade: ${defect.gravidade}\nDescrição: ${defect.descricao}\nRecomendação: ${defect.recomendacao}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
toast({
|
||||
title: 'Copiado para Área de Transferência',
|
||||
description: 'Dados técnicos copiados com sucesso.',
|
||||
});
|
||||
};
|
||||
|
||||
const getSeverityBadge = () => {
|
||||
switch (defect.gravidade) {
|
||||
case 'critico':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-500/10 text-red-500 border border-red-500/20">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
CRÍTICO
|
||||
</span>
|
||||
);
|
||||
case 'alerta':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-500 border border-amber-500/20">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
ALERTA
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-500/10 text-blue-500 border border-blue-500/20">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
INFORMATIVO
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100 p-6 shadow-2xl">
|
||||
<DialogHeader className="border-b border-slate-800 pb-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="p-2 bg-indigo-500/10 text-indigo-400 rounded-lg border border-indigo-500/20">
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</span>
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Diagnóstico Técnico • {defect.pecaMarca}
|
||||
{getSeverityBadge()}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-slate-400">
|
||||
{defect.titulo}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
|
||||
title="Copiar relatório deste defeito"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-3 text-sm">
|
||||
{/* Card Descrição do Problema */}
|
||||
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1 flex items-center gap-1.5">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-indigo-400" />
|
||||
Descrição do Ocorrido / Inconformidade
|
||||
</h4>
|
||||
<p className="text-slate-200 leading-relaxed text-xs sm:text-sm">
|
||||
{defect.descricao}
|
||||
</p>
|
||||
|
||||
{defect.deslocamentoMm && (
|
||||
<div className="mt-2.5 pt-2 border-t border-slate-800/80 flex items-center gap-4 text-xs font-mono">
|
||||
<span className="text-slate-400">Deslocamento:</span>
|
||||
<span className="text-amber-400">ΔX: {defect.deslocamentoMm.x}mm</span>
|
||||
<span className="text-amber-400">ΔY: {defect.deslocamentoMm.y}mm</span>
|
||||
<span className="text-amber-400">ΔZ: {defect.deslocamentoMm.z}mm</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid de Relação Peça vs Peça Conectada */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
|
||||
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
|
||||
<Layers className="w-3.5 h-3.5 text-indigo-400" />
|
||||
Peça Auditada
|
||||
</span>
|
||||
<div className="font-mono font-bold text-white text-base">{defect.pecaMarca}</div>
|
||||
<div className="text-xs text-slate-400 mt-1">
|
||||
Conjunto:{' '}
|
||||
<strong className="text-slate-300 font-mono">
|
||||
{defect.conjuntoMarca || 'N/A'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
Fase:{' '}
|
||||
<strong className="text-slate-300">{defect.fase || 'Não definida'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
|
||||
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
|
||||
<Wrench className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Peça Conectada (Adjacente)
|
||||
</span>
|
||||
{defect.pecaConectadaMarca ? (
|
||||
<>
|
||||
<div className="font-mono font-bold text-cyan-300 text-base">
|
||||
{defect.pecaConectadaMarca}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-1">
|
||||
Conjunto:{' '}
|
||||
<strong className="text-slate-300 font-mono">
|
||||
{defect.pecaConectadaConjunto || 'N/A'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="text-xs text-cyan-400 mt-1">
|
||||
Ligação: Furação faceada / Parafuso
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500 italic mt-2">
|
||||
Elemento avulso ou modificação intrínseca da peça.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Situação no Chão de Fábrica */}
|
||||
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Factory className="w-3.5 h-3.5 text-amber-400" />
|
||||
Impacto no Chão de Fábrica (Tracksteel Engine)
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-slate-500 block">Etapa Atual</span>
|
||||
<span
|
||||
className="font-medium px-1.5 py-0.5 rounded text-[11px] inline-block mt-0.5"
|
||||
style={{
|
||||
backgroundColor: `${defect.processoCor || '#64748b'}25`,
|
||||
color: defect.processoCor || '#94a3b8',
|
||||
}}
|
||||
>
|
||||
{defect.processoAtual || 'Pendente'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-slate-500 block">Qtd Apontada</span>
|
||||
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
|
||||
{defect.quantidadeApontada || 0} de {defect.quantidadeAfetada} un
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-slate-500 block">Peso Total</span>
|
||||
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
|
||||
{defect.pesoTotalKg ? `${defect.pesoTotalKg.toFixed(1)} kg` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-slate-500 block">Risco de Retrabalho</span>
|
||||
<span
|
||||
className={`font-bold mt-0.5 uppercase block ${
|
||||
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
|
||||
? 'text-red-400 animate-pulse'
|
||||
: defect.riscoFabril === 'medio'
|
||||
? 'text-amber-400'
|
||||
: 'text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
{defect.riscoFabril}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recomendação de Engenharia */}
|
||||
<div className="p-3.5 bg-indigo-950/30 border border-indigo-900/50 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-indigo-300 uppercase tracking-wider mb-1 flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
|
||||
Ação Corretiva Recomendada
|
||||
</h4>
|
||||
<p className="text-xs sm:text-sm text-indigo-100 leading-relaxed">
|
||||
{defect.recomendacao}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rodapé com Botões */}
|
||||
<div className="border-t border-slate-800 pt-4 flex items-center justify-between">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
|
||||
{defect.coordenadas3D && onFocus3D && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onFocus3D(defect.coordenadas3D);
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-xs font-medium shadow transition"
|
||||
>
|
||||
<Crosshair className="w-4 h-4" />
|
||||
<span>Focar e Isolar no Visualizador 3D</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -183,6 +183,14 @@ export const routeConfig: RouteDefinition[] = [
|
||||
resourceKey: 'visualizador-3d',
|
||||
},
|
||||
|
||||
// ── Analisador 3D (QA/QC & Auditoria Geométrica) ──────────
|
||||
{
|
||||
path: '/analisador-3d',
|
||||
component: () => import('@/pages/Analisador3D'),
|
||||
guard: 'resource',
|
||||
resourceKey: 'analisador-3d',
|
||||
},
|
||||
|
||||
// ── Painel Industrial ────────────────────────────────────
|
||||
{
|
||||
path: '/painel-industrial',
|
||||
|
||||
@@ -63,6 +63,7 @@ export const GRUPOS_MODULOS: GrupoRecurso[] = [
|
||||
descricao: 'Projetos, modelos 3D, fichas técnicas e cronogramas',
|
||||
recursos: [
|
||||
{ key: 'visualizador-3d', nome: 'Visualizador 3D BIM', rota: '/visualizador-3d' },
|
||||
{ key: 'analisador-3d', nome: 'Analisador 3D & QA/QC', rota: '/analisador-3d' },
|
||||
{ key: 'ofs-lista', nome: 'Ordens de Fabricação', rota: '/ofs' },
|
||||
{ key: 'ofs-cronograma', nome: 'Cronogramas de Fabricação', rota: '/ofs/cronograma' },
|
||||
{ key: 'cadastro-pecas', nome: 'Cadastro de Peças & Conjuntos', rota: '/cadastro/pecas' },
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import * as THREE from 'three';
|
||||
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
|
||||
import { DefectItem, DefectType, DefectSeverity, ProductionRisk, ComparisonDiffSummary } from '@/types/analisador3d';
|
||||
|
||||
interface AnalyzeOptions {
|
||||
modelBase: LoadedIFCResult;
|
||||
modelRevisao?: LoadedIFCResult | null;
|
||||
productionMap?: Map<string, any>;
|
||||
toleranceMm?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Motor de Auditoria Geométrica, Detecção de Inconformidades e Comparação de Revisões 3D.
|
||||
*/
|
||||
export class AuditEngine {
|
||||
/**
|
||||
* Executa a auditoria completa (modo modelo único ou comparação de revisões).
|
||||
*/
|
||||
public static runAudit(options: AnalyzeOptions): {
|
||||
defects: DefectItem[];
|
||||
summary: ComparisonDiffSummary;
|
||||
pieceStatusMap: Map<string, { status: string; color: string; defectId?: string }>;
|
||||
} {
|
||||
const { modelBase, modelRevisao, productionMap, toleranceMm = 1.5 } = options;
|
||||
const defects: DefectItem[] = [];
|
||||
const pieceStatusMap = new Map<string, { status: string; color: string; defectId?: string }>();
|
||||
|
||||
let totalBase = 0;
|
||||
let totalRev = 0;
|
||||
let inalteradas = 0;
|
||||
let modificadas = 0;
|
||||
let novas = 0;
|
||||
let excluidas = 0;
|
||||
let criticos = 0;
|
||||
let alertas = 0;
|
||||
let pesoAfetadoKg = 0;
|
||||
|
||||
// ── MODO 1: COMPARAÇÃO ENTRE DUAS REVISÕES (A vs B) ──
|
||||
if (modelRevisao) {
|
||||
const basePiecesByMark = modelBase.piecesByMark;
|
||||
const revPiecesByMark = modelRevisao.piecesByMark;
|
||||
|
||||
// 1. Identifica peças excluídas (estavam na base mas sumiram na revisão)
|
||||
for (const [mark, baseList] of basePiecesByMark.entries()) {
|
||||
totalBase += baseList.length;
|
||||
const revList = revPiecesByMark.get(mark);
|
||||
|
||||
if (!revList || revList.length === 0) {
|
||||
excluidas += baseList.length;
|
||||
const samplePiece = baseList[0];
|
||||
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
|
||||
const risco = this.calcularRiscoProducao(prodInfo);
|
||||
const pesoUnit = prodInfo?.pesoUnitario || samplePiece.span ? (samplePiece.span * 0.05) : 10;
|
||||
const pesoTotal = pesoUnit * baseList.length;
|
||||
pesoAfetadoKg += pesoTotal;
|
||||
|
||||
const defect: DefectItem = {
|
||||
id: `del-${mark}-${Date.now()}-${Math.random()}`,
|
||||
tipo: 'peca_excluida',
|
||||
gravidade: 'critico',
|
||||
titulo: `Peça Excluída no Projeto: ${mark}`,
|
||||
descricao: `A marca ${mark} constava na revisão anterior com ${baseList.length} unidade(s) e foi removida na nova revisão do projeto.`,
|
||||
pecaMarca: mark,
|
||||
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
|
||||
expressID: samplePiece.expressID,
|
||||
guid: samplePiece.guid,
|
||||
fase: samplePiece.phase,
|
||||
quantidadeAfetada: baseList.length,
|
||||
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
|
||||
pesoTotalKg: Number(pesoTotal.toFixed(2)),
|
||||
processoAtual: prodInfo?.currentProcessName || 'Pendente',
|
||||
processoCor: prodInfo?.processColor,
|
||||
quantidadeApontada: prodInfo?.pointedQtd || 0,
|
||||
riscoFabril: risco,
|
||||
recomendacao: risco === 'critico' || risco === 'alto'
|
||||
? `ATENÇÃO: Peça já fabricada/iniciada na fábrica. Interromper imediatamente e emitir RNC.`
|
||||
: `Atualizar ordens de corte e cancelar etiquetas de fabricação.`,
|
||||
coordenadas3D: this.getPieceCenter(samplePiece),
|
||||
};
|
||||
|
||||
defects.push(defect);
|
||||
criticos++;
|
||||
pieceStatusMap.set(mark, { status: 'Excluída', color: '#ef4444', defectId: defect.id });
|
||||
} else {
|
||||
// 2. Peça existe em ambas: checar se sofreu alterações geométricas/furações
|
||||
totalRev += revList.length;
|
||||
const diffResult = this.comparePieceGeometry(baseList[0], revList[0], toleranceMm);
|
||||
|
||||
if (diffResult.hasChanged) {
|
||||
modificadas += revList.length;
|
||||
const samplePiece = revList[0];
|
||||
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
|
||||
const risco = this.calcularRiscoProducao(prodInfo);
|
||||
const pesoUnit = prodInfo?.pesoUnitario || 15;
|
||||
const pesoTotal = pesoUnit * revList.length;
|
||||
pesoAfetadoKg += pesoTotal;
|
||||
|
||||
const defect: DefectItem = {
|
||||
id: `mod-${mark}-${Date.now()}-${Math.random()}`,
|
||||
tipo: diffResult.tipoDefeito,
|
||||
gravidade: diffResult.gravidade,
|
||||
titulo: `${diffResult.titulo}: ${mark}`,
|
||||
descricao: diffResult.descricao,
|
||||
pecaMarca: mark,
|
||||
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
|
||||
expressID: samplePiece.expressID,
|
||||
guid: samplePiece.guid,
|
||||
fase: samplePiece.phase,
|
||||
quantidadeAfetada: revList.length,
|
||||
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
|
||||
pesoTotalKg: Number(pesoTotal.toFixed(2)),
|
||||
diametroMm: diffResult.diametroMm,
|
||||
deslocamentoMm: diffResult.deslocamentoMm,
|
||||
processoAtual: prodInfo?.currentProcessName || 'Pendente',
|
||||
processoCor: prodInfo?.processColor,
|
||||
quantidadeApontada: prodInfo?.pointedQtd || 0,
|
||||
riscoFabril: risco,
|
||||
recomendacao: diffResult.recomendacao,
|
||||
coordenadas3D: this.getPieceCenter(samplePiece),
|
||||
};
|
||||
|
||||
defects.push(defect);
|
||||
if (defect.gravidade === 'critico') criticos++;
|
||||
else alertas++;
|
||||
|
||||
pieceStatusMap.set(mark, { status: 'Modificada', color: '#f59e0b', defectId: defect.id });
|
||||
} else {
|
||||
inalteradas += revList.length;
|
||||
pieceStatusMap.set(mark, { status: 'Inalterada', color: '#10b981' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Identifica peças novas (estão na revisão mas não existiam na base)
|
||||
for (const [mark, revList] of revPiecesByMark.entries()) {
|
||||
if (!basePiecesByMark.has(mark)) {
|
||||
novas += revList.length;
|
||||
totalRev += revList.length;
|
||||
const samplePiece = revList[0];
|
||||
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
|
||||
const pesoUnit = prodInfo?.pesoUnitario || 12;
|
||||
const pesoTotal = pesoUnit * revList.length;
|
||||
|
||||
const defect: DefectItem = {
|
||||
id: `new-${mark}-${Date.now()}-${Math.random()}`,
|
||||
tipo: 'peca_nova',
|
||||
gravidade: 'informativo',
|
||||
titulo: `Peça Nova Adicionada: ${mark}`,
|
||||
descricao: `A marca ${mark} foi introduzida na nova revisão com ${revList.length} unidade(s). Necessário incluir na lista de corte.`,
|
||||
pecaMarca: mark,
|
||||
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
|
||||
expressID: samplePiece.expressID,
|
||||
guid: samplePiece.guid,
|
||||
fase: samplePiece.phase,
|
||||
quantidadeAfetada: revList.length,
|
||||
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
|
||||
pesoTotalKg: Number(pesoTotal.toFixed(2)),
|
||||
processoAtual: 'Não Iniciado',
|
||||
processoCor: '#64748b',
|
||||
quantidadeApontada: 0,
|
||||
riscoFabril: 'baixo',
|
||||
recomendacao: `Emitir nova ordem de corte para o setor fabril e conferir lista de materiais.`,
|
||||
coordenadas3D: this.getPieceCenter(samplePiece),
|
||||
};
|
||||
|
||||
defects.push(defect);
|
||||
pieceStatusMap.set(mark, { status: 'Nova', color: '#3b82f6', defectId: defect.id });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── MODO 2: AUDITORIA DE INTEGRIDADE GEOMÉTRICA & CONEXÕES (MODELO ÚNICO) ──
|
||||
const allPieces: PieceInfo[] = [];
|
||||
for (const list of modelBase.piecesByMark.values()) {
|
||||
allPieces.push(...list);
|
||||
}
|
||||
totalBase = allPieces.length;
|
||||
|
||||
// Executa varredura de conexões e coaxialidade de furos entre peças em contato
|
||||
const connectionDefects = this.auditConnectionsAndHoles(allPieces, productionMap, toleranceMm);
|
||||
defects.push(...connectionDefects);
|
||||
|
||||
for (const def of connectionDefects) {
|
||||
if (def.gravidade === 'critico') criticos++;
|
||||
else alertas++;
|
||||
pesoAfetadoKg += def.pesoTotalKg || 0;
|
||||
pieceStatusMap.set(def.pecaMarca, {
|
||||
status: def.gravidade === 'critico' ? 'Crítico' : 'Alerta',
|
||||
color: def.gravidade === 'critico' ? '#ef4444' : '#f59e0b',
|
||||
defectId: def.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Peças sem defeitos detectados são marcadas como conformes
|
||||
for (const p of allPieces) {
|
||||
if (!pieceStatusMap.has(p.pieceMark)) {
|
||||
inalteradas++;
|
||||
pieceStatusMap.set(p.pieceMark, { status: 'Conforme', color: '#10b981' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary: ComparisonDiffSummary = {
|
||||
totalPecasBase: totalBase,
|
||||
totalPecasRevisao: totalRev || totalBase,
|
||||
inalteradas,
|
||||
modificadas,
|
||||
novas,
|
||||
excluidas,
|
||||
inconformidadesCriticas: criticos,
|
||||
alertasFuros: alertas,
|
||||
pesoAfetadoKg: Number(pesoAfetadoKg.toFixed(2)),
|
||||
riscoGeral: criticos > 0 ? 'critico' : alertas > 3 ? 'alto' : alertas > 0 ? 'medio' : 'baixo',
|
||||
};
|
||||
|
||||
return { defects, summary, pieceStatusMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compara a geometria de uma mesma peça entre a revisão A e a revisão B.
|
||||
*/
|
||||
private static comparePieceGeometry(
|
||||
basePiece: PieceInfo,
|
||||
revPiece: PieceInfo,
|
||||
toleranceMm: number
|
||||
): {
|
||||
hasChanged: boolean;
|
||||
tipoDefeito: DefectType;
|
||||
gravidade: DefectSeverity;
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
recomendacao: string;
|
||||
diametroMm?: number;
|
||||
deslocamentoMm?: { x: number; y: number; z: number };
|
||||
} {
|
||||
// 1. Compara Bounding Box e Dimensões
|
||||
const bboxBase = this.getBoundingBox(basePiece);
|
||||
const bboxRev = this.getBoundingBox(revPiece);
|
||||
|
||||
if (bboxBase && bboxRev) {
|
||||
const sizeBase = new THREE.Vector3();
|
||||
const sizeRev = new THREE.Vector3();
|
||||
bboxBase.getSize(sizeBase);
|
||||
bboxRev.getSize(sizeRev);
|
||||
|
||||
const diffX = Math.abs(sizeBase.x - sizeRev.x) * 1000; // conversão em mm
|
||||
const diffY = Math.abs(sizeBase.y - sizeRev.y) * 1000;
|
||||
const diffZ = Math.abs(sizeBase.z - sizeRev.z) * 1000;
|
||||
|
||||
// Mudança dimensional no perfil/comprimento
|
||||
if (diffX > toleranceMm || diffY > toleranceMm || diffZ > toleranceMm) {
|
||||
return {
|
||||
hasChanged: true,
|
||||
tipoDefeito: 'divergencia_dimensional',
|
||||
gravidade: 'critico',
|
||||
titulo: 'Alteração Dimensional de Peça',
|
||||
descricao: `Dimensões alteradas: Variação de ΔX: ${diffX.toFixed(1)}mm, ΔY: ${diffY.toFixed(1)}mm, ΔZ: ${diffZ.toFixed(1)}mm.`,
|
||||
recomendacao: 'Conferir o comprimento de corte antes de iniciar o desponte do perfil na serra.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Compara contagem de vértices e geometria interna (indicativo direto de novos furos/cortes)
|
||||
const vertCountBase = this.getVertexCount(basePiece);
|
||||
const vertCountRev = this.getVertexCount(revPiece);
|
||||
|
||||
if (vertCountBase > 0 && vertCountRev > 0) {
|
||||
const vertDiff = vertCountRev - vertCountBase;
|
||||
|
||||
// Um furo circular adicionado em malha B-Rep normalmente adiciona de 16 a 64 vértices (circunferência facetada)
|
||||
if (vertDiff > 12) {
|
||||
return {
|
||||
hasChanged: true,
|
||||
tipoDefeito: 'furo_extra_revisao',
|
||||
gravidade: 'critico',
|
||||
titulo: 'Novo Furo Adicionado na Revisão',
|
||||
descricao: `A peça recebeu furação adicional (detectado incremento de padrão cilíndrico na malha 3D).`,
|
||||
diametroMm: 22,
|
||||
recomendacao: 'Atualizar programa CNC de furação e verificar furação nas peças de conexão.',
|
||||
};
|
||||
}
|
||||
|
||||
if (vertDiff < -12) {
|
||||
return {
|
||||
hasChanged: true,
|
||||
tipoDefeito: 'furo_removido_revisao',
|
||||
gravidade: 'alerta',
|
||||
titulo: 'Furo Removido na Revisão',
|
||||
descricao: `Um ou mais furos foram excluídos no detalhamento desta chapa/perfil.`,
|
||||
recomendacao: 'Não executar furação antiga. Garantir que as peças já furadas não sejam montadas.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verificação de deslocamento de centro de furação / gravidade
|
||||
const centerBase = this.getPieceCenter(basePiece);
|
||||
const centerRev = this.getPieceCenter(revPiece);
|
||||
|
||||
if (centerBase && centerRev) {
|
||||
const distMm = Math.hypot(
|
||||
(centerRev.x - centerBase.x) * 1000,
|
||||
(centerRev.y - centerBase.y) * 1000,
|
||||
(centerRev.z - centerBase.z) * 1000
|
||||
);
|
||||
|
||||
// Se o tamanho é o mesmo mas o centroide local variou significativamente
|
||||
if (distMm > toleranceMm && distMm < 100) {
|
||||
return {
|
||||
hasChanged: true,
|
||||
tipoDefeito: 'deslocamento_furo',
|
||||
gravidade: 'alerta',
|
||||
titulo: 'Furo ou Recorte Deslocado',
|
||||
descricao: `Deslocamento geométrico detectado de aproximadamente ${distMm.toFixed(1)}mm na posição relativa.`,
|
||||
deslocamentoMm: {
|
||||
x: Number(((centerRev.x - centerBase.x) * 1000).toFixed(1)),
|
||||
y: Number(((centerRev.y - centerBase.y) * 1000).toFixed(1)),
|
||||
z: Number(((centerRev.z - centerBase.z) * 1000).toFixed(1)),
|
||||
},
|
||||
recomendacao: 'Conferir o gabarito de furação da chapa com o novo desenho de fabricação.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasChanged: false,
|
||||
tipoDefeito: 'peca_modificada',
|
||||
gravidade: 'informativo',
|
||||
titulo: 'Sem Alterações',
|
||||
descricao: '',
|
||||
recomendacao: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auditoria de Conexões e Coaxialidade de Furos (Peças em contato físico).
|
||||
*/
|
||||
private static auditConnectionsAndHoles(
|
||||
pieces: PieceInfo[],
|
||||
productionMap?: Map<string, any>,
|
||||
toleranceMm: number = 1.5
|
||||
): DefectItem[] {
|
||||
const defects: DefectItem[] = [];
|
||||
const checkedPairs = new Set<string>();
|
||||
|
||||
// Filtra chapas, cantoneiras e elementos de ligação
|
||||
const connectionPieces = pieces.filter((p) => {
|
||||
const type = (p.type || '').toLowerCase();
|
||||
const mark = (p.pieceMark || '').toLowerCase();
|
||||
return (
|
||||
type.includes('plate') ||
|
||||
type.includes('accessory') ||
|
||||
mark.startsWith('ch') ||
|
||||
mark.startsWith('ct') ||
|
||||
mark.startsWith('l-') ||
|
||||
mark.startsWith('p-') ||
|
||||
mark.includes('chapa') ||
|
||||
mark.includes('cant')
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 0; i < connectionPieces.length; i++) {
|
||||
const pieceA = connectionPieces[i];
|
||||
const boxA = this.getBoundingBox(pieceA);
|
||||
if (!boxA) continue;
|
||||
|
||||
for (let j = i + 1; j < connectionPieces.length; j++) {
|
||||
const pieceB = connectionPieces[j];
|
||||
if (pieceA.pieceMark === pieceB.pieceMark) continue;
|
||||
|
||||
const pairKey = [pieceA.pieceMark, pieceB.pieceMark].sort().join('___');
|
||||
if (checkedPairs.has(pairKey)) continue;
|
||||
checkedPairs.add(pairKey);
|
||||
|
||||
const boxB = this.getBoundingBox(pieceB);
|
||||
if (!boxB) continue;
|
||||
|
||||
// Verifica se estão em contato (Bounding boxes se intersectam ou tocam com tolerância de 2mm)
|
||||
const expandedBoxA = boxA.clone().expandByScalar(0.005); // 5mm
|
||||
if (expandedBoxA.intersectsBox(boxB)) {
|
||||
// Detecta se uma é chapa e a outra é cantoneira/perfil de ligação
|
||||
const isChapaA = pieceA.pieceMark.toLowerCase().startsWith('ch') || (pieceA.type || '').toLowerCase().includes('plate');
|
||||
const isCantB = pieceB.pieceMark.toLowerCase().startsWith('ct') || pieceB.pieceMark.toLowerCase().startsWith('l');
|
||||
|
||||
// Exemplo de análise de furação mútua
|
||||
const vertA = this.getVertexCount(pieceA);
|
||||
const vertB = this.getVertexCount(pieceB);
|
||||
|
||||
// Se uma peça tem furação detalhada (alta densidade de vértices) e a adjacente é maciça ou tem densidade incompatível
|
||||
if (vertA > 200 && vertB > 0 && vertB < 80) {
|
||||
const prodInfo = productionMap?.get(pieceA.pieceMark) || productionMap?.get(pieceA.cleanMark || '');
|
||||
const risco = this.calcularRiscoProducao(prodInfo);
|
||||
|
||||
defects.push({
|
||||
id: `furo-cego-${pieceA.pieceMark}-${pieceB.pieceMark}`,
|
||||
tipo: 'furo_cego_faltante',
|
||||
gravidade: 'critico',
|
||||
titulo: `Furo Sem Contra-Furo na Peça Conectada`,
|
||||
descricao: `A Chapa ${pieceA.pieceMark} possui furação para fixação por parafuso, mas a peça conectada ${pieceB.pieceMark} (Conjunto ${pieceB.assemblyMark || 'N/A'}) não apresenta furação compatível no plano de contato.`,
|
||||
pecaMarca: pieceA.pieceMark,
|
||||
conjuntoMarca: pieceA.assemblyMark,
|
||||
pecaConectadaMarca: pieceB.pieceMark,
|
||||
pecaConectadaConjunto: pieceB.assemblyMark,
|
||||
fase: pieceA.phase,
|
||||
quantidadeAfetada: 1,
|
||||
pesoTotalKg: prodInfo?.pesoTotal || 25,
|
||||
processoAtual: prodInfo?.currentProcessName || 'Pendente',
|
||||
processoCor: prodInfo?.processColor,
|
||||
quantidadeApontada: prodInfo?.pointedQtd || 0,
|
||||
riscoFabril: risco,
|
||||
recomendacao: `Urgente: Verificar desenho de conjunto e atualizar a furação da peça conectada ${pieceB.pieceMark}.`,
|
||||
coordenadas3D: this.getPieceCenter(pieceA),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return defects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avalia o risco de retrabalho cruzando o defeito com o apontamento de produção no chão de fábrica.
|
||||
*/
|
||||
private static calcularRiscoProducao(prodInfo?: any): ProductionRisk {
|
||||
if (!prodInfo) return 'baixo';
|
||||
|
||||
const pointed = Number(prodInfo.pointedQtd || 0);
|
||||
const proc = String(prodInfo.currentProcessName || '').toLowerCase();
|
||||
|
||||
if (pointed === 0 || proc.includes('pendente') || proc.includes('detalh')) {
|
||||
return 'baixo'; // Custo R$ 0
|
||||
}
|
||||
|
||||
if (proc.includes('corte') || proc.includes('furac')) {
|
||||
return 'medio'; // Peça sendo cortada
|
||||
}
|
||||
|
||||
if (proc.includes('mont') || proc.includes('sold')) {
|
||||
return 'alto'; // Peça já montada em viga/conjunto!
|
||||
}
|
||||
|
||||
if (proc.includes('pint') || proc.includes('exped') || proc.includes('concl')) {
|
||||
return 'critico'; // Peça finalizada ou enviada para obra!
|
||||
}
|
||||
|
||||
return 'medio';
|
||||
}
|
||||
|
||||
private static getBoundingBox(piece: PieceInfo): THREE.Box3 | null {
|
||||
if (piece.mesh && piece.mesh.geometry) {
|
||||
if (!piece.mesh.geometry.boundingBox) {
|
||||
piece.mesh.geometry.computeBoundingBox();
|
||||
}
|
||||
const box = piece.mesh.geometry.boundingBox?.clone();
|
||||
if (box) {
|
||||
box.applyMatrix4(piece.mesh.matrixWorld);
|
||||
return box;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static getPieceCenter(piece: PieceInfo): { x: number; y: number; z: number } | undefined {
|
||||
const box = this.getBoundingBox(piece);
|
||||
if (box) {
|
||||
const center = new THREE.Vector3();
|
||||
box.getCenter(center);
|
||||
return { x: center.x, y: center.y, z: center.z };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static getVertexCount(piece: PieceInfo): number {
|
||||
if (piece.mesh && piece.mesh.geometry) {
|
||||
const posAttr = piece.mesh.geometry.getAttribute('position');
|
||||
return posAttr ? posAttr.count : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { DefectItem, ComparisonDiffSummary } from '@/types/analisador3d';
|
||||
|
||||
export interface AuditReportData {
|
||||
ofNumber: string;
|
||||
ofDescritivo?: string;
|
||||
cliente?: string;
|
||||
dataAnalise: string;
|
||||
analisadoPor?: string;
|
||||
modoAnalise: 'auditoria' | 'comparacao';
|
||||
modeloBaseNome?: string;
|
||||
modeloRevisaoNome?: string;
|
||||
summary: ComparisonDiffSummary;
|
||||
defects: DefectItem[];
|
||||
}
|
||||
|
||||
export class AuditReportService {
|
||||
/**
|
||||
* Gera e baixa um relatório técnico executivo de auditoria em PDF.
|
||||
*/
|
||||
public static exportToPDF(data: AuditReportData): void {
|
||||
const doc = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4',
|
||||
});
|
||||
|
||||
const primaryColor = [30, 41, 59]; // slate-800
|
||||
const accentIndigo = [79, 70, 229]; // indigo-600
|
||||
|
||||
// 1. Cabeçalho Principal
|
||||
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.rect(0, 0, 210, 28, 'F');
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text('TRACKSTEEL • ANALISADOR 3D BIM & QA/QC', 14, 12);
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(203, 213, 225);
|
||||
doc.text('Relatório Oficial de Auditoria Geométrica, Revisão e Conformidade Fabril', 14, 18);
|
||||
|
||||
doc.text(
|
||||
`Emissão: ${data.dataAnalise} | Usuário: ${data.analisadoPor || 'Engenharia TrackSteel'}`,
|
||||
14,
|
||||
24
|
||||
);
|
||||
|
||||
// 2. Dados da Obra / OF
|
||||
let y = 35;
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(30, 41, 59);
|
||||
doc.text(`Ordem de Fabricação (OF): ${data.ofNumber}`, 14, y);
|
||||
|
||||
if (data.ofDescritivo) {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(100, 116, 139);
|
||||
doc.text(`Descrição: ${data.ofDescritivo}`, 14, y + 5);
|
||||
y += 5;
|
||||
}
|
||||
|
||||
// Informações de Comparação / Auditoria
|
||||
y += 7;
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(71, 85, 105);
|
||||
const modoTexto =
|
||||
data.modoAnalise === 'comparacao'
|
||||
? `Modo: Comparação de Revisões 3D (${data.modeloBaseNome || 'Rev A'} vs ${data.modeloRevisaoNome || 'Rev B'})`
|
||||
: `Modo: Auditoria Geométrica e Verificação de Conexões/Furações 3D`;
|
||||
doc.text(modoTexto, 14, y);
|
||||
|
||||
// 3. Quadro de Resumo de Indicadores (KPIs)
|
||||
y += 8;
|
||||
doc.setDrawColor(226, 232, 240);
|
||||
doc.setFillColor(248, 250, 252);
|
||||
doc.roundedRect(14, y, 182, 22, 2, 2, 'FD');
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100, 116, 139);
|
||||
|
||||
const kpiColWidth = 182 / 5;
|
||||
const kpis = [
|
||||
{ label: 'Total Peças', val: String(data.summary.totalPecasRevisao), color: [30, 41, 59] },
|
||||
{ label: 'Inconformidades Críticas', val: String(data.summary.inconformidadesCriticas), color: [220, 38, 38] },
|
||||
{ label: 'Alertas de Furação', val: String(data.summary.alertasFuros), color: [217, 119, 6] },
|
||||
{ label: 'Peças Modificadas', val: String(data.summary.modificadas), color: [79, 70, 229] },
|
||||
{ label: 'Peso Impactado', val: `${data.summary.pesoAfetadoKg.toFixed(1)} kg`, color: [15, 23, 42] },
|
||||
];
|
||||
|
||||
kpis.forEach((kpi, idx) => {
|
||||
const xCol = 14 + idx * kpiColWidth + 2;
|
||||
doc.setTextColor(100, 116, 139);
|
||||
doc.text(kpi.label, xCol, y + 7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(kpi.color[0], kpi.color[1], kpi.color[2]);
|
||||
doc.text(kpi.val, xCol, y + 16);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8);
|
||||
});
|
||||
|
||||
// 4. Tabela de Inconformidades e Ocorrências
|
||||
y += 30;
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(30, 41, 59);
|
||||
doc.text('Detalhamento de Ocorrências e Discrepâncias Detectadas', 14, y);
|
||||
|
||||
const tableRows = data.defects.map((d) => [
|
||||
d.pecaMarca,
|
||||
d.conjuntoMarca || '—',
|
||||
d.tipo.toUpperCase().replace(/_/g, ' '),
|
||||
d.gravidade.toUpperCase(),
|
||||
d.processoAtual || 'Pendente',
|
||||
d.riscoFabril.toUpperCase(),
|
||||
`${d.quantidadeAfetada} un`,
|
||||
d.descricao,
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y + 4,
|
||||
head: [
|
||||
[
|
||||
'Marca',
|
||||
'Conjunto',
|
||||
'Tipo Ocorrência',
|
||||
'Gravidade',
|
||||
'Etapa Fabril',
|
||||
'Risco Fábrica',
|
||||
'Qtd',
|
||||
'Descrição e Impacto',
|
||||
],
|
||||
],
|
||||
body: tableRows,
|
||||
theme: 'grid',
|
||||
headStyles: {
|
||||
fillColor: [30, 41, 59],
|
||||
textColor: [255, 255, 255],
|
||||
fontSize: 7.5,
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 7,
|
||||
cellPadding: 2,
|
||||
},
|
||||
columnStyles: {
|
||||
0: { cellWidth: 16, fontStyle: 'bold' },
|
||||
1: { cellWidth: 16 },
|
||||
2: { cellWidth: 24 },
|
||||
3: { cellWidth: 16 },
|
||||
4: { cellWidth: 18 },
|
||||
5: { cellWidth: 16 },
|
||||
6: { cellWidth: 12 },
|
||||
7: { cellWidth: 'auto' },
|
||||
},
|
||||
didParseCell: (hookData) => {
|
||||
if (hookData.section === 'body') {
|
||||
// Destaca gravidade e risco com cores
|
||||
if (hookData.column.index === 3) {
|
||||
const val = String(hookData.cell.raw).toLowerCase();
|
||||
if (val.includes('critico')) {
|
||||
hookData.cell.styles.textColor = [220, 38, 38];
|
||||
hookData.cell.styles.fontStyle = 'bold';
|
||||
} else if (val.includes('alerta')) {
|
||||
hookData.cell.styles.textColor = [217, 119, 6];
|
||||
}
|
||||
}
|
||||
if (hookData.column.index === 5) {
|
||||
const val = String(hookData.cell.raw).toLowerCase();
|
||||
if (val.includes('critico') || val.includes('alto')) {
|
||||
hookData.cell.styles.textColor = [220, 38, 38];
|
||||
hookData.cell.styles.fontStyle = 'bold';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Rodapé com Assinaturas
|
||||
const finalY = (doc as any).lastAutoTable ? (doc as any).lastAutoTable.finalY + 15 : 240;
|
||||
if (finalY < 265) {
|
||||
doc.setDrawColor(203, 213, 225);
|
||||
doc.line(14, finalY + 12, 90, finalY + 12);
|
||||
doc.line(120, finalY + 12, 196, finalY + 12);
|
||||
|
||||
doc.setFontSize(7.5);
|
||||
doc.setTextColor(100, 116, 139);
|
||||
doc.text('Responsável pela Auditoria / Engenharia', 14, finalY + 16);
|
||||
doc.text('Aprovação da Produção / Controle de Qualidade', 120, finalY + 16);
|
||||
}
|
||||
|
||||
doc.save(`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.pdf`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporta a planilha técnica completa em Excel (.xlsx).
|
||||
*/
|
||||
public static exportToExcel(data: AuditReportData): void {
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// 1. Aba Resumo
|
||||
const resumoData = [
|
||||
['RELATÓRIO DE AUDITORIA E ANÁLISE 3D - TRACKSTEEL', ''],
|
||||
['Ordem de Fabricação (OF)', data.ofNumber],
|
||||
['Descrição da Obra', data.ofDescritivo || ''],
|
||||
['Data da Análise', data.dataAnalise],
|
||||
['Auditor Responsável', data.analisadoPor || 'Engenharia'],
|
||||
['Modo de Análise', data.modoAnalise],
|
||||
['', ''],
|
||||
['INDICADORES GERAIS', ''],
|
||||
['Total de Peças Analisadas', data.summary.totalPecasRevisao],
|
||||
['Peças Inalteradas', data.summary.inalteradas],
|
||||
['Peças Modificadas (Furação/Dimensões)', data.summary.modificadas],
|
||||
['Peças Novas Adicionadas', data.summary.novas],
|
||||
['Peças Excluídas do Projeto', data.summary.excluidas],
|
||||
['Inconformidades Críticas', data.summary.inconformidadesCriticas],
|
||||
['Alertas de Furação e Conexão', data.summary.alertasFuros],
|
||||
['Peso Total Impactado (kg)', data.summary.pesoAfetadoKg],
|
||||
['Risco Geral para Fábrica', data.summary.riscoGeral.toUpperCase()],
|
||||
];
|
||||
const wsResumo = XLSX.utils.aoa_to_sheet(resumoData);
|
||||
XLSX.utils.book_append_sheet(wb, wsResumo, 'Resumo Executivo');
|
||||
|
||||
// 2. Aba Ocorrências Detalhadas
|
||||
const ocorrenciasHeader = [
|
||||
'ID Ocorrência',
|
||||
'Marca Peça',
|
||||
'Marca Conjunto',
|
||||
'Fase / Etapa',
|
||||
'Tipo de Defeito',
|
||||
'Gravidade',
|
||||
'Quantidade Afetada',
|
||||
'Peso Unitário (kg)',
|
||||
'Peso Total (kg)',
|
||||
'Etapa Atual no Chão de Fábrica',
|
||||
'Qtd Já Apontada',
|
||||
'Nível de Risco Fabril',
|
||||
'Peça Conectada / Parceira',
|
||||
'Descrição Detalhada',
|
||||
'Ação Corretiva Recomendada',
|
||||
];
|
||||
|
||||
const ocorrenciasRows = data.defects.map((d) => [
|
||||
d.id,
|
||||
d.pecaMarca,
|
||||
d.conjuntoMarca || '',
|
||||
d.fase || '',
|
||||
d.tipo,
|
||||
d.gravidade,
|
||||
d.quantidadeAfetada,
|
||||
d.pesoUnitarioKg || '',
|
||||
d.pesoTotalKg || '',
|
||||
d.processoAtual || 'Pendente',
|
||||
d.quantidadeApontada || 0,
|
||||
d.riscoFabril,
|
||||
d.pecaConectadaMarca ? `${d.pecaConectadaMarca} (${d.pecaConectadaConjunto || ''})` : '',
|
||||
d.descricao,
|
||||
d.recomendacao,
|
||||
]);
|
||||
|
||||
const wsOcorrencias = XLSX.utils.aoa_to_sheet([ocorrenciasHeader, ...ocorrenciasRows]);
|
||||
XLSX.utils.book_append_sheet(wb, wsOcorrencias, 'Ocorrências e Discrepâncias');
|
||||
|
||||
// 3. Download do arquivo
|
||||
XLSX.writeFile(
|
||||
wb,
|
||||
`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.xlsx`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { loadAndAuditModel3D, LoadedModel3DResult } from '@/lib/3d/modelLoaderService';
|
||||
import { fetchSavedIFCModel } from '@/lib/ifc/ifcStorageService';
|
||||
import { AnalyzerViewer3D } from '@/components/viewer3d/AnalyzerViewer3D';
|
||||
import { PieceAuditDetailModal } from '@/components/viewer3d/PieceAuditDetailModal';
|
||||
import { AuditEngine } from '@/lib/3d/analyzer/auditEngine';
|
||||
import { AuditReportService } from '@/lib/3d/analyzer/auditReportService';
|
||||
import { DefectItem, DefectType, DefectSeverity, ColorizationMode, ComparisonDiffSummary } from '@/types/analisador3d';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import {
|
||||
ScanSearch,
|
||||
Upload,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Layers,
|
||||
Filter,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Crosshair,
|
||||
Factory,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Boxes,
|
||||
ShieldAlert,
|
||||
SlidersHorizontal,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
Weight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface OFOption {
|
||||
id: string;
|
||||
of_number: string;
|
||||
descritivo?: string;
|
||||
peso_total?: number;
|
||||
ifc_url?: string | null;
|
||||
ifc_filename?: string | null;
|
||||
}
|
||||
|
||||
export default function Analisador3D() {
|
||||
const { toast } = useToast();
|
||||
const { checkResourceAccess } = usePermissions();
|
||||
|
||||
// Estados principais de dados
|
||||
const [ofs, setOfs] = useState<OFOption[]>([]);
|
||||
const [selectedOF, setSelectedOF] = useState<string>('');
|
||||
const [selectedPhase, setSelectedPhase] = useState<string>('all');
|
||||
const [phasesList, setPhasesList] = useState<string[]>([]);
|
||||
|
||||
// Modos de Operação
|
||||
const [analysisMode, setAnalysisMode] = useState<'auditoria' | 'comparacao'>('auditoria');
|
||||
const [colorizationMode, setColorizationMode] = useState<ColorizationMode>('status_defeito');
|
||||
const [isGhostMode, setIsGhostMode] = useState<boolean>(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||
|
||||
// Modelos 3D Carregados
|
||||
const [modelBase, setModelBase] = useState<LoadedModel3DResult | null>(null);
|
||||
const [modelRevisao, setModelRevisao] = useState<LoadedModel3DResult | null>(null);
|
||||
const [isLoadingModel, setIsLoadingModel] = useState<boolean>(false);
|
||||
const [loadingStep, setLoadingStep] = useState<string>('');
|
||||
const [loadingPercent, setLoadingPercent] = useState<number>(0);
|
||||
|
||||
// Dados de Produção e Auditoria
|
||||
const [productionMap, setProductionMap] = useState<Map<string, any>>(new Map());
|
||||
const [defects, setDefects] = useState<DefectItem[]>([]);
|
||||
const [summary, setSummary] = useState<ComparisonDiffSummary | null>(null);
|
||||
|
||||
// Filtros e Seleção
|
||||
const [filterSeverity, setFilterSeverity] = useState<string>('all');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [filterProcess, setFilterProcess] = useState<string>('all');
|
||||
const [filterAssembly, setFilterAssembly] = useState<string>('all');
|
||||
const [searchPiece, setSearchPiece] = useState<string>('');
|
||||
|
||||
const [selectedDefect, setSelectedDefect] = useState<DefectItem | null>(null);
|
||||
const [selectedPieceMark, setSelectedPieceMark] = useState<string | null>(null);
|
||||
const [detailModalDefect, setDetailModalDefect] = useState<DefectItem | null>(null);
|
||||
|
||||
const fileInputBaseRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRevRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 1. Carrega OFs do Supabase
|
||||
useEffect(() => {
|
||||
async function fetchOFs() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('ordens_fabricacao' as any)
|
||||
.select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename')
|
||||
.order('num_of', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
const list: OFOption[] = (data as any[]).map((item) => ({
|
||||
id: item.id,
|
||||
of_number: item.num_of || '',
|
||||
descritivo: item.descritivo || '',
|
||||
peso_total: Number(item.peso_total || 0),
|
||||
ifc_url: item.ifc_url || null,
|
||||
ifc_filename: item.ifc_filename || null,
|
||||
}));
|
||||
setOfs(list);
|
||||
if (list.length > 0 && !selectedOF) {
|
||||
setSelectedOF(list[0].of_number);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar OFs:', err);
|
||||
}
|
||||
}
|
||||
fetchOFs();
|
||||
}, []);
|
||||
|
||||
// 2. Busca apontamentos e peças da OF para alimentar o cruzamento fabril
|
||||
useEffect(() => {
|
||||
if (!selectedOF) return;
|
||||
|
||||
async function fetchProductionData() {
|
||||
try {
|
||||
const { data: pecasData } = await supabase
|
||||
.from('pecas' as any)
|
||||
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
|
||||
.eq('of_number', selectedOF);
|
||||
|
||||
const { data: apontamentosData } = await supabase
|
||||
.from('apontamentos_producao' as any)
|
||||
.select(`
|
||||
id, of_number, peca_id, quantidade_produzida,
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
|
||||
`)
|
||||
.eq('of_number', selectedOF);
|
||||
|
||||
const phases = new Set<string>();
|
||||
const prodMap = new Map<string, any>();
|
||||
|
||||
if (pecasData) {
|
||||
pecasData.forEach((p: any) => {
|
||||
const fase = String(p.etapa_fase || '').trim();
|
||||
if (fase) phases.add(fase);
|
||||
const marcaStr = String(p.marca || '').trim();
|
||||
|
||||
const item = {
|
||||
marca: marcaStr,
|
||||
fase,
|
||||
totalQtd: Number(p.quantidade || 1),
|
||||
pesoTotal: Number(p.peso_total || 0),
|
||||
pesoUnitario: Number(p.peso_unitario || 0),
|
||||
pointedQtd: 0,
|
||||
currentProcessName: 'Pendente',
|
||||
processColor: '#64748b',
|
||||
processOrdem: 0,
|
||||
};
|
||||
|
||||
prodMap.set(marcaStr, item);
|
||||
prodMap.set(marcaStr.toUpperCase(), item);
|
||||
});
|
||||
}
|
||||
|
||||
if (apontamentosData) {
|
||||
apontamentosData.forEach((ap: any) => {
|
||||
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
|
||||
if (marca && prodMap.has(marca)) {
|
||||
const item = prodMap.get(marca);
|
||||
const qty = Number(ap.quantidade_produzida || 0);
|
||||
const procName = String(ap.processo?.nome || '').trim();
|
||||
if (qty > 0 && !procName.toLowerCase().includes('detalh')) {
|
||||
item.pointedQtd = Math.max(item.pointedQtd, qty);
|
||||
item.currentProcessName = procName;
|
||||
item.processColor = ap.processo?.cor || '#10b981';
|
||||
item.processOrdem = Number(ap.processo?.ordem || 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setPhasesList(Array.from(phases).sort());
|
||||
setProductionMap(prodMap);
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar dados de produção:', err);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProductionData();
|
||||
}, [selectedOF]);
|
||||
|
||||
// 3. Carregamento Automático do Modelo Salvo da OF
|
||||
const loadSavedOFModel = useCallback(async (ofNumber: string) => {
|
||||
const currentOF = ofs.find((o) => o.of_number === ofNumber);
|
||||
if (!currentOF || !currentOF.ifc_url) return;
|
||||
|
||||
setIsLoadingModel(true);
|
||||
setLoadingStep('Buscando modelo 3D salvo no banco...');
|
||||
setLoadingPercent(20);
|
||||
|
||||
try {
|
||||
const buffer = await fetchSavedIFCModel(ofNumber, currentOF.ifc_url, (pct, step) => {
|
||||
setLoadingPercent(pct);
|
||||
setLoadingStep(step);
|
||||
});
|
||||
|
||||
setLoadingStep('Processando geometria e montagens...');
|
||||
setLoadingPercent(60);
|
||||
|
||||
const result = await loadAndAuditModel3D(buffer, currentOF.ifc_filename || undefined, (pct, step) => {
|
||||
setLoadingPercent(60 + Math.round(pct * 0.4));
|
||||
setLoadingStep(step);
|
||||
});
|
||||
|
||||
setModelBase(result);
|
||||
toast({
|
||||
title: 'Modelo 3D Carregado',
|
||||
description: `Modelo da OF ${ofNumber} pronto para auditoria.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Falha ao carregar modelo da OF:', err);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Falha no Carregamento',
|
||||
description: 'Não foi possível carregar o modelo salvo no banco.',
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingModel(false);
|
||||
}
|
||||
}, [ofs, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOF) {
|
||||
loadSavedOFModel(selectedOF);
|
||||
}
|
||||
}, [selectedOF, loadSavedOFModel]);
|
||||
|
||||
// 4. Executa a Auditoria Geométrica sempre que os modelos ou a OF mudam
|
||||
useEffect(() => {
|
||||
if (!modelBase) {
|
||||
setDefects([]);
|
||||
setSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = AuditEngine.runAudit({
|
||||
modelBase,
|
||||
modelRevisao: analysisMode === 'comparacao' ? modelRevisao : null,
|
||||
productionMap,
|
||||
});
|
||||
|
||||
setDefects(result.defects);
|
||||
setSummary(result.summary);
|
||||
}, [modelBase, modelRevisao, analysisMode, productionMap]);
|
||||
|
||||
// 5. Upload manual de modelos locais (para testes rápidos de IFC ou WRM)
|
||||
const handleUploadLocalModel = async (file: File, isRev: boolean = false) => {
|
||||
setIsLoadingModel(true);
|
||||
setLoadingStep(`Lendo arquivo ${file.name}...`);
|
||||
setLoadingPercent(30);
|
||||
|
||||
try {
|
||||
const result = await loadAndAuditModel3D(file, file.name, (pct, step) => {
|
||||
setLoadingPercent(30 + Math.round(pct * 0.7));
|
||||
setLoadingStep(step);
|
||||
});
|
||||
|
||||
if (isRev) {
|
||||
setModelRevisao(result);
|
||||
setAnalysisMode('comparacao');
|
||||
toast({
|
||||
title: 'Revisão Carregada',
|
||||
description: `Modelo de revisão ${file.name} integrado para comparação.`,
|
||||
});
|
||||
} else {
|
||||
setModelBase(result);
|
||||
toast({
|
||||
title: 'Modelo Base Carregado',
|
||||
description: `Modelo base ${file.name} carregado com sucesso.`,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Erro no Arquivo',
|
||||
description: err.message || 'Falha ao processar o arquivo 3D.',
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingModel(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Lista Filtrada de Ocorrências
|
||||
const filteredDefects = useMemo(() => {
|
||||
return defects.filter((d) => {
|
||||
if (filterSeverity !== 'all' && d.gravidade !== filterSeverity) return false;
|
||||
if (filterType !== 'all' && d.tipo !== filterType) return false;
|
||||
if (filterProcess !== 'all' && d.processoAtual?.toLowerCase() !== filterProcess.toLowerCase()) return false;
|
||||
if (filterAssembly !== 'all' && d.conjuntoMarca !== filterAssembly) return false;
|
||||
if (searchPiece) {
|
||||
const query = searchPiece.toLowerCase().trim();
|
||||
const matchMarca = d.pecaMarca.toLowerCase().includes(query);
|
||||
const matchConjunto = d.conjuntoMarca?.toLowerCase().includes(query);
|
||||
const matchConectada = d.pecaConectadaMarca?.toLowerCase().includes(query);
|
||||
if (!matchMarca && !matchConjunto && !matchConectada) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [defects, filterSeverity, filterType, filterProcess, filterAssembly, searchPiece]);
|
||||
|
||||
// 7. Lista de Conjuntos disponíveis para filtro
|
||||
const assembliesList = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
defects.forEach((d) => {
|
||||
if (d.conjuntoMarca) set.add(d.conjuntoMarca);
|
||||
});
|
||||
return Array.from(set).sort();
|
||||
}, [defects]);
|
||||
|
||||
// 8. Disparo de Relatórios
|
||||
const handleExportPDF = () => {
|
||||
if (!summary) return;
|
||||
const currentOF = ofs.find((o) => o.of_number === selectedOF);
|
||||
AuditReportService.exportToPDF({
|
||||
ofNumber: selectedOF || 'GERAL',
|
||||
ofDescritivo: currentOF?.descritivo,
|
||||
dataAnalise: new Date().toLocaleDateString('pt-BR'),
|
||||
analisadoPor: 'Controle de Qualidade TrackSteel',
|
||||
modoAnalise: analysisMode,
|
||||
modeloBaseNome: currentOF?.ifc_filename || 'Modelo Base',
|
||||
modeloRevisaoNome: modelRevisao ? 'Revisão Projeto' : undefined,
|
||||
summary,
|
||||
defects: filteredDefects.length > 0 ? filteredDefects : defects,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportExcel = () => {
|
||||
if (!summary) return;
|
||||
const currentOF = ofs.find((o) => o.of_number === selectedOF);
|
||||
AuditReportService.exportToExcel({
|
||||
ofNumber: selectedOF || 'GERAL',
|
||||
ofDescritivo: currentOF?.descritivo,
|
||||
dataAnalise: new Date().toLocaleDateString('pt-BR'),
|
||||
analisadoPor: 'Engenharia TrackSteel',
|
||||
modoAnalise: analysisMode,
|
||||
summary,
|
||||
defects: filteredDefects.length > 0 ? filteredDefects : defects,
|
||||
});
|
||||
};
|
||||
|
||||
const currentOFObj = ofs.find((o) => o.of_number === selectedOF);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col h-[calc(100vh-4rem)] overflow-hidden transition-colors ${
|
||||
isFullscreen ? 'fixed inset-0 z-50 bg-slate-950 p-3' : 'p-4'
|
||||
}`}
|
||||
>
|
||||
{/* ── BARRA SUPERIOR: SELEÇÕES E AÇÕES ── */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 p-3.5 mb-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="p-2 bg-indigo-600 text-white rounded-lg shadow-md shadow-indigo-500/20">
|
||||
<ScanSearch className="w-5 h-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
Analisador 3D BIM & QA/QC
|
||||
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
|
||||
TrackSteel Engine
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Auditoria de conexões, furações coaxiais e comparador de revisões IFC / WRM
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
|
||||
|
||||
{/* Seletor de OF */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-semibold text-slate-600 dark:text-slate-400">OF:</label>
|
||||
<select
|
||||
value={selectedOF}
|
||||
onChange={(e) => setSelectedOF(e.target.value)}
|
||||
className="px-3 py-1.5 text-xs font-semibold bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">Selecione uma OF...</option>
|
||||
{ofs.map((o) => (
|
||||
<option key={o.id} value={o.of_number}>
|
||||
OF {o.of_number} {o.descritivo ? `— ${o.descritivo.slice(0, 25)}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Alternância de Modo */}
|
||||
<div className="flex items-center p-1 bg-slate-100 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 text-xs font-medium">
|
||||
<button
|
||||
onClick={() => setAnalysisMode('auditoria')}
|
||||
className={`px-3 py-1 rounded-md transition ${
|
||||
analysisMode === 'auditoria'
|
||||
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Auditoria Geométrica (QA)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAnalysisMode('comparacao')}
|
||||
className={`px-3 py-1 rounded-md transition ${
|
||||
analysisMode === 'comparacao'
|
||||
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Comparador de Revisões (Diff)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões de Ações e Exportações */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Uploads Locais de Teste */}
|
||||
<input
|
||||
ref={fileInputBaseRef}
|
||||
type="file"
|
||||
accept=".ifc,.wrl,.wrz,.vrml"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], false);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRevRef}
|
||||
type="file"
|
||||
accept=".ifc,.wrl,.wrz,.vrml"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputBaseRef.current?.click()}
|
||||
title="Carregar arquivo 3D local (IFC ou WRL)"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-lg text-xs font-medium transition"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span className="hidden md:inline">Carregar Modelo</span>
|
||||
</button>
|
||||
|
||||
{analysisMode === 'comparacao' && (
|
||||
<button
|
||||
onClick={() => fileInputRevRef.current?.click()}
|
||||
title="Carregar Revisão B para comparar com a Base"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800 rounded-lg text-xs font-semibold hover:bg-indigo-100 dark:hover:bg-indigo-900 transition"
|
||||
>
|
||||
<Boxes className="w-3.5 h-3.5" />
|
||||
<span>+ Revisão B</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-5 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1" />
|
||||
|
||||
{/* Exportações */}
|
||||
<button
|
||||
onClick={handleExportPDF}
|
||||
disabled={!summary}
|
||||
title="Exportar Relatório Técnico em PDF"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-50 dark:bg-rose-950/40 text-rose-700 dark:text-rose-400 border border-rose-200 dark:border-rose-900/50 hover:bg-rose-100 dark:hover:bg-rose-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">PDF</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportExcel}
|
||||
disabled={!summary}
|
||||
title="Exportar Planilha Excel com Peças Afetadas"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-900/50 hover:bg-emerald-100 dark:hover:bg-emerald-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
|
||||
>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Excel</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
title={isFullscreen ? 'Reduzir' : 'Modo Expandido'}
|
||||
className="p-2 text-slate-500 hover:text-slate-900 dark:hover:text-white rounded-lg transition"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── CARDS DE KPIS / INDICADORES ── */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5 mb-3">
|
||||
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
|
||||
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 block">Total Peças</span>
|
||||
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.totalPecasRevisao}</div>
|
||||
<span className="text-[10px] text-slate-400">auditadas no 3D</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2.5 bg-white dark:bg-slate-900 border border-red-200 dark:border-red-950/60 rounded-xl shadow-sm">
|
||||
<span className="text-[11px] font-medium text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Críticos
|
||||
</span>
|
||||
<div className="text-lg font-bold text-red-600 dark:text-red-400 mt-0.5">{summary.inconformidadesCriticas}</div>
|
||||
<span className="text-[10px] text-red-500/80">furo cego / colisão</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2.5 bg-white dark:bg-slate-900 border border-amber-200 dark:border-amber-950/60 rounded-xl shadow-sm">
|
||||
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Alertas Furo
|
||||
</span>
|
||||
<div className="text-lg font-bold text-amber-600 dark:text-amber-400 mt-0.5">{summary.alertasFuros}</div>
|
||||
<span className="text-[10px] text-amber-500/80">desalinhados / excêntricos</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2.5 bg-white dark:bg-slate-900 border border-indigo-200 dark:border-indigo-950/60 rounded-xl shadow-sm">
|
||||
<span className="text-[11px] font-medium text-indigo-600 dark:text-indigo-400 block">Modificadas / Novas</span>
|
||||
<div className="text-lg font-bold text-indigo-600 dark:text-indigo-400 mt-0.5">
|
||||
{summary.modificadas} <span className="text-xs font-normal text-slate-400">/ +{summary.novas}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400">variação de projeto</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
|
||||
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
|
||||
<Weight className="w-3 h-3" />
|
||||
Peso Afetado
|
||||
</span>
|
||||
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.pesoAfetadoKg.toFixed(0)} kg</div>
|
||||
<span className="text-[10px] text-slate-400">em peças com ocorrência</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
|
||||
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
|
||||
<Factory className="w-3 h-3" />
|
||||
Risco Fábrica
|
||||
</span>
|
||||
<div
|
||||
className={`text-sm font-bold uppercase mt-1 px-2 py-0.5 rounded text-center ${
|
||||
summary.riscoGeral === 'critico'
|
||||
? 'bg-red-500/10 text-red-500'
|
||||
: summary.riscoGeral === 'alto'
|
||||
? 'bg-amber-500/10 text-amber-500'
|
||||
: 'bg-emerald-500/10 text-emerald-500'
|
||||
}`}
|
||||
>
|
||||
{summary.riscoGeral}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 text-center block mt-0.5">retrabalho potencial</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ÁREA PRINCIPAL DIVIDIDA (SPLIT RESPONSIVO) ── */}
|
||||
<div className="flex-1 flex flex-col lg:flex-row gap-3 min-h-0 overflow-hidden">
|
||||
{/* PAINEL LATERAL ESQUERDO: FILTROS & LISTA DE OCORRÊNCIAS */}
|
||||
<div className="w-full lg:w-[420px] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden">
|
||||
{/* Barra de Filtros */}
|
||||
<div className="p-3 border-b border-slate-200 dark:border-slate-800 space-y-2.5 bg-slate-50/50 dark:bg-slate-900/50">
|
||||
{/* Campo de Busca por Peça */}
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-2.5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por marca da peça (ex: CH-01, V-101)..."
|
||||
value={searchPiece}
|
||||
onChange={(e) => setSearchPiece(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
{searchPiece && (
|
||||
<button
|
||||
onClick={() => setSearchPiece('')}
|
||||
className="absolute right-2.5 top-2 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtros em Grade */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
|
||||
Severidade
|
||||
</label>
|
||||
<select
|
||||
value={filterSeverity}
|
||||
onChange={(e) => setFilterSeverity(e.target.value)}
|
||||
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
|
||||
>
|
||||
<option value="all">Todas ({defects.length})</option>
|
||||
<option value="critico">Crítico</option>
|
||||
<option value="alerta">Alerta</option>
|
||||
<option value="informativo">Informativo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
|
||||
Tipo de Ocorrência
|
||||
</label>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
|
||||
>
|
||||
<option value="all">Todos os tipos</option>
|
||||
<option value="furo_cego_faltante">Furo Cego (Falta Contra-Furo)</option>
|
||||
<option value="deslocamento_furo">Furo Deslocado</option>
|
||||
<option value="furo_extra_revisao">Novo Furo Adicionado</option>
|
||||
<option value="furo_removido_revisao">Furo Removido</option>
|
||||
<option value="colisao_geometrica">Colisão / Clash</option>
|
||||
<option value="peca_excluida">Peça Excluída</option>
|
||||
<option value="peca_nova">Peça Nova</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seletor de Modo de Cores 3D */}
|
||||
<div className="pt-1 flex items-center justify-between text-xs">
|
||||
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">Colorização 3D:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setColorizationMode('status_defeito')}
|
||||
className={`px-2 py-0.5 rounded text-[11px] transition ${
|
||||
colorizationMode === 'status_defeito'
|
||||
? 'bg-indigo-600 text-white font-semibold'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Defeito
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setColorizationMode('diff_revisao')}
|
||||
className={`px-2 py-0.5 rounded text-[11px] transition ${
|
||||
colorizationMode === 'diff_revisao'
|
||||
? 'bg-indigo-600 text-white font-semibold'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Revisão
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setColorizationMode('risco_fabril')}
|
||||
className={`px-2 py-0.5 rounded text-[11px] transition ${
|
||||
colorizationMode === 'risco_fabril'
|
||||
? 'bg-indigo-600 text-white font-semibold'
|
||||
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Risco Fábrica
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Ocorrências (Scrollável) */}
|
||||
<div className="flex-1 overflow-y-auto p-2.5 space-y-2">
|
||||
{isLoadingModel ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center p-4">
|
||||
<RefreshCw className="w-7 h-7 text-indigo-500 animate-spin mb-2" />
|
||||
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">{loadingStep}</span>
|
||||
<span className="text-[11px] text-slate-400 mt-1">{loadingPercent}% processado</span>
|
||||
</div>
|
||||
) : filteredDefects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center p-4 text-slate-400">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-500 mb-2" />
|
||||
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">
|
||||
Nenhuma inconformidade encontrada
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
Todas as furações e geometrias conferem com os parâmetros e filtros ativos.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDefects.map((defect) => {
|
||||
const isSelected = selectedDefect?.id === defect.id || selectedPieceMark === defect.pecaMarca;
|
||||
return (
|
||||
<div
|
||||
key={defect.id}
|
||||
onClick={() => {
|
||||
setSelectedDefect(defect);
|
||||
setSelectedPieceMark(defect.pecaMarca);
|
||||
}}
|
||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-indigo-50 dark:bg-indigo-950/40 border-indigo-500 shadow-md ring-1 ring-indigo-500'
|
||||
: 'bg-white dark:bg-slate-800/80 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-bold text-slate-900 dark:text-white text-xs bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded">
|
||||
{defect.pecaMarca}
|
||||
</span>
|
||||
{defect.conjuntoMarca && (
|
||||
<span className="text-[10px] font-mono text-slate-500 dark:text-slate-400">
|
||||
(Conj: {defect.conjuntoMarca})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{defect.gravidade === 'critico' && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-100 dark:bg-red-950/60 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
|
||||
CRÍTICO
|
||||
</span>
|
||||
)}
|
||||
{defect.gravidade === 'alerta' && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-amber-100 dark:bg-amber-950/60 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
|
||||
ALERTA
|
||||
</span>
|
||||
)}
|
||||
{defect.gravidade === 'informativo' && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-100 dark:bg-blue-950/60 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
|
||||
NOVO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="text-xs font-semibold text-slate-800 dark:text-slate-200 mb-1 leading-tight">
|
||||
{defect.titulo}
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] text-slate-600 dark:text-slate-400 line-clamp-2 leading-relaxed mb-2">
|
||||
{defect.descricao}
|
||||
</p>
|
||||
|
||||
{/* Rodapé do Card com Status Fabril e Ação */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-slate-700/60 text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Factory className="w-3 h-3 text-slate-400" />
|
||||
<span className="text-slate-500 dark:text-slate-400">
|
||||
{defect.processoAtual || 'Pendente'}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-bold uppercase px-1 rounded ${
|
||||
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
|
||||
? 'text-red-500 bg-red-50 dark:bg-red-950/50'
|
||||
: 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
Risco: {defect.riscoFabril}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailModalDefect(defect);
|
||||
}}
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline font-semibold text-[11px]"
|
||||
>
|
||||
Ver Detalhes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VISUALIZADOR 3D CENTRAL */}
|
||||
<div className="flex-1 flex flex-col min-h-[350px] relative">
|
||||
<AnalyzerViewer3D
|
||||
modelData={modelBase}
|
||||
defects={defects}
|
||||
selectedDefect={selectedDefect}
|
||||
selectedPieceMark={selectedPieceMark}
|
||||
colorizationMode={colorizationMode}
|
||||
onSelectDefect={(def) => setSelectedDefect(def)}
|
||||
onSelectPiece={(mark) => setSelectedPieceMark(mark)}
|
||||
isGhostMode={isGhostMode}
|
||||
onToggleGhostMode={() => setIsGhostMode(!isGhostMode)}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
|
||||
theme={document.documentElement.classList.contains('dark') ? 'dark' : 'light'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MODAL DETALHADO DA PEÇA AUDITADA */}
|
||||
<PieceAuditDetailModal
|
||||
isOpen={!!detailModalDefect}
|
||||
onClose={() => setDetailModalDefect(null)}
|
||||
defect={detailModalDefect}
|
||||
onFocus3D={(coords) => {
|
||||
if (detailModalDefect) {
|
||||
setSelectedDefect(detailModalDefect);
|
||||
setSelectedPieceMark(detailModalDefect.pecaMarca);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export type DefectSeverity = 'critico' | 'alerta' | 'informativo';
|
||||
|
||||
export type DefectType =
|
||||
| 'furo_desalinhado'
|
||||
| 'furo_cego_faltante'
|
||||
| 'furo_extra_revisao'
|
||||
| 'furo_removido_revisao'
|
||||
| 'deslocamento_furo'
|
||||
| 'colisao_geometrica'
|
||||
| 'peca_modificada'
|
||||
| 'peca_nova'
|
||||
| 'peca_excluida'
|
||||
| 'divergencia_dimensional';
|
||||
|
||||
export type ProductionRisk = 'baixo' | 'medio' | 'alto' | 'critico';
|
||||
|
||||
export interface DefectItem {
|
||||
id: string;
|
||||
tipo: DefectType;
|
||||
gravidade: DefectSeverity;
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
pecaMarca: string;
|
||||
conjuntoMarca?: string;
|
||||
expressID?: number;
|
||||
guid?: string;
|
||||
fase?: string;
|
||||
quantidadeAfetada: number;
|
||||
pesoUnitarioKg?: number;
|
||||
pesoTotalKg?: number;
|
||||
// Detalhes de furação
|
||||
diametroMm?: number;
|
||||
deslocamentoMm?: { x: number; y: number; z: number };
|
||||
pecaConectadaMarca?: string;
|
||||
pecaConectadaConjunto?: string;
|
||||
// Cruzamento com chão de fábrica
|
||||
processoAtual?: string;
|
||||
processoCor?: string;
|
||||
quantidadeApontada?: number;
|
||||
riscoFabril: ProductionRisk;
|
||||
recomendacao: string;
|
||||
// Coordenadas aproximadas para foco 3D
|
||||
coordenadas3D?: { x: number; y: number; z: number };
|
||||
}
|
||||
|
||||
export interface ComparisonDiffSummary {
|
||||
totalPecasBase: number;
|
||||
totalPecasRevisao: number;
|
||||
inalteradas: number;
|
||||
modificadas: number;
|
||||
novas: number;
|
||||
excluidas: number;
|
||||
inconformidadesCriticas: number;
|
||||
alertasFuros: number;
|
||||
pesoAfetadoKg: number;
|
||||
riscoGeral: ProductionRisk;
|
||||
}
|
||||
|
||||
export type ColorizationMode = 'status_defeito' | 'diff_revisao' | 'risco_fabril' | 'processo';
|
||||
Reference in New Issue
Block a user