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;
|
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[]>(`
|
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||||
SELECT COALESCE(SUM(total), 0)::text AS total
|
SELECT COALESCE(SUM(total), 0)::text AS total
|
||||||
FROM vw_pedidos_erp
|
FROM vw_pedidos_erp
|
||||||
@@ -178,6 +195,30 @@ export class DashboardService {
|
|||||||
AND e.dt_pedido <= '${monthEndStr}'
|
AND e.dt_pedido <= '${monthEndStr}'
|
||||||
GROUP BY p.cod_grupo, grupo
|
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);
|
const atingido = Number(atingidoRows[0]?.total ?? 0);
|
||||||
@@ -267,7 +308,17 @@ export class DashboardService {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => b.valorMeta - a.valorMeta);
|
.sort((a, b) => b.valorMeta - a.valorMeta);
|
||||||
|
|
||||||
const naoPositivados = naoPositivadosRows.map((c) => ({
|
// 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),
|
idCliente: Number(c.id_cliente),
|
||||||
nome: c.nome,
|
nome: c.nome,
|
||||||
razao: c.razao ?? null,
|
razao: c.razao ?? null,
|
||||||
@@ -277,11 +328,28 @@ export class DashboardService {
|
|||||||
whatsapp: c.whatsapp ?? null,
|
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 {
|
return {
|
||||||
meta: { atingido, total: targetAmount, pct, falta },
|
meta: { atingido, total: targetAmount, pct, falta },
|
||||||
metaDimensao,
|
metaDimensao,
|
||||||
metasPorGrupo,
|
metasPorGrupo,
|
||||||
pedidosMes,
|
pedidosMes,
|
||||||
|
historicoMensal,
|
||||||
pedidosRecentes: recentRows.map((o) => ({
|
pedidosRecentes: recentRows.map((o) => ({
|
||||||
id: `erp-${o.id_pedido}`,
|
id: `erp-${o.id_pedido}`,
|
||||||
numPedSar: (o.num_ped_sar ?? '').trim(),
|
numPedSar: (o.num_ped_sar ?? '').trim(),
|
||||||
|
|||||||
@@ -18,13 +18,35 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import {
|
import {
|
||||||
faArrowTrendUp,
|
faArrowTrendUp,
|
||||||
faBullseye,
|
faBullseye,
|
||||||
|
faChartBar,
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faClipboardList,
|
faClipboardList,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { WhatsAppOutlined } from '@ant-design/icons';
|
import { WhatsAppOutlined } from '@ant-design/icons';
|
||||||
import { Link, useNavigate } from '@tanstack/react-router';
|
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';
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ChartTooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||||
import { useCurrentUser } from '../../lib/queries/auth';
|
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 ──────────────────────────────────────────────────────────
|
// ─── Não positivados ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type OrdemNP = 'longe' | 'recentes';
|
type OrdemNP = 'longe' | 'recentes';
|
||||||
@@ -204,7 +329,7 @@ function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[];
|
|||||||
const dias = c.diasSemPedido ?? 999;
|
const dias = c.diasSemPedido ?? 999;
|
||||||
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
|
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={0}>
|
<Space orientation="vertical" size={0}>
|
||||||
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
|
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
|
||||||
{dias}d sem pedido
|
{dias}d sem pedido
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -339,9 +464,14 @@ export function RepPainel() {
|
|||||||
pedidosRecentes = [],
|
pedidosRecentes = [],
|
||||||
naoPositivados = [],
|
naoPositivados = [],
|
||||||
totalNaoPositivados = 0,
|
totalNaoPositivados = 0,
|
||||||
|
historicoMensal = [],
|
||||||
syncedAt,
|
syncedAt,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const anoAtual = now.getFullYear();
|
||||||
|
const mesAtual = now.getMonth() + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
{/* Saudação */}
|
{/* Saudação */}
|
||||||
@@ -499,6 +629,9 @@ export function RepPainel() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Gráfico anual */}
|
||||||
|
<GraficoAnual dados={historicoMensal} ano={anoAtual} mesAtual={mesAtual} />
|
||||||
|
|
||||||
{/* Linha 2 — Não positivados + Pedidos recentes */}
|
{/* Linha 2 — Não positivados + Pedidos recentes */}
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ export const MetaItemSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type MetaItem = z.infer<typeof MetaItemSchema>;
|
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({
|
export const RepDashboardSchema = z.object({
|
||||||
meta: z.object({
|
meta: z.object({
|
||||||
atingido: z.number(),
|
atingido: z.number(),
|
||||||
@@ -52,6 +60,7 @@ export const RepDashboardSchema = z.object({
|
|||||||
pedidosRecentes: z.array(PedidoSummarySchema),
|
pedidosRecentes: z.array(PedidoSummarySchema),
|
||||||
naoPositivados: z.array(ClienteNaoPositivadoSchema),
|
naoPositivados: z.array(ClienteNaoPositivadoSchema),
|
||||||
totalNaoPositivados: z.number().int(),
|
totalNaoPositivados: z.number().int(),
|
||||||
|
historicoMensal: z.array(MesAnoSchema).default([]),
|
||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user