Files
sar/apps/web/src/cockpits/ger/MapaBrasilCard.tsx
julian 78612d59bd 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>
2026-07-23 11:22:21 +00:00

361 lines
12 KiB
TypeScript

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 &mdash; {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>
);
}