From 78612d59bd936a9b7938a290787eed2799eb72b5 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 23 Jul 2026 11:22:21 +0000 Subject: [PATCH] feat(api,web): mapa do brasil com faturamento por estado no painel gerencial - KPIs financeiros agrupados (faturamento, meta, atingimento, falta) em metade da primeira dobra - MapaBrasilCard: choropleth por UF (@svg-maps/brazil), tooltip, ranking lateral e modal Detalhar com representantes por estado - API: faturamentoPorUf no /dashboard/manager (pedido -> cliente -> municipio -> UF) Co-Authored-By: Claude Fable 5 --- .../src/app/dashboard/dashboard.service.ts | 67 ++++ apps/web/package.json | 1 + apps/web/src/cockpits/ger/GerPainel.tsx | 312 ++++++++------- apps/web/src/cockpits/ger/MapaBrasilCard.tsx | 360 ++++++++++++++++++ apps/web/src/svg-maps.d.ts | 14 + .../src/lib/dashboard.contract.ts | 21 + pnpm-lock.yaml | 8 + 7 files changed, 640 insertions(+), 143 deletions(-) create mode 100644 apps/web/src/cockpits/ger/MapaBrasilCard.tsx create mode 100644 apps/web/src/svg-maps.d.ts diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 15923a8..5cef32f 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -422,6 +422,14 @@ export class DashboardService { cod_vendedor: number; novos: string; } + interface UfRepRow { + uf: string; + cod_vendedor: number; + nome_vendedor: string | null; + pedidos: string; + clientes: string; + faturamento: string; + } const [ statsRows, @@ -433,6 +441,7 @@ export class DashboardService { realizadoGrupoRows, positivacaoDiariaRows, novosClientesRows, + ufRepRows, ] = await Promise.all([ prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total @@ -555,6 +564,31 @@ export class DashboardService { AND f.primeira_compra <= '${monthEndStr}' GROUP BY c.cod_vendedor `), + // Faturamento por UF (município do cliente) e representante. + // vw_clientes pode repetir o cliente por empresa → LATERAL pega 1 município. + prisma.$queryRawUnsafe(` + SELECT COALESCE(NULLIF(TRIM(loc.uf), ''), 'ND') AS uf, + p.cod_vendedor, + (SELECT r.nome FROM vw_representantes r + WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome_vendedor, + COUNT(*)::text AS pedidos, + COUNT(DISTINCT p.id_cliente)::text AS clientes, + COALESCE(SUM(p.total), 0)::text AS faturamento + FROM vw_pedidos_erp p + LEFT JOIN LATERAL ( + SELECT mu.uf::text AS uf + FROM vw_clientes c + JOIN sar.vw_municipios mu ON mu.id_municipio = c.id_municipio + WHERE c.id_cliente = p.id_cliente + LIMIT 1 + ) loc ON true + WHERE p.id_empresa = ${idEmpresa} + AND p.situa NOT IN (1, 5) + AND p.dt_pedido >= '${monthStartStr}' + AND p.dt_pedido <= '${monthEndStr}' + GROUP BY 1, p.cod_vendedor + ORDER BY 1, SUM(p.total) DESC + `), ]); const pedidosMes = Number(statsRows[0]?.count ?? 0); @@ -626,6 +660,38 @@ export class DashboardService { }; }); + // Agrega as linhas UF×rep em UF → { totais, reps[] } ordenado por faturamento. + const porUf = new Map< + string, + { faturamento: number; pedidos: number; clientes: number; reps: UfRepRow[] } + >(); + for (const r of ufRepRows) { + const acc = porUf.get(r.uf) ?? { faturamento: 0, pedidos: 0, clientes: 0, reps: [] }; + acc.faturamento += Number(r.faturamento); + acc.pedidos += Number(r.pedidos); + acc.clientes += Number(r.clientes); + acc.reps.push(r); + porUf.set(r.uf, acc); + } + const faturamentoPorUf = [...porUf.entries()] + .map(([uf, t]) => ({ + uf, + faturamento: t.faturamento, + pedidos: t.pedidos, + clientes: t.clientes, + pct: faturamentoMes > 0 ? Math.round((t.faturamento / faturamentoMes) * 1000) / 10 : 0, + reps: t.reps + .map((r) => ({ + codVendedor: Number(r.cod_vendedor), + nomeVendedor: r.nome_vendedor ?? null, + faturamento: Number(r.faturamento), + pedidos: Number(r.pedidos), + clientes: Number(r.clientes), + })) + .sort((a, b) => b.faturamento - a.faturamento), + })) + .sort((a, b) => b.faturamento - a.faturamento); + return { faturamentoMes, pedidosMes, @@ -640,6 +706,7 @@ export class DashboardService { dia: r.dia, clientes: Number(r.clientes), })), + faturamentoPorUf, metaPositivacaoDia: config?.metaPositivacaoDia ?? null, syncedAt: now.toISOString(), }; diff --git a/apps/web/package.json b/apps/web/package.json index c8bc62a..bff3314 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.3.1", "@hookform/resolvers": "^5.4.0", + "@svg-maps/brazil": "^2.0.0", "@tanstack/react-query": "^5.100.14", "@tanstack/react-router": "^1.170.8", "antd": "^6.4.3", diff --git a/apps/web/src/cockpits/ger/GerPainel.tsx b/apps/web/src/cockpits/ger/GerPainel.tsx index 1f38a14..528471f 100644 --- a/apps/web/src/cockpits/ger/GerPainel.tsx +++ b/apps/web/src/cockpits/ger/GerPainel.tsx @@ -46,6 +46,7 @@ import type { Dayjs } from 'dayjs'; import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface'; import { useManagerDashboard } from '../../lib/queries/gerente'; import { apiFetch } from '../../lib/api-client'; +import { MapaBrasilCard } from './MapaBrasilCard'; ChartJS.register( CategoryScale, @@ -403,6 +404,7 @@ export function GerPainel() { rankingReps, positivacaoReps, positivacaoDiaria, + faturamentoPorUf, syncedAt, } = data; @@ -451,155 +453,179 @@ export function GerPainel() { - {/* KPIs linha 1 */} + {/* Primeira dobra: visão financeira (½) + mapa do Brasil (½) */} - - - - - FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()} - - - {fmt(faturamentoMes)} - - - - + + + + + + + + + FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()} + + + + + {fmt(faturamentoMes)} + + + {pedidosMes} pedido{pedidosMes !== 1 ? 's' : ''} no{' '} + {isMesAtual ? 'mês' : 'período'} + + + + + + + + + + + META DO {isMesAtual ? 'MÊS' : 'PERÍODO'} + + + + + {metaTotal > 0 ? fmt(metaTotal) : '—'} + + + {metaTotal > 0 + ? `soma das metas por representante` + : 'sem meta cadastrada no ERP'} + + + + + + + + + + + ATINGIMENTO DA META + + + + 0 ? metaCorColor : undefined }} + className="tabular-nums" + > + {metaTotal > 0 ? `${pctMeta}%` : '—'} + + 0 ? Math.min(pctMeta, 100) : 0} + strokeColor={metaCorColor} + showInfo={false} + size="small" + /> + + + + + + + + + + {superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'} + + 0 ? 'var(--green)' : 'var(--jcs-blue)', + opacity: 0.5, + }} + /> + + 0 ? 'var(--green)' : undefined }} + className="tabular-nums" + > + {metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'} + + {metaTotal === 0 ? ( + + sem meta cadastrada no ERP + + ) : superouMeta > 0 ? ( + + {pctMeta - 100}% acima da meta + + ) : vendaPorDia > 0 ? ( + + {fmt(vendaPorDia)}/dia — {diasRestantes} dia + {diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''} + + ) : diasRestantes === 0 ? ( + + período encerrado + + ) : null} + + + + + + {/* Indicadores secundários */} + + + + + TICKET MÉDIO + + + {fmt(ticketMedio)} + + + + + REPS COM PEDIDOS + + + + {totalReps} + + + + + PROMOÇÕES ATIVAS + + + + {promocoesAtivas} + + + + + - - - - - PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'} - - - {pedidosMes} - - - - - - - - - - - TICKET MÉDIO - - - {fmt(ticketMedio)} - - - - - - - - - - - PROMOÇÕES ATIVAS - - - {promocoesAtivas} - - - - + + - {/* KPIs linha 2 — Meta */} - {metaTotal > 0 && ( - - - - - - META DO {isMesAtual ? 'MÊS' : 'PERÍODO'} - - - {fmt(metaTotal)} - - - - - - - - - - - - ATINGIMENTO DA META - - - - - {pctMeta}% - - - - - - - - - - - {superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'} - - 0 ? 'var(--green)' : undefined }} - className="tabular-nums" - > - {fmt(superouMeta > 0 ? superouMeta : faltaMeta)} - - {superouMeta > 0 ? ( - - {pctMeta - 100}% acima da meta - - ) : vendaPorDia > 0 ? ( - - {fmt(vendaPorDia)}/dia — {diasRestantes} dia - {diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''} - - ) : diasRestantes === 0 ? ( - - período encerrado - - ) : null} - - - - - )} - {/* Metas por Grupo */} {metasPorGrupo.length > 0 && ( = 1_000_000) + return `R$ ${(v / 1_000_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mi`; + if (v >= 1_000) + return `R$ ${(v / 1_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mil`; + return fmt(v); +} + +// Escala sequencial (um matiz, claro→escuro) derivada do azul JCS. +const SEQ_STEPS = ['#dbe9f6', '#aecde9', '#7BA7D7', '#3d74b8', '#004a99']; +const COR_SEM_VENDA = '#edf0f3'; + +function corDaUf(valor: number, max: number): string { + if (valor <= 0 || max <= 0) return COR_SEM_VENDA; + const idx = Math.min(SEQ_STEPS.length - 1, Math.floor((valor / max) * SEQ_STEPS.length)); + return SEQ_STEPS[idx] ?? COR_SEM_VENDA; +} + +const NOME_UF = new Map(brazilMap.locations.map((l) => [l.id.toUpperCase(), l.name])); + +const repColumns: TableColumnsType = [ + { + title: 'Representante', + key: 'rep', + render: (_: unknown, r: UfRep) => ( + + + {r.nomeVendedor ?? `Cód. ${r.codVendedor}`} + + ), + ellipsis: true, + }, + { + title: 'Pedidos', + dataIndex: 'pedidos', + width: 90, + align: 'right', + className: 'tabular-nums', + }, + { + title: 'Clientes', + dataIndex: 'clientes', + width: 90, + align: 'right', + className: 'tabular-nums', + }, + { + title: 'Faturamento', + dataIndex: 'faturamento', + width: 150, + align: 'right', + render: (v: number) => fmt(v), + className: 'tabular-nums', + }, +]; + +function detalheColumns(): TableColumnsType { + return [ + { + title: 'Estado', + key: 'uf', + render: (_: unknown, r: UfFaturamento) => ( + + {r.uf} + {NOME_UF.get(r.uf) ?? 'Sem UF cadastrada'} + + ), + }, + { + title: 'Pedidos', + dataIndex: 'pedidos', + width: 90, + align: 'right', + className: 'tabular-nums', + }, + { + title: 'Clientes', + dataIndex: 'clientes', + width: 90, + align: 'right', + className: 'tabular-nums', + }, + { + title: 'Faturamento', + dataIndex: 'faturamento', + width: 150, + align: 'right', + render: (v: number) => ( + + {fmt(v)} + + ), + }, + { + title: 'Participação', + dataIndex: 'pct', + width: 170, + render: (pct: number) => ( + + + + {pct.toLocaleString('pt-BR')}% + + + ), + }, + ]; +} + +export function MapaBrasilCard({ + dados, + periodoLabel, +}: { + dados: UfFaturamento[]; + periodoLabel: string; +}) { + const [detalheAberto, setDetalheAberto] = useState(false); + const [ufsExpandidas, setUfsExpandidas] = useState([]); + const [ufHover, setUfHover] = useState(null); + + const porUf = useMemo(() => new Map(dados.map((d) => [d.uf, d])), [dados]); + const max = dados.reduce((m, d) => Math.max(m, d.faturamento), 0); + const comVenda = dados.filter((d) => d.faturamento > 0 && d.uf !== 'ND'); + const topUfs = comVenda.slice(0, 8); + const semUf = dados.find((d) => d.uf === 'ND'); + + const abrirDetalhe = (uf?: string) => { + const primeira = comVenda[0]?.uf; + setUfsExpandidas(uf ? [uf] : primeira ? [primeira] : []); + setDetalheAberto(true); + }; + + return ( + + + Faturamento por Estado + + } + extra={ + + } + style={{ height: '100%' }} + styles={{ body: { paddingTop: 12 } }} + > + {dados.length === 0 ? ( + + ) : ( + + {/* Mapa choropleth */} + + + {brazilMap.locations.map((loc) => { + const uf = loc.id.toUpperCase(); + const dado = porUf.get(uf); + const valor = dado?.faturamento ?? 0; + const hover = ufHover === uf; + return ( + + + {loc.name} ({uf}) + + {dado ? ( + <> + + {fmt(valor)} · {dado.pct.toLocaleString('pt-BR')}% do total + + + {dado.pedidos} pedido{dado.pedidos !== 1 ? 's' : ''} · {dado.clientes}{' '} + cliente{dado.clientes !== 1 ? 's' : ''} + + + ) : ( + Sem vendas no período + )} + + } + > + setUfHover(uf)} + onMouseLeave={() => setUfHover(null)} + onClick={() => dado && abrirDetalhe(uf)} + /> + + ); + })} + + + {/* Legenda da escala */} + + + Sem venda + + + + + Menor + + {SEQ_STEPS.map((c) => ( + + ))} + + Maior + + + + + {/* Ranking de estados */} + + {topUfs.map((d, i) => ( + setUfHover(d.uf)} + onMouseLeave={() => setUfHover(null)} + onClick={() => abrirDetalhe(d.uf)} + > + + {i + 1}º + + + + {d.uf} + + + {fmtCompacto(d.faturamento)} + + + {d.pct.toLocaleString('pt-BR')}% + + + ))} + {comVenda.length > topUfs.length && ( + + + {comVenda.length - topUfs.length} outro + {comVenda.length - topUfs.length !== 1 ? 's' : ''} estado + {comVenda.length - topUfs.length !== 1 ? 's' : ''} — ver Detalhar + + )} + {semUf && ( + + {fmtCompacto(semUf.faturamento)} sem UF cadastrada + + )} + + + )} + + {/* Detalhe por estado → representantes */} + + + Faturamento por Estado — {periodoLabel} + + } + open={detalheAberto} + onCancel={() => setDetalheAberto(false)} + footer={null} + width={900} + centered + > + + rowKey="uf" + columns={detalheColumns()} + dataSource={dados} + pagination={false} + size="small" + scroll={{ y: 420 }} + expandable={{ + expandedRowKeys: ufsExpandidas, + onExpandedRowsChange: (keys) => setUfsExpandidas([...keys] as string[]), + expandedRowRender: (r) => ( + + rowKey="codVendedor" + columns={repColumns} + dataSource={r.reps} + pagination={false} + size="small" + /> + ), + }} + locale={{ emptyText: 'Nenhuma venda no período.' }} + /> + + + ); +} diff --git a/apps/web/src/svg-maps.d.ts b/apps/web/src/svg-maps.d.ts new file mode 100644 index 0000000..85485c5 --- /dev/null +++ b/apps/web/src/svg-maps.d.ts @@ -0,0 +1,14 @@ +declare module '@svg-maps/brazil' { + interface SvgMapLocation { + id: string; // sigla da UF em minúsculas (sp, pr, …) + name: string; + path: string; + } + interface SvgMap { + label: string; + viewBox: string; + locations: SvgMapLocation[]; + } + const map: SvgMap; + export default map; +} diff --git a/libs/shared/api-interface/src/lib/dashboard.contract.ts b/libs/shared/api-interface/src/lib/dashboard.contract.ts index d8555e6..bc460d0 100644 --- a/libs/shared/api-interface/src/lib/dashboard.contract.ts +++ b/libs/shared/api-interface/src/lib/dashboard.contract.ts @@ -114,6 +114,26 @@ export const PositivacaoDiaSchema = z.object({ }); export type PositivacaoDia = z.infer; +// Faturamento por estado (UF do município do cliente), com detalhe por representante. +export const UfRepSchema = z.object({ + codVendedor: z.number().int(), + nomeVendedor: z.string().nullable(), + faturamento: z.number(), + pedidos: z.number().int(), + clientes: z.number().int(), +}); +export type UfRep = z.infer; + +export const UfFaturamentoSchema = z.object({ + uf: z.string(), // sigla (SP, PR…); 'ND' = cliente sem município/UF + faturamento: z.number(), + pedidos: z.number().int(), + clientes: z.number().int(), + pct: z.number(), // participação no faturamento do período + reps: z.array(UfRepSchema), +}); +export type UfFaturamento = z.infer; + export const ManagerDashboardSchema = z.object({ faturamentoMes: z.number(), pedidosMes: z.number().int(), @@ -125,6 +145,7 @@ export const ManagerDashboardSchema = z.object({ rankingReps: z.array(RankingRepSchema), positivacaoReps: z.array(PositivacaoRepSchema), positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]), + faturamentoPorUf: z.array(UfFaturamentoSchema).default([]), // Meta diária de positivação salva pelo gerente (config_empresa) metaPositivacaoDia: z.number().int().nullable().optional(), syncedAt: z.iso.datetime(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f1f6c8..8544a09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,6 +319,9 @@ importers: '@hookform/resolvers': specifier: ^5.4.0 version: 5.4.0(react-hook-form@7.76.1(react@19.2.6)) + '@svg-maps/brazil': + specifier: ^2.0.0 + version: 2.0.0 '@tanstack/react-query': specifier: ^5.100.14 version: 5.100.14(react@19.2.6) @@ -3380,6 +3383,9 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@svg-maps/brazil@2.0.0': + resolution: {integrity: sha512-6le594K1tO6c8Y/0KgWyurksyLnSU/8mTSnr48cKH0eaiG153UQP8aadQ/pKoeCBVd8khLVdmV7wKbkfIAbqBA==} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -13385,6 +13391,8 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@svg-maps/brazil@2.0.0': {} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7