feat: Implantação do módulo PCP com Mesa Tática Multi-OF e simulação what-if
This commit is contained in:
@@ -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 (< 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 (> 100%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] italic">
|
||||
* Linha tracejada vermelha representa a capacidade instalada da fábrica.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -179,6 +179,7 @@ export function useInterfaceResources() {
|
||||
'diario-producao',
|
||||
'producao-apontamento',
|
||||
'producao-dashboard',
|
||||
'producao-pcp',
|
||||
'prioridades-fabricacao',
|
||||
'painel-industrial',
|
||||
'expedicao',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user