🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 18:35:52

This commit is contained in:
2026-08-21 18:35:52 +00:00
parent 8478eecb93
commit dccb9acdce
3 changed files with 304 additions and 116 deletions
@@ -1,9 +1,19 @@
import React, { useState, useMemo } from 'react';
import React, { useMemo } from 'react'; import {
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; ResponsiveContainer,
import { format, parseISO, eachDayOfInterval } from 'date-fns'; LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend
} from 'recharts';
import { format, parseISO } from 'date-fns';
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado'; import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
import { Button } from '@/components/ui/button';
import { Scale, Percent, FilterX } from 'lucide-react';
interface GraficoMestreProps { interface GraficoMestreProps {
processos: DashboardProcesso[]; processos: DashboardProcesso[];
@@ -11,126 +21,296 @@ interface GraficoMestreProps {
processoSelecionado?: string | null; processoSelecionado?: string | null;
} }
// Ordem sequencial padrão de produção para ordenação linear
const ORDEM_PROCESSOS_LINEAR = [
'detalhamento',
'corte',
'solda',
'pintura/galv',
'pintura',
'galvanizacao',
'expedicao',
'expedição',
'montagem',
'aceite/db',
'aceite',
'concluido',
'concluído'
];
// Paleta de cores vibrantes e distintas para cada processo
const CORES_PROCESSOS: Record<string, string> = {
'detalhamento': '#a855f7', // Roxo / Purple
'corte': '#3b82f6', // Azul Royal
'solda': '#f97316', // Laranja Vibrante
'pintura/galv': '#ec4899', // Rosa / Magenta
'pintura': '#ec4899', // Rosa
'galvanizacao': '#ec4899', // Rosa
'expedicao': '#06b6d4', // Ciano
'expedição': '#06b6d4', // Ciano
'montagem': '#14b8a6', // Verde Água / Teal
'aceite/db': '#6366f1', // Índigo
'aceite': '#6366f1', // Índigo
'concluido': '#10b981', // Verde Esmeralda
'concluído': '#10b981' // Verde Esmeralda
};
// Paleta de fallback se houver processo fora do padrão
const CORES_FALLBACK = [
'#a855f7', '#3b82f6', '#f97316', '#ec4899',
'#06b6d4', '#14b8a6', '#6366f1', '#10b981', '#eab308'
];
export const GraficoMestre: React.FC<GraficoMestreProps> = ({ export const GraficoMestre: React.FC<GraficoMestreProps> = ({
processos, processos,
onProcessoClick, onProcessoClick,
processoSelecionado processoSelecionado: processoSelecionadoProp
}) => { }) => {
// Processar dados dos gráficos individuais para criar um gráfico sobreposto const [modoExibicao, setModoExibicao] = useState<'kg' | 'percent'>('kg');
const dadosGraficoSobreposto = useMemo(() => { const [selectedProcessoInternal, setSelectedProcessoInternal] = useState<string | null>(null);
if (!processos || processos.length === 0) return [];
const selectedProcesso = processoSelecionadoProp !== undefined ? processoSelecionadoProp : selectedProcessoInternal;
const handleSelectProcesso = (nome: string) => {
const nextSelected = selectedProcesso === nome ? null : nome;
setSelectedProcessoInternal(nextSelected);
if (onProcessoClick) {
onProcessoClick(nextSelected || '');
}
};
// 1. Ordenar processos na sequência linear real da produção
const processosOrdenados = useMemo(() => {
if (!processos) return [];
return [...processos].sort((a, b) => {
const nomeA = a.nome.toLowerCase().trim();
const nomeB = b.nome.toLowerCase().trim();
const indexA = ORDEM_PROCESSOS_LINEAR.findIndex(p => nomeA.includes(p));
const indexB = ORDEM_PROCESSOS_LINEAR.findIndex(p => nomeB.includes(p));
const posA = indexA !== -1 ? indexA : 99;
const posB = indexB !== -1 ? indexB : 99;
return posA - posB;
});
}, [processos]);
// Função utilitária para pegar a cor de um processo
const getCorProcesso = (nomeProcesso: string, index: number) => {
const nomeLower = nomeProcesso.toLowerCase().trim();
for (const [key, color] of Object.entries(CORES_PROCESSOS)) {
if (nomeLower.includes(key)) return color;
}
return CORES_FALLBACK[index % CORES_FALLBACK.length];
};
// 2. Processar dados agregados por data
const dadosGrafico = useMemo(() => {
if (!processosOrdenados || processosOrdenados.length === 0) return [];
// Coletar todas as datas dos gráficos individuais
const todasAsDatas = new Set<string>(); const todasAsDatas = new Set<string>();
processos.forEach(processo => { processosOrdenados.forEach(processo => {
processo.dadosGrafico.forEach(ponto => { processo.dadosGrafico.forEach(ponto => {
todasAsDatas.add(ponto.data); todasAsDatas.add(ponto.data);
}); });
}); });
// Ordenar as datas
const datasOrdenadas = Array.from(todasAsDatas).sort(); const datasOrdenadas = Array.from(todasAsDatas).sort();
// Construir dados do gráfico sobreposto
return datasOrdenadas.map(data => { return datasOrdenadas.map(data => {
const dataFormatada = format(parseISO(data), 'dd/MM', { locale: ptBR });
const pontoGrafico: any = { const pontoGrafico: any = {
data: format(parseISO(data), 'dd/MM', { locale: ptBR }), data: dataFormatada,
dataCompleta: data dataCompleta: data
}; };
// Para cada processo, buscar o valor realizado na data processosOrdenados.forEach(processo => {
processos.forEach(processo => { const pontoProcesso = processo.dadosGrafico.find(p => p.data === data);
const pontoProcesso = processo.dadosGrafico.find(ponto => ponto.data === data); const valorKg = pontoProcesso ? pontoProcesso.realizado : 0;
// Converter para toneladas (dividir por 1000)
pontoGrafico[processo.nome] = pontoProcesso ? Math.round(pontoProcesso.realizado / 1000 * 100) / 100 : 0; if (modoExibicao === 'percent') {
const pesoTotal = processo.pesoTotal > 0 ? processo.pesoTotal : 1;
const percentual = Math.min(100, (valorKg / pesoTotal) * 100);
pontoGrafico[processo.nome] = Math.round(percentual * 10) / 10;
} else {
pontoGrafico[processo.nome] = Math.round(valorKg * 10) / 10;
}
}); });
return pontoGrafico; return pontoGrafico;
}); });
}, [processos]); }, [processosOrdenados, modoExibicao]);
// Obter cores dos processos baseado no status // Formatação customizada para o Tooltip estilo Glassmorphism
const obterCorProcesso = (status: string) => { const CustomTooltip = ({ active, payload, label }: any) => {
switch (status) { if (!active || !payload || !payload.length) return null;
case 'verde':
return '#10b981'; return (
case 'amarelo': <div className="bg-popover/95 backdrop-blur-md border border-border shadow-2xl rounded-xl p-3 text-popover-foreground min-w-[200px] z-50">
return '#f59e0b'; <div className="flex items-center justify-between border-b border-border/50 pb-2 mb-2">
case 'vermelho': <span className="text-xs font-semibold text-muted-foreground">Data</span>
return '#ef4444'; <span className="text-xs font-bold bg-primary/10 text-primary px-2 py-0.5 rounded-md">{label}</span>
case 'azul': </div>
return '#3b82f6'; <div className="space-y-1.5">
default: {payload.map((entry: any, index: number) => {
return '#8884d8'; const isHighlight = !selectedProcesso || selectedProcesso === entry.name;
} return (
<div
key={`item-${index}`}
className={`flex items-center justify-between text-xs transition-opacity ${
isHighlight ? 'opacity-100 font-medium' : 'opacity-40'
}`}
>
<div className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-sm"
style={{ backgroundColor: entry.color }}
/>
<span className="truncate max-w-[130px]">{entry.name}</span>
</div>
<span className="font-mono font-semibold ml-2">
{modoExibicao === 'kg'
? `${entry.value.toLocaleString('pt-BR')} kg`
: `${entry.value.toFixed(1)}%`
}
</span>
</div>
);
})}
</div>
</div>
);
}; };
const handleProcessoClick = (processoNome: string) => {
if (onProcessoClick) {
onProcessoClick(processoNome);
}
};
const formatTooltipValue = (value: number, name: string) => [
`${value.toFixed(2)} t`,
name
];
return ( return (
<div className="w-full h-96"> <div className="space-y-4 w-full">
<ResponsiveContainer width="100%" height="100%"> {/* Barra Superior de Controles */}
<LineChart <div className="flex flex-wrap items-center justify-between gap-2 bg-muted/40 p-2.5 rounded-lg border border-border/50">
data={dadosGraficoSobreposto} <div className="flex items-center gap-2">
margin={{ top: 20, right: 30, left: 20, bottom: 5 }} <span className="text-xs font-medium text-muted-foreground hidden sm:inline">Métrica:</span>
> <div className="flex items-center bg-background border border-border rounded-md p-0.5">
<CartesianGrid strokeDasharray="3 3" stroke="#374151" /> <Button
<XAxis type="button"
dataKey="data" variant={modoExibicao === 'kg' ? 'default' : 'ghost'}
stroke="#9ca3af" size="sm"
fontSize={12} onClick={() => setModoExibicao('kg')}
tick={{ fill: '#9ca3af' }} className="h-7 text-xs px-2.5 gap-1.5"
/> >
<YAxis <Scale className="h-3.5 w-3.5" />
stroke="#9ca3af" <span>Quilogramas (kg)</span>
fontSize={12} </Button>
tick={{ fill: '#9ca3af' }} <Button
label={{ type="button"
value: 'Peso Acumulado (t)', variant={modoExibicao === 'percent' ? 'default' : 'ghost'}
angle: -90, size="sm"
position: 'insideLeft', onClick={() => setModoExibicao('percent')}
style: { textAnchor: 'middle', fill: '#9ca3af' } className="h-7 text-xs px-2.5 gap-1.5"
}} >
/> <Percent className="h-3.5 w-3.5" />
<Tooltip <span>Porcentagem (%)</span>
contentStyle={{ </Button>
backgroundColor: '#1f2937', </div>
border: '1px solid #374151', </div>
borderRadius: '6px',
color: '#f3f4f6' {selectedProcesso && (
}} <Button
formatter={formatTooltipValue} type="button"
labelFormatter={(label) => `Data: ${label}`} variant="outline"
/> size="sm"
<Legend onClick={() => handleSelectProcesso(selectedProcesso)}
wrapperStyle={{ color: '#9ca3af' }} className="h-7 text-xs gap-1 border-dashed"
onClick={(e) => handleProcessoClick(e.value)} >
/> <FilterX className="h-3.5 w-3.5" />
<span>Limpar seleção ({selectedProcesso})</span>
{processos.map((processo) => ( </Button>
<Line )}
key={processo.nome} </div>
type="monotone"
dataKey={processo.nome} {/* Legenda Customizada em Ordem Linear Sequencial */}
stroke={obterCorProcesso(processo.status)} <div className="flex flex-wrap items-center justify-center gap-2 py-1">
strokeWidth={2} {processosOrdenados.map((processo, idx) => {
dot={{ fill: obterCorProcesso(processo.status), strokeWidth: 2, r: 3 }} const color = getCorProcesso(processo.nome, idx);
activeDot={{ r: 5, fill: obterCorProcesso(processo.status) }} const isSelected = selectedProcesso === processo.nome;
opacity={processoSelecionado ? (processoSelecionado === processo.nome ? 1 : 0.3) : 1} const isDimmed = selectedProcesso && !isSelected;
style={{ cursor: 'pointer' }}
return (
<button
key={processo.id}
type="button"
onClick={() => handleSelectProcesso(processo.nome)}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all cursor-pointer ${
isSelected
? 'bg-primary text-primary-foreground border-primary font-semibold shadow-md scale-105'
: isDimmed
? 'bg-muted/30 text-muted-foreground border-transparent opacity-40 hover:opacity-70'
: 'bg-muted/60 hover:bg-muted text-foreground border-border/60 hover:border-border'
}`}
>
<span
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-sm"
style={{ backgroundColor: color }}
/>
<span>{processo.nome}</span>
<span className="text-[10px] opacity-75 font-mono">
({modoExibicao === 'kg' ? `${Math.round(processo.pesoFabricado)}kg` : `${processo.progressoReal.toFixed(0)}%`})
</span>
</button>
);
})}
</div>
{/* Container de Altura Fixa com minWidth=0 para EVITAR Avisos no Console */}
<div className="w-full h-[380px] min-h-[380px] relative">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<LineChart
data={dadosGrafico}
margin={{ top: 15, right: 25, left: 10, bottom: 25 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border/40" />
<XAxis
dataKey="data"
stroke="currentColor"
fontSize={11}
className="text-muted-foreground"
tickLine={false}
axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
/> />
))} <YAxis
</LineChart> stroke="currentColor"
</ResponsiveContainer> fontSize={11}
className="text-muted-foreground"
tickLine={false}
axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
unit={modoExibicao === 'kg' ? ' kg' : '%'}
tickFormatter={(val) => modoExibicao === 'kg' && val >= 1000 ? `${(val / 1000).toFixed(1)}k` : `${val}`}
/>
<Tooltip content={<CustomTooltip />} />
{processosOrdenados.map((processo, idx) => {
const color = getCorProcesso(processo.nome, idx);
const isSelected = selectedProcesso === processo.nome;
const isDimmed = selectedProcesso && !isSelected;
return (
<Line
key={processo.id}
type="monotone"
dataKey={processo.nome}
name={processo.nome}
stroke={color}
strokeWidth={isSelected ? 3.5 : isDimmed ? 1 : 2.5}
strokeOpacity={isDimmed ? 0.2 : 1}
dot={isSelected ? { fill: color, r: 4.5, strokeWidth: 2, stroke: '#fff' } : false}
activeDot={{ r: 6, fill: color, stroke: '#fff', strokeWidth: 2 }}
connectNulls
/>
);
})}
</LineChart>
</ResponsiveContainer>
</div>
</div> </div>
); );
}; };
@@ -1,4 +1,3 @@
import React from 'react'; import React from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado'; import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
@@ -11,11 +10,16 @@ interface GraficoProgressoIndividualProps {
export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProps> = ({ processos }) => { export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProps> = ({ processos }) => {
const formatTooltipValue = (value: number, name: string) => [ const formatTooltipValue = (value: number, name: string) => [
`${(value / 1000).toFixed(2)} t`, `${Math.round(value).toLocaleString('pt-BR')} kg`,
name === 'planejado' ? 'Planejado' : 'Realizado' name === 'planejado' ? 'Planejado' : 'Realizado'
]; ];
const formatAxisValue = (value: number) => `${(value / 1000).toFixed(1)}t`; const formatAxisValue = (value: number) => {
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}k kg`;
}
return `${Math.round(value)} kg`;
};
const formatDateLabel = (tickItem: string) => { const formatDateLabel = (tickItem: string) => {
try { try {
@@ -55,13 +59,13 @@ export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProp
<h3 className="text-lg font-semibold text-card-foreground"> <h3 className="text-lg font-semibold text-card-foreground">
Progresso - {processo.nome} Progresso - {processo.nome}
</h3> </h3>
<div className="ml-auto text-sm text-muted-foreground"> <div className="ml-auto text-sm text-muted-foreground font-mono">
{processo.progressoReal.toFixed(1)}% realizado {processo.progressoReal.toFixed(1)}% realizado ({Math.round(processo.pesoFabricado)} kg)
</div> </div>
</div> </div>
<div className="h-64 w-full"> <div className="h-64 w-full relative min-h-[256px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<AreaChart data={processo.dadosGrafico}> <AreaChart data={processo.dadosGrafico}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" /> <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis <XAxis
+16 -12
View File
@@ -178,20 +178,24 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
console.log(`PROGRESSO GERAL: ${pesoTotalFabricado} kg / ${pesoTotalPlanejado} kg = ${progressoGeral.toFixed(2)}%`); console.log(`PROGRESSO GERAL: ${pesoTotalFabricado} kg / ${pesoTotalPlanejado} kg = ${progressoGeral.toFixed(2)}%`);
// 7. Buscar range de datas // 7. Buscar range de datas (opcional, ignora erro se a RPC não existir no Supabase)
const { data: dateRangeData, error: dateRangeError } = await supabase let dateRangeData = null;
.rpc('get_dashboard_date_range', { of_number_param: ofNumber }); try {
const { data, error } = await supabase
if (dateRangeError) { .rpc('get_dashboard_date_range', { of_number_param: ofNumber });
console.error('Erro ao buscar range de datas:', dateRangeError); if (!error) dateRangeData = data;
} catch (e) {
// Ignora caso RPC não exista
} }
// 8. Buscar dados consolidados // 8. Buscar dados consolidados (opcional, ignora erro se a RPC não existir no Supabase)
const { data: consolidatedData, error: consolidatedError } = await supabase let consolidatedData = null;
.rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber }); try {
const { data, error } = await supabase
if (consolidatedError) { .rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber });
console.error('Erro ao buscar dados consolidados:', consolidatedError); if (!error) consolidatedData = data;
} catch (e) {
// Ignora caso RPC não exista
} }
// 9. Buscar processos // 9. Buscar processos