diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index c1ef200..09f9a08 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -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(` 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(` + 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(` + 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(); + 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(); + const metaGrPorMes = new Map(); + 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(), diff --git a/apps/web/src/cockpits/rep/RepPainel.tsx b/apps/web/src/cockpits/rep/RepPainel.tsx index da5c6c3..f30b6b0 100644 --- a/apps/web/src/cockpits/rep/RepPainel.tsx +++ b/apps/web/src/cockpits/rep/RepPainel.tsx @@ -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 = [ }, ]; +// ─── 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 ( + + + Realizado vs Meta — {ano} + + } + > +
+ +
+
+ ); +} + // ─── 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 ( - + {dias}d sem pedido @@ -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 ( {/* Saudação */} @@ -499,6 +629,9 @@ export function RepPainel() { )} + {/* Gráfico anual */} + + {/* Linha 2 — Não positivados + Pedidos recentes */} diff --git a/libs/shared/api-interface/src/lib/dashboard.contract.ts b/libs/shared/api-interface/src/lib/dashboard.contract.ts index 2b92ca3..afc3c59 100644 --- a/libs/shared/api-interface/src/lib/dashboard.contract.ts +++ b/libs/shared/api-interface/src/lib/dashboard.contract.ts @@ -39,6 +39,14 @@ export const MetaItemSchema = z.object({ }); export type MetaItem = z.infer; +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; + 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;