Files
tracksteel_app/src/pages/Analisador3D.tsx
T

823 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
model_3d_rotation?: { x: number; y: number; z: number } | 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);
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(() =>
typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false
);
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDarkTheme(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
// 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, model_3d_rotation')
.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,
model_3d_rotation: item.model_3d_rotation || 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={isDarkTheme ? 'dark' : 'light'}
selectedOF={selectedOF}
initialRotation={currentOFObj?.model_3d_rotation || null}
/>
</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>
);
}