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:
@@ -86,6 +86,7 @@ export class ReportsService {
|
|||||||
dias_sem_pedido: unknown;
|
dias_sem_pedido: unknown;
|
||||||
total_pedidos: unknown;
|
total_pedidos: unknown;
|
||||||
ticket_medio: unknown;
|
ticket_medio: unknown;
|
||||||
|
faturamento_total: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
|
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
|
||||||
@@ -93,15 +94,16 @@ export class ReportsService {
|
|||||||
SELECT
|
SELECT
|
||||||
id_cliente,
|
id_cliente,
|
||||||
MAX(dt_pedido) AS ultimo_pedido,
|
MAX(dt_pedido) AS ultimo_pedido,
|
||||||
COUNT(id) AS total_pedidos,
|
COUNT(*) AS total_pedidos,
|
||||||
CASE WHEN COUNT(id) > 0
|
CASE WHEN COUNT(*) > 0
|
||||||
THEN SUM(total)::numeric / COUNT(id)
|
THEN SUM(total)::numeric / COUNT(*)
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END AS ticket_medio
|
END AS ticket_medio,
|
||||||
FROM sar.pedidos
|
COALESCE(SUM(total), 0)::numeric AS faturamento_total
|
||||||
|
FROM vw_pedidos_erp
|
||||||
WHERE id_empresa = ${idEmpresa}
|
WHERE id_empresa = ${idEmpresa}
|
||||||
AND cod_vendedor = ${codVendedor}
|
AND cod_vendedor = ${codVendedor}
|
||||||
AND situa <> 3
|
AND situa NOT IN (1, 5)
|
||||||
GROUP BY id_cliente
|
GROUP BY id_cliente
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -112,15 +114,29 @@ export class ReportsService {
|
|||||||
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
|
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
|
||||||
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
|
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
|
||||||
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
|
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
|
||||||
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio
|
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio,
|
||||||
FROM sar.vw_clientes c
|
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
|
||||||
|
FROM vw_clientes c
|
||||||
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
|
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
|
||||||
WHERE c.id_empresa = ${idEmpresa}
|
WHERE c.cod_vendedor = ${codVendedor}
|
||||||
AND c.cod_vendedor = ${codVendedor}
|
AND c.ativo = 1
|
||||||
ORDER BY u.ultimo_pedido ASC NULLS FIRST, c.nome ASC
|
ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const data = rows.map((r) => ({
|
// 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),
|
idCliente: n(r.id_cliente),
|
||||||
nome: r.nome ?? null,
|
nome: r.nome ?? null,
|
||||||
razao: r.razao ?? null,
|
razao: r.razao ?? null,
|
||||||
@@ -129,13 +145,24 @@ export class ReportsService {
|
|||||||
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
|
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
|
||||||
totalPedidos: n(r.total_pedidos),
|
totalPedidos: n(r.total_pedidos),
|
||||||
ticketMedio: d(r.ticket_medio),
|
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 inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length;
|
||||||
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
|
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
|
||||||
const semPedido = data.filter((c) => c.totalPedidos === 0).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> {
|
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {
|
||||||
|
|||||||
398
apps/web/src/cockpits/rep/CarteirePage.tsx
Normal file
398
apps/web/src/cockpits/rep/CarteirePage.tsx
Normal file
@@ -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 <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedido</Text>} />;
|
||||||
|
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="#ef4444"
|
||||||
|
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="#f59e0b"
|
||||||
|
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Em risco 30d+</Text>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsightCard({
|
||||||
|
icon,
|
||||||
|
cor,
|
||||||
|
titulo,
|
||||||
|
descricao,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
cor: string;
|
||||||
|
titulo: string;
|
||||||
|
descricao: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
styles={{ body: { padding: '14px 18px' } }}
|
||||||
|
style={{ borderLeft: `4px solid ${cor}`, borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<Flex gap={12} align="flex-start">
|
||||||
|
<span style={{ color: cor, fontSize: 18, marginTop: 2 }}>{icon}</span>
|
||||||
|
{/* minWidth: 0 — flex item precisa poder encolher; titulo/descricao
|
||||||
|
embutem razão social e valores que podem não ter ponto de quebra. */}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1e293b' }}>
|
||||||
|
{titulo}
|
||||||
|
</Text>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{descricao}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarteirePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, isError, error } = useReportCarteira();
|
||||||
|
const [filtro, setFiltro] = useState<Filtro>('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: <ExclamationCircleOutlined />,
|
||||||
|
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: <WarningOutlined />,
|
||||||
|
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: <FireOutlined />,
|
||||||
|
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: <RiseOutlined />,
|
||||||
|
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<CarteiraCliente> = [
|
||||||
|
{
|
||||||
|
title: 'Cliente',
|
||||||
|
key: 'cliente',
|
||||||
|
render: (_: unknown, c: CarteiraCliente) => (
|
||||||
|
<Space orientation="vertical" size={0}>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ fontSize: 13, cursor: 'pointer', color: '#003B8E' }}
|
||||||
|
onClick={() => navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })}
|
||||||
|
>
|
||||||
|
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
|
||||||
|
</Text>
|
||||||
|
{c.razao && c.nome && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{c.nome}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
key: 'status',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Último Pedido',
|
||||||
|
dataIndex: 'ultimoPedido',
|
||||||
|
width: 130,
|
||||||
|
render: (v: string | null) => (
|
||||||
|
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
|
||||||
|
),
|
||||||
|
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 ? (
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
className="tabular-nums"
|
||||||
|
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
|
||||||
|
>
|
||||||
|
{v}d
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">—</Text>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => (a.diasSemPedido ?? 9999) - (b.diasSemPedido ?? 9999),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Faturado (total)',
|
||||||
|
dataIndex: 'faturamentoTotal',
|
||||||
|
width: 150,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
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) => (
|
||||||
|
<Tag
|
||||||
|
color={v >= 10 ? 'blue' : v >= 5 ? 'geekblue' : 'default'}
|
||||||
|
style={{ borderRadius: 20, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{v.toFixed(1)}%
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => a.participacaoPct - b.participacaoPct,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ticket Médio',
|
||||||
|
dataIndex: 'ticketMedio',
|
||||||
|
width: 130,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Text className="tabular-nums" style={{ color: '#475569' }}>
|
||||||
|
{fmt(v)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
sorter: (a, b) => Number(a.ticketMedio) - Number(b.ticketMedio),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedidos',
|
||||||
|
dataIndex: 'totalPedidos',
|
||||||
|
width: 80,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
|
||||||
|
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 (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<Flex align="center" gap={12} style={{ marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate({ to: '/clientes' })}
|
||||||
|
type="text"
|
||||||
|
style={{ color: '#64748b' }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
<TeamOutlined style={{ marginRight: 8 }} />
|
||||||
|
Carteira de Clientes
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Visão completa da sua carteira com indicadores de saúde e oportunidades
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
title="Erro ao carregar carteira"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 10 }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Cards de resumo */}
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
|
{filtros.map((f) => (
|
||||||
|
<Col key={f.key} xs={12} sm={8} md={4} style={{ minWidth: 120 }}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={() => setFiltro(f.key)}
|
||||||
|
styles={{ body: { padding: '12px 16px' } }}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: filtro === f.key ? `2px solid ${f.cor}` : '1px solid #EBF0F5',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={f.label}
|
||||||
|
value={f.count}
|
||||||
|
styles={{ content: { color: f.cor, fontSize: 24 } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
{data && (
|
||||||
|
<Col xs={12} sm={8} md={6}>
|
||||||
|
<Card styles={{ body: { padding: '12px 16px' } }} style={{ borderRadius: 8 }}>
|
||||||
|
<Statistic
|
||||||
|
title="Faturamento Total (ERP)"
|
||||||
|
value={fmt(data.faturamentoRepTotal)}
|
||||||
|
styles={{ content: { color: '#003B8E', fontSize: 18 } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Insights */}
|
||||||
|
{insights.length > 0 && (
|
||||||
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
|
{insights.map((ins, i) => (
|
||||||
|
<Col key={i} xs={24} md={12}>
|
||||||
|
<InsightCard {...ins} />
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabela */}
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<CarteiraCliente>
|
||||||
|
rowKey="idCliente"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filtered}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
onRow={(c) => ({
|
||||||
|
onClick: () =>
|
||||||
|
navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } }),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -186,7 +186,13 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
|
|||||||
|
|
||||||
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
|
// ─── 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 mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||||
const total = stats.ativos + stats.emAlerta + stats.inativos;
|
const total = stats.ativos + stats.emAlerta + stats.inativos;
|
||||||
|
|
||||||
@@ -313,6 +319,7 @@ function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
|
|||||||
<Divider style={{ margin: '12px 0' }} />
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
<Button
|
<Button
|
||||||
block
|
block
|
||||||
|
onClick={onDetalhar}
|
||||||
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
|
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
|
||||||
>
|
>
|
||||||
Detalhar carteira
|
Detalhar carteira
|
||||||
@@ -500,7 +507,7 @@ function CustomerDetailsModal({
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||||
{/* Identificação */}
|
{/* Identificação */}
|
||||||
<Card
|
<Card
|
||||||
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
||||||
@@ -544,7 +551,7 @@ function CustomerDetailsModal({
|
|||||||
>
|
>
|
||||||
Dados Cadastrais
|
Dados Cadastrais
|
||||||
</Text>
|
</Text>
|
||||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||||
<div>
|
<div>
|
||||||
<span style={label}>Telefone</span>
|
<span style={label}>Telefone</span>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
@@ -596,7 +603,7 @@ function CustomerDetailsModal({
|
|||||||
>
|
>
|
||||||
Dados Comerciais
|
Dados Comerciais
|
||||||
</Text>
|
</Text>
|
||||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||||
{limiteFormatado !== '—' && (
|
{limiteFormatado !== '—' && (
|
||||||
<div>
|
<div>
|
||||||
<span style={label}>Limite de Crédito</span>
|
<span style={label}>Limite de Crédito</span>
|
||||||
@@ -657,7 +664,7 @@ function CustomerDetailsModal({
|
|||||||
{loadingOrders ? (
|
{loadingOrders ? (
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||||
{orders.slice(0, 5).map((p) => (
|
{orders.slice(0, 5).map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -811,7 +818,7 @@ function CustomerAnalysisModal({
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||||
<div>
|
<div>
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
<CustomerStatusBadge status={summary.activityStatus} />
|
<CustomerStatusBadge status={summary.activityStatus} />
|
||||||
@@ -1456,7 +1463,10 @@ export function ClientsPage() {
|
|||||||
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
|
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<>
|
<>
|
||||||
<CustomerPortfolioCard stats={stats} />
|
<CustomerPortfolioCard
|
||||||
|
stats={stats}
|
||||||
|
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
||||||
|
/>
|
||||||
<div style={{ marginBottom: 16 }} />
|
<div style={{ marginBottom: 16 }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1539,7 +1549,10 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Col lg={7}>
|
<Col lg={7}>
|
||||||
<CustomerPortfolioCard stats={stats} />
|
<CustomerPortfolioCard
|
||||||
|
stats={stats}
|
||||||
|
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
|||||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||||
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||||
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||||
|
import { CarteirePage } from '../cockpits/rep/CarteirePage';
|
||||||
import { FunilPage } from '../cockpits/rep/FunilPage';
|
import { FunilPage } from '../cockpits/rep/FunilPage';
|
||||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||||
@@ -140,6 +141,12 @@ const relatoriosRoute = createRoute({
|
|||||||
component: RelatoriosPage,
|
component: RelatoriosPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const carteiraRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/clientes/carteira',
|
||||||
|
component: CarteirePage,
|
||||||
|
});
|
||||||
|
|
||||||
const funilRoute = createRoute({
|
const funilRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/funil',
|
path: '/funil',
|
||||||
@@ -195,6 +202,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
catalogoRoute,
|
catalogoRoute,
|
||||||
pedidosErpRoute,
|
pedidosErpRoute,
|
||||||
relatoriosRoute,
|
relatoriosRoute,
|
||||||
|
carteiraRoute,
|
||||||
funilRoute,
|
funilRoute,
|
||||||
aprovacoes,
|
aprovacoes,
|
||||||
gerPainelRoute,
|
gerPainelRoute,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const CarteiraClienteSchema = z.object({
|
|||||||
diasSemPedido: z.number().nullable(),
|
diasSemPedido: z.number().nullable(),
|
||||||
totalPedidos: z.number(),
|
totalPedidos: z.number(),
|
||||||
ticketMedio: z.string(),
|
ticketMedio: z.string(),
|
||||||
|
faturamentoTotal: z.string(),
|
||||||
|
participacaoPct: z.number(),
|
||||||
});
|
});
|
||||||
export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>;
|
export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>;
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ export const ReportCarteiraResponseSchema = z.object({
|
|||||||
inativos30: z.number(),
|
inativos30: z.number(),
|
||||||
inativos60: z.number(),
|
inativos60: z.number(),
|
||||||
semPedido: z.number(),
|
semPedido: z.number(),
|
||||||
|
faturamentoRepTotal: z.string(),
|
||||||
});
|
});
|
||||||
export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>;
|
export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user