feat(web+api): gráfico anual realizado vs meta no painel do representante

Barras (azul/verde) por mês + linha tracejada de meta; meses futuros em cinza.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 16:51:20 +00:00
parent a6cb1df2aa
commit 795b805ae5
3 changed files with 222 additions and 12 deletions

View File

@@ -18,13 +18,35 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faArrowTrendUp,
faBullseye,
faChartBar,
faCircleExclamation,
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { WhatsAppOutlined } from '@ant-design/icons';
import { Link, useNavigate } from '@tanstack/react-router';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary } from '@sar/api-interface';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Tooltip as ChartTooltip,
Legend,
} from 'chart.js';
import { Chart } from 'react-chartjs-2';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ChartTooltip,
Legend,
);
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
@@ -148,6 +170,109 @@ const metaColumns: TableColumnsType<MetaItem> = [
},
];
// ─── Gráfico anual ────────────────────────────────────────────────────────────
const MESES = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
function GraficoAnual({
dados,
ano,
mesAtual,
}: {
dados: MesAno[];
ano: number;
mesAtual: number;
}) {
const labels = MESES;
const barColors = dados.map((d) => {
if (d.mes > mesAtual) return 'rgba(203,213,225,0.5)'; // futuro — cinza claro
if (d.mes === mesAtual) return d.atingiu ? 'rgba(56,158,13,0.75)' : 'rgba(0,59,142,0.75)'; // mês atual
return d.atingiu ? 'rgba(56,158,13,0.85)' : 'rgba(0,59,142,0.65)'; // passado
});
const borderColors = dados.map((d) => (d.mes === mesAtual ? '#1a1a1a' : 'transparent'));
const chartData = {
labels,
datasets: [
{
type: 'bar' as const,
label: 'Realizado',
data: dados.map((d) => (d.mes <= mesAtual ? d.valor : null)),
backgroundColor: barColors,
borderColor: borderColors,
borderWidth: dados.map((d) => (d.mes === mesAtual ? 2 : 0)),
borderRadius: 4,
order: 2,
},
{
type: 'line' as const,
label: 'Meta',
data: dados.map((d) => (d.meta > 0 ? d.meta : null)),
borderColor: '#f5222d',
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [5, 4],
pointRadius: dados.map((d) => (d.meta > 0 ? 4 : 0)),
pointBackgroundColor: dados.map((d) =>
d.meta > 0 && d.mes <= mesAtual ? (d.atingiu ? '#52c41a' : '#f5222d') : '#f5222d',
),
order: 1,
tension: 0.1,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: {
callbacks: {
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
const val = ctx.parsed.y;
if (val == null) return '';
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}`;
},
},
},
},
scales: {
x: { grid: { display: false } },
y: {
ticks: {
callback: (v: number | string) =>
Number(v).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
notation: 'compact',
}),
font: { size: 10 },
},
grid: { color: '#f0f0f0' },
},
},
};
return (
<Card
title={
<Space>
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
Realizado vs Meta {ano}
</Space>
}
>
<div style={{ height: 240 }}>
<Chart type="bar" data={chartData} options={options} />
</div>
</Card>
);
}
// ─── Não positivados ──────────────────────────────────────────────────────────
type OrdemNP = 'longe' | 'recentes';
@@ -204,7 +329,7 @@ function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[];
const dias = c.diasSemPedido ?? 999;
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
return (
<Space direction="vertical" size={0}>
<Space orientation="vertical" size={0}>
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
{dias}d sem pedido
</Tag>
@@ -339,9 +464,14 @@ export function RepPainel() {
pedidosRecentes = [],
naoPositivados = [],
totalNaoPositivados = 0,
historicoMensal = [],
syncedAt,
} = data;
const now = new Date();
const anoAtual = now.getFullYear();
const mesAtual = now.getMonth() + 1;
return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Saudação */}
@@ -499,6 +629,9 @@ export function RepPainel() {
</Card>
)}
{/* Gráfico anual */}
<GraficoAnual dados={historicoMensal} ano={anoAtual} mesAtual={mesAtual} />
{/* Linha 2 — Não positivados + Pedidos recentes */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={14}>