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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<TotalRow[]>(`
|
||||
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<UfRepRow[]>(`
|
||||
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(),
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* KPIs linha 1 */}
|
||||
{/* Primeira dobra: visão financeira (½) + mapa do Brasil (½) */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(faturamentoMes)}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faFileInvoiceDollar}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
<Col xs={24} xl={12}>
|
||||
<Flex vertical gap={16} style={{ height: '100%' }}>
|
||||
<Row gutter={[16, 16]} style={{ flex: 1 }}>
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faFileInvoiceDollar}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(faturamentoMes)}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{pedidosMes} pedido{pedidosMes !== 1 ? 's' : ''} no{' '}
|
||||
{isMesAtual ? 'mês' : 'período'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faBullseye}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{metaTotal > 0 ? fmt(metaTotal) : '—'}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{metaTotal > 0
|
||||
? `soma das metas por representante`
|
||||
: 'sem meta cadastrada no ERP'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={8}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
ATINGIMENTO DA META
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowTrendUp}
|
||||
style={{ color: metaCorColor, opacity: 0.7 }}
|
||||
/>
|
||||
</Flex>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: metaTotal > 0 ? metaCorColor : undefined }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{metaTotal > 0 ? `${pctMeta}%` : '—'}
|
||||
</Title>
|
||||
<Progress
|
||||
percent={metaTotal > 0 ? Math.min(pctMeta, 100) : 0}
|
||||
strokeColor={metaCorColor}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faChartBar}
|
||||
style={{
|
||||
color: superouMeta > 0 ? 'var(--green)' : 'var(--jcs-blue)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'}
|
||||
</Title>
|
||||
{metaTotal === 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
sem meta cadastrada no ERP
|
||||
</Text>
|
||||
) : superouMeta > 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{pctMeta - 100}% acima da meta
|
||||
</Text>
|
||||
) : vendaPorDia > 0 ? (
|
||||
<Text
|
||||
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
|
||||
>
|
||||
{fmt(vendaPorDia)}/dia — {diasRestantes} dia
|
||||
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
) : diasRestantes === 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
período encerrado
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Indicadores secundários */}
|
||||
<Card styles={{ body: { padding: '12px 24px' } }}>
|
||||
<Flex justify="space-around" align="center" wrap="wrap" gap={16}>
|
||||
<Flex vertical align="center" gap={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{fmt(ticketMedio)}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex vertical align="center" gap={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
REPS COM PEDIDOS
|
||||
</Text>
|
||||
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
<FontAwesomeIcon
|
||||
icon={faUsers}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
|
||||
/>
|
||||
{totalReps}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex vertical align="center" gap={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
PROMOÇÕES ATIVAS
|
||||
</Text>
|
||||
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
<FontAwesomeIcon
|
||||
icon={faTags}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
|
||||
/>
|
||||
{promocoesAtivas}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{pedidosMes}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faChartBar}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(ticketMedio)}
|
||||
</Title>
|
||||
<FontAwesomeIcon icon={faUsers} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
PROMOÇÕES ATIVAS
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{promocoesAtivas}
|
||||
</Title>
|
||||
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
|
||||
</Flex>
|
||||
</Card>
|
||||
<Col xs={24} xl={12}>
|
||||
<MapaBrasilCard dados={faturamentoPorUf} periodoLabel={periodoLabel} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* KPIs linha 2 — Meta */}
|
||||
{metaTotal > 0 && (
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(metaTotal)}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faBullseye}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Flex vertical gap={8}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
ATINGIMENTO DA META
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowTrendUp}
|
||||
style={{ color: metaCorColor, opacity: 0.7 }}
|
||||
/>
|
||||
</Flex>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: metaCorColor }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{pctMeta}%
|
||||
</Title>
|
||||
<Progress
|
||||
percent={Math.min(pctMeta, 100)}
|
||||
strokeColor={metaCorColor}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
||||
</Text>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
|
||||
</Title>
|
||||
{superouMeta > 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{pctMeta - 100}% acima da meta
|
||||
</Text>
|
||||
) : vendaPorDia > 0 ? (
|
||||
<Text
|
||||
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
|
||||
>
|
||||
{fmt(vendaPorDia)}/dia — {diasRestantes} dia
|
||||
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
) : diasRestantes === 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
período encerrado
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Metas por Grupo */}
|
||||
{metasPorGrupo.length > 0 && (
|
||||
<Card
|
||||
|
||||
360
apps/web/src/cockpits/ger/MapaBrasilCard.tsx
Normal file
360
apps/web/src/cockpits/ger/MapaBrasilCard.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Card, Empty, Flex, Modal, Progress, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faMapLocationDot, faUserTie } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { UfFaturamento, UfRep } from '@sar/api-interface';
|
||||
import brazilMap from '@svg-maps/brazil';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
// R$ compacto para a lista lateral (R$ 12,4 mil / R$ 1,2 mi)
|
||||
function fmtCompacto(v: number): string {
|
||||
if (v >= 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<UfRep> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, r: UfRep) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<FontAwesomeIcon icon={faUserTie} style={{ color: 'var(--jcs-blue)', opacity: 0.45 }} />
|
||||
{r.nomeVendedor ?? `Cód. ${r.codVendedor}`}
|
||||
</Flex>
|
||||
),
|
||||
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<UfFaturamento> {
|
||||
return [
|
||||
{
|
||||
title: 'Estado',
|
||||
key: 'uf',
|
||||
render: (_: unknown, r: UfFaturamento) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Tag style={{ margin: 0, fontWeight: 700, width: 40, textAlign: 'center' }}>{r.uf}</Tag>
|
||||
<Text>{NOME_UF.get(r.uf) ?? 'Sem UF cadastrada'}</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Text strong className="tabular-nums">
|
||||
{fmt(v)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Participação',
|
||||
dataIndex: 'pct',
|
||||
width: 170,
|
||||
render: (pct: number) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Progress
|
||||
percent={Math.min(pct, 100)}
|
||||
size="small"
|
||||
strokeColor="var(--jcs-blue)"
|
||||
showInfo={false}
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 48 }}>
|
||||
{pct.toLocaleString('pt-BR')}%
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function MapaBrasilCard({
|
||||
dados,
|
||||
periodoLabel,
|
||||
}: {
|
||||
dados: UfFaturamento[];
|
||||
periodoLabel: string;
|
||||
}) {
|
||||
const [detalheAberto, setDetalheAberto] = useState(false);
|
||||
const [ufsExpandidas, setUfsExpandidas] = useState<string[]>([]);
|
||||
const [ufHover, setUfHover] = useState<string | null>(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 (
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Faturamento por Estado
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
<Button type="primary" ghost size="small" onClick={() => abrirDetalhe()}>
|
||||
Detalhar
|
||||
</Button>
|
||||
}
|
||||
style={{ height: '100%' }}
|
||||
styles={{ body: { paddingTop: 12 } }}
|
||||
>
|
||||
{dados.length === 0 ? (
|
||||
<Empty description="Nenhuma venda no período." image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<Flex gap={16} wrap="wrap">
|
||||
{/* Mapa choropleth */}
|
||||
<Flex vertical gap={8} style={{ flex: '1 1 260px', minWidth: 240 }}>
|
||||
<svg
|
||||
viewBox={brazilMap.viewBox}
|
||||
role="img"
|
||||
aria-label={`Mapa do Brasil com faturamento por estado — ${periodoLabel}`}
|
||||
style={{ width: '100%', height: 'auto', maxHeight: 330 }}
|
||||
>
|
||||
{brazilMap.locations.map((loc) => {
|
||||
const uf = loc.id.toUpperCase();
|
||||
const dado = porUf.get(uf);
|
||||
const valor = dado?.faturamento ?? 0;
|
||||
const hover = ufHover === uf;
|
||||
return (
|
||||
<Tooltip
|
||||
key={loc.id}
|
||||
title={
|
||||
<Flex vertical gap={2}>
|
||||
<Text strong style={{ color: '#fff' }}>
|
||||
{loc.name} ({uf})
|
||||
</Text>
|
||||
{dado ? (
|
||||
<>
|
||||
<span>
|
||||
{fmt(valor)} · {dado.pct.toLocaleString('pt-BR')}% do total
|
||||
</span>
|
||||
<span>
|
||||
{dado.pedidos} pedido{dado.pedidos !== 1 ? 's' : ''} · {dado.clientes}{' '}
|
||||
cliente{dado.clientes !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Sem vendas no período</span>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<path
|
||||
d={loc.path}
|
||||
fill={corDaUf(valor, max)}
|
||||
stroke={hover ? 'var(--jcs-blue-hover)' : '#fff'}
|
||||
strokeWidth={hover ? 2 : 1}
|
||||
style={{ cursor: dado ? 'pointer' : 'default', transition: 'fill 0.15s' }}
|
||||
onMouseEnter={() => setUfHover(uf)}
|
||||
onMouseLeave={() => setUfHover(null)}
|
||||
onClick={() => dado && abrirDetalhe(uf)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Legenda da escala */}
|
||||
<Flex align="center" gap={6} style={{ paddingInline: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Sem venda
|
||||
</Text>
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 10,
|
||||
background: COR_SEM_VENDA,
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
<span style={{ width: 8 }} />
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Menor
|
||||
</Text>
|
||||
{SEQ_STEPS.map((c) => (
|
||||
<span key={c} style={{ width: 14, height: 10, background: c, borderRadius: 2 }} />
|
||||
))}
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Maior
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Ranking de estados */}
|
||||
<Flex vertical gap={6} style={{ flex: '1 1 190px', minWidth: 180 }}>
|
||||
{topUfs.map((d, i) => (
|
||||
<Flex
|
||||
key={d.uf}
|
||||
align="center"
|
||||
gap={8}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
background: ufHover === d.uf ? 'var(--jcs-blue-light)' : undefined,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={() => setUfHover(d.uf)}
|
||||
onMouseLeave={() => setUfHover(null)}
|
||||
onClick={() => abrirDetalhe(d.uf)}
|
||||
>
|
||||
<Text
|
||||
type="secondary"
|
||||
className="tabular-nums"
|
||||
style={{ width: 16, fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
{i + 1}º
|
||||
</Text>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
background: corDaUf(d.faturamento, max),
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text strong style={{ width: 28 }}>
|
||||
{d.uf}
|
||||
</Text>
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{ marginLeft: 'auto', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
{fmtCompacto(d.faturamento)}
|
||||
</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
className="tabular-nums"
|
||||
style={{ width: 44, textAlign: 'right', fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
{d.pct.toLocaleString('pt-BR')}%
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
{comVenda.length > topUfs.length && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
|
||||
+ {comVenda.length - topUfs.length} outro
|
||||
{comVenda.length - topUfs.length !== 1 ? 's' : ''} estado
|
||||
{comVenda.length - topUfs.length !== 1 ? 's' : ''} — ver Detalhar
|
||||
</Text>
|
||||
)}
|
||||
{semUf && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
|
||||
{fmtCompacto(semUf.faturamento)} sem UF cadastrada
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Detalhe por estado → representantes */}
|
||||
<Modal
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Faturamento por Estado — {periodoLabel}
|
||||
</Flex>
|
||||
}
|
||||
open={detalheAberto}
|
||||
onCancel={() => setDetalheAberto(false)}
|
||||
footer={null}
|
||||
width={900}
|
||||
centered
|
||||
>
|
||||
<Table<UfFaturamento>
|
||||
rowKey="uf"
|
||||
columns={detalheColumns()}
|
||||
dataSource={dados}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ y: 420 }}
|
||||
expandable={{
|
||||
expandedRowKeys: ufsExpandidas,
|
||||
onExpandedRowsChange: (keys) => setUfsExpandidas([...keys] as string[]),
|
||||
expandedRowRender: (r) => (
|
||||
<Table<UfRep>
|
||||
rowKey="codVendedor"
|
||||
columns={repColumns}
|
||||
dataSource={r.reps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
locale={{ emptyText: 'Nenhuma venda no período.' }}
|
||||
/>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/svg-maps.d.ts
vendored
Normal file
14
apps/web/src/svg-maps.d.ts
vendored
Normal file
@@ -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;
|
||||
}
|
||||
@@ -114,6 +114,26 @@ export const PositivacaoDiaSchema = z.object({
|
||||
});
|
||||
export type PositivacaoDia = z.infer<typeof PositivacaoDiaSchema>;
|
||||
|
||||
// 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<typeof UfRepSchema>;
|
||||
|
||||
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<typeof UfFaturamentoSchema>;
|
||||
|
||||
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(),
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user