feat: Novo módulo Analisador 3D BIM & QA/QC (Auditoria de Furação e Comparador de Reviseeeees)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user