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,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> {
|
||||
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
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 }) {
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Button
|
||||
block
|
||||
onClick={onDetalhar}
|
||||
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
|
||||
>
|
||||
Detalhar carteira
|
||||
@@ -500,7 +507,7 @@ function CustomerDetailsModal({
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
{/* Identificação */}
|
||||
<Card
|
||||
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
||||
@@ -544,7 +551,7 @@ function CustomerDetailsModal({
|
||||
>
|
||||
Dados Cadastrais
|
||||
</Text>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<span style={label}>Telefone</span>
|
||||
<Space size={4}>
|
||||
@@ -596,7 +603,7 @@ function CustomerDetailsModal({
|
||||
>
|
||||
Dados Comerciais
|
||||
</Text>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||
{limiteFormatado !== '—' && (
|
||||
<div>
|
||||
<span style={label}>Limite de Crédito</span>
|
||||
@@ -657,7 +664,7 @@ function CustomerDetailsModal({
|
||||
{loadingOrders ? (
|
||||
<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) => (
|
||||
<div
|
||||
key={p.id}
|
||||
@@ -811,7 +818,7 @@ function CustomerAnalysisModal({
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Space size={8}>
|
||||
<CustomerStatusBadge status={summary.activityStatus} />
|
||||
@@ -1456,7 +1463,10 @@ export function ClientsPage() {
|
||||
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<CustomerPortfolioCard stats={stats} />
|
||||
<CustomerPortfolioCard
|
||||
stats={stats}
|
||||
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
||||
/>
|
||||
<div style={{ marginBottom: 16 }} />
|
||||
</>
|
||||
)}
|
||||
@@ -1539,7 +1549,10 @@ export function ClientsPage() {
|
||||
|
||||
{!isMobile && (
|
||||
<Col lg={7}>
|
||||
<CustomerPortfolioCard stats={stats} />
|
||||
<CustomerPortfolioCard
|
||||
stats={stats}
|
||||
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||
import { CarteirePage } from '../cockpits/rep/CarteirePage';
|
||||
import { FunilPage } from '../cockpits/rep/FunilPage';
|
||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||
@@ -140,6 +141,12 @@ const relatoriosRoute = createRoute({
|
||||
component: RelatoriosPage,
|
||||
});
|
||||
|
||||
const carteiraRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/clientes/carteira',
|
||||
component: CarteirePage,
|
||||
});
|
||||
|
||||
const funilRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/funil',
|
||||
@@ -195,6 +202,7 @@ const routeTree = rootRoute.addChildren([
|
||||
catalogoRoute,
|
||||
pedidosErpRoute,
|
||||
relatoriosRoute,
|
||||
carteiraRoute,
|
||||
funilRoute,
|
||||
aprovacoes,
|
||||
gerPainelRoute,
|
||||
|
||||
Reference in New Issue
Block a user