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:
2026-07-23 11:22:21 +00:00
parent dfdcca4674
commit 78612d59bd
7 changed files with 640 additions and 143 deletions

View File

@@ -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(),
};

View File

@@ -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",

View File

@@ -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,93 +453,60 @@ 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>
<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>
<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>
<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}
{fmt(faturamentoMes)}
</Title>
<FontAwesomeIcon
icon={faChartBar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/>
<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} 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>
</Row>
{/* KPIs linha 2 — Meta */}
{metaTotal > 0 && (
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={8}>
<Card>
<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>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(metaTotal)}
</Title>
<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} lg={8}>
<Card>
<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)' }}>
@@ -550,13 +519,13 @@ export function GerPainel() {
</Flex>
<Title
level={3}
style={{ margin: 0, color: metaCorColor }}
style={{ margin: 0, color: metaTotal > 0 ? metaCorColor : undefined }}
className="tabular-nums"
>
{pctMeta}%
{metaTotal > 0 ? `${pctMeta}%` : '—'}
</Title>
<Progress
percent={Math.min(pctMeta, 100)}
percent={metaTotal > 0 ? Math.min(pctMeta, 100) : 0}
strokeColor={metaCorColor}
showInfo={false}
size="small"
@@ -565,20 +534,33 @@ export function GerPainel() {
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card>
<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"
>
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
{metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'}
</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)' }}>
{pctMeta - 100}% acima da meta
</Text>
@@ -598,7 +580,51 @@ export function GerPainel() {
</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} xl={12}>
<MapaBrasilCard dados={faturamentoPorUf} periodoLabel={periodoLabel} />
</Col>
</Row>
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (

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

14
apps/web/src/svg-maps.d.ts vendored Normal file
View 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;
}

View File

@@ -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
View File

@@ -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