feat: adicionar visualizador 3d touch-first no modo smart com filtros e toast discreto

This commit is contained in:
2026-09-23 14:06:56 +00:00
parent 38bd3ee0e2
commit ade8d48233
5 changed files with 983 additions and 36 deletions
+18 -9
View File
@@ -6,6 +6,7 @@ import { SmartProducaoFlow } from './smart/components/SmartProducaoFlow';
import { SmartEmbarqueFlow } from './smart/components/SmartEmbarqueFlow';
import { SmartMontagemFlow } from './smart/components/SmartMontagemFlow';
import { SmartConsultaRelatoriosFlow } from './smart/components/SmartConsultaRelatoriosFlow';
import { SmartViewer3DFlow } from './smart/components/SmartViewer3DFlow';
import { OFAtiva } from '@/hooks/useOFsAtivas';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
@@ -23,9 +24,9 @@ export const ModoSmart: React.FC = () => {
}
});
// Tela ativa: 'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf'
// Tela ativa: 'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf' | 'modelo3d'
const [telaAtiva, setTelaAtiva] = useState<
'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf'
'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf' | 'modelo3d'
>('hub');
// Produção do operador hoje
@@ -113,6 +114,7 @@ export const ModoSmart: React.FC = () => {
montagem: '3. Apontar Montagem',
consulta: '4. Consultar Peças',
turno_pdf: 'Relatório do Turno',
modelo3d: 'Modelo 3D da Obra',
};
return {
@@ -126,13 +128,15 @@ export const ModoSmart: React.FC = () => {
return (
<div className="min-h-screen bg-slate-100/90 text-slate-800 dark:bg-slate-950 dark:text-slate-100 flex flex-col font-sans select-none antialiased transition-colors duration-200">
{/* Cabeçalho Fixo do Modo Smart */}
<SmartHeader
titulo={headerInfo.titulo}
subtitulo={headerInfo.subtitulo}
mostrarVoltar={headerInfo.mostrarVoltar}
onVoltar={() => setTelaAtiva('hub')}
/>
{/* Cabeçalho Fixo do Modo Smart (oculto quando em tela cheia do modelo 3D) */}
{telaAtiva !== 'modelo3d' && (
<SmartHeader
titulo={headerInfo.titulo}
subtitulo={headerInfo.subtitulo}
mostrarVoltar={headerInfo.mostrarVoltar}
onVoltar={() => setTelaAtiva('hub')}
/>
)}
{/* Conteúdo Principal Touch-First */}
<main className="flex-1 flex flex-col overflow-y-auto">
@@ -173,6 +177,11 @@ export const ModoSmart: React.FC = () => {
onVoltarHub={() => setTelaAtiva('hub')}
abrirDiretoTurno={true}
/>
) : telaAtiva === 'modelo3d' ? (
<SmartViewer3DFlow
obra={obraAtiva}
onVoltarHub={() => setTelaAtiva('hub')}
/>
) : null}
</main>
</div>
+29 -15
View File
@@ -7,7 +7,8 @@ import {
ArrowLeftRight,
FileText,
TrendingUp,
Sparkles
Sparkles,
Box
} from 'lucide-react';
import { OFAtiva } from '@/hooks/useOFsAtivas';
import { smartAudio } from '@/utils/smartAudio';
@@ -16,7 +17,7 @@ import { Button } from '@/components/ui/button';
interface SmartHubProps {
obra: OFAtiva;
onTrocarObra: () => void;
onNavigate: (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => void;
onNavigate: (destino: 'producao' | 'embarque' | 'montagem' | 'consulta' | 'modelo3d') => void;
producaoHoje: { totalPecas: number; totalKg: number };
onGerarRelatorioTurno: () => void;
}
@@ -28,7 +29,7 @@ export const SmartHub: React.FC<SmartHubProps> = ({
producaoHoje,
onGerarRelatorioTurno,
}) => {
const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => {
const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta' | 'modelo3d') => {
smartAudio.playClick();
onNavigate(destino);
};
@@ -45,18 +46,31 @@ export const SmartHub: React.FC<SmartHubProps> = ({
OF {obra.of_number} — {obra.cliente || 'Obra Industrial'}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
smartAudio.playClick();
onTrocarObra();
}}
className="h-10 px-3 border-slate-300 dark:border-slate-600 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700/60 dark:hover:bg-slate-650 text-slate-800 dark:text-slate-200 rounded-xl text-xs font-bold shrink-0 active:scale-95 gap-1.5 transition-colors"
>
<ArrowLeftRight className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span>Trocar</span>
</Button>
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => handleAcao('modelo3d')}
className="h-10 px-3 border-cyan-400 dark:border-cyan-500/50 bg-cyan-50 hover:bg-cyan-100 dark:bg-cyan-950/40 dark:hover:bg-cyan-900/50 text-cyan-800 dark:text-cyan-200 rounded-xl text-xs font-black shrink-0 active:scale-95 gap-1.5 shadow-sm transition-all"
title="Abrir Visualizador 3D do modelo IFC desta obra"
>
<Box className="h-4 w-4 text-cyan-600 dark:text-cyan-400 stroke-[2.2]" />
<span>Modelo 3D</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
smartAudio.playClick();
onTrocarObra();
}}
className="h-10 px-3 border-slate-300 dark:border-slate-600 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700/60 dark:hover:bg-slate-650 text-slate-800 dark:text-slate-200 rounded-xl text-xs font-bold shrink-0 active:scale-95 gap-1.5 transition-colors"
>
<ArrowLeftRight className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span>Trocar</span>
</Button>
</div>
</div>
{/* Grade dos 4 Botões Principais Gigantes */}
@@ -0,0 +1,233 @@
import React, { useState, useEffect, useCallback } from 'react';
import { OFAtiva } from '@/hooks/useOFsAtivas';
import { supabase } from '@/integrations/supabase/client';
import { loadAndAuditIFC, LoadedIFCResult } from '@/lib/ifc/ifcLoaderService';
import { fetchSavedIFCModel } from '@/lib/ifc/ifcStorageService';
import { SmartModelViewer3D, ProductionPieceStatus } from '@/components/viewer3d/SmartModelViewer3D';
import { smartAudio } from '@/utils/smartAudio';
import { Box, ArrowLeft, Loader2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface SmartViewer3DFlowProps {
obra: OFAtiva;
onVoltarHub: () => void;
}
export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
obra,
onVoltarHub,
}) => {
const [modelData, setModelData] = useState<LoadedIFCResult | null>(null);
const [phasesList, setPhasesList] = useState<string[]>([]);
const [productionMap, setProductionMap] = useState<Map<string, ProductionPieceStatus>>(new Map());
// Loading States
const [isLoading, setIsLoading] = useState<boolean>(true);
const [loadingStep, setLoadingStep] = useState<string>('Buscando modelo da obra...');
const [loadingPercent, setLoadingPercent] = useState<number>(10);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// 1. Carrega dados de produção e modelo IFC
const carregarModeloEProducao = useCallback(async () => {
setIsLoading(true);
setErrorMessage(null);
setLoadingStep('Buscando modelo IFC vinculado à obra...');
setLoadingPercent(15);
try {
// 1.1. Buscar registro da OF para obter URL do IFC
const { data: ofData, error: ofError } = await supabase
.from('ordens_fabricacao' as any)
.select('id, num_of, descritivo, ifc_url, ifc_filename')
.eq('num_of', obra.of_number)
.maybeSingle();
if (ofError) throw ofError;
if (!ofData || !(ofData as any).ifc_url) {
setIsLoading(false);
setErrorMessage('Nenhum modelo IFC 3D cadastrado na nuvem para esta OF.');
return;
}
const ifcUrl = (ofData as any).ifc_url;
const ifcFilename = (ofData as any).ifc_filename;
// 1.2. Buscar Peças e Apontamentos em paralelo
setLoadingStep('Carregando dados das peças e apontamentos...');
setLoadingPercent(30);
const [pecasRes, apontamentosRes] = await Promise.all([
supabase
.from('pecas' as any)
.select('id, of_number, etapa_fase, marca, descricao, quantidade, perfil_principal')
.eq('of_number', obra.of_number),
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', obra.of_number),
]);
// Mapeamento de Produção
const prodMap = new Map<string, ProductionPieceStatus>();
const phasesSet = new Set<string>();
if (pecasRes.data) {
pecasRes.data.forEach((p: any) => {
const marca = p.marca ? String(p.marca).trim().toUpperCase() : '';
const fase = p.etapa_fase ? String(p.etapa_fase).trim() : '';
if (fase) phasesSet.add(fase);
if (marca) {
const existing = prodMap.get(marca);
if (!existing) {
prodMap.set(marca, {
marca,
etapa_fase: fase,
totalQtd: Number(p.quantidade || 1),
pointedQtd: 0,
currentProcessName: 'Pendente',
processColor: '#94a3b8',
processOrdem: 0,
});
} else {
existing.totalQtd += Number(p.quantidade || 0);
}
}
});
}
if (apontamentosRes.data) {
apontamentosRes.data.forEach((ap: any) => {
const marca = ap.peca?.marca ? String(ap.peca.marca).trim().toUpperCase() : '';
if (marca && prodMap.has(marca)) {
const item = prodMap.get(marca)!;
const q = Number(ap.quantidade_produzida || 0);
const procNome = ap.processo?.nome || 'Produção';
const procOrdem = Number(ap.processo?.ordem || 0);
if (q > 0 && procOrdem >= (item.processOrdem || 0)) {
item.pointedQtd = Math.max(item.pointedQtd, q);
item.currentProcessName = procNome;
item.processColor = ap.processo?.cor || '#10b981';
item.processOrdem = procOrdem;
}
}
});
}
setProductionMap(prodMap);
// 1.3. Baixar IFC da nuvem ou cache IndexedDB
setLoadingStep('Baixando arquivo 3D de alta performance...');
setLoadingPercent(50);
const buffer = await fetchSavedIFCModel(obra.of_number, ifcUrl, (pct, msg) => {
setLoadingPercent(30 + Math.round(pct * 0.35));
setLoadingStep(msg);
});
// 1.4. Decodificar Entidades IFC e Gerar Geometrias Three.js
setLoadingStep('Decodificando montagens e geometrias do modelo...');
setLoadingPercent(68);
const result = await loadAndAuditIFC(buffer, (pct, msg) => {
setLoadingPercent(68 + Math.round(pct * 0.32));
setLoadingStep(msg);
});
// Mescla fases encontradas no IFC
if (result.detectedPhases && result.detectedPhases.length > 0) {
result.detectedPhases.forEach((f) => phasesSet.add(f));
}
const sortedPhases = Array.from(phasesSet).sort((a, b) => {
const na = Number(a);
const nb = Number(b);
return !isNaN(na) && !isNaN(nb) ? na - nb : a.localeCompare(b);
});
setPhasesList(sortedPhases);
setModelData(result);
setLoadingPercent(100);
setIsLoading(false);
} catch (err: any) {
console.error('Erro ao carregar modelo no modo Smart:', err);
setIsLoading(false);
setErrorMessage(err.message || 'Falha ao processar o modelo 3D desta obra.');
}
}, [obra.of_number]);
useEffect(() => {
carregarModeloEProducao();
}, [carregarModeloEProducao]);
// Se estiver carregando, exibe progresso elegante
if (isLoading) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 bg-slate-950 text-slate-100 min-h-[500px]">
<div className="p-4 rounded-3xl bg-slate-900 border border-slate-800 shadow-2xl mb-6">
<Loader2 className="w-10 h-10 text-cyan-400 animate-spin" />
</div>
<div className="text-center max-w-sm w-full space-y-3">
<span className="text-[11px] font-black uppercase tracking-wider text-cyan-400">
OF {obra.of_number} — Visualizador 3D
</span>
<div className="text-sm font-bold text-slate-200">{loadingStep}</div>
<div className="w-full bg-slate-800 h-2.5 rounded-full overflow-hidden border border-slate-700">
<div
className="bg-gradient-to-r from-cyan-500 to-blue-500 h-full transition-all duration-300 rounded-full"
style={{ width: `${loadingPercent}%` }}
/>
</div>
<div className="text-xs font-mono text-cyan-400 font-bold">{loadingPercent}%</div>
</div>
</div>
);
}
// Se houver erro ou a OF não tiver IFC
if (errorMessage || !modelData) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 bg-slate-950 text-slate-100 min-h-[500px]">
<div className="p-5 rounded-3xl bg-slate-900 border border-slate-800 shadow-xl mb-4 text-amber-400">
<AlertCircle className="w-12 h-12" />
</div>
<h2 className="text-base font-bold text-slate-100 text-center mb-1">
Modelo 3D Indisponível
</h2>
<p className="text-xs text-slate-400 text-center max-w-sm leading-relaxed mb-6">
{errorMessage || `A OF ${obra.of_number} ainda não possui arquivo IFC cadastrado.`}
</p>
<Button
onClick={() => {
smartAudio.playClick();
onVoltarHub();
}}
className="h-11 px-5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-100 border border-slate-700 font-bold text-xs gap-2 active:scale-95 transition-all"
>
<ArrowLeft className="w-4 h-4 text-cyan-400" />
<span>Voltar ao Menu</span>
</Button>
</div>
);
}
// Renderiza o Visualizador 3D Touch-First em Tela Cheia
return (
<div className="fixed inset-0 z-50 flex flex-col bg-slate-950">
<SmartModelViewer3D
modelData={modelData}
productionData={productionMap}
selectedOF={obra.of_number}
cliente={obra.cliente}
phasesList={phasesList}
onVoltar={onVoltarHub}
/>
</div>
);
};