From 51602dd47ea38b1564dac1b3751d6d09ed1a3721 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 20 Jul 2026 18:16:47 +0000 Subject: [PATCH] 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) --- apps/api/src/app/reports/reports.service.ts | 83 ++-- apps/web/src/cockpits/rep/CarteirePage.tsx | 398 ++++++++++++++++++ apps/web/src/cockpits/rep/ClientsPage.tsx | 29 +- apps/web/src/lib/router.tsx | 8 + .../api-interface/src/lib/report.contract.ts | 3 + 5 files changed, 485 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/cockpits/rep/CarteirePage.tsx diff --git a/apps/api/src/app/reports/reports.service.ts b/apps/api/src/app/reports/reports.service.ts index 2fb0589..bda3ce3 100644 --- a/apps/api/src/app/reports/reports.service.ts +++ b/apps/api/src/app/reports/reports.service.ts @@ -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(` 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(); + 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 { diff --git a/apps/web/src/cockpits/rep/CarteirePage.tsx b/apps/web/src/cockpits/rep/CarteirePage.tsx new file mode 100644 index 0000000..23c97f4 --- /dev/null +++ b/apps/web/src/cockpits/rep/CarteirePage.tsx @@ -0,0 +1,398 @@ +import { useState } from 'react'; +import { + Alert, + Badge, + Button, + Card, + Col, + Flex, + Row, + Skeleton, + Space, + Statistic, + Table, + Tag, + Typography, +} from 'antd'; +import type { TableColumnsType } from 'antd'; +import { + ArrowLeftOutlined, + ExclamationCircleOutlined, + FireOutlined, + RiseOutlined, + TeamOutlined, + WarningOutlined, +} from '@ant-design/icons'; +import { useNavigate } from '@tanstack/react-router'; +import type { CarteiraCliente } from '@sar/api-interface'; +import { useReportCarteira } from '../../lib/queries/reports'; + +const { Title, Text } = Typography; + +function fmt(v: number | string): string { + return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); +} + +function fmtDate(v: string | null | undefined): string { + if (!v) return '—'; + return new Date(v + 'T00:00:00').toLocaleDateString('pt-BR'); +} + +type Filtro = 'todos' | 'ativos' | 'risco' | 'inativos' | 'semPedido'; + +function statusBadge(c: CarteiraCliente) { + if (c.totalPedidos === 0) + return Sem pedido} />; + if (c.diasSemPedido != null && c.diasSemPedido >= 60) + return ( + Inativo 60d+} + /> + ); + if (c.diasSemPedido != null && c.diasSemPedido >= 30) + return ( + Em risco 30d+} + /> + ); + return ( + Ativo} /> + ); +} + +function InsightCard({ + icon, + cor, + titulo, + descricao, +}: { + icon: React.ReactNode; + cor: string; + titulo: string; + descricao: string; +}) { + return ( + + + {icon} + {/* minWidth: 0 — flex item precisa poder encolher; titulo/descricao + embutem razão social e valores que podem não ter ponto de quebra. */} +
+ + {titulo} + +
+ + {descricao} + +
+
+
+
+ ); +} + +export function CarteirePage() { + const navigate = useNavigate(); + const { data, isLoading, isError, error } = useReportCarteira(); + const [filtro, setFiltro] = useState('todos'); + + const clientes = data?.data ?? []; + + const ativos = clientes.filter( + (c) => c.totalPedidos > 0 && (c.diasSemPedido == null || c.diasSemPedido < 30), + ); + const emRisco = clientes.filter( + (c) => c.diasSemPedido != null && c.diasSemPedido >= 30 && c.diasSemPedido < 60, + ); + const inativos = clientes.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60); + const semPedido = clientes.filter((c) => c.totalPedidos === 0); + + const filtered = (() => { + if (filtro === 'ativos') return ativos; + if (filtro === 'risco') return emRisco; + if (filtro === 'inativos') return inativos; + if (filtro === 'semPedido') return semPedido; + return clientes; + })(); + + // Insights calculados + const top5Fat = clientes + .slice() + .sort((a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal)) + .slice(0, 5); + const top5Pct = top5Fat.reduce((acc, c) => acc + c.participacaoPct, 0); + + const maiorCliEmRisco = emRisco.sort( + (a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal), + )[0]; + const maiorCliInativo = inativos.sort( + (a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal), + )[0]; + + const insights: { icon: React.ReactNode; cor: string; titulo: string; descricao: string }[] = []; + + if (top5Pct >= 60) { + insights.push({ + icon: , + cor: '#f59e0b', + titulo: `Concentração: top 5 clientes = ${top5Pct.toFixed(0)}% do faturamento`, + descricao: 'Risco alto de dependência. Considere expandir outros clientes da carteira.', + }); + } + + if (maiorCliEmRisco) { + const nome = + maiorCliEmRisco.razao ?? maiorCliEmRisco.nome ?? `Cliente ${maiorCliEmRisco.idCliente}`; + insights.push({ + icon: , + cor: '#f59e0b', + titulo: `${nome} está ${maiorCliEmRisco.diasSemPedido}d sem comprar`, + descricao: `Faturamento histórico: ${fmt(maiorCliEmRisco.faturamentoTotal)} — priorize o contato.`, + }); + } + + if (maiorCliInativo) { + const nome = + maiorCliInativo.razao ?? maiorCliInativo.nome ?? `Cliente ${maiorCliInativo.idCliente}`; + insights.push({ + icon: , + cor: '#ef4444', + titulo: `${nome} inativo há ${maiorCliInativo.diasSemPedido}d`, + descricao: `Era um cliente de ${fmt(maiorCliInativo.faturamentoTotal)} — vale reativar.`, + }); + } + + if (semPedido.length > 0) { + insights.push({ + icon: , + cor: '#003B8E', + titulo: `${semPedido.length} cliente${semPedido.length > 1 ? 's' : ''} sem nenhum pedido`, + descricao: 'Potencial inexplorado na sua carteira. Primeira visita pode gerar receita nova.', + }); + } + + const columns: TableColumnsType = [ + { + title: 'Cliente', + key: 'cliente', + render: (_: unknown, c: CarteiraCliente) => ( + + navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })} + > + {c.razao ?? c.nome ?? `Cód. ${c.idCliente}`} + + {c.razao && c.nome && ( + + {c.nome} + + )} + + ), + }, + { + title: 'Status', + key: 'status', + width: 140, + render: (_: unknown, c: CarteiraCliente) => statusBadge(c), + }, + { + title: 'Último Pedido', + dataIndex: 'ultimoPedido', + width: 130, + render: (v: string | null) => ( + {fmtDate(v)} + ), + sorter: (a, b) => (a.ultimoPedido ?? '').localeCompare(b.ultimoPedido ?? ''), + }, + { + title: 'Dias Sem Compra', + dataIndex: 'diasSemPedido', + width: 140, + align: 'right' as const, + render: (v: number | null) => + v != null ? ( + = 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }} + > + {v}d + + ) : ( + + ), + sorter: (a, b) => (a.diasSemPedido ?? 9999) - (b.diasSemPedido ?? 9999), + }, + { + title: 'Faturado (total)', + dataIndex: 'faturamentoTotal', + width: 150, + align: 'right' as const, + render: (v: string) => ( + + {fmt(v)} + + ), + sorter: (a, b) => Number(a.faturamentoTotal) - Number(b.faturamentoTotal), + defaultSortOrder: 'descend' as const, + }, + { + title: 'Participação', + dataIndex: 'participacaoPct', + width: 110, + align: 'right' as const, + render: (v: number) => ( + = 10 ? 'blue' : v >= 5 ? 'geekblue' : 'default'} + style={{ borderRadius: 20, fontWeight: 600 }} + > + {v.toFixed(1)}% + + ), + sorter: (a, b) => a.participacaoPct - b.participacaoPct, + }, + { + title: 'Ticket Médio', + dataIndex: 'ticketMedio', + width: 130, + align: 'right' as const, + render: (v: string) => ( + + {fmt(v)} + + ), + sorter: (a, b) => Number(a.ticketMedio) - Number(b.ticketMedio), + }, + { + title: 'Pedidos', + dataIndex: 'totalPedidos', + width: 80, + align: 'right' as const, + render: (v: number) => {v}, + sorter: (a, b) => a.totalPedidos - b.totalPedidos, + }, + ]; + + const filtros: { key: Filtro; label: string; count: number; cor: string }[] = [ + { key: 'todos', label: 'Todos', count: clientes.length, cor: '#64748b' }, + { key: 'ativos', label: 'Ativos', count: ativos.length, cor: '#22c55e' }, + { key: 'risco', label: 'Em risco (30d+)', count: emRisco.length, cor: '#f59e0b' }, + { key: 'inativos', label: 'Inativos (60d+)', count: inativos.length, cor: '#ef4444' }, + { key: 'semPedido', label: 'Sem pedido', count: semPedido.length, cor: '#94A3B8' }, + ]; + + return ( +
+ {/* Cabeçalho */} + +
+ ); +} diff --git a/apps/web/src/cockpits/rep/ClientsPage.tsx b/apps/web/src/cockpits/rep/ClientsPage.tsx index e8e8821..0ac023e 100644 --- a/apps/web/src/cockpits/rep/ClientsPage.tsx +++ b/apps/web/src/cockpits/rep/ClientsPage.tsx @@ -186,7 +186,13 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) { // ─── CustomerPortfolioCard ──────────────────────────────────────────────────── -function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) { +function CustomerPortfolioCard({ + stats, + onDetalhar, +}: { + stats: PortfolioStats; + onDetalhar: () => void; +}) { const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); const total = stats.ativos + stats.emAlerta + stats.inativos; @@ -313,6 +319,7 @@ function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {