From 53deebe67a3a1309dea63bf2a2137eba938eaca1 Mon Sep 17 00:00:00 2001 From: Marcos Reifonas Date: Tue, 22 Sep 2026 13:55:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Implanta=C3=A7=C3=A3o=20do=20m=C3=B3dul?= =?UTF-8?q?o=20PCP=20com=20Mesa=20T=C3=A1tica=20Multi-OF=20e=20simula?= =?UTF-8?q?=C3=A7=C3=A3o=20what-if?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/pcp/PcpHistogramaCarga.tsx | 253 +++++++++ src/components/pcp/PcpMesaTatica.tsx | 214 +++++++ src/components/pcp/PcpMultiGantt.tsx | 199 +++++++ src/components/pcp/PcpOfThumbnail.tsx | 128 +++++ src/components/pcp/PcpRecomendacoesPainel.tsx | 145 +++++ src/components/pcp/PcpWhatIfSimulator.tsx | 149 +++++ src/components/sidebar/menuConfig.ts | 5 + src/config/routeConfig.ts | 6 + src/hooks/useInterfaceResources.tsx | 1 + src/hooks/usePcpAnalysis.ts | 523 ++++++++++++++++++ src/pages/PCP.tsx | 257 +++++++++ src/pages/index.ts | 1 + src/utils/syncInterfaceResources.ts | 3 +- 13 files changed, 1883 insertions(+), 1 deletion(-) create mode 100644 src/components/pcp/PcpHistogramaCarga.tsx create mode 100644 src/components/pcp/PcpMesaTatica.tsx create mode 100644 src/components/pcp/PcpMultiGantt.tsx create mode 100644 src/components/pcp/PcpOfThumbnail.tsx create mode 100644 src/components/pcp/PcpRecomendacoesPainel.tsx create mode 100644 src/components/pcp/PcpWhatIfSimulator.tsx create mode 100644 src/hooks/usePcpAnalysis.ts create mode 100644 src/pages/PCP.tsx diff --git a/src/components/pcp/PcpHistogramaCarga.tsx b/src/components/pcp/PcpHistogramaCarga.tsx new file mode 100644 index 0000000..3a26f02 --- /dev/null +++ b/src/components/pcp/PcpHistogramaCarga.tsx @@ -0,0 +1,253 @@ +import React, { useState, useMemo } from 'react'; +import { CargaPeriodoProcesso } from '@/hooks/usePcpAnalysis'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + ReferenceLine, + Cell, +} from 'recharts'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { BarChart3, AlertOctagon, CheckCircle2, TrendingUp } from 'lucide-react'; + +interface PcpHistogramaCargaProps { + dadosCarga: CargaPeriodoProcesso[]; +} + +export const PcpHistogramaCarga: React.FC = ({ dadosCarga }) => { + const [processoFiltro, setProcessoFiltro] = useState('todos'); + + // Obter lista única de processos presentes nos dados + const listaProcessos = useMemo(() => { + const set = new Set(); + dadosCarga.forEach((d) => set.add(d.processoNome)); + return Array.from(set); + }, [dadosCarga]); + + // Consolidar dados para o gráfico + const dadosGrafico = useMemo(() => { + if (processoFiltro === 'todos') { + // Agrupar todas as semanas somando as toneladas + const mapSemana: Record = {}; + dadosCarga.forEach((d) => { + if (!mapSemana[d.semanaKey]) { + mapSemana[d.semanaKey] = { + semana: d.semanaKey, + cargaTon: 0, + capacidadeTon: 0, + ofDetalhes: [] as { num_of: string; kg: number }[], + }; + } + mapSemana[d.semanaKey].cargaTon += d.cargaKg / 1000; + mapSemana[d.semanaKey].capacidadeTon += d.capacidadeRealKg / 1000; + d.ofDetalhes.forEach((det) => { + const ex = mapSemana[d.semanaKey].ofDetalhes.find((x: any) => x.num_of === det.num_of); + if (ex) ex.kg += det.kg; + else mapSemana[d.semanaKey].ofDetalhes.push({ ...det }); + }); + }); + + return Object.values(mapSemana).map((item: any) => { + const perc = item.capacidadeTon > 0 ? Math.round((item.cargaTon / item.capacidadeTon) * 100) : 0; + return { + semana: item.semana, + cargaTon: Number(item.cargaTon.toFixed(2)), + capacidadeTon: Number(item.capacidadeTon.toFixed(2)), + ocupacaoPercent: perc, + ofDetalhes: item.ofDetalhes, + }; + }); + } else { + // Filtrar pelo processo específico + return dadosCarga + .filter((d) => d.processoNome === processoFiltro) + .map((d) => ({ + semana: d.semanaKey, + cargaTon: Number((d.cargaKg / 1000).toFixed(2)), + capacidadeTon: Number((d.capacidadeRealKg / 1000).toFixed(2)), + ocupacaoPercent: d.percentualOcupacao, + ofDetalhes: d.ofDetalhes, + })); + } + }, [dadosCarga, processoFiltro]); + + // Identificar capacidade média de referência para desenhar linha + const capacidadeReferenciaTon = dadosGrafico.length > 0 ? dadosGrafico[0].capacidadeTon : 20; + + // Total de semanas em sobrecarga + const semanasGargalo = dadosGrafico.filter((d) => d.ocupacaoPercent > 100).length; + + return ( +
+ {/* Header do Gráfico */} +
+
+
+
+ +
+

+ Histograma de Carga Acumulada vs Capacidade Fabril +

+
+

+ Distribuição temporal da demanda (toneladas por semana) somando as OFs em análise contra o teto da fábrica. +

+
+ +
+ {semanasGargalo > 0 ? ( + + + {semanasGargalo} {semanasGargalo === 1 ? 'Semana em Sobrecarga' : 'Semanas em Sobrecarga'} + + ) : ( + + + Capacidade Equilibrada + + )} + + +
+
+ + {/* Área do Gráfico */} + {dadosGrafico.length === 0 ? ( +
+ Nenhum dado de carga para as OFs selecionadas. Arraste ordens para a mesa tática. +
+ ) : ( +
+ + + + + + { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

+ {data.semana} +

+
+ Demanda Acumulada: + {data.cargaTon} ton +
+
+ Capacidade Teto: + {data.capacidadeTon} ton +
+
+ Taxa de Ocupação: + 100 + ? 'text-rose-400' + : data.ocupacaoPercent > 80 + ? 'text-amber-400' + : 'text-emerald-400' + }`} + > + {data.ocupacaoPercent}% + +
+ + {data.ofDetalhes && data.ofDetalhes.length > 0 && ( +
+ + Composição por OF: + + {data.ofDetalhes.map((det: any) => ( +
+ OF {det.num_of}: + {(det.kg / 1000).toFixed(1)} t +
+ ))} +
+ )} +
+ ); + } + return null; + }} + /> + + + {dadosGrafico.map((entry, index) => { + let fillColor = '#10b981'; // Verde (normal) + if (entry.ocupacaoPercent > 100) { + fillColor = '#f43f5e'; // Vermelho (sobrecarga crítica) + } else if (entry.ocupacaoPercent >= 85) { + fillColor = '#f59e0b'; // Amarelo (atenção) + } + return ; + })} + +
+
+
+ )} + + {/* Legenda do Histograma */} +
+
+
+
+ Capacidade Normal (< 85%) +
+
+
+ Atenção (85% a 100%) +
+
+
+ Sobrecarga / Gargalo (> 100%) +
+
+ +
+ * Linha tracejada vermelha representa a capacidade instalada da fábrica. +
+
+
+ ); +}; diff --git a/src/components/pcp/PcpMesaTatica.tsx b/src/components/pcp/PcpMesaTatica.tsx new file mode 100644 index 0000000..58a61cb --- /dev/null +++ b/src/components/pcp/PcpMesaTatica.tsx @@ -0,0 +1,214 @@ +import React, { useState } from 'react'; +import { OfPcpCard } from '@/hooks/usePcpAnalysis'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Layers, Trash2, CheckSquare, Sparkles, Scale, AlertCircle, X } from 'lucide-react'; + +interface PcpMesaTaticaProps { + ofsNaMesa: OfPcpCard[]; + totais: { + totalOfs: number; + pesoTotalKg: number; + pesoTotalTon: string; + pesoConcluidoKg: number; + pesoConcluidoTon: string; + progressoMedio: number; + }; + onRemoveOf: (numOf: string) => void; + onDropOf: (numOf: string) => void; + onClear: () => void; + onSelectAll: () => void; + onAnalisar: () => void; +} + +export const PcpMesaTatica: React.FC = ({ + ofsNaMesa, + totais, + onRemoveOf, + onDropOf, + onClear, + onSelectAll, + onAnalisar, +}) => { + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setIsDragOver(true); + }; + + const handleDragLeave = () => { + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const numOf = e.dataTransfer.getData('text/plain'); + if (numOf) { + onDropOf(numOf); + } + }; + + return ( +
0 + ? 'border-border shadow-sm' + : 'border-dashed border-border/80 bg-muted/20' + }`} + > + {/* Cabeçalho da Mesa */} +
+
+
+ +
+
+
+

+ Mesa Tática de Agrupamento Multi-OF +

+ + {ofsNaMesa.length} {ofsNaMesa.length === 1 ? 'OF agrupada' : 'OFs agrupadas'} + +
+

+ Arraste figurinhas de OF para esta área ou clique nos cards para adicionar à análise acumulada. +

+
+
+ + {/* Ações da Mesa */} +
+ + {ofsNaMesa.length > 0 && ( + + )} + +
+
+ + {/* Cards Agrupados na Mesa */} + {ofsNaMesa.length === 0 ? ( +
+
+ +
+

+ A mesa de simulação está vazia +

+

+ Arraste os thumbnails do catálogo ao lado para agrupar as OFs e calcular o impacto acumulado na fábrica e na obra. +

+
+ ) : ( +
+ {/* Tags de OFs na Mesa */} +
+ + Ordens na Mesa: + + {ofsNaMesa.map((of) => ( + + OF {of.num_of} + + ({(of.pesoTotalKg / 1000).toFixed(1)} t) + + + + ))} +
+ + {/* Cards Rápidos de Totais Acumulados */} +
+
+ + Carga Total em Análise + +
+ + {totais.pesoTotalTon} toneladas +
+
+ +
+ + Produzido Acumulado + +
+ {totais.pesoConcluidoTon} + toneladas +
+
+ +
+ + Saldo Pendente a Fabricar + +
+ + {((totais.pesoTotalKg - totais.pesoConcluidoKg) / 1000).toFixed(1)} + + toneladas +
+
+ +
+ + Avanço Médio Ponderado + +
+ {totais.progressoMedio}% + concluído +
+
+
+
+ )} +
+ ); +}; diff --git a/src/components/pcp/PcpMultiGantt.tsx b/src/components/pcp/PcpMultiGantt.tsx new file mode 100644 index 0000000..a4b56ec --- /dev/null +++ b/src/components/pcp/PcpMultiGantt.tsx @@ -0,0 +1,199 @@ +import React, { useMemo } from 'react'; +import { OfPcpCard } from '@/hooks/usePcpAnalysis'; +import { Badge } from '@/components/ui/badge'; +import { Calendar, Flag, CheckCircle2, Clock, AlertTriangle } from 'lucide-react'; + +interface PcpMultiGanttProps { + ofs: OfPcpCard[]; +} + +export const PcpMultiGantt: React.FC = ({ ofs }) => { + // Encontrar o menor início e maior término entre as OFs + const { minDate, maxDate, totalDias } = useMemo(() => { + if (ofs.length === 0) { + const hoje = new Date(); + return { minDate: hoje, maxDate: new Date(hoje.getTime() + 30 * 86400000), totalDias: 30 }; + } + + let min = Infinity; + let max = -Infinity; + + ofs.forEach((of) => { + of.processos.forEach((p) => { + const dIni = new Date(p.dataInicio).getTime(); + const dFim = new Date(p.dataFim).getTime(); + if (dIni < min) min = dIni; + if (dFim > max) max = dFim; + }); + if (of.dataEntregaObra) { + const dObra = new Date(of.dataEntregaObra).getTime(); + if (dObra > max) max = dObra; + } + }); + + if (min === Infinity || max === -Infinity) { + const hoje = new Date(); + return { minDate: hoje, maxDate: new Date(hoje.getTime() + 30 * 86400000), totalDias: 30 }; + } + + // Adicionar margem de 3 dias antes e depois + const dMin = new Date(min - 3 * 86400000); + const dMax = new Date(max + 4 * 86400000); + const diff = Math.max(1, Math.ceil((dMax.getTime() - dMin.getTime()) / 86400000)); + + return { minDate: dMin, maxDate: dMax, totalDias: diff }; + }, [ofs]); + + // Função auxiliar para calcular percentual horizontal + const getPercentualPosicao = (dateStr: string) => { + if (!dateStr) return 0; + const t = new Date(dateStr).getTime(); + const diff = t - minDate.getTime(); + const perc = (diff / (totalDias * 86400000)) * 100; + return Math.max(0, Math.min(100, perc)); + }; + + // Cores por tipo de processo industrial + const coresProcesso: Record = { + 'Corte': 'bg-blue-500/80 border-blue-400', + 'Furação': 'bg-indigo-500/80 border-indigo-400', + 'Montagem': 'bg-amber-500/80 border-amber-400', + 'Solda': 'bg-rose-500/80 border-rose-400', + 'Jateamento': 'bg-purple-500/80 border-purple-400', + 'Pintura': 'bg-teal-500/80 border-teal-400', + 'Expedição': 'bg-emerald-500/80 border-emerald-400', + }; + + return ( +
+
+
+
+
+ +
+

+ Gantt Integrado Multi-OF & Prazos de Obra +

+
+

+ Visualização comparada do ciclo produtivo e marcos de atendimento na montagem da obra. +

+
+ +
+ +
Corte + + +
Montagem + + +
Solda + + +
Pintura + + +
Expedição + +
+
+ + {ofs.length === 0 ? ( +
+ Nenhuma OF na mesa de análise para exibir cronograma integrado. +
+ ) : ( +
+ {/* Régua de Datas */} +
+ {minDate.toLocaleDateString('pt-BR')} + {new Date(minDate.getTime() + (totalDias / 2) * 86400000).toLocaleDateString('pt-BR')} + {maxDate.toLocaleDateString('pt-BR')} +
+ + {/* Raias por OF */} +
+ {ofs.map((of) => { + const marcoObraPerc = getPercentualPosicao(of.dataEntregaObra); + + return ( +
+ {/* Cabeçalho da Raia */} +
+
+ OF {of.num_of} + + {of.descritivo} + + + {(of.pesoTotalKg / 1000).toFixed(1)} t + +
+ +
+ + + Obra: {of.dataEntregaObra ? new Date(of.dataEntregaObra).toLocaleDateString('pt-BR') : 'N/D'} + + + {of.folgaDiasObra < 0 + ? `Atraso: ${Math.abs(of.folgaDiasObra)} dias` + : `Folga: ${of.folgaDiasObra} dias`} + +
+
+ + {/* Linha do Tempo / Barras dos Processos */} +
+ {/* Barras de cada processo */} + {of.processos.map((proc) => { + const left = getPercentualPosicao(proc.dataInicio); + const right = getPercentualPosicao(proc.dataFim); + const width = Math.max(2, right - left); + const corClass = coresProcesso[proc.nome] || 'bg-slate-500/80 border-slate-400'; + + return ( +
+ {proc.nome} +
+ ); + })} + + {/* Marcador de Entrega em Obra */} + {of.dataEntregaObra && ( +
+
+
+ )} +
+
+ ); + })} +
+
+ )} +
+ ); +}; diff --git a/src/components/pcp/PcpOfThumbnail.tsx b/src/components/pcp/PcpOfThumbnail.tsx new file mode 100644 index 0000000..ead89ad --- /dev/null +++ b/src/components/pcp/PcpOfThumbnail.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { OfPcpCard } from '@/hooks/usePcpAnalysis'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Calendar, Scale, Clock, CheckCircle2, AlertTriangle, XCircle, GripVertical } from 'lucide-react'; + +interface PcpOfThumbnailProps { + of: OfPcpCard; + isSelected: boolean; + onToggle: (numOf: string) => void; + isCompact?: boolean; +} + +export const PcpOfThumbnail: React.FC = ({ + of, + isSelected, + onToggle, + isCompact = false, +}) => { + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.setData('text/plain', of.num_of); + e.dataTransfer.effectAllowed = 'copyMove'; + }; + + const statusConfig = { + no_prazo: { + color: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-400', + badge: 'No Prazo', + icon: CheckCircle2, + }, + atencao: { + color: 'border-amber-500/40 bg-amber-500/10 text-amber-400', + badge: 'Alerta Prazo', + icon: AlertTriangle, + }, + critico: { + color: 'border-rose-500/40 bg-rose-500/10 text-rose-400', + badge: 'Gargalo/Risco', + icon: XCircle, + }, + }[of.status]; + + const StatusIcon = statusConfig.icon; + + return ( +
onToggle(of.num_of)} + className={`group relative rounded-xl border p-3.5 transition-all duration-200 cursor-pointer select-none ${ + isSelected + ? 'border-primary ring-2 ring-primary/30 bg-primary/5 shadow-md shadow-primary/10' + : 'border-border/60 bg-card hover:border-border hover:bg-accent/40 hover:shadow-sm' + }`} + > + {/* Topo do Card */} +
+
+ + + OF {of.num_of} + +
+ + + {statusConfig.badge} + +
+ + {/* Descritivo / Obra */} +

+ {of.descritivo} +

+ + {/* Métricas Principais */} +
+
+ + + {(of.pesoTotalKg / 1000).toFixed(1)} t + +
+
+ + + {of.folgaDiasObra < 0 ? `${Math.abs(of.folgaDiasObra)}d atraso` : `${of.folgaDiasObra}d folga`} + +
+
+ + {/* Barra de Progresso Físico */} +
+
+ Avanço Físico + {of.progressoPercent}% +
+ +
+ + {/* Data Obra no rodapé */} + {!isCompact && of.dataEntregaObra && ( +
+ + + Obra: + + + {new Date(of.dataEntregaObra).toLocaleDateString('pt-BR')} + +
+ )} + + {/* Checkbox visual flutuante indicando seleção */} +
+ ✓ +
+
+ ); +}; diff --git a/src/components/pcp/PcpRecomendacoesPainel.tsx b/src/components/pcp/PcpRecomendacoesPainel.tsx new file mode 100644 index 0000000..6d98b4e --- /dev/null +++ b/src/components/pcp/PcpRecomendacoesPainel.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { RecomendacaoPcp } from '@/hooks/usePcpAnalysis'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Lightbulb, + ArrowRightLeft, + Users, + Clock, + ExternalLink, + Sparkles, + CheckCircle2, + AlertCircle, + HelpCircle, +} from 'lucide-react'; + +interface PcpRecomendacoesPainelProps { + recomendacoes: RecomendacaoPcp[]; + onAplicarSimulacao: (rec: RecomendacaoPcp) => void; +} + +export const PcpRecomendacoesPainel: React.FC = ({ + recomendacoes, + onAplicarSimulacao, +}) => { + const getIcone = (tipo: RecomendacaoPcp['tipo']) => { + switch (tipo) { + case 'nivelamento_prazo': + return ArrowRightLeft; + case 'rebalanceamento_equipe': + return Users; + case 'hora_extra': + return Clock; + case 'terceirizacao': + return ExternalLink; + default: + return Lightbulb; + } + }; + + const getNivelBadge = (nivel: RecomendacaoPcp['nivel']) => { + switch (nivel) { + case 'critico': + return ( + + Ação Prioritária + + ); + case 'alerta': + return ( + + Recomendação + + ); + default: + return ( + + Otimização + + ); + } + }; + + return ( +
+
+
+
+ +
+
+

+ Plano de Ação & Recomendações do PCP +

+

+ Sugestões geradas pelo cruzamento de capacidade real, ritmo de apontamentos e datas de entrega em obra. +

+
+
+ + + {recomendacoes.length} {recomendacoes.length === 1 ? 'Ação Sugerida' : 'Ações Sugeridas'} + +
+ + {recomendacoes.length === 0 ? ( +
+ +

+ Fluxo Perfeitamente Balanceado +

+

+ As OFs selecionadas estão com demanda compatível com a capacidade instalada da fábrica e com datas confortáveis para a obra. +

+
+ ) : ( +
+ {recomendacoes.map((rec) => { + const Icon = getIcone(rec.tipo); + return ( +
+
+
+
+
+ +
+ + {rec.titulo} + +
+ {getNivelBadge(rec.nivel)} +
+ +

+ {rec.descricao} +

+
+ +
+ + + {rec.impactoEstimado} + + + +
+
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/components/pcp/PcpWhatIfSimulator.tsx b/src/components/pcp/PcpWhatIfSimulator.tsx new file mode 100644 index 0000000..0d7427c --- /dev/null +++ b/src/components/pcp/PcpWhatIfSimulator.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { OfPcpCard, SimuladorConfig } from '@/hooks/usePcpAnalysis'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Slider } from '@/components/ui/slider'; +import { RotateCcw, Sliders, Clock, Users, Calendar, ArrowRight } from 'lucide-react'; + +interface PcpWhatIfSimulatorProps { + ofs: OfPcpCard[]; + simulador: SimuladorConfig; + onSetHorasExtras: (processo: string, horas: number) => void; + onSetEfetivoFator: (processo: string, fator: number) => void; + onSetDeslocamentoOf: (numOf: string, dias: number) => void; + onReset: () => void; +} + +export const PcpWhatIfSimulator: React.FC = ({ + ofs, + simulador, + onSetHorasExtras, + onSetEfetivoFator, + onSetDeslocamentoOf, + onReset, +}) => { + const processosPrincipais = ['Corte', 'Montagem', 'Solda', 'Pintura']; + + return ( +
+
+
+
+ +
+
+
+

+ Simulador Tático What-If (E se...?) +

+ + Simulação Ativa + +
+

+ Faça testes de estresse, altere horas de turno, amplie equipes ou balanceie prazos de OFs em tempo real. +

+
+
+ + +
+ +
+ {/* Painel 1: Ajuste de Recursos e Horas Extras */} +
+

+ + Capacidade de Turno & Horas Extras por Posto +

+ +
+ {processosPrincipais.map((proc) => { + const he = simulador.horasExtrasPorProcesso[proc] || 0; + return ( +
+
+ {proc} + + {he === 0 ? 'Turno Padrão (8h)' : `+${he}h extras/dia (Total: ${8 + he}h)`} + +
+
+ onSetHorasExtras(proc, val[0])} + className="flex-1" + /> + + +{he}h + +
+
+ ); + })} +
+
+ + {/* Painel 2: Balanceamento e Deslocamento de Prazos entre OFs */} +
+

+ + Nivelamento Temporal entre OFs (Deslocamento de Dias) +

+ + {ofs.length === 0 ? ( +

+ Adicione OFs à mesa tática para simular reprogramação de prazos. +

+ ) : ( +
+ {ofs.map((of) => { + const desloc = simulador.deslocamentoDiasOf[of.num_of] || 0; + return ( +
+
+
+ OF {of.num_of} + + ({of.folgaDiasObra}d folga original) + +
+ 0 ? 'text-amber-400' : desloc < 0 ? 'text-sky-400' : 'text-muted-foreground'}`}> + {desloc === 0 ? 'Data Original' : desloc > 0 ? `+${desloc} dias (Dilatar)` : `${desloc} dias (Antecipar)`} + +
+ +
+ onSetDeslocamentoOf(of.num_of, val[0])} + className="flex-1" + /> + + {desloc > 0 ? `+${desloc}d` : `${desloc}d`} + +
+
+ ); + })} +
+ )} +
+
+
+ ); +}; diff --git a/src/components/sidebar/menuConfig.ts b/src/components/sidebar/menuConfig.ts index 6238f07..9f15bac 100644 --- a/src/components/sidebar/menuConfig.ts +++ b/src/components/sidebar/menuConfig.ts @@ -138,6 +138,11 @@ export const menuGroups: MenuGroup[] = [ title: "Dashboard de Produção", url: "/dashboard-producao", }, + { + key: "pcp", + title: "PCP (Planejamento)", + url: "/pcp", + }, { key: "prioridades-fabricacao", title: "Prioridades de Fabricação", diff --git a/src/config/routeConfig.ts b/src/config/routeConfig.ts index a59c459..c2b2561 100644 --- a/src/config/routeConfig.ts +++ b/src/config/routeConfig.ts @@ -162,6 +162,12 @@ export const routeConfig: RouteDefinition[] = [ guard: 'resource', resourceKey: 'producao-dashboard', }, + { + path: '/pcp', + component: () => import('@/pages/PCP'), + guard: 'resource', + resourceKey: 'producao-pcp', + }, // ── Visualizador 3D ────────────────────────────────────── { diff --git a/src/hooks/useInterfaceResources.tsx b/src/hooks/useInterfaceResources.tsx index 3d31b1e..1076f0a 100644 --- a/src/hooks/useInterfaceResources.tsx +++ b/src/hooks/useInterfaceResources.tsx @@ -179,6 +179,7 @@ export function useInterfaceResources() { 'diario-producao', 'producao-apontamento', 'producao-dashboard', + 'producao-pcp', 'prioridades-fabricacao', 'painel-industrial', 'expedicao', diff --git a/src/hooks/usePcpAnalysis.ts b/src/hooks/usePcpAnalysis.ts new file mode 100644 index 0000000..ee420fb --- /dev/null +++ b/src/hooks/usePcpAnalysis.ts @@ -0,0 +1,523 @@ +import { useState, useEffect, useMemo } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { toast } from 'sonner'; + +export interface ProcessoSimulado { + nome: string; + ordem: number; + dataInicio: string; + dataFim: string; + pesoKg: number; + pesoConcluidoKg: number; + pesoPendenteKg: number; + diasUteis: number; +} + +export interface OfPcpCard { + id: string; + num_of: string; + descritivo: string; + cliente?: string; + pesoTotalKg: number; + pesoConcluidoKg: number; + progressoPercent: number; + dataInicioPrevista: string; + dataEntregaObra: string; + dataFimCronograma: string; + status: 'no_prazo' | 'atencao' | 'critico'; + folgaDiasObra: number; // dias entre fim da fabricação e necessidade na obra + processos: ProcessoSimulado[]; +} + +export interface CargaPeriodoProcesso { + semanaKey: string; // Ex: "Sem 41 (12/10)" + semanaInicio: string; + semanaFim: string; + processoNome: string; + cargaKg: number; + capacidadeNominalKg: number; + capacidadeRealKg: number; + percentualOcupacao: number; + ofDetalhes: { num_of: string; kg: number }[]; +} + +export interface RecomendacaoPcp { + id: string; + tipo: 'nivelamento_prazo' | 'rebalanceamento_equipe' | 'hora_extra' | 'terceirizacao' | 'priorizacao'; + nivel: 'info' | 'alerta' | 'critico'; + titulo: string; + descricao: string; + ofAlvo?: string; + processoAlvo?: string; + impactoEstimado: string; + sugestaoDiasAjuste?: number; +} + +export interface SimuladorConfig { + horasExtrasPorProcesso: Record; // processoNome -> horas extras/dia (0 a 4) + efetivoFatorPorProcesso: Record; // processoNome -> multiplicador (ex: 1.2 = +20% equipe) + deslocamentoDiasOf: Record; // num_of -> deslocar dias (+5, -3, etc) +} + +export const usePcpAnalysis = () => { + const [loading, setLoading] = useState(true); + const [allOfs, setAllOfs] = useState([]); + const [selectedOfNumbers, setSelectedOfNumbers] = useState([]); + const [simulador, setSimulador] = useState({ + horasExtrasPorProcesso: {}, + efetivoFatorPorProcesso: {}, + deslocamentoDiasOf: {}, + }); + + // Capacidade nominal diária padrão por posto (em kg/dia) - pode ser customizada + const capacidadePadraoKgDia: Record = { + 'Corte': 5000, + 'Furação': 4500, + 'Montagem': 4000, + 'Solda': 3500, + 'Jateamento': 5000, + 'Pintura': 5000, + 'Expedição': 6000, + }; + + // Carregar dados brutos das OFs, cronogramas e apontamentos + const carregarDados = async () => { + try { + setLoading(true); + + // 1. Buscar todas as OFs em andamento/abertas + const { data: ordens, error: ordensError } = await supabase + .from('ordens_fabricacao') + .select(` + id, + num_of, + descritivo, + peso_total, + data_abertura, + data_prazo, + data_termino_prev, + status + `) + .order('num_of', { ascending: true }); + + if (ordensError) throw ordensError; + + // 2. Buscar cronogramas cadastrados com processos + const { data: cronogramasRaw, error: cronoError } = await supabase + .from('cronogramas_of') + .select(` + id, + of_id, + revisao, + processos_cronograma ( + id, + nome_processo, + data_inicio, + data_fim, + ordem + ) + `); + + if (cronoError) console.warn('Aviso ao carregar cronogramas:', cronoError); + + // 3. Buscar apontamentos acumulados agrupados por OF e Processo + const { data: apontamentos, error: aptError } = await supabase + .from('apontamentos_producao') + .select(` + of_number, + peso_total, + quantidade, + data_apontamento, + processos_fabricacao ( + nome, + ordem + ) + `); + + if (aptError) console.warn('Aviso ao carregar apontamentos:', aptError); + + // 4. Buscar peças para conferência do peso total real + const { data: pecasData } = await supabase + .from('pecas') + .select('of_number, peso_unitario, quantidade'); + + // Indexar pesos de peças por OF + const pesoPecasPorOf: Record = {}; + (pecasData || []).forEach((p) => { + const peso = (Number(p.peso_unitario) || 0) * (Number(p.quantidade) || 0); + pesoPecasPorOf[p.of_number] = (pesoPecasPorOf[p.of_number] || 0) + peso; + }); + + // Indexar apontamentos por OF e nome do processo + const apontadoPorOfProcesso: Record> = {}; + (apontamentos || []).forEach((apt: any) => { + const ofNum = apt.of_number; + const procNome = apt.processos_fabricacao?.nome || 'Geral'; + const peso = Number(apt.peso_total) || 0; + if (!apontadoPorOfProcesso[ofNum]) apontadoPorOfProcesso[ofNum] = {}; + apontadoPorOfProcesso[ofNum][procNome] = (apontadoPorOfProcesso[ofNum][procNome] || 0) + peso; + }); + + // Montar lista de OFs para o PCP + const cards: OfPcpCard[] = (ordens || []).map((of) => { + const pesoTotal = pesoPecasPorOf[of.num_of] || Number(of.peso_total) || 1000; + const crono = (cronogramasRaw || []).find((c) => c.of_id === of.id); + const processosCronograma = crono?.processos_cronograma || []; + + // Montar processos + let processosMapeados: ProcessoSimulado[] = []; + if (processosCronograma.length > 0) { + processosMapeados = processosCronograma + .sort((a: any, b: any) => a.ordem - b.ordem) + .map((p: any) => { + const procNome = p.nome_processo; + const concluido = apontadoPorOfProcesso[of.num_of]?.[procNome] || 0; + const inicio = new Date(p.data_inicio); + const fim = new Date(p.data_fim); + const dias = Math.max(1, Math.ceil((fim.getTime() - inicio.getTime()) / (1000 * 3600 * 24))); + return { + nome: procNome, + ordem: p.ordem, + dataInicio: p.data_inicio, + dataFim: p.data_fim, + pesoKg: pesoTotal, + pesoConcluidoKg: Math.min(pesoTotal, concluido), + pesoPendenteKg: Math.max(0, pesoTotal - concluido), + diasUteis: dias, + }; + }); + } else { + // Fallback caso não haja cronograma detalhado ainda cadastrado + const dataIniStr = of.data_abertura || new Date().toISOString().split('T')[0]; + const dataFimStr = of.data_termino_prev || of.data_prazo || new Date(Date.now() + 30 * 24 * 3600 * 1000).toISOString().split('T')[0]; + const procsFallback = ['Corte', 'Montagem', 'Solda', 'Pintura', 'Expedição']; + processosMapeados = procsFallback.map((nome, idx) => ({ + nome, + ordem: idx + 1, + dataInicio: dataIniStr, + dataFim: dataFimStr, + pesoKg: pesoTotal, + pesoConcluidoKg: 0, + pesoPendenteKg: pesoTotal, + diasUteis: 20, + })); + } + + const totalConcluido = Object.values(apontadoPorOfProcesso[of.num_of] || {}).reduce((acc, v) => acc + v, 0); + // Média de avanço considerando os processos + const progressoPercent = pesoTotal > 0 + ? Math.min(100, Math.round((totalConcluido / (pesoTotal * Math.max(1, processosMapeados.length))) * 100)) + : 0; + + // Datas limites + const dataFimCronograma = processosMapeados.length > 0 + ? processosMapeados[processosMapeados.length - 1].dataFim + : (of.data_termino_prev || of.data_prazo || ''); + + const dataEntregaObra = of.data_termino_prev || of.data_prazo || dataFimCronograma; + + // Calcular folga em relação à obra + let folgaDiasObra = 0; + if (dataEntregaObra && dataFimCronograma) { + const dtObra = new Date(dataEntregaObra).getTime(); + const dtFimCrono = new Date(dataFimCronograma).getTime(); + folgaDiasObra = Math.round((dtObra - dtFimCrono) / (1000 * 3600 * 24)); + } + + let status: 'no_prazo' | 'atencao' | 'critico' = 'no_prazo'; + if (folgaDiasObra < 0) { + status = 'critico'; + } else if (folgaDiasObra <= 5) { + status = 'atencao'; + } + + return { + id: of.id, + num_of: of.num_of, + descritivo: of.descritivo || `Obra OF ${of.num_of}`, + pesoTotalKg: pesoTotal, + pesoConcluidoKg: totalConcluido, + progressoPercent, + dataInicioPrevista: of.data_abertura || '', + dataEntregaObra, + dataFimCronograma, + status, + folgaDiasObra, + processos: processosMapeados, + }; + }); + + setAllOfs(cards); + // Selecionar por padrão as 3 primeiras OFs ativas + if (cards.length > 0 && selectedOfNumbers.length === 0) { + setSelectedOfNumbers(cards.slice(0, Math.min(3, cards.length)).map(c => c.num_of)); + } + } catch (err: any) { + console.error('Erro ao carregar dados do PCP:', err); + toast.error('Erro ao carregar dados de produção e cronogramas.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + carregarDados(); + }, []); + + // OFs selecionadas na mesa de análise + const ofsNaMesa = useMemo(() => { + return allOfs.filter((c) => selectedOfNumbers.includes(c.num_of)); + }, [allOfs, selectedOfNumbers]); + + // Totais agregados da mesa + const totaisMesa = useMemo(() => { + const pesoTotalKg = ofsNaMesa.reduce((acc, c) => acc + c.pesoTotalKg, 0); + const pesoConcluidoKg = ofsNaMesa.reduce((acc, c) => acc + c.pesoConcluidoKg, 0); + const progressoMedio = ofsNaMesa.length > 0 + ? Math.round(ofsNaMesa.reduce((acc, c) => acc + c.progressoPercent, 0) / ofsNaMesa.length) + : 0; + + return { + totalOfs: ofsNaMesa.length, + pesoTotalKg, + pesoTotalTon: (pesoTotalKg / 1000).toFixed(1), + pesoConcluidoKg, + pesoConcluidoTon: (pesoConcluidoKg / 1000).toFixed(1), + progressoMedio, + }; + }, [ofsNaMesa]); + + // Função auxiliar para obter início da semana (Segunda-feira) + const getSegundaFeira = (d: Date) => { + const date = new Date(d); + const day = date.getDay(); + const diff = date.getDate() - day + (day === 0 ? -6 : 1); + date.setDate(diff); + date.setHours(0, 0, 0, 0); + return date; + }; + + // CÁLCULO DE CARGA ACUMULADA POR SEMANA E PROCESSO (HISTOGRAMA) + const cargaPorPeriodo = useMemo(() => { + if (ofsNaMesa.length === 0) return []; + + // Mapear intervalos semanais + const acumulador: Record = {}; + + ofsNaMesa.forEach((of) => { + const deslocamentoDias = simulador.deslocamentoDiasOf[of.num_of] || 0; + + of.processos.forEach((proc) => { + const procNome = proc.nome; + const pesoPendente = proc.pesoPendenteKg; + if (pesoPendente <= 0) return; + + // Ajustar datas pelo simulador se houver deslocamento + const dtInicio = new Date(proc.dataInicio); + dtInicio.setDate(dtInicio.getDate() + deslocamentoDias); + + const dtFim = new Date(proc.dataFim); + dtFim.setDate(dtFim.getDate() + deslocamentoDias); + + const diasTotais = Math.max(1, Math.ceil((dtFim.getTime() - dtInicio.getTime()) / (1000 * 3600 * 24))); + const pesoPorDia = pesoPendente / diasTotais; + + // Percorrer cada dia do processo e alocar na semana correspondente + const cursor = new Date(dtInicio); + while (cursor <= dtFim) { + const seg = getSegundaFeira(cursor); + const sex = new Date(seg); + sex.setDate(sex.getDate() + 4); + + const semanaKey = `Sem ${String(seg.getDate()).padStart(2, '0')}/${String(seg.getMonth() + 1).padStart(2, '0')}`; + const mapKey = `${semanaKey}__${procNome}`; + + if (!acumulador[mapKey]) { + // Capacidade nominal base (5 dias úteis por semana) + const capBaseDia = capacidadePadraoKgDia[procNome] || 4000; + const horaExtra = simulador.horasExtrasPorProcesso[procNome] || 0; + const fatorEfetivo = simulador.efetivoFatorPorProcesso[procNome] || 1; + // 8h base + horaExtra + const fatorHoras = (8 + horaExtra) / 8; + const capSemanal = capBaseDia * 5 * fatorHoras * fatorEfetivo; + + acumulador[mapKey] = { + semanaKey, + semanaInicio: seg.toISOString().split('T')[0], + semanaFim: sex.toISOString().split('T')[0], + processoNome: procNome, + cargaKg: 0, + capacidadeNominalKg: capBaseDia * 5, + capacidadeRealKg: capSemanal, + percentualOcupacao: 0, + ofDetalhes: [], + }; + } + + acumulador[mapKey].cargaKg += pesoPorDia; + + const existente = acumulador[mapKey].ofDetalhes.find(d => d.num_of === of.num_of); + if (existente) { + existente.kg += pesoPorDia; + } else { + acumulador[mapKey].ofDetalhes.push({ num_of: of.num_of, kg: pesoPorDia }); + } + + cursor.setDate(cursor.getDate() + 1); + } + }); + }); + + // Calcular percentuais de ocupação + const resultado = Object.values(acumulador).map((item) => { + const perc = item.capacidadeRealKg > 0 ? Math.round((item.cargaKg / item.capacidadeRealKg) * 100) : 0; + return { + ...item, + percentualOcupacao: perc, + }; + }); + + // Ordenar por semana cronológica + return resultado.sort((a, b) => a.semanaInicio.localeCompare(b.semanaInicio)); + }, [ofsNaMesa, simulador]); + + // MOTOR INTELIGENTE DE RECOMENDAÇÕES E BALANCEAMENTO + const recomendacoes = useMemo(() => { + const list: RecomendacaoPcp[] = []; + if (ofsNaMesa.length === 0) return list; + + // 1. Verificar sobrecarga/gargalo nos períodos + const sobrecargas = cargaPorPeriodo.filter((c) => c.percentualOcupacao > 100); + const processosSobrecarga = new Set(sobrecargas.map(s => s.processoNome)); + + // 2. Verificar processos com folga acentuada no mesmo período + const processosFolga = cargaPorPeriodo.filter((c) => c.percentualOcupacao < 60 && c.cargaKg > 0); + + // Regra A: Nivelamento de Prazo entre OFs (Load Leveling) + // Se há OFs críticas e OFs com folga alta em obra + const ofsComFolga = ofsNaMesa.filter((o) => o.folgaDiasObra > 10); + const ofsApertadas = ofsNaMesa.filter((o) => o.folgaDiasObra <= 3); + + if (ofsComFolga.length > 0 && ofsApertadas.length > 0 && sobrecargas.length > 0) { + const ofDoadora = ofsComFolga[0]; + const ofReceptora = ofsApertadas[0]; + list.push({ + id: 'rec_nivelamento_1', + tipo: 'nivelamento_prazo', + nivel: 'critico', + titulo: `Balanceamento de Prazos: Postergar OF ${ofDoadora.num_of} em favor da OF ${ofReceptora.num_of}`, + descricao: `A OF ${ofDoadora.num_of} possui ${ofDoadora.folgaDiasObra} dias de folga antes da montagem em obra, enquanto a OF ${ofReceptora.num_of} está com prazo crítico. Postergar o início da OF ${ofDoadora.num_of} em 5 a 7 dias desobstruirá o fluxo produtivo sem comprometer nenhuma data de obra.`, + ofAlvo: ofDoadora.num_of, + impactoEstimado: 'Redução de até 35% na sobrecarga simultânea de fabricação.', + sugestaoDiasAjuste: 7, + }); + } + + // Regra B: Rebalanceamento de Equipe entre postos + if (processosSobrecarga.has('Solda') && processosFolga.some(p => p.processoNome === 'Montagem' || p.processoNome === 'Corte')) { + list.push({ + id: 'rec_equipe_solda', + tipo: 'rebalanceamento_equipe', + nivel: 'alerta', + titulo: 'Rebalanceamento de Mão de Obra: Transferir efetivo para Solda', + descricao: 'O posto de Solda está em pico de sobrecarga (> 100%), enquanto a Montagem/Corte opera com capacidade ociosa no mesmo período. Recomenda-se realocar caldeireiros/montadores qualificados para suporte na soldagem.', + processoAlvo: 'Solda', + impactoEstimado: '+25% de capacidade diária na Solda, eliminando 4 dias de represamento.', + }); + } + + // Regra C: Sugestão de Horas Extras pontuais + sobrecargas.forEach((sb) => { + if (sb.percentualOcupacao > 120) { + list.push({ + id: `rec_he_${sb.semanaKey}_${sb.processoNome}`, + tipo: 'hora_extra', + nivel: 'alerta', + titulo: `Horas Extras recomendadas em ${sb.processoNome} (${sb.semanaKey})`, + descricao: `A demanda atinge ${(sb.cargaKg / 1000).toFixed(1)} ton contra capacidade de ${(sb.capacidadeRealKg / 1000).toFixed(1)} ton (${sb.percentualOcupacao}% de ocupação). Abrir 2 horas extras por dia neste processo restabelece o fluxo programado.`, + processoAlvo: sb.processoNome, + impactoEstimado: 'Equalização da carga semanal sem atraso para os processos seguintes.', + }); + } + }); + + // Regra D: Priorização de Ataque na Linha + if (ofsApertadas.length > 0) { + const prioritarias = [...ofsApertadas].sort((a, b) => a.folgaDiasObra - b.folgaDiasObra); + list.push({ + id: 'rec_prioridade', + tipo: 'priorizacao', + nivel: 'info', + titulo: `Ordem de Ataque Recomendada na Fábrica`, + descricao: `Priorizar a liberação sequencial dos lotes na ordem: ${prioritarias.map(p => `OF ${p.num_of}`).join(' ➔ ')}. Colocar as ordens com maior folga em pulmão de espera temporário caso haja fila.`, + impactoEstimado: 'Prevenção de paradas e penalidades contratuais em obra.', + }); + } + + return list; + }, [ofsNaMesa, cargaPorPeriodo]); + + // Ações de manipulação da mesa + const alternarSelecaoOf = (numOf: string) => { + setSelectedOfNumbers((prev) => + prev.includes(numOf) ? prev.filter((n) => n !== numOf) : [...prev, numOf] + ); + }; + + const adicionarTodasOfs = () => { + setSelectedOfNumbers(allOfs.map((o) => o.num_of)); + }; + + const limparMesa = () => { + setSelectedOfNumbers([]); + }; + + // Ajustes no simulador What-If + const setHorasExtras = (processo: string, horas: number) => { + setSimulador((prev) => ({ + ...prev, + horasExtrasPorProcesso: { ...prev.horasExtrasPorProcesso, [processo]: horas }, + })); + }; + + const setEfetivoFator = (processo: string, fator: number) => { + setSimulador((prev) => ({ + ...prev, + efetivoFatorPorProcesso: { ...prev.efetivoFatorPorProcesso, [processo]: fator }, + })); + }; + + const setDeslocamentoOf = (numOf: string, dias: number) => { + setSimulador((prev) => ({ + ...prev, + deslocamentoDiasOf: { ...prev.deslocamentoDiasOf, [numOf]: dias }, + })); + }; + + const resetarSimulador = () => { + setSimulador({ + horasExtrasPorProcesso: {}, + efetivoFatorPorProcesso: {}, + deslocamentoDiasOf: {}, + }); + }; + + return { + loading, + allOfs, + selectedOfNumbers, + ofsNaMesa, + totaisMesa, + cargaPorPeriodo, + recomendacoes, + simulador, + alternarSelecaoOf, + adicionarTodasOfs, + limparMesa, + setHorasExtras, + setEfetivoFator, + setDeslocamentoOf, + resetarSimulador, + recarregarDados: carregarDados, + }; +}; diff --git a/src/pages/PCP.tsx b/src/pages/PCP.tsx new file mode 100644 index 0000000..29e12f4 --- /dev/null +++ b/src/pages/PCP.tsx @@ -0,0 +1,257 @@ +import React, { useState } from 'react'; +import { StandardPageLayout } from '@/components/layout/StandardPageLayout'; +import { usePcpAnalysis, RecomendacaoPcp } from '@/hooks/usePcpAnalysis'; +import { PcpOfThumbnail } from '@/components/pcp/PcpOfThumbnail'; +import { PcpMesaTatica } from '@/components/pcp/PcpMesaTatica'; +import { PcpHistogramaCarga } from '@/components/pcp/PcpHistogramaCarga'; +import { PcpMultiGantt } from '@/components/pcp/PcpMultiGantt'; +import { PcpRecomendacoesPainel } from '@/components/pcp/PcpRecomendacoesPainel'; +import { PcpWhatIfSimulator } from '@/components/pcp/PcpWhatIfSimulator'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { + Layers, + BarChart3, + Calendar, + Sparkles, + Sliders, + Search, + RefreshCw, + PlusCircle, + HelpCircle, +} from 'lucide-react'; +import { toast } from 'sonner'; + +const PCP = () => { + const { + loading, + allOfs, + selectedOfNumbers, + ofsNaMesa, + totaisMesa, + cargaPorPeriodo, + recomendacoes, + simulador, + alternarSelecaoOf, + adicionarTodasOfs, + limparMesa, + setHorasExtras, + setEfetivoFator, + setDeslocamentoOf, + resetarSimulador, + recarregarDados, + } = usePcpAnalysis(); + + const [busca, setBusca] = useState(''); + const [activeTab, setActiveTab] = useState<'histograma' | 'gantt' | 'recomendacoes' | 'whatif'>('histograma'); + + // Filtragem de OFs na gaveta lateral + const ofsFiltradas = allOfs.filter((of) => { + const termo = busca.toLowerCase(); + return ( + of.num_of.toLowerCase().includes(termo) || + of.descritivo.toLowerCase().includes(termo) + ); + }); + + // Ação ao clicar em "Simular Impacto" de uma recomendação + const handleAplicarRecomendacao = (rec: RecomendacaoPcp) => { + if (rec.tipo === 'nivelamento_prazo' && rec.ofAlvo && rec.sugestaoDiasAjuste) { + setDeslocamentoOf(rec.ofAlvo, rec.sugestaoDiasAjuste); + setActiveTab('whatif'); + toast.success(`Simulação aplicada: OF ${rec.ofAlvo} dilatada em +${rec.sugestaoDiasAjuste} dias no Simulador!`); + } else if (rec.tipo === 'hora_extra' && rec.processoAlvo) { + setHorasExtras(rec.processoAlvo, 2); + setActiveTab('whatif'); + toast.success(`Simulação aplicada: +2h extras/dia adicionadas em ${rec.processoAlvo}!`); + } else if (rec.tipo === 'rebalanceamento_equipe' && rec.processoAlvo) { + setEfetivoFator(rec.processoAlvo, 1.25); + setActiveTab('whatif'); + toast.success(`Simulação aplicada: +25% de capacidade transferida para ${rec.processoAlvo}!`); + } else { + setActiveTab('whatif'); + toast.info('Abra os controles do simulador para testar este cenário.'); + } + }; + + return ( +
+ + +
+ } + > +
+ {/* COLUNA ESQUERDA: GAVETA DE FIGURINHAS / THUMBNAILS DAS OFS (4 colunas) */} +
+
+
+ +

+ Catálogo de OFs +

+
+ + {selectedOfNumbers.length}/{allOfs.length} na mesa + +
+ + {/* Barra de Busca de OFs */} +
+ + setBusca(e.target.value)} + className="pl-8 text-xs h-9" + /> +
+ +

+ * Dica: Arraste a figurinha para a mesa ou clique nela para incluir/remover do cálculo de capacidade. +

+ + {/* Lista com scroll das Figurinhas */} +
+ {loading ? ( +
+ Carregando ordens de fabricação e cronogramas... +
+ ) : ofsFiltradas.length === 0 ? ( +
+ Nenhuma OF encontrada para o filtro. +
+ ) : ( + ofsFiltradas.map((of) => ( + + )) + )} +
+
+ + {/* COLUNA DIREITA: MESA TÁTICA E VISUALIZADORES ACUMULADOS (8 colunas) */} +
+ {/* 1. MESA TÁTICA DE DROP E AGRUPAMENTO */} + { + if (!selectedOfNumbers.includes(numOf)) { + alternarSelecaoOf(numOf); + toast.success(`OF ${numOf} adicionada à mesa de análise acumulada!`); + } + }} + onClear={limparMesa} + onSelectAll={adicionarTodasOfs} + onAnalisar={() => { + setActiveTab('histograma'); + toast.success('Cenário consolidado processado!'); + }} + /> + + {/* 2. ABAS DE VISUALIZAÇÃO DO CENÁRIO ACUMULADO */} + setActiveTab(val)} + className="w-full space-y-4" + > + + + + Carga vs Capacidade + + + + + Cronograma Multi-OF + + + + + Plano & Ações + {recomendacoes.length > 0 && ( + + )} + + + + + Simulador What-If + + + + {/* ABA 1: HISTOGRAMA DE CARGA VS CAPACIDADE */} + + + + + {/* ABA 2: CRONOGRAMA GANTT INTEGRADO */} + + + + + {/* ABA 3: RECOMENDAÇÕES E BALANCEAMENTO */} + + + + + {/* ABA 4: SIMULADOR WHAT-IF */} + + + + +
+
+ +
+ ); +}; + +export default PCP; diff --git a/src/pages/index.ts b/src/pages/index.ts index 0dfb800..8e20038 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -15,6 +15,7 @@ export { default as Producao } from './Producao'; export { default as DiarioProducao } from './DiarioProducao'; export { default as ApontamentoProducao } from './ApontamentoProducao'; export { default as DashboardProducao } from './DashboardProducao'; +export { default as PCP } from './PCP'; export { default as Expedicao } from './Expedicao'; export { default as Obra } from './Obra'; export { default as ObraConfiguracoes } from './ObraConfiguracoes'; diff --git a/src/utils/syncInterfaceResources.ts b/src/utils/syncInterfaceResources.ts index af91cd1..e691844 100644 --- a/src/utils/syncInterfaceResources.ts +++ b/src/utils/syncInterfaceResources.ts @@ -35,7 +35,8 @@ export const syncInterfaceResources = async (menuItems: any[], adminMenuItems: a { resource_key: 'diario-producao', resource_name: 'Diário de Produção', icon_name: null, route_path: '/diario-producao', is_submenu: true, parent_key: 'producao', order_index: 2 }, { resource_key: 'producao-apontamento', resource_name: 'Apontamento de Produção', icon_name: null, route_path: '/apontamento-producao', is_submenu: true, parent_key: 'producao', order_index: 3 }, { resource_key: 'producao-dashboard', resource_name: 'Dashboard de Produção', icon_name: null, route_path: '/dashboard-producao', is_submenu: true, parent_key: 'producao', order_index: 4 }, - { resource_key: 'prioridades-fabricacao', resource_name: 'Prioridades de Fabricação', icon_name: null, route_path: '/prioridades-fabricacao', is_submenu: true, parent_key: 'producao', order_index: 5 }, + { resource_key: 'producao-pcp', resource_name: 'PCP (Planejamento)', icon_name: null, route_path: '/pcp', is_submenu: true, parent_key: 'producao', order_index: 5 }, + { resource_key: 'prioridades-fabricacao', resource_name: 'Prioridades de Fabricação', icon_name: null, route_path: '/prioridades-fabricacao', is_submenu: true, parent_key: 'producao', order_index: 6 }, // Painel Industrial { resource_key: 'painel-industrial', resource_name: 'Painel Industrial', icon_name: 'Monitor', route_path: '/painel-industrial', is_submenu: false, order_index: 7 },