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>
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|