feat: Novo módulo Analisador 3D BIM & QA/QC (Auditoria de Furação e Comparador de Reviseeeees)

This commit is contained in:
2026-10-08 10:16:48 -03:00
parent d177184a62
commit f42976a09d
10 changed files with 2394 additions and 1 deletions
+807
View File
@@ -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>
);
}