feat: Implantação do módulo PCP com Mesa Tática Multi-OF e simulação what-if

This commit is contained in:
2026-09-22 13:55:33 +00:00
parent f4a15ef751
commit 53deebe67a
13 changed files with 1883 additions and 1 deletions
+253
View File
@@ -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<PcpHistogramaCargaProps> = ({ dadosCarga }) => {
const [processoFiltro, setProcessoFiltro] = useState<string>('todos');
// Obter lista única de processos presentes nos dados
const listaProcessos = useMemo(() => {
const set = new Set<string>();
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<string, any> = {};
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 (
<div className="rounded-2xl border border-border bg-card p-4 md:p-6 shadow-sm">
{/* Header do Gráfico */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<div>
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-sky-500/10 text-sky-400">
<BarChart3 className="h-5 w-5" />
</div>
<h3 className="font-bold text-base md:text-lg text-foreground">
Histograma de Carga Acumulada vs Capacidade Fabril
</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
Distribuição temporal da demanda (toneladas por semana) somando as OFs em análise contra o teto da fábrica.
</p>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
{semanasGargalo > 0 ? (
<Badge variant="outline" className="border-rose-500/40 bg-rose-500/10 text-rose-400 text-xs px-2.5 py-1 flex items-center gap-1.5 font-semibold">
<AlertOctagon className="h-3.5 w-3.5" />
{semanasGargalo} {semanasGargalo === 1 ? 'Semana em Sobrecarga' : 'Semanas em Sobrecarga'}
</Badge>
) : (
<Badge variant="outline" className="border-emerald-500/40 bg-emerald-500/10 text-emerald-400 text-xs px-2.5 py-1 flex items-center gap-1.5 font-semibold">
<CheckCircle2 className="h-3.5 w-3.5" />
Capacidade Equilibrada
</Badge>
)}
<Select value={processoFiltro} onValueChange={setProcessoFiltro}>
<SelectTrigger className="w-[160px] text-xs h-9">
<SelectValue placeholder="Filtrar Processo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos os Processos</SelectItem>
{listaProcessos.map((proc) => (
<SelectItem key={proc} value={proc}>
{proc}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Área do Gráfico */}
{dadosGrafico.length === 0 ? (
<div className="py-16 text-center text-muted-foreground text-xs">
Nenhum dado de carga para as OFs selecionadas. Arraste ordens para a mesa tática.
</div>
) : (
<div className="h-[280px] md:h-[340px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={dadosGrafico} margin={{ top: 20, right: 20, left: -10, bottom: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#ffffff15" />
<XAxis
dataKey="semana"
tick={{ fontSize: 11, fill: '#888888' }}
axisLine={{ stroke: '#ffffff20' }}
/>
<YAxis
tick={{ fontSize: 11, fill: '#888888' }}
axisLine={{ stroke: '#ffffff20' }}
unit=" t"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-popover border border-border rounded-xl p-3 shadow-xl text-xs space-y-1.5 min-w-[200px]">
<p className="font-bold text-foreground border-b border-border/60 pb-1">
{data.semana}
</p>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Demanda Acumulada:</span>
<span className="font-bold text-sky-400">{data.cargaTon} ton</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Capacidade Teto:</span>
<span className="font-medium text-foreground">{data.capacidadeTon} ton</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Taxa de Ocupação:</span>
<span
className={`font-bold ${
data.ocupacaoPercent > 100
? 'text-rose-400'
: data.ocupacaoPercent > 80
? 'text-amber-400'
: 'text-emerald-400'
}`}
>
{data.ocupacaoPercent}%
</span>
</div>
{data.ofDetalhes && data.ofDetalhes.length > 0 && (
<div className="pt-2 border-t border-border/40 mt-1">
<span className="text-[10px] text-muted-foreground block mb-1 font-semibold">
Composição por OF:
</span>
{data.ofDetalhes.map((det: any) => (
<div key={det.num_of} className="flex justify-between text-[11px]">
<span>OF {det.num_of}:</span>
<span className="font-medium">{(det.kg / 1000).toFixed(1)} t</span>
</div>
))}
</div>
)}
</div>
);
}
return null;
}}
/>
<ReferenceLine
y={capacidadeReferenciaTon}
stroke="#ef4444"
strokeDasharray="4 4"
label={{
value: `Capacidade Teto (${capacidadeReferenciaTon} t)`,
fill: '#ef4444',
fontSize: 10,
position: 'top',
}}
/>
<Bar dataKey="cargaTon" radius={[6, 6, 0, 0]}>
{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 <Cell key={`cell-${index}`} fill={fillColor} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Legenda do Histograma */}
<div className="mt-4 pt-3 border-t border-border/50 flex flex-wrap items-center justify-between text-xs text-muted-foreground gap-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-emerald-500" />
<span>Capacidade Normal (&lt; 85%)</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-amber-500" />
<span>Atenção (85% a 100%)</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-rose-500" />
<span>Sobrecarga / Gargalo (&gt; 100%)</span>
</div>
</div>
<div className="text-[11px] italic">
* Linha tracejada vermelha representa a capacidade instalada da fábrica.
</div>
</div>
</div>
);
};
+214
View File
@@ -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<PcpMesaTaticaProps> = ({
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 (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`rounded-2xl border-2 transition-all duration-200 p-4 md:p-6 bg-card/60 backdrop-blur-sm ${
isDragOver
? 'border-primary border-dashed bg-primary/10 shadow-lg ring-4 ring-primary/20 scale-[1.005]'
: ofsNaMesa.length > 0
? 'border-border shadow-sm'
: 'border-dashed border-border/80 bg-muted/20'
}`}
>
{/* Cabeçalho da Mesa */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pb-4 border-b border-border/50">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-primary/10 text-primary">
<Layers className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-base md:text-lg font-bold text-foreground">
Mesa Tática de Agrupamento Multi-OF
</h2>
<Badge variant="secondary" className="font-semibold text-xs">
{ofsNaMesa.length} {ofsNaMesa.length === 1 ? 'OF agrupada' : 'OFs agrupadas'}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Arraste figurinhas de OF para esta área ou clique nos cards para adicionar à análise acumulada.
</p>
</div>
</div>
{/* Ações da Mesa */}
<div className="flex items-center gap-2 w-full sm:w-auto justify-end flex-wrap">
<Button
size="sm"
variant="ghost"
onClick={onSelectAll}
className="text-xs h-8 text-muted-foreground hover:text-foreground"
>
<CheckSquare className="h-3.5 w-3.5 mr-1.5" />
Todas as OFs
</Button>
{ofsNaMesa.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={onClear}
className="text-xs h-8 text-rose-400 hover:text-rose-500 hover:bg-rose-500/10 border-rose-500/30"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Limpar Mesa
</Button>
)}
<Button
size="sm"
onClick={onAnalisar}
disabled={ofsNaMesa.length === 0}
className="text-xs h-8 bg-primary hover:bg-primary/90 shadow-sm"
>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
Analisar Cenário Acumulado
</Button>
</div>
</div>
{/* Cards Agrupados na Mesa */}
{ofsNaMesa.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 rounded-full bg-muted/40 border border-dashed border-border flex items-center justify-center mb-3 text-muted-foreground">
<Layers className="h-8 w-8 opacity-40 animate-pulse" />
</div>
<p className="text-sm font-medium text-foreground">
A mesa de simulação está vazia
</p>
<p className="text-xs text-muted-foreground max-w-sm mt-1">
Arraste os thumbnails do catálogo ao lado para agrupar as OFs e calcular o impacto acumulado na fábrica e na obra.
</p>
</div>
) : (
<div className="mt-4 space-y-4">
{/* Tags de OFs na Mesa */}
<div className="flex flex-wrap gap-2 items-center">
<span className="text-xs font-semibold text-muted-foreground mr-1">
Ordens na Mesa:
</span>
{ofsNaMesa.map((of) => (
<Badge
key={of.id}
variant="outline"
className="pl-2.5 pr-1.5 py-1 text-xs bg-card/80 border-border/80 flex items-center gap-2 group hover:border-primary/50 transition-colors"
>
<span className="font-bold text-foreground">OF {of.num_of}</span>
<span className="text-muted-foreground text-[11px]">
({(of.pesoTotalKg / 1000).toFixed(1)} t)
</span>
<button
onClick={(e) => {
e.stopPropagation();
onRemoveOf(of.num_of);
}}
className="rounded-full p-0.5 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Remover da mesa"
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
))}
</div>
{/* Cards Rápidos de Totais Acumulados */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 pt-2">
<div className="bg-muted/40 border border-border/50 rounded-xl p-3">
<span className="text-[11px] text-muted-foreground block mb-0.5">
Carga Total em Análise
</span>
<div className="flex items-center gap-1.5 text-foreground font-bold text-lg md:text-xl">
<Scale className="h-4 w-4 text-sky-400" />
{totais.pesoTotalTon} <span className="text-xs font-normal text-muted-foreground">toneladas</span>
</div>
</div>
<div className="bg-muted/40 border border-border/50 rounded-xl p-3">
<span className="text-[11px] text-muted-foreground block mb-0.5">
Produzido Acumulado
</span>
<div className="flex items-center gap-1.5 text-foreground font-bold text-lg md:text-xl">
<span className="text-emerald-400 font-bold">{totais.pesoConcluidoTon}</span>
<span className="text-xs font-normal text-muted-foreground">toneladas</span>
</div>
</div>
<div className="bg-muted/40 border border-border/50 rounded-xl p-3">
<span className="text-[11px] text-muted-foreground block mb-0.5">
Saldo Pendente a Fabricar
</span>
<div className="flex items-center gap-1.5 text-foreground font-bold text-lg md:text-xl">
<span className="text-amber-400 font-bold">
{((totais.pesoTotalKg - totais.pesoConcluidoKg) / 1000).toFixed(1)}
</span>
<span className="text-xs font-normal text-muted-foreground">toneladas</span>
</div>
</div>
<div className="bg-muted/40 border border-border/50 rounded-xl p-3">
<span className="text-[11px] text-muted-foreground block mb-0.5">
Avanço Médio Ponderado
</span>
<div className="flex items-center gap-1.5 text-foreground font-bold text-lg md:text-xl">
<span className="text-primary font-bold">{totais.progressoMedio}%</span>
<span className="text-xs font-normal text-muted-foreground">concluído</span>
</div>
</div>
</div>
</div>
)}
</div>
);
};
+199
View File
@@ -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<PcpMultiGanttProps> = ({ 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<string, string> = {
'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 (
<div className="rounded-2xl border border-border bg-card p-4 md:p-6 shadow-sm">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6 pb-4 border-b border-border/50">
<div>
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<Calendar className="h-5 w-5" />
</div>
<h3 className="font-bold text-base md:text-lg text-foreground">
Gantt Integrado Multi-OF & Prazos de Obra
</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
Visualização comparada do ciclo produtivo e marcos de atendimento na montagem da obra.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground flex-wrap">
<span className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded bg-blue-500" /> Corte
</span>
<span className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded bg-amber-500" /> Montagem
</span>
<span className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded bg-rose-500" /> Solda
</span>
<span className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded bg-teal-500" /> Pintura
</span>
<span className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded bg-emerald-500" /> Expedição
</span>
</div>
</div>
{ofs.length === 0 ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Nenhuma OF na mesa de análise para exibir cronograma integrado.
</div>
) : (
<div className="space-y-6 overflow-x-auto">
{/* Régua de Datas */}
<div className="relative h-6 border-b border-border/60 text-[10px] text-muted-foreground flex justify-between min-w-[600px]">
<span>{minDate.toLocaleDateString('pt-BR')}</span>
<span>{new Date(minDate.getTime() + (totalDias / 2) * 86400000).toLocaleDateString('pt-BR')}</span>
<span>{maxDate.toLocaleDateString('pt-BR')}</span>
</div>
{/* Raias por OF */}
<div className="space-y-5 min-w-[600px]">
{ofs.map((of) => {
const marcoObraPerc = getPercentualPosicao(of.dataEntregaObra);
return (
<div key={of.id} className="space-y-2 border-b border-border/30 pb-4 last:border-0">
{/* Cabeçalho da Raia */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="font-bold text-foreground">OF {of.num_of}</span>
<span className="text-muted-foreground text-[11px] truncate max-w-[200px]" title={of.descritivo}>
{of.descritivo}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-border">
{(of.pesoTotalKg / 1000).toFixed(1)} t
</Badge>
</div>
<div className="flex items-center gap-3 text-[11px]">
<span className="flex items-center gap-1 text-muted-foreground">
<Flag className="h-3 w-3 text-sky-400" />
Obra: {of.dataEntregaObra ? new Date(of.dataEntregaObra).toLocaleDateString('pt-BR') : 'N/D'}
</span>
<span
className={`font-semibold ${
of.folgaDiasObra < 0
? 'text-rose-400'
: of.folgaDiasObra <= 5
? 'text-amber-400'
: 'text-emerald-400'
}`}
>
{of.folgaDiasObra < 0
? `Atraso: ${Math.abs(of.folgaDiasObra)} dias`
: `Folga: ${of.folgaDiasObra} dias`}
</span>
</div>
</div>
{/* Linha do Tempo / Barras dos Processos */}
<div className="relative h-9 bg-muted/30 rounded-xl border border-border/40 p-1 flex items-center overflow-hidden">
{/* 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 (
<div
key={proc.nome}
style={{
left: `${left}%`,
width: `${width}%`,
}}
title={`${proc.nome}: ${new Date(proc.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(proc.dataFim).toLocaleDateString('pt-BR')} (${(proc.pesoKg / 1000).toFixed(1)} t)`}
className={`absolute h-6 rounded-md border text-[9px] font-semibold text-white flex items-center justify-center truncate px-1 shadow-sm transition-all hover:brightness-110 hover:z-10 ${corClass}`}
>
<span className="truncate">{proc.nome}</span>
</div>
);
})}
{/* Marcador de Entrega em Obra */}
{of.dataEntregaObra && (
<div
style={{ left: `${marcoObraPerc}%` }}
className="absolute top-0 bottom-0 w-0.5 bg-rose-500 z-20 flex flex-col items-center"
title={`Entrega Obra: ${new Date(of.dataEntregaObra).toLocaleDateString('pt-BR')}`}
>
<div className="w-2.5 h-2.5 bg-rose-500 rounded-full -mt-1 shadow" />
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
+128
View File
@@ -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<PcpOfThumbnailProps> = ({
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 (
<div
draggable
onDragStart={handleDragStart}
onClick={() => 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 */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5">
<GripVertical className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors cursor-grab" />
<span className="font-bold text-sm tracking-wide text-foreground">
OF {of.num_of}
</span>
</div>
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0.5 border font-medium flex items-center gap-1 ${statusConfig.color}`}
>
<StatusIcon className="h-3 w-3" />
{statusConfig.badge}
</Badge>
</div>
{/* Descritivo / Obra */}
<p className="text-xs text-muted-foreground line-clamp-1 mb-3 font-normal" title={of.descritivo}>
{of.descritivo}
</p>
{/* Métricas Principais */}
<div className="grid grid-cols-2 gap-2 text-[11px] mb-3 bg-muted/30 rounded-lg p-2 border border-border/40">
<div className="flex items-center gap-1 text-muted-foreground">
<Scale className="h-3.5 w-3.5 text-sky-400" />
<span className="font-semibold text-foreground">
{(of.pesoTotalKg / 1000).toFixed(1)} t
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground justify-end">
<Clock className="h-3.5 w-3.5 text-amber-400" />
<span className={of.folgaDiasObra < 0 ? 'text-rose-400 font-bold' : 'text-foreground'}>
{of.folgaDiasObra < 0 ? `${Math.abs(of.folgaDiasObra)}d atraso` : `${of.folgaDiasObra}d folga`}
</span>
</div>
</div>
{/* Barra de Progresso Físico */}
<div className="space-y-1">
<div className="flex justify-between items-center text-[10px] text-muted-foreground">
<span>Avanço Físico</span>
<span className="font-semibold text-foreground">{of.progressoPercent}%</span>
</div>
<Progress value={of.progressoPercent} className="h-1.5" />
</div>
{/* Data Obra no rodapé */}
{!isCompact && of.dataEntregaObra && (
<div className="mt-2.5 pt-2 border-t border-border/40 flex items-center justify-between text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3 text-muted-foreground/70" />
Obra:
</span>
<span className="font-medium text-foreground">
{new Date(of.dataEntregaObra).toLocaleDateString('pt-BR')}
</span>
</div>
)}
{/* Checkbox visual flutuante indicando seleção */}
<div
className={`absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold transition-transform ${
isSelected
? 'bg-primary text-primary-foreground scale-100 shadow'
: 'opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-75 bg-muted text-muted-foreground'
}`}
>
✓
</div>
</div>
);
};
@@ -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<PcpRecomendacoesPainelProps> = ({
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 (
<Badge variant="outline" className="border-rose-500/40 bg-rose-500/10 text-rose-400 text-[10px] font-semibold">
Ação Prioritária
</Badge>
);
case 'alerta':
return (
<Badge variant="outline" className="border-amber-500/40 bg-amber-500/10 text-amber-400 text-[10px] font-semibold">
Recomendação
</Badge>
);
default:
return (
<Badge variant="outline" className="border-sky-500/40 bg-sky-500/10 text-sky-400 text-[10px] font-semibold">
Otimização
</Badge>
);
}
};
return (
<div className="rounded-2xl border border-border bg-card p-4 md:p-6 shadow-sm">
<div className="flex items-center justify-between pb-4 mb-4 border-b border-border/50">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-400">
<Sparkles className="h-5 w-5" />
</div>
<div>
<h3 className="font-bold text-base md:text-lg text-foreground">
Plano de Ação & Recomendações do PCP
</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Sugestões geradas pelo cruzamento de capacidade real, ritmo de apontamentos e datas de entrega em obra.
</p>
</div>
</div>
<Badge variant="secondary" className="font-bold text-xs">
{recomendacoes.length} {recomendacoes.length === 1 ? 'Ação Sugerida' : 'Ações Sugeridas'}
</Badge>
</div>
{recomendacoes.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-center">
<CheckCircle2 className="h-10 w-10 text-emerald-400 mb-2 opacity-80" />
<p className="text-sm font-semibold text-foreground">
Fluxo Perfeitamente Balanceado
</p>
<p className="text-xs text-muted-foreground max-w-md mt-1">
As OFs selecionadas estão com demanda compatível com a capacidade instalada da fábrica e com datas confortáveis para a obra.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
{recomendacoes.map((rec) => {
const Icon = getIcone(rec.tipo);
return (
<div
key={rec.id}
className="group relative rounded-xl border border-border/70 bg-muted/20 hover:bg-muted/40 p-4 transition-all duration-200 flex flex-col justify-between"
>
<div>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</div>
<span className="font-bold text-xs md:text-sm text-foreground">
{rec.titulo}
</span>
</div>
{getNivelBadge(rec.nivel)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed mb-3">
{rec.descricao}
</p>
</div>
<div className="pt-3 border-t border-border/40 flex items-center justify-between gap-2 mt-auto">
<span className="text-[11px] font-medium text-emerald-400/90 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block" />
{rec.impactoEstimado}
</span>
<Button
size="sm"
variant="outline"
onClick={() => onAplicarSimulacao(rec)}
className="h-7 text-xs px-2.5 font-medium border-primary/30 text-primary hover:bg-primary/10"
>
Simular Impacto
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
+149
View File
@@ -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<PcpWhatIfSimulatorProps> = ({
ofs,
simulador,
onSetHorasExtras,
onSetEfetivoFator,
onSetDeslocamentoOf,
onReset,
}) => {
const processosPrincipais = ['Corte', 'Montagem', 'Solda', 'Pintura'];
return (
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4 md:p-6 shadow-sm space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pb-4 border-b border-primary/20">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-primary/20 text-primary">
<Sliders className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-base md:text-lg text-foreground">
Simulador Tático What-If (E se...?)
</h3>
<Badge variant="outline" className="text-xs border-primary/40 text-primary font-semibold">
Simulação Ativa
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Faça testes de estresse, altere horas de turno, amplie equipes ou balanceie prazos de OFs em tempo real.
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={onReset}
className="text-xs h-8 border-border hover:bg-muted text-muted-foreground hover:text-foreground"
>
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Restaurar Padrão
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Painel 1: Ajuste de Recursos e Horas Extras */}
<div className="space-y-4 bg-card/60 rounded-xl p-4 border border-border/50">
<h4 className="text-xs font-bold text-foreground flex items-center gap-2 uppercase tracking-wider text-muted-foreground">
<Clock className="h-4 w-4 text-sky-400" />
Capacidade de Turno & Horas Extras por Posto
</h4>
<div className="space-y-4 pt-1">
{processosPrincipais.map((proc) => {
const he = simulador.horasExtrasPorProcesso[proc] || 0;
return (
<div key={proc} className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="font-medium text-foreground">{proc}</span>
<span className="text-muted-foreground font-semibold">
{he === 0 ? 'Turno Padrão (8h)' : `+${he}h extras/dia (Total: ${8 + he}h)`}
</span>
</div>
<div className="flex items-center gap-3">
<Slider
value={[he]}
min={0}
max={4}
step={1}
onValueChange={(val) => onSetHorasExtras(proc, val[0])}
className="flex-1"
/>
<Badge variant="secondary" className="w-12 justify-center text-xs font-mono">
+{he}h
</Badge>
</div>
</div>
);
})}
</div>
</div>
{/* Painel 2: Balanceamento e Deslocamento de Prazos entre OFs */}
<div className="space-y-4 bg-card/60 rounded-xl p-4 border border-border/50">
<h4 className="text-xs font-bold text-foreground flex items-center gap-2 uppercase tracking-wider text-muted-foreground">
<Calendar className="h-4 w-4 text-amber-400" />
Nivelamento Temporal entre OFs (Deslocamento de Dias)
</h4>
{ofs.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">
Adicione OFs à mesa tática para simular reprogramação de prazos.
</p>
) : (
<div className="space-y-4 pt-1 max-h-[220px] overflow-y-auto pr-1">
{ofs.map((of) => {
const desloc = simulador.deslocamentoDiasOf[of.num_of] || 0;
return (
<div key={of.num_of} className="space-y-1.5">
<div className="flex justify-between text-xs">
<div className="flex items-center gap-1.5">
<span className="font-bold text-foreground">OF {of.num_of}</span>
<span className="text-[10px] text-muted-foreground">
({of.folgaDiasObra}d folga original)
</span>
</div>
<span className={`font-semibold ${desloc > 0 ? 'text-amber-400' : desloc < 0 ? 'text-sky-400' : 'text-muted-foreground'}`}>
{desloc === 0 ? 'Data Original' : desloc > 0 ? `+${desloc} dias (Dilatar)` : `${desloc} dias (Antecipar)`}
</span>
</div>
<div className="flex items-center gap-3">
<Slider
value={[desloc]}
min={-15}
max={20}
step={1}
onValueChange={(val) => onSetDeslocamentoOf(of.num_of, val[0])}
className="flex-1"
/>
<Badge variant="secondary" className="w-14 justify-center text-xs font-mono">
{desloc > 0 ? `+${desloc}d` : `${desloc}d`}
</Badge>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
+5
View File
@@ -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",
+6
View File
@@ -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 ──────────────────────────────────────
{
+1
View File
@@ -179,6 +179,7 @@ export function useInterfaceResources() {
'diario-producao',
'producao-apontamento',
'producao-dashboard',
'producao-pcp',
'prioridades-fabricacao',
'painel-industrial',
'expedicao',
+523
View File
@@ -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<string, number>; // processoNome -> horas extras/dia (0 a 4)
efetivoFatorPorProcesso: Record<string, number>; // processoNome -> multiplicador (ex: 1.2 = +20% equipe)
deslocamentoDiasOf: Record<string, number>; // num_of -> deslocar dias (+5, -3, etc)
}
export const usePcpAnalysis = () => {
const [loading, setLoading] = useState(true);
const [allOfs, setAllOfs] = useState<OfPcpCard[]>([]);
const [selectedOfNumbers, setSelectedOfNumbers] = useState<string[]>([]);
const [simulador, setSimulador] = useState<SimuladorConfig>({
horasExtrasPorProcesso: {},
efetivoFatorPorProcesso: {},
deslocamentoDiasOf: {},
});
// Capacidade nominal diária padrão por posto (em kg/dia) - pode ser customizada
const capacidadePadraoKgDia: Record<string, number> = {
'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<string, number> = {};
(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<string, Record<string, number>> = {};
(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<string, CargaPeriodoProcesso> = {};
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,
};
};
+257
View File
@@ -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 (
<div className="space-y-4 md:space-y-6 p-2 md:p-0">
<StandardPageLayout
title="PCP (Planejamento e Controle da Produção)"
subtitle="Mesa Tática & Torre de Controle de Capacidade Fabril e Prazos de Obra"
badge={{
text: `${allOfs.length} OFs Ativas`,
variant: 'secondary',
}}
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={recarregarDados}
disabled={loading}
className="text-xs h-9"
>
<RefreshCw className={`h-3.5 w-3.5 mr-1.5 ${loading ? 'animate-spin' : ''}`} />
Atualizar
</Button>
</div>
}
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* COLUNA ESQUERDA: GAVETA DE FIGURINHAS / THUMBNAILS DAS OFS (4 colunas) */}
<div className="lg:col-span-4 space-y-4 bg-card/40 border border-border/60 rounded-2xl p-4 backdrop-blur-sm">
<div className="flex items-center justify-between pb-3 border-b border-border/40">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />
<h3 className="font-bold text-sm text-foreground">
Catálogo de OFs
</h3>
</div>
<Badge variant="outline" className="text-xs font-mono">
{selectedOfNumbers.length}/{allOfs.length} na mesa
</Badge>
</div>
{/* Barra de Busca de OFs */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar OF ou obra..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 text-xs h-9"
/>
</div>
<p className="text-[11px] text-muted-foreground italic">
* Dica: Arraste a figurinha para a mesa ou clique nela para incluir/remover do cálculo de capacidade.
</p>
{/* Lista com scroll das Figurinhas */}
<div className="space-y-3 max-h-[700px] overflow-y-auto pr-1">
{loading ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Carregando ordens de fabricação e cronogramas...
</div>
) : ofsFiltradas.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
Nenhuma OF encontrada para o filtro.
</div>
) : (
ofsFiltradas.map((of) => (
<PcpOfThumbnail
key={of.id}
of={of}
isSelected={selectedOfNumbers.includes(of.num_of)}
onToggle={alternarSelecaoOf}
/>
))
)}
</div>
</div>
{/* COLUNA DIREITA: MESA TÁTICA E VISUALIZADORES ACUMULADOS (8 colunas) */}
<div className="lg:col-span-8 space-y-6">
{/* 1. MESA TÁTICA DE DROP E AGRUPAMENTO */}
<PcpMesaTatica
ofsNaMesa={ofsNaMesa}
totais={totaisMesa}
onRemoveOf={alternarSelecaoOf}
onDropOf={(numOf) => {
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 */}
<Tabs
value={activeTab}
onValueChange={(val: any) => setActiveTab(val)}
className="w-full space-y-4"
>
<TabsList className="grid grid-cols-2 md:grid-cols-4 w-full h-auto p-1 bg-muted/60">
<TabsTrigger
value="histograma"
className="flex items-center gap-1.5 text-xs py-2"
>
<BarChart3 className="h-3.5 w-3.5 text-sky-400" />
Carga vs Capacidade
</TabsTrigger>
<TabsTrigger
value="gantt"
className="flex items-center gap-1.5 text-xs py-2"
>
<Calendar className="h-3.5 w-3.5 text-indigo-400" />
Cronograma Multi-OF
</TabsTrigger>
<TabsTrigger
value="recomendacoes"
className="flex items-center gap-1.5 text-xs py-2 relative"
>
<Sparkles className="h-3.5 w-3.5 text-amber-400" />
Plano & Ações
{recomendacoes.length > 0 && (
<span className="w-2 h-2 rounded-full bg-rose-500 inline-block ml-0.5" />
)}
</TabsTrigger>
<TabsTrigger
value="whatif"
className="flex items-center gap-1.5 text-xs py-2"
>
<Sliders className="h-3.5 w-3.5 text-emerald-400" />
Simulador What-If
</TabsTrigger>
</TabsList>
{/* ABA 1: HISTOGRAMA DE CARGA VS CAPACIDADE */}
<TabsContent value="histograma" className="m-0 focus-visible:outline-none">
<PcpHistogramaCarga dadosCarga={cargaPorPeriodo} />
</TabsContent>
{/* ABA 2: CRONOGRAMA GANTT INTEGRADO */}
<TabsContent value="gantt" className="m-0 focus-visible:outline-none">
<PcpMultiGantt ofs={ofsNaMesa} />
</TabsContent>
{/* ABA 3: RECOMENDAÇÕES E BALANCEAMENTO */}
<TabsContent value="recomendacoes" className="m-0 focus-visible:outline-none">
<PcpRecomendacoesPainel
recomendacoes={recomendacoes}
onAplicarSimulacao={handleAplicarRecomendacao}
/>
</TabsContent>
{/* ABA 4: SIMULADOR WHAT-IF */}
<TabsContent value="whatif" className="m-0 focus-visible:outline-none">
<PcpWhatIfSimulator
ofs={ofsNaMesa}
simulador={simulador}
onSetHorasExtras={setHorasExtras}
onSetEfetivoFator={setEfetivoFator}
onSetDeslocamentoOf={setDeslocamentoOf}
onReset={resetarSimulador}
/>
</TabsContent>
</Tabs>
</div>
</div>
</StandardPageLayout>
</div>
);
};
export default PCP;
+1
View File
@@ -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';
+2 -1
View File
@@ -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 },