feat(smart-3d): replica card de engenharia e adiciona ferramenta girar 90 com trava
This commit is contained in:
@@ -22,8 +22,12 @@ import {
|
||||
Moon,
|
||||
FileSpreadsheet,
|
||||
Grid as GridIcon,
|
||||
Bell
|
||||
Bell,
|
||||
RotateCw,
|
||||
Lock,
|
||||
Unlock
|
||||
} from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
export interface ProductionPieceStatus {
|
||||
pecaId?: string;
|
||||
@@ -38,6 +42,91 @@ export interface ProductionPieceStatus {
|
||||
processesCompleted?: string[];
|
||||
processPointedQtds?: Record<string, number>;
|
||||
hasDetalhamento?: boolean;
|
||||
perfilPrincipal?: string;
|
||||
descricao?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca de apontamento e status fabril com máxima tolerância e amostragem de chaves para IFC e WRL.
|
||||
*/
|
||||
function findProductionDataForPiece(
|
||||
productionData?: Map<string, ProductionPieceStatus>,
|
||||
piece?: {
|
||||
pieceMark?: string;
|
||||
cleanMark?: string;
|
||||
assemblyMark?: string;
|
||||
cleanAssemblyMark?: string;
|
||||
phase?: string;
|
||||
} | null,
|
||||
selectedOF?: string
|
||||
): ProductionPieceStatus | undefined {
|
||||
if (!productionData || !piece) return undefined;
|
||||
|
||||
const pmark = String(piece.pieceMark || '').trim().toUpperCase();
|
||||
const cleanMark = String(piece.cleanMark || pmark).trim().toUpperCase();
|
||||
const assMark = String(piece.assemblyMark || '').trim().toUpperCase();
|
||||
const cleanAssMark = String(piece.cleanAssemblyMark || cleanMark).trim().toUpperCase();
|
||||
const elementPhase = piece.phase ? String(piece.phase).trim() : '';
|
||||
|
||||
const searchKeys = [
|
||||
elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanAssMark}` : '',
|
||||
elementPhase ? `${elementPhase}-${cleanAssMark}` : '',
|
||||
elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanMark}` : '',
|
||||
elementPhase ? `${elementPhase}-${cleanMark}` : '',
|
||||
selectedOF ? `${selectedOF}-${cleanAssMark}` : '',
|
||||
selectedOF ? `${selectedOF}-${cleanMark}` : '',
|
||||
assMark,
|
||||
pmark,
|
||||
cleanAssMark,
|
||||
cleanMark,
|
||||
].filter(Boolean).map((k) => k.toUpperCase());
|
||||
|
||||
for (const key of searchKeys) {
|
||||
const match = productionData.get(key);
|
||||
if (match) {
|
||||
if (!elementPhase || !match.fase || String(match.fase) === String(elementPhase)) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper para formatar a Marca Principal da Peça no padrão de Engenharia (OF-Fase-Marca, ex: B138-1-59 / B138-2-10)
|
||||
* e obter o Perfil/Bitola oficial a partir do banco de dados ou do modelo 3D.
|
||||
*/
|
||||
function resolvePieceEngineeringDetails(
|
||||
rawPieceMark: string,
|
||||
rawSection: string | undefined,
|
||||
phase: string | undefined,
|
||||
singlePartMark: string | undefined,
|
||||
prod: ProductionPieceStatus | undefined,
|
||||
selectedOF: string | undefined
|
||||
): {
|
||||
displayMark: string;
|
||||
displaySection: string;
|
||||
} {
|
||||
const baseMark = prod?.marca || rawPieceMark || 'Peça';
|
||||
let displayMark = baseMark;
|
||||
|
||||
// Se tivermos OF e Fase e a marca for pura (ex: "1" ou "8" ou "59"), formata como "B138-1-59"
|
||||
if (selectedOF && phase && !baseMark.toUpperCase().startsWith(selectedOF.toUpperCase())) {
|
||||
displayMark = `${selectedOF}-${phase}-${baseMark}`;
|
||||
}
|
||||
|
||||
// Perfil / Bitola oficial: prioriza banco de produção (TS_ERP.pecas.perfil_principal), seguido de section limpo do modelo
|
||||
let displaySection = prod?.perfilPrincipal || '';
|
||||
if (!displaySection || displaySection === '1001' || displaySection === 'Perfil') {
|
||||
if (rawSection && rawSection !== '1001' && rawSection !== 'Perfil') {
|
||||
displaySection = rawSection;
|
||||
} else {
|
||||
displaySection = prod?.descricao || 'Perfil';
|
||||
}
|
||||
}
|
||||
|
||||
return { displayMark, displaySection };
|
||||
}
|
||||
|
||||
interface SmartModelViewer3DProps {
|
||||
@@ -46,6 +135,7 @@ interface SmartModelViewer3DProps {
|
||||
selectedOF: string;
|
||||
cliente?: string;
|
||||
phasesList: string[];
|
||||
initialRotation?: { x: number; y: number; z: number } | null;
|
||||
onVoltar: () => void;
|
||||
}
|
||||
|
||||
@@ -65,6 +155,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
selectedOF,
|
||||
cliente,
|
||||
phasesList,
|
||||
initialRotation,
|
||||
onVoltar,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -97,6 +188,11 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
// Modal de Lista da Seleção
|
||||
const [isSelectionModalOpen, setIsSelectionModalOpen] = useState<boolean>(false);
|
||||
|
||||
// Controles de Rotação 90° e Trava com Cadeado (Memória de Posição)
|
||||
const [rotationX, setRotationX] = useState<number>(0);
|
||||
const [rotationY, setRotationY] = useState<number>(0);
|
||||
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
|
||||
|
||||
// Seleção e Toast Discreto
|
||||
interface SelectedElement {
|
||||
expressID: number;
|
||||
@@ -372,7 +468,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
controlsRef.current.update();
|
||||
}, []);
|
||||
|
||||
// 3. Montar malhas do modelo IFC na cena Three.js
|
||||
// 3. Montar malhas do modelo IFC/WRL na cena Three.js & Aplicar Rotação Memorizada
|
||||
useEffect(() => {
|
||||
if (!modelData || !sceneRef.current) return;
|
||||
|
||||
@@ -384,11 +480,87 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
modelGroupRef.current = group;
|
||||
sceneRef.current.add(group);
|
||||
|
||||
// Recupera rotação memorizada para esta OF (localStorage ou banco de dados)
|
||||
const storageKey = selectedOF ? `tracksteel_model_rot_${selectedOF}` : null;
|
||||
let savedX = 0;
|
||||
let savedY = 0;
|
||||
let isLocked = false;
|
||||
|
||||
if (storageKey) {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (typeof parsed.x === 'number') savedX = parsed.x;
|
||||
if (typeof parsed.y === 'number') savedY = parsed.y;
|
||||
isLocked = true;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLocked && initialRotation) {
|
||||
if (typeof initialRotation.x === 'number') savedX = initialRotation.x;
|
||||
if (typeof initialRotation.y === 'number') savedY = initialRotation.y;
|
||||
isLocked = true;
|
||||
}
|
||||
|
||||
setRotationX(savedX);
|
||||
setRotationY(savedY);
|
||||
setIsRotationLocked(isLocked);
|
||||
|
||||
// Aplica rotação no grupo do modelo Three.js (corrige modelos tombados)
|
||||
group.rotation.set(
|
||||
THREE.MathUtils.degToRad(savedX),
|
||||
THREE.MathUtils.degToRad(savedY),
|
||||
0
|
||||
);
|
||||
group.updateMatrixWorld(true);
|
||||
|
||||
// Enquadra a câmera automaticamente após carregar o modelo
|
||||
setTimeout(() => {
|
||||
fitModelToView();
|
||||
}, 150);
|
||||
}, [modelData, fitModelToView]);
|
||||
}, [modelData, selectedOF, initialRotation, fitModelToView]);
|
||||
|
||||
// Gira o modelo em 90 graus no eixo X (tombamento vertical para alinhar Z-Up do CAD com Y-Up)
|
||||
const handleRotate90X = useCallback(() => {
|
||||
if (!modelGroupRef.current) return;
|
||||
|
||||
const nextX = (rotationX + 90) % 360;
|
||||
setRotationX(nextX);
|
||||
setIsRotationLocked(false);
|
||||
|
||||
modelGroupRef.current.rotation.x = THREE.MathUtils.degToRad(nextX);
|
||||
modelGroupRef.current.rotation.y = THREE.MathUtils.degToRad(rotationY);
|
||||
modelGroupRef.current.updateMatrixWorld(true);
|
||||
|
||||
fitModelToView();
|
||||
smartAudio.playClick();
|
||||
}, [rotationX, rotationY, fitModelToView]);
|
||||
|
||||
// Alterna o bloqueio/gravação da posição ideal de visualização no banco e no navegador
|
||||
const handleToggleLockRotation = useCallback(async () => {
|
||||
if (!selectedOF) return;
|
||||
|
||||
if (!isRotationLocked) {
|
||||
setIsRotationLocked(true);
|
||||
const rotData = { x: rotationX, y: rotationY, z: 0 };
|
||||
localStorage.setItem(`tracksteel_model_rot_${selectedOF}`, JSON.stringify(rotData));
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from('ordens_fabricacao' as any)
|
||||
.update({ model_3d_rotation: rotData })
|
||||
.eq('num_of', selectedOF);
|
||||
} catch (err) {
|
||||
console.error('Erro ao salvar rotação no banco:', err);
|
||||
}
|
||||
smartAudio.playSuccess();
|
||||
} else {
|
||||
setIsRotationLocked(false);
|
||||
smartAudio.playClick();
|
||||
}
|
||||
}, [selectedOF, isRotationLocked, rotationX, rotationY]);
|
||||
|
||||
// 4. Aplicação de Filtros (Fase e Processo) e Cores de Produção idêntico ao modo desktop
|
||||
useEffect(() => {
|
||||
@@ -655,16 +827,40 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const pmark = hitMesh.userData.pieceMark;
|
||||
const phase = hitMesh.userData.phase;
|
||||
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || {
|
||||
const rawPiece = modelData?.pieceByExpressID.get(targetIfcId);
|
||||
const rawMark = hitMesh.userData.pieceMark || rawPiece?.pieceMark || '';
|
||||
const phase = hitMesh.userData.phase || rawPiece?.phase;
|
||||
const singlePartMark = hitMesh.userData.singlePartMark || rawPiece?.singlePartMark;
|
||||
|
||||
const prodInfo = findProductionDataForPiece(productionData, {
|
||||
pieceMark: rawMark,
|
||||
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
|
||||
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
|
||||
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
|
||||
phase,
|
||||
}, selectedOF);
|
||||
|
||||
const { displayMark, displaySection } = resolvePieceEngineeringDetails(
|
||||
rawMark,
|
||||
hitMesh.userData.section || rawPiece?.section,
|
||||
phase,
|
||||
singlePartMark,
|
||||
prodInfo,
|
||||
selectedOF
|
||||
);
|
||||
|
||||
const piece: PieceInfo = {
|
||||
expressID: targetIfcId,
|
||||
guid: '',
|
||||
name: hitMesh.userData.section || 'Peça',
|
||||
type: 'PIECE',
|
||||
pieceMark: pmark,
|
||||
section: hitMesh.userData.section,
|
||||
phase: phase,
|
||||
guid: hitMesh.userData.guid || rawPiece?.guid || '',
|
||||
name: hitMesh.userData.name || rawPiece?.name || 'Peça',
|
||||
type: hitMesh.userData.type || rawPiece?.type || 'PIECE',
|
||||
pieceMark: displayMark,
|
||||
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
|
||||
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
|
||||
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
|
||||
section: displaySection,
|
||||
phase,
|
||||
singlePartMark,
|
||||
};
|
||||
|
||||
nextMap.set(targetIfcId, {
|
||||
@@ -675,22 +871,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
setSelectedElementsMap(nextMap);
|
||||
smartAudio.playSuccess();
|
||||
|
||||
// Abre o Toast discreto ao lado do toque com matching enriquecido
|
||||
let prodInfo: ProductionPieceStatus | undefined = undefined;
|
||||
if (productionData && productionData.size > 0 && pmark) {
|
||||
const searchKeys = [
|
||||
phase && selectedOF ? `${selectedOF}-${phase}-${pmark}` : '',
|
||||
phase ? `${phase}-${pmark}` : '',
|
||||
selectedOF ? `${selectedOF}-${pmark}` : '',
|
||||
pmark,
|
||||
pmark.toUpperCase(),
|
||||
].filter(Boolean);
|
||||
for (const k of searchKeys) {
|
||||
const m = productionData.get(k);
|
||||
if (m) { prodInfo = m; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// Abre o Toast com dados de engenharia enriquecidos
|
||||
setToastInfo({
|
||||
piece,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
@@ -718,26 +899,10 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
// Lista consolidada de peças físicas para o modal de seleção (Print 3)
|
||||
const selectedPiecesList: SelectedPieceItem[] = useMemo(() => {
|
||||
return Array.from(selectedElementsMap.values()).map(({ expressID, piece }) => {
|
||||
const pmark = piece.pieceMark || 'Sem Marca';
|
||||
const phase = piece.phase;
|
||||
let prod: ProductionPieceStatus | undefined = undefined;
|
||||
if (productionData && productionData.size > 0 && pmark) {
|
||||
const searchKeys = [
|
||||
phase && selectedOF ? `${selectedOF}-${phase}-${pmark}` : '',
|
||||
phase ? `${phase}-${pmark}` : '',
|
||||
selectedOF ? `${selectedOF}-${pmark}` : '',
|
||||
pmark,
|
||||
pmark.toUpperCase(),
|
||||
].filter(Boolean);
|
||||
for (const k of searchKeys) {
|
||||
const m = productionData.get(k);
|
||||
if (m) { prod = m; break; }
|
||||
}
|
||||
}
|
||||
|
||||
const prod = findProductionDataForPiece(productionData, piece, selectedOF);
|
||||
return {
|
||||
id: String(expressID),
|
||||
pieceMark: pmark,
|
||||
pieceMark: piece.pieceMark || 'Sem Marca',
|
||||
phase: piece.phase,
|
||||
section: piece.section,
|
||||
name: piece.name,
|
||||
@@ -775,13 +940,14 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
let left = toastInfo.position.x - rect.left + 14;
|
||||
let top = toastInfo.position.y - rect.top - 20;
|
||||
|
||||
if (left + 230 > rect.width) {
|
||||
left = Math.max(12, left - 245);
|
||||
if (left + 310 > rect.width) {
|
||||
left = Math.max(12, left - 325);
|
||||
}
|
||||
if (top + 160 > rect.height) {
|
||||
top = Math.max(12, rect.height - 170);
|
||||
if (top + 230 > rect.height) {
|
||||
top = Math.max(12, rect.height - 240);
|
||||
}
|
||||
if (top < 12) top = 12;
|
||||
if (left < 12) left = 12;
|
||||
|
||||
return { left: `${left}px`, top: `${top}px` };
|
||||
}, [toastInfo]);
|
||||
@@ -943,7 +1109,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Top Right: Cubo 3D Compacto + Card de Ações da Seleção (Print 3) */}
|
||||
{/* 2. Top Right: Cubo 3D Compacto + Card de Giro 90° + Card de Ações da Seleção */}
|
||||
<div className="absolute top-16 right-3 z-20 flex flex-col items-end gap-2 pointer-events-auto">
|
||||
{/* Cubo Compacto Discreto */}
|
||||
<ViewCube
|
||||
@@ -953,11 +1119,57 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
compact={true}
|
||||
/>
|
||||
|
||||
{/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */}
|
||||
<div className="w-[140px] flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto">
|
||||
{/* Botão Rotacionar / Tombar 90° */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRotate90X}
|
||||
disabled={!modelData}
|
||||
className={`flex-1 min-w-0 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-xl text-[10px] font-bold transition-all duration-150 active:scale-95 ${
|
||||
modelData
|
||||
? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm'
|
||||
: 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50'
|
||||
}`}
|
||||
title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)"
|
||||
>
|
||||
<RotateCw className="w-3 h-3 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||
<span className="whitespace-nowrap">Girar 90°</span>
|
||||
<span className="text-[9px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800 shrink-0">
|
||||
{rotationX}°
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Botão Cadeado: Travar / Gravar ou Destravar */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleLockRotation}
|
||||
disabled={!modelData}
|
||||
className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border shrink-0 ${
|
||||
!modelData
|
||||
? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40'
|
||||
: isRotationLocked
|
||||
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/60 dark:hover:bg-emerald-900/80 dark:text-emerald-300 dark:border-emerald-700/80 shadow-sm cursor-pointer'
|
||||
: 'bg-amber-50 hover:bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/50 dark:hover:bg-amber-900/70 dark:text-amber-300 dark:border-amber-700/70 shadow-sm cursor-pointer animate-pulse'
|
||||
}`}
|
||||
title={
|
||||
isRotationLocked
|
||||
? 'Posição TRAVADA e salva para esta OF. Clique para destravar e ajustar.'
|
||||
: 'Posição DESTRAVADA. Clique aqui no cadeado para travar e salvar a posição ideal.'
|
||||
}
|
||||
>
|
||||
{isRotationLocked ? (
|
||||
<Lock className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
) : (
|
||||
<Unlock className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card Flutuante de Seleção com "Gerar lista da seleção" (Print 3) */}
|
||||
{selectedCount > 0 && (
|
||||
<div
|
||||
className="flex flex-col items-stretch gap-1.5 p-2 rounded-2xl bg-white/95 dark:bg-slate-900/95 border border-slate-300 dark:border-slate-700 shadow-xl dark:shadow-slate-950/80 backdrop-blur-md animate-in fade-in zoom-in-95 duration-200"
|
||||
style={{ minWidth: '135px' }}
|
||||
className="w-[140px] flex flex-col items-stretch gap-1.5 p-2 rounded-2xl bg-white/95 dark:bg-slate-900/95 border border-slate-300 dark:border-slate-700 shadow-xl dark:shadow-slate-950/80 backdrop-blur-md animate-in fade-in zoom-in-95 duration-200"
|
||||
>
|
||||
{/* Badge Indicador de Seleção Ativa */}
|
||||
<div className="flex items-center justify-center gap-1.5 px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 text-[10px] font-mono font-bold">
|
||||
@@ -995,7 +1207,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Toast Discreto ao Lado da Peça Clicada */}
|
||||
{/* 3. Toast Discreto ao Lado da Peça Clicada (Réplica Idêntica do Modo Completo - Print 2) */}
|
||||
{toastInfo && (
|
||||
<div
|
||||
onClick={() => {
|
||||
@@ -1003,63 +1215,100 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
setToastInfo(null);
|
||||
}}
|
||||
style={toastStyle}
|
||||
className="absolute z-40 p-3 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl border border-fuchsia-500/50 shadow-2xl shadow-fuchsia-950/20 text-slate-800 dark:text-slate-100 min-w-[210px] max-w-[260px] animate-in fade-in zoom-in-95 duration-150 cursor-pointer pointer-events-auto select-none transition-all group"
|
||||
title="Toque no toast para fechar (a peça continua marcada)"
|
||||
className="absolute z-40 p-4 rounded-2xl bg-slate-900/95 backdrop-blur-xl border border-cyan-500/40 shadow-2xl shadow-cyan-950/60 text-slate-100 min-w-[270px] max-w-[330px] animate-in fade-in zoom-in-95 duration-150 cursor-pointer pointer-events-auto select-none transition-all group"
|
||||
title="Toque no card para fechar (a peça continua marcada)"
|
||||
>
|
||||
{/* Cabeçalho do Toast */}
|
||||
<div className="flex items-center justify-between gap-1.5 pb-1.5 mb-1.5 border-b border-slate-200 dark:border-slate-800">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="w-2 h-2 rounded-full bg-fuchsia-500 shadow-sm" />
|
||||
<span className="text-xs font-black text-fuchsia-600 dark:text-fuchsia-300 font-mono tracking-tight truncate">
|
||||
{toastInfo.piece.pieceMark || 'Sem Marca'}
|
||||
</span>
|
||||
{/* Cabeçalho do Toast com Ícone Box e Marca Principal de Engenharia */}
|
||||
<div className="flex items-center justify-between gap-2 pb-2.5 mb-2.5 border-b border-slate-800">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="p-1.5 rounded-lg bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 shrink-0">
|
||||
<Box className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-[10px] uppercase font-mono tracking-widest text-slate-400 block leading-tight">
|
||||
Marca Principal
|
||||
</span>
|
||||
<span className="text-base font-extrabold text-cyan-300 font-mono tracking-tight truncate block">
|
||||
{toastInfo.piece.pieceMark || 'Sem Marca'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{toastInfo.piece.phase && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold font-mono bg-slate-800 text-slate-300 border border-slate-700">
|
||||
Fase {toastInfo.piece.phase}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setToastInfo(null);
|
||||
}}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setToastInfo(null);
|
||||
}}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dados Rápidos da Peça */}
|
||||
<div className="space-y-1 text-[11px]">
|
||||
{toastInfo.piece.phase && (
|
||||
<div className="flex items-center justify-between text-slate-600 dark:text-slate-300">
|
||||
<span className="text-slate-400">Fase:</span>
|
||||
<span className="font-bold text-slate-800 dark:text-slate-200">Fase {toastInfo.piece.phase}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Detalhes Técnicos da Peça */}
|
||||
<div className="space-y-1.5 text-xs">
|
||||
{toastInfo.piece.section && (
|
||||
<div className="flex items-center justify-between text-slate-600 dark:text-slate-300">
|
||||
<span className="text-slate-400">Perfil:</span>
|
||||
<span className="font-mono font-semibold text-slate-800 dark:text-slate-100 truncate max-w-[120px]">
|
||||
<div className="flex items-center justify-between text-slate-300">
|
||||
<span className="text-slate-400">Perfil / Bitola:</span>
|
||||
<span className="font-semibold text-white font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
|
||||
{toastInfo.piece.section}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{toastInfo.prod && (
|
||||
<div className="pt-1 mt-1 border-t border-slate-200 dark:border-slate-800/80 flex items-center justify-between">
|
||||
<span className="text-slate-400">Status:</span>
|
||||
<span
|
||||
className="font-bold px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wider"
|
||||
style={{
|
||||
backgroundColor: `${toastInfo.prod.processColor || '#10b981'}25`,
|
||||
color: toastInfo.prod.processColor || '#10b981',
|
||||
}}
|
||||
>
|
||||
{toastInfo.prod.currentProcessName || 'Produção'} ({toastInfo.prod.pointedQtd}/{toastInfo.prod.totalQtd})
|
||||
|
||||
{toastInfo.piece.singlePartMark && toastInfo.piece.singlePartMark !== toastInfo.piece.pieceMark && (
|
||||
<div className="flex items-center justify-between text-slate-300">
|
||||
<span className="text-slate-400">Sub-peça / Corte:</span>
|
||||
<span className="font-semibold text-slate-200 font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
|
||||
Pos. {toastInfo.piece.singlePartMark}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apontamentos e Etapa Atual */}
|
||||
{toastInfo.prod && (
|
||||
<div className="mt-3 pt-2.5 border-t border-slate-800/80 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Apontamento:
|
||||
</span>
|
||||
<span className="font-bold text-emerald-400 font-mono">
|
||||
{toastInfo.prod.pointedQtd ?? 0} de {toastInfo.prod.totalQtd ?? 1} peças
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{toastInfo.prod.currentProcessName && (
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-slate-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-amber-400" />
|
||||
Etapa Atual:
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full font-bold uppercase tracking-wider text-[10px]"
|
||||
style={{
|
||||
backgroundColor: `${toastInfo.prod.processColor || '#10b981'}25`,
|
||||
color: toastInfo.prod.processColor || '#10b981',
|
||||
border: `1px solid ${toastInfo.prod.processColor || '#10b981'}40`,
|
||||
}}
|
||||
>
|
||||
{toastInfo.prod.currentProcessName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dica para Fechar */}
|
||||
<div className="mt-2 pt-1 border-t border-slate-200 dark:border-slate-800 text-[9px] text-center text-slate-400 font-medium">
|
||||
<div className="mt-2.5 pt-1.5 border-t border-slate-800 text-[9px] text-center text-slate-400 font-medium">
|
||||
Toque no card para fechar ✕
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
|
||||
const [modelData, setModelData] = useState<LoadedModel3DResult | null>(null);
|
||||
const [phasesList, setPhasesList] = useState<string[]>([]);
|
||||
const [productionMap, setProductionMap] = useState<Map<string, ProductionPieceStatus>>(new Map());
|
||||
const [initialRotation, setInitialRotation] = useState<{ x: number; y: number; z: number } | null>(null);
|
||||
|
||||
// Loading States
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
@@ -35,10 +36,10 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
|
||||
setLoadingPercent(15);
|
||||
|
||||
try {
|
||||
// 1.1. Buscar registro da OF para obter URL do IFC
|
||||
// 1.1. Buscar registro da OF para obter URL do IFC e rotação memorizada
|
||||
const { data: ofData, error: ofError } = await supabase
|
||||
.from('ordens_fabricacao' as any)
|
||||
.select('id, num_of, descritivo, ifc_url, ifc_filename')
|
||||
.select('id, num_of, descritivo, ifc_url, ifc_filename, model_3d_rotation')
|
||||
.eq('num_of', obra.of_number)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -52,6 +53,9 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
|
||||
|
||||
const ifcUrl = (ofData as any).ifc_url;
|
||||
const ifcFilename = (ofData as any).ifc_filename;
|
||||
if ((ofData as any).model_3d_rotation) {
|
||||
setInitialRotation((ofData as any).model_3d_rotation);
|
||||
}
|
||||
|
||||
// 1.2. Buscar Peças e Apontamentos em paralelo
|
||||
setLoadingStep('Carregando dados das peças e apontamentos...');
|
||||
@@ -87,6 +91,8 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
|
||||
marca: marcaStr,
|
||||
fase,
|
||||
etapa_fase: fase,
|
||||
descricao: p.descricao || '',
|
||||
perfilPrincipal: p.perfil_principal || p.descricao || '',
|
||||
totalQtd: Number(p.quantidade || 1),
|
||||
pointedQtd: 0,
|
||||
currentProcessName: 'Pendente',
|
||||
@@ -277,6 +283,7 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
|
||||
selectedOF={obra.of_number}
|
||||
cliente={obra.cliente}
|
||||
phasesList={phasesList}
|
||||
initialRotation={initialRotation}
|
||||
onVoltar={onVoltarHub}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user