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:
@@ -124,7 +124,24 @@ export class DashboardService {
|
||||
obs: string | null;
|
||||
}
|
||||
|
||||
const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([
|
||||
interface RealizadoMesRow {
|
||||
mes: string;
|
||||
valor: string;
|
||||
}
|
||||
interface MetaMesRow {
|
||||
mes: string;
|
||||
tipo: string;
|
||||
valor: string;
|
||||
}
|
||||
|
||||
const [
|
||||
atingidoRows,
|
||||
pedidosMesRows,
|
||||
recentRows,
|
||||
realizadoGrupoRows,
|
||||
realizadoMesRows,
|
||||
metaMesRows,
|
||||
] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||
SELECT COALESCE(SUM(total), 0)::text AS total
|
||||
FROM vw_pedidos_erp
|
||||
@@ -178,6 +195,30 @@ export class DashboardService {
|
||||
AND e.dt_pedido <= '${monthEndStr}'
|
||||
GROUP BY p.cod_grupo, grupo
|
||||
`),
|
||||
// Realizado por mês do ano corrente.
|
||||
prisma.$queryRawUnsafe<RealizadoMesRow[]>(`
|
||||
SELECT EXTRACT(MONTH FROM dt_pedido)::int::text AS mes,
|
||||
COALESCE(SUM(total), 0)::text AS valor
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND cod_vendedor = ${codVendedor}
|
||||
AND situa NOT IN (1, 5)
|
||||
AND EXTRACT(YEAR FROM dt_pedido) = ${year}
|
||||
GROUP BY mes
|
||||
ORDER BY mes
|
||||
`),
|
||||
// Meta (GL + GR) por mês do ano corrente.
|
||||
prisma.$queryRawUnsafe<MetaMesRow[]>(`
|
||||
SELECT mes::text, TRIM(tipo) AS tipo,
|
||||
COALESCE(SUM(valor), 0)::text AS valor
|
||||
FROM vw_metas
|
||||
WHERE id_empresa = ${idEmpresaMatriz}
|
||||
AND cod_vendedor = ${codVendedor}
|
||||
AND ano = ${year}
|
||||
AND TRIM(tipo) IN ('GL', 'GR')
|
||||
GROUP BY mes, tipo
|
||||
ORDER BY mes
|
||||
`),
|
||||
]);
|
||||
|
||||
const atingido = Number(atingidoRows[0]?.total ?? 0);
|
||||
@@ -267,21 +308,48 @@ export class DashboardService {
|
||||
})
|
||||
.sort((a, b) => b.valorMeta - a.valorMeta);
|
||||
|
||||
const naoPositivados = naoPositivadosRows.map((c) => ({
|
||||
idCliente: Number(c.id_cliente),
|
||||
nome: c.nome,
|
||||
razao: c.razao ?? null,
|
||||
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
|
||||
ultimoPedido: c.dt_ultimo_pedido ?? null,
|
||||
comprouAntes: Boolean(c.comprou_antes),
|
||||
whatsapp: c.whatsapp ?? null,
|
||||
}));
|
||||
// Deduplica por idCliente — vw_clientes pode retornar o mesmo cliente em
|
||||
// mais de uma empresa; mantemos a primeira ocorrência (ORDER BY dt_max ASC NULLS FIRST).
|
||||
const seenNP = new Set<number>();
|
||||
const naoPositivados = naoPositivadosRows
|
||||
.filter((c) => {
|
||||
const id = Number(c.id_cliente);
|
||||
if (seenNP.has(id)) return false;
|
||||
seenNP.add(id);
|
||||
return true;
|
||||
})
|
||||
.map((c) => ({
|
||||
idCliente: Number(c.id_cliente),
|
||||
nome: c.nome,
|
||||
razao: c.razao ?? null,
|
||||
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
|
||||
ultimoPedido: c.dt_ultimo_pedido ?? null,
|
||||
comprouAntes: Boolean(c.comprou_antes),
|
||||
whatsapp: c.whatsapp ?? null,
|
||||
}));
|
||||
|
||||
// Monta histório mensal: 12 meses do ano, cruzando realizado e meta.
|
||||
const realizadoPorMes = new Map(realizadoMesRows.map((r) => [Number(r.mes), Number(r.valor)]));
|
||||
const metaGlPorMes = new Map<number, number>();
|
||||
const metaGrPorMes = new Map<number, number>();
|
||||
for (const r of metaMesRows) {
|
||||
const m = Number(r.mes);
|
||||
if (r.tipo === 'GL') metaGlPorMes.set(m, (metaGlPorMes.get(m) ?? 0) + Number(r.valor));
|
||||
else metaGrPorMes.set(m, (metaGrPorMes.get(m) ?? 0) + Number(r.valor));
|
||||
}
|
||||
const historicoMensal = Array.from({ length: 12 }, (_, i) => {
|
||||
const m = i + 1;
|
||||
const valor = realizadoPorMes.get(m) ?? 0;
|
||||
const meta = metaGlPorMes.has(m) ? (metaGlPorMes.get(m) ?? 0) : (metaGrPorMes.get(m) ?? 0);
|
||||
return { mes: m, valor, meta, atingiu: meta > 0 && valor >= meta };
|
||||
});
|
||||
|
||||
return {
|
||||
meta: { atingido, total: targetAmount, pct, falta },
|
||||
metaDimensao,
|
||||
metasPorGrupo,
|
||||
pedidosMes,
|
||||
historicoMensal,
|
||||
pedidosRecentes: recentRows.map((o) => ({
|
||||
id: `erp-${o.id_pedido}`,
|
||||
numPedSar: (o.num_ped_sar ?? '').trim(),
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -39,6 +39,14 @@ export const MetaItemSchema = z.object({
|
||||
});
|
||||
export type MetaItem = z.infer<typeof MetaItemSchema>;
|
||||
|
||||
export const MesAnoSchema = z.object({
|
||||
mes: z.number().int().min(1).max(12),
|
||||
valor: z.number(),
|
||||
meta: z.number(),
|
||||
atingiu: z.boolean(),
|
||||
});
|
||||
export type MesAno = z.infer<typeof MesAnoSchema>;
|
||||
|
||||
export const RepDashboardSchema = z.object({
|
||||
meta: z.object({
|
||||
atingido: z.number(),
|
||||
@@ -52,6 +60,7 @@ export const RepDashboardSchema = z.object({
|
||||
pedidosRecentes: z.array(PedidoSummarySchema),
|
||||
naoPositivados: z.array(ClienteNaoPositivadoSchema),
|
||||
totalNaoPositivados: z.number().int(),
|
||||
historicoMensal: z.array(MesAnoSchema).default([]),
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
||||
|
||||
Reference in New Issue
Block a user