- 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>
361 lines
12 KiB
TypeScript
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 — {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>
|
|
);
|
|
}
|