feat(api,web): detalhamento da carteira do representante

Tela dedicada que abre pelo botao "Detalhar carteira" na listagem de
clientes, com leitura acionavel da carteira em vez de so uma tabela.

- `/reports/carteira` passa a devolver `faturamentoTotal`,
  `participacaoPct` e `faturamentoRepTotal`, permitindo medir o peso de
  cada cliente no faturamento do rep.
- `CarteirePage`: cards de resumo (ativos / em risco / inativos / sem
  pedido), faturamento total e InsightCards que apontam concentracao em
  poucos clientes, maior cliente em risco, maior inativo e clientes nunca
  positivados.
- Clientes classificados por dias sem comprar (30d em risco, 60d inativo),
  com navegacao direta para a ficha.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:16:47 +00:00
parent 2649bc9e94
commit 51602dd47e
5 changed files with 485 additions and 36 deletions

View File

@@ -86,56 +86,83 @@ export class ReportsService {
dias_sem_pedido: unknown;
total_pedidos: unknown;
ticket_medio: unknown;
faturamento_total: unknown;
}
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
WITH ultimos AS (
SELECT
id_cliente,
MAX(dt_pedido) AS ultimo_pedido,
COUNT(id) AS total_pedidos,
CASE WHEN COUNT(id) > 0
THEN SUM(total)::numeric / COUNT(id)
MAX(dt_pedido) AS ultimo_pedido,
COUNT(*) AS total_pedidos,
CASE WHEN COUNT(*) > 0
THEN SUM(total)::numeric / COUNT(*)
ELSE 0
END AS ticket_medio
FROM sar.pedidos
END AS ticket_medio,
COALESCE(SUM(total), 0)::numeric AS faturamento_total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa <> 3
AND situa NOT IN (1, 5)
GROUP BY id_cliente
)
SELECT
c.id_cliente,
TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao,
TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao,
c.ativo,
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio
FROM sar.vw_clientes c
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio,
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
FROM vw_clientes c
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
WHERE c.id_empresa = ${idEmpresa}
AND c.cod_vendedor = ${codVendedor}
ORDER BY u.ultimo_pedido ASC NULLS FIRST, c.nome ASC
WHERE c.cod_vendedor = ${codVendedor}
AND c.ativo = 1
ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
`);
const data = rows.map((r) => ({
idCliente: n(r.id_cliente),
nome: r.nome ?? null,
razao: r.razao ?? null,
ativo: n(r.ativo),
ultimoPedido: r.ultimo_pedido ?? null,
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
totalPedidos: n(r.total_pedidos),
ticketMedio: d(r.ticket_medio),
}));
// Deduplica por id_cliente — vw_clientes pode retornar o mesmo cliente em mais de uma empresa.
const seen = new Set<number>();
const unique = rows.filter((r) => {
const id = n(r.id_cliente);
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const faturamentoRepTotal = unique.reduce((acc, r) => acc + n(r.faturamento_total), 0);
const data = unique.map((r) => {
const fat = n(r.faturamento_total);
return {
idCliente: n(r.id_cliente),
nome: r.nome ?? null,
razao: r.razao ?? null,
ativo: n(r.ativo),
ultimoPedido: r.ultimo_pedido ?? null,
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
totalPedidos: n(r.total_pedidos),
ticketMedio: d(r.ticket_medio),
faturamentoTotal: fat.toFixed(2),
participacaoPct:
faturamentoRepTotal > 0 ? Math.round((fat / faturamentoRepTotal) * 1000) / 10 : 0,
};
});
const inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length;
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
const semPedido = data.filter((c) => c.totalPedidos === 0).length;
return { data, total: data.length, inativos30, inativos60, semPedido };
return {
data,
total: data.length,
inativos30,
inativos60,
semPedido,
faturamentoRepTotal: faturamentoRepTotal.toFixed(2),
};
}
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {