🚀 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';
case 'amarelo':
return '#f59e0b';
case 'vermelho':
return '#ef4444';
case 'azul':
return '#3b82f6';
default:
return '#8884d8';
}
};
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="bg-popover/95 backdrop-blur-md border border-border shadow-2xl rounded-xl p-3 text-popover-foreground min-w-[200px] z-50">
<ResponsiveContainer width="100%" height="100%"> <div className="flex items-center justify-between border-b border-border/50 pb-2 mb-2">
<LineChart <span className="text-xs font-semibold text-muted-foreground">Data</span>
data={dadosGraficoSobreposto} <span className="text-xs font-bold bg-primary/10 text-primary px-2 py-0.5 rounded-md">{label}</span>
margin={{ top: 20, right: 30, left: 20, bottom: 5 }} </div>
<div className="space-y-1.5">
{payload.map((entry: any, index: number) => {
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'
}`}
> >
<CartesianGrid strokeDasharray="3 3" stroke="#374151" /> <div className="flex items-center gap-1.5">
<XAxis <span
dataKey="data" className="w-2.5 h-2.5 rounded-full shrink-0 shadow-sm"
stroke="#9ca3af" style={{ backgroundColor: entry.color }}
fontSize={12}
tick={{ fill: '#9ca3af' }}
/> />
<YAxis <span className="truncate max-w-[130px]">{entry.name}</span>
stroke="#9ca3af" </div>
fontSize={12} <span className="font-mono font-semibold ml-2">
tick={{ fill: '#9ca3af' }} {modoExibicao === 'kg'
label={{ ? `${entry.value.toLocaleString('pt-BR')} kg`
value: 'Peso Acumulado (t)', : `${entry.value.toFixed(1)}%`
angle: -90, }
position: 'insideLeft', </span>
style: { textAnchor: 'middle', fill: '#9ca3af' } </div>
}} );
/> })}
<Tooltip </div>
contentStyle={{ </div>
backgroundColor: '#1f2937', );
border: '1px solid #374151', };
borderRadius: '6px',
color: '#f3f4f6' return (
}} <div className="space-y-4 w-full">
formatter={formatTooltipValue} {/* Barra Superior de Controles */}
labelFormatter={(label) => `Data: ${label}`} <div className="flex flex-wrap items-center justify-between gap-2 bg-muted/40 p-2.5 rounded-lg border border-border/50">
/> <div className="flex items-center gap-2">
<Legend <span className="text-xs font-medium text-muted-foreground hidden sm:inline">Métrica:</span>
wrapperStyle={{ color: '#9ca3af' }} <div className="flex items-center bg-background border border-border rounded-md p-0.5">
onClick={(e) => handleProcessoClick(e.value)} <Button
/> type="button"
variant={modoExibicao === 'kg' ? 'default' : 'ghost'}
{processos.map((processo) => ( size="sm"
<Line onClick={() => setModoExibicao('kg')}
key={processo.nome} className="h-7 text-xs px-2.5 gap-1.5"
type="monotone" >
dataKey={processo.nome} <Scale className="h-3.5 w-3.5" />
stroke={obterCorProcesso(processo.status)} <span>Quilogramas (kg)</span>
strokeWidth={2} </Button>
dot={{ fill: obterCorProcesso(processo.status), strokeWidth: 2, r: 3 }} <Button
activeDot={{ r: 5, fill: obterCorProcesso(processo.status) }} type="button"
opacity={processoSelecionado ? (processoSelecionado === processo.nome ? 1 : 0.3) : 1} variant={modoExibicao === 'percent' ? 'default' : 'ghost'}
style={{ cursor: 'pointer' }} size="sm"
/> onClick={() => setModoExibicao('percent')}
))} className="h-7 text-xs px-2.5 gap-1.5"
</LineChart> >
</ResponsiveContainer> <Percent className="h-3.5 w-3.5" />
<span>Porcentagem (%)</span>
</Button>
</div>
</div>
{selectedProcesso && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleSelectProcesso(selectedProcesso)}
className="h-7 text-xs gap-1 border-dashed"
>
<FilterX className="h-3.5 w-3.5" />
<span>Limpar seleção ({selectedProcesso})</span>
</Button>
)}
</div>
{/* Legenda Customizada em Ordem Linear Sequencial */}
<div className="flex flex-wrap items-center justify-center gap-2 py-1">
{processosOrdenados.map((processo, idx) => {
const color = getCorProcesso(processo.nome, idx);
const isSelected = selectedProcesso === processo.nome;
const isDimmed = selectedProcesso && !isSelected;
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
stroke="currentColor"
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
+14 -10
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;
try {
const { data, error } = await supabase
.rpc('get_dashboard_date_range', { of_number_param: ofNumber }); .rpc('get_dashboard_date_range', { of_number_param: ofNumber });
if (!error) dateRangeData = data;
if (dateRangeError) { } catch (e) {
console.error('Erro ao buscar range de datas:', dateRangeError); // 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;
try {
const { data, error } = await supabase
.rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber }); .rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber });
if (!error) consolidatedData = data;
if (consolidatedError) { } catch (e) {
console.error('Erro ao buscar dados consolidados:', consolidatedError); // Ignora caso RPC não exista
} }
// 9. Buscar processos // 9. Buscar processos