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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user