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;
|
cod_vendedor: number;
|
||||||
novos: string;
|
novos: string;
|
||||||
}
|
}
|
||||||
|
interface UfRepRow {
|
||||||
|
uf: string;
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
|
pedidos: string;
|
||||||
|
clientes: string;
|
||||||
|
faturamento: string;
|
||||||
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
statsRows,
|
statsRows,
|
||||||
@@ -433,6 +441,7 @@ export class DashboardService {
|
|||||||
realizadoGrupoRows,
|
realizadoGrupoRows,
|
||||||
positivacaoDiariaRows,
|
positivacaoDiariaRows,
|
||||||
novosClientesRows,
|
novosClientesRows,
|
||||||
|
ufRepRows,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.$queryRawUnsafe<TotalRow[]>(`
|
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
||||||
@@ -555,6 +564,31 @@ export class DashboardService {
|
|||||||
AND f.primeira_compra <= '${monthEndStr}'
|
AND f.primeira_compra <= '${monthEndStr}'
|
||||||
GROUP BY c.cod_vendedor
|
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);
|
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 {
|
return {
|
||||||
faturamentoMes,
|
faturamentoMes,
|
||||||
pedidosMes,
|
pedidosMes,
|
||||||
@@ -640,6 +706,7 @@ export class DashboardService {
|
|||||||
dia: r.dia,
|
dia: r.dia,
|
||||||
clientes: Number(r.clientes),
|
clientes: Number(r.clientes),
|
||||||
})),
|
})),
|
||||||
|
faturamentoPorUf,
|
||||||
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
|
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@svg-maps/brazil": "^2.0.0",
|
||||||
"@tanstack/react-query": "^5.100.14",
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"@tanstack/react-router": "^1.170.8",
|
"@tanstack/react-router": "^1.170.8",
|
||||||
"antd": "^6.4.3",
|
"antd": "^6.4.3",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import type { Dayjs } from 'dayjs';
|
|||||||
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
|
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
|
||||||
import { useManagerDashboard } from '../../lib/queries/gerente';
|
import { useManagerDashboard } from '../../lib/queries/gerente';
|
||||||
import { apiFetch } from '../../lib/api-client';
|
import { apiFetch } from '../../lib/api-client';
|
||||||
|
import { MapaBrasilCard } from './MapaBrasilCard';
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -403,6 +404,7 @@ export function GerPainel() {
|
|||||||
rankingReps,
|
rankingReps,
|
||||||
positivacaoReps,
|
positivacaoReps,
|
||||||
positivacaoDiaria,
|
positivacaoDiaria,
|
||||||
|
faturamentoPorUf,
|
||||||
syncedAt,
|
syncedAt,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
@@ -451,93 +453,60 @@ export function GerPainel() {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* KPIs linha 1 */}
|
{/* Primeira dobra: visão financeira (½) + mapa do Brasil (½) */}
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} xl={12}>
|
||||||
<Card>
|
<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 vertical gap={4}>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
|
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
|
||||||
{fmt(faturamentoMes)}
|
|
||||||
</Title>
|
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faFileInvoiceDollar}
|
icon={faFileInvoiceDollar}
|
||||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
|
||||||
</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">
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
{pedidosMes}
|
{fmt(faturamentoMes)}
|
||||||
</Title>
|
</Title>
|
||||||
<FontAwesomeIcon
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
icon={faChartBar}
|
{pedidosMes} pedido{pedidosMes !== 1 ? 's' : ''} no{' '}
|
||||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
{isMesAtual ? 'mês' : 'período'}
|
||||||
/>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||||
<Card>
|
<Card style={{ flex: 1 }}>
|
||||||
<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>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* KPIs linha 2 — Meta */}
|
|
||||||
{metaTotal > 0 && (
|
|
||||||
<Row gutter={[24, 24]}>
|
|
||||||
<Col xs={24} sm={12} lg={8}>
|
|
||||||
<Card>
|
|
||||||
<Flex vertical gap={4}>
|
<Flex vertical gap={4}>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||||
</Text>
|
</Text>
|
||||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
|
||||||
{fmt(metaTotal)}
|
|
||||||
</Title>
|
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faBullseye}
|
icon={faBullseye}
|
||||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</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>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||||
<Card>
|
<Card style={{ flex: 1 }}>
|
||||||
<Flex vertical gap={8}>
|
<Flex vertical gap={8}>
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
@@ -550,13 +519,13 @@ export function GerPainel() {
|
|||||||
</Flex>
|
</Flex>
|
||||||
<Title
|
<Title
|
||||||
level={3}
|
level={3}
|
||||||
style={{ margin: 0, color: metaCorColor }}
|
style={{ margin: 0, color: metaTotal > 0 ? metaCorColor : undefined }}
|
||||||
className="tabular-nums"
|
className="tabular-nums"
|
||||||
>
|
>
|
||||||
{pctMeta}%
|
{metaTotal > 0 ? `${pctMeta}%` : '—'}
|
||||||
</Title>
|
</Title>
|
||||||
<Progress
|
<Progress
|
||||||
percent={Math.min(pctMeta, 100)}
|
percent={metaTotal > 0 ? Math.min(pctMeta, 100) : 0}
|
||||||
strokeColor={metaCorColor}
|
strokeColor={metaCorColor}
|
||||||
showInfo={false}
|
showInfo={false}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -565,20 +534,33 @@ export function GerPainel() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||||
<Card>
|
<Card style={{ flex: 1 }}>
|
||||||
<Flex vertical gap={4}>
|
<Flex vertical gap={4}>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
||||||
</Text>
|
</Text>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faChartBar}
|
||||||
|
style={{
|
||||||
|
color: superouMeta > 0 ? 'var(--green)' : 'var(--jcs-blue)',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
<Title
|
<Title
|
||||||
level={3}
|
level={3}
|
||||||
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
||||||
className="tabular-nums"
|
className="tabular-nums"
|
||||||
>
|
>
|
||||||
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
|
{metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'}
|
||||||
</Title>
|
</Title>
|
||||||
{superouMeta > 0 ? (
|
{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)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
{pctMeta - 100}% acima da meta
|
{pctMeta - 100}% acima da meta
|
||||||
</Text>
|
</Text>
|
||||||
@@ -598,7 +580,51 @@ export function GerPainel() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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} xl={12}>
|
||||||
|
<MapaBrasilCard dados={faturamentoPorUf} periodoLabel={periodoLabel} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
{/* Metas por Grupo */}
|
{/* Metas por Grupo */}
|
||||||
{metasPorGrupo.length > 0 && (
|
{metasPorGrupo.length > 0 && (
|
||||||
|
|||||||
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>;
|
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({
|
export const ManagerDashboardSchema = z.object({
|
||||||
faturamentoMes: z.number(),
|
faturamentoMes: z.number(),
|
||||||
pedidosMes: z.number().int(),
|
pedidosMes: z.number().int(),
|
||||||
@@ -125,6 +145,7 @@ export const ManagerDashboardSchema = z.object({
|
|||||||
rankingReps: z.array(RankingRepSchema),
|
rankingReps: z.array(RankingRepSchema),
|
||||||
positivacaoReps: z.array(PositivacaoRepSchema),
|
positivacaoReps: z.array(PositivacaoRepSchema),
|
||||||
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
|
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
|
||||||
|
faturamentoPorUf: z.array(UfFaturamentoSchema).default([]),
|
||||||
// Meta diária de positivação salva pelo gerente (config_empresa)
|
// Meta diária de positivação salva pelo gerente (config_empresa)
|
||||||
metaPositivacaoDia: z.number().int().nullable().optional(),
|
metaPositivacaoDia: z.number().int().nullable().optional(),
|
||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -319,6 +319,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.4.0(react-hook-form@7.76.1(react@19.2.6))
|
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':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.100.14
|
specifier: ^5.100.14
|
||||||
version: 5.100.14(react@19.2.6)
|
version: 5.100.14(react@19.2.6)
|
||||||
@@ -3380,6 +3383,9 @@ packages:
|
|||||||
'@standard-schema/utils@0.3.0':
|
'@standard-schema/utils@0.3.0':
|
||||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
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':
|
'@svgr/babel-plugin-add-jsx-attribute@8.0.0':
|
||||||
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
|
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -13385,6 +13391,8 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@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)':
|
'@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
|
|||||||
Reference in New Issue
Block a user