823 lines
36 KiB
TypeScript
823 lines
36 KiB
TypeScript
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>
|
||
);
|
||
}
|