feat(api,web): positivacao de novos clientes por representante no painel gerencial

- novosClientes por rep no dashboard do gerente: clientes da carteira
  cuja PRIMEIRA compra (historico todo do ERP) caiu no periodo
- Coluna "Novos no mes" (tag verde +N, ordenavel) na tabela de
  positivacao + total de novos clientes no cabecalho do card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:23:13 +00:00
parent 54ef63f5c7
commit b6b16e1e21
3 changed files with 57 additions and 3 deletions

View File

@@ -418,6 +418,10 @@ export class DashboardService {
dia: string; dia: string;
clientes: string; clientes: string;
} }
interface NovosClientesRow {
cod_vendedor: number;
novos: string;
}
const [ const [
statsRows, statsRows,
@@ -428,6 +432,7 @@ export class DashboardService {
metaGrupoRows, metaGrupoRows,
realizadoGrupoRows, realizadoGrupoRows,
positivacaoDiariaRows, positivacaoDiariaRows,
novosClientesRows,
] = 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
@@ -535,6 +540,21 @@ export class DashboardService {
GROUP BY dt_pedido::date GROUP BY dt_pedido::date
ORDER BY dia ORDER BY dia
`), `),
prisma.$queryRawUnsafe<NovosClientesRow[]>(`
SELECT c.cod_vendedor, COUNT(*)::text AS novos
FROM (
SELECT id_cliente, MIN(dt_pedido) AS primeira_compra
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
GROUP BY id_cliente
) f
JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c
ON c.id_cliente = f.id_cliente
WHERE f.primeira_compra >= '${monthStartStr}'
AND f.primeira_compra <= '${monthEndStr}'
GROUP BY c.cod_vendedor
`),
]); ]);
const pedidosMes = Number(statsRows[0]?.count ?? 0); const pedidosMes = Number(statsRows[0]?.count ?? 0);
@@ -585,6 +605,9 @@ export class DashboardService {
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size; const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0); const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
const novosMap = new Map(
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
);
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => { const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
const total = Number(r.total_clientes); const total = Number(r.total_clientes);
const positivados = Number(r.clientes_positivados); const positivados = Number(r.clientes_positivados);
@@ -594,6 +617,7 @@ export class DashboardService {
totalClientes: total, totalClientes: total,
clientesPositivados: positivados, clientesPositivados: positivados,
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0, pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
}; };
}); });

View File

@@ -11,6 +11,7 @@ import {
Skeleton, Skeleton,
Space, Space,
Table, Table,
Tag,
Typography, Typography,
} from 'antd'; } from 'antd';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
@@ -84,6 +85,21 @@ const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
</Text> </Text>
), ),
}, },
{
title: 'Novos no mês',
key: 'novos',
width: 120,
align: 'center' as const,
sorter: (a: PositivacaoRep, b: PositivacaoRep) => a.novosClientes - b.novosClientes,
render: (_: unknown, row: PositivacaoRep) =>
row.novosClientes > 0 ? (
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
+{row.novosClientes}
</Tag>
) : (
<Text type="secondary"></Text>
),
},
{ {
title: '% Positivação', title: '% Positivação',
dataIndex: 'pctPositivacao', dataIndex: 'pctPositivacao',
@@ -643,9 +659,20 @@ export function GerPainel() {
</Space> </Space>
} }
extra={ extra={
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Flex align="center" gap={12}>
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação {positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) > 0 && (
</Text> <Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
+{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0)} novo
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''}{' '}
cliente
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''} no
período
</Tag>
)}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
</Text>
</Flex>
} }
> >
<Table<PositivacaoRep> <Table<PositivacaoRep>

View File

@@ -85,7 +85,10 @@ export const SupervisorDashboardSchema = z.object({
}); });
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>; export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
// caiu no periodo selecionado
export const PositivacaoRepSchema = z.object({ export const PositivacaoRepSchema = z.object({
novosClientes: z.number().int().default(0),
codVendedor: z.number().int(), codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(), nomeVendedor: z.string().nullable(),
totalClientes: z.number().int(), totalClientes: z.number().int(),