Files
sar/apps/web/src/cockpits/rep/ClientsPage.tsx
julian 518769218f fix(api,web): corrige autorização da área do rep e falhas silenciosas no front
Auditoria de segurança + robustez da área do representante antes de produção.

API (autorização e injeção):
- IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos,
  histórico, contatos) validam carteira do rep via assertClienteDaCarteira;
  cliente alheio retorna 404. orders.create idem antes de aceitar idCliente.
- Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403).
- Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação);
  dtAniversario de contato validado e escapado.
- id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded.
- Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela
  pauta>promo>base + promoção ativa), não só o desconto global.
- Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono.

Web (erros nunca silenciosos):
- Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e
  politicas — corrige criar oportunidade, abrir chamado e editar políticas.
- Handler global de erro no QueryClient (query e mutation viram toast).
- Error boundary por rota (payload divergente não derruba o app em branco).
- Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis.

Qualidade:
- Corrige ping.contract.spec (idEmpresa) e zera erros de lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:40:19 +00:00

1610 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from 'react';
import {
App,
Button,
Card,
Col,
Modal,
Dropdown,
Grid,
Row,
Select,
Space,
Spin,
Table,
Tag,
Typography,
Divider,
} from 'antd';
import type { TableColumnsType } from 'antd';
import type { MenuProps } from 'antd';
import {
BarChartOutlined,
CalendarOutlined,
ClearOutlined,
DollarOutlined,
EllipsisOutlined,
EyeOutlined,
ImportOutlined,
MailOutlined,
PhoneOutlined,
PlusOutlined,
SearchOutlined,
ShoppingCartOutlined,
TeamOutlined,
UserOutlined,
WhatsAppOutlined,
} from '@ant-design/icons';
import { Doughnut } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from 'chart.js';
import { useNavigate } from '@tanstack/react-router';
import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useClientList, useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
ChartJS.register(ArcElement, ChartTooltip, Legend);
const { Title, Text } = Typography;
const { useBreakpoint } = Grid;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmt(v: string | number | null | undefined): string {
if (v == null || v === '') return '—';
const n = typeof v === 'string' ? parseFloat(v) : v;
if (isNaN(n) || n === 0) return '—';
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '—';
return new Date(v).toLocaleDateString('pt-BR');
}
function diasSemComprar(dt: string | null | undefined): number | null {
if (!dt) return null;
return Math.floor((Date.now() - new Date(dt).getTime()) / 86_400_000);
}
function getSugestao(status: ActivityStatus, dt: string | null | undefined): string {
const dias = diasSemComprar(dt);
if (status === 'active') {
if (!dias || dias < 30)
return 'Cliente ativo com compra recente. Mantenha o relacionamento e explore oportunidades de upsell.';
return `Cliente ativo há ${dias} dias sem comprar. Reforce o contato e apresente novidades.`;
}
if (status === 'alert') {
return `Cliente em alerta há ${dias ?? '—'} dias sem comprar. Priorize contato comercial para reativação.`;
}
if (!dias) return 'Cliente inativo. Sem histórico de compra disponível.';
return `Cliente inativo há ${dias} dias. Situação crítica — ofereça condições especiais para reativação.`;
}
// ─── Status Config ────────────────────────────────────────────────────────────
const ACTIVITY_CFG: Record<ActivityStatus, { label: string; tagColor: string; color: string }> = {
active: { label: 'Ativo', tagColor: 'success', color: '#389e0d' },
alert: { label: 'Em alerta', tagColor: 'warning', color: '#d46b08' },
inactive: { label: 'Inativo', tagColor: 'error', color: '#cf1322' },
};
// ─── CustomerStatusBadge ──────────────────────────────────────────────────────
function CustomerStatusBadge({ status }: { status: ActivityStatus }) {
const cfg = ACTIVITY_CFG[status];
return (
<Tag
color={cfg.tagColor}
style={{ borderRadius: 20, fontWeight: 600, fontSize: 11, padding: '1px 10px' }}
>
{cfg.label}
</Tag>
);
}
// ─── usePortfolioStats ────────────────────────────────────────────────────────
function usePortfolioStats() {
const all = useClientList({ limit: 1 });
const active = useClientList({ limit: 1, status: 'active' });
const alertQ = useClientList({ limit: 1, status: 'alert' });
const inactive = useClientList({ limit: 1, status: 'inactive' });
return {
total: all.data?.total ?? 0,
ativos: active.data?.total ?? 0,
emAlerta: alertQ.data?.total ?? 0,
inativos: inactive.data?.total ?? 0,
loaded: !!all.data,
};
}
type PortfolioStats = ReturnType<typeof usePortfolioStats>;
// ─── CustomerMetrics ──────────────────────────────────────────────────────────
function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
const metrics = [
{ label: 'Total de Clientes', value: stats.total, icon: <TeamOutlined />, color: '#003B8E' },
{ label: 'Ativos', value: stats.ativos, icon: <UserOutlined />, color: '#389e0d' },
{ label: 'Em alerta', value: stats.emAlerta, icon: <UserOutlined />, color: '#d46b08' },
{ label: 'Inativos', value: stats.inativos, icon: <UserOutlined />, color: '#cf1322' },
];
return (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{metrics.map((m) => (
<Col key={m.label} xs={12} sm={6}>
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
}}
styles={{ body: { padding: '14px 18px' } }}
>
<Space size={10} align="center">
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
background: `${m.color}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
color: m.color,
flexShrink: 0,
}}
>
{m.icon}
</div>
<div>
<Text
style={{
fontSize: 11,
color: '#64748B',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.06em',
display: 'block',
}}
>
{m.label}
</Text>
<Text strong style={{ fontSize: 20, color: '#1F2937', lineHeight: 1.2 }}>
{stats.loaded ? m.value.toLocaleString('pt-BR') : <Spin size="small" />}
</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
);
}
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
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;
const donutData = {
labels: ['Ativos', 'Em alerta', 'Inativos'],
datasets: [
{
data: [stats.ativos, stats.emAlerta, stats.inativos],
backgroundColor: ['#52C41A', '#FAAD14', '#FF4D4F'],
borderColor: '#fff',
borderWidth: 3,
hoverOffset: 6,
},
],
};
const donutOptions = {
responsive: true,
maintainAspectRatio: true,
cutout: '68%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: { label: string; raw: unknown }) => {
const v = ctx.raw as number;
const pct = total > 0 ? ((v / total) * 100).toFixed(1) : '0.0';
return `${ctx.label}: ${v.toLocaleString('pt-BR')} (${pct}%)`;
},
},
},
},
};
const legendItems = [
{ label: 'Ativos', value: stats.ativos, color: '#52C41A' },
{ label: 'Em alerta', value: stats.emAlerta, color: '#FAAD14' },
{ label: 'Inativos', value: stats.inativos, color: '#FF4D4F' },
];
return (
<Card
style={{
borderRadius: 12,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 6px rgba(0,0,0,0.07)',
}}
styles={{ body: { padding: '20px 20px 16px' } }}
>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ fontSize: 14, color: '#1F2937', display: 'block' }}>
Carteira de Clientes
</Text>
<Text style={{ fontSize: 12, color: '#64748B', textTransform: 'capitalize' }}>
{mesAtual}
</Text>
</div>
<div style={{ position: 'relative', width: 180, margin: '0 auto 16px' }}>
{stats.loaded && total > 0 ? (
<>
<Doughnut data={donutData} options={donutOptions} />
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
textAlign: 'center',
pointerEvents: 'none',
}}
>
<Text
strong
style={{ fontSize: 22, color: '#1F2937', display: 'block', lineHeight: 1.1 }}
>
{stats.total.toLocaleString('pt-BR')}
</Text>
<Text style={{ fontSize: 11, color: '#64748B' }}>Clientes</Text>
</div>
</>
) : (
<div
style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<Spin />
</div>
)}
</div>
<Space orientation="vertical" size={6} style={{ width: '100%', marginBottom: 16 }}>
{legendItems.map((item) => {
const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0';
return (
<div
key={item.label}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<Space size={6}>
<div
style={{
width: 10,
height: 10,
borderRadius: 2,
background: item.color,
flexShrink: 0,
}}
/>
<Text style={{ fontSize: 12, color: '#475569' }}>{item.label}</Text>
</Space>
<Space size={8}>
<Text strong style={{ fontSize: 12 }}>
{item.value.toLocaleString('pt-BR')}
</Text>
<Text style={{ fontSize: 11, color: '#94A3B8', width: 44, textAlign: 'right' }}>
{pct}%
</Text>
</Space>
</div>
);
})}
</Space>
<Divider style={{ margin: '12px 0' }} />
<Button
block
onClick={onDetalhar}
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
>
Detalhar carteira
</Button>
</Card>
);
}
// ─── CustomerExpandedDetail ───────────────────────────────────────────────────
function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
const { data: detail, isLoading } = useClientDetail(summary.idCliente);
const label: React.CSSProperties = {
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: '#94A3B8',
display: 'block',
marginBottom: 2,
};
if (isLoading) {
return (
<div style={{ padding: '16px 24px', background: '#F8FAFC', borderTop: '1px solid #EBF0F5' }}>
<Spin size="small" />
</div>
);
}
const d = detail;
const phone = d
? d.ddd
? `(${d.ddd}) ${d.telefone ?? ''}`.trim()
: (d.telefone ?? '—')
: (summary.telefone ?? '—');
const endereco = d
? [d.endereco, d.numEndereco, d.bairro, d.cep ? `CEP ${d.cep}` : '']
.filter(Boolean)
.join(', ') || '—'
: '—';
const limiteFormatado = fmt(d?.limiteCreditoStr ?? summary.limiteCreditoStr);
return (
<div
style={{ padding: '12px 24px 20px', background: '#F8FAFC', borderTop: '1px solid #EBF0F5' }}
>
<Row gutter={[24, 12]}>
{endereco !== '—' && (
<Col xs={24} md={8}>
<span style={label}>Endereço</span>
<Text style={{ fontSize: 13 }}>{endereco}</Text>
</Col>
)}
<Col xs={12} md={4}>
<span style={label}>Telefone</span>
<Text style={{ fontSize: 13 }}>{phone}</Text>
</Col>
{d?.inscricaoEstadual && (
<Col xs={12} md={4}>
<span style={label}>Insc. Estadual</span>
<Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text>
</Col>
)}
{limiteFormatado !== '—' && (
<Col xs={12} md={4}>
<span style={label}>Limite de Crédito</span>
<Text strong style={{ fontSize: 13, color: '#003B8E' }}>
{limiteFormatado}
</Text>
</Col>
)}
{d?.obs && (
<Col xs={24} md={12}>
<span style={label}>Observações</span>
<Text style={{ fontSize: 13, color: '#475569', fontStyle: 'italic' }}>{d.obs}</Text>
</Col>
)}
</Row>
</div>
);
}
// ─── CustomerDetailsModal ────────────────────────────────────────────────────
function CustomerDetailsModal({
summary,
onClose,
onAnalyze,
}: {
summary: ClientSummary | null;
onClose: () => void;
onAnalyze: () => void;
}) {
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: detail, isLoading: loadingDetail } = useClientDetail(summary?.idCliente);
const { data: orders = [], isLoading: loadingOrders } = useClientOrders(summary?.idCliente);
if (!summary) return null;
const d = detail;
const phone = d
? d.ddd
? `(${d.ddd}) ${d.telefone ?? ''}`.trim()
: (d.telefone ?? '—')
: (summary.telefone ?? '—');
const endereco = d
? [d.endereco, d.numEndereco, d.bairro, d.cep ? `CEP ${d.cep}` : '']
.filter(Boolean)
.join(', ') || '—'
: '—';
const limiteFormatado = fmt(d?.limiteCreditoStr ?? summary.limiteCreditoStr);
const dias = diasSemComprar(summary.dtUltimaCompra);
const label: React.CSSProperties = {
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: '#94A3B8',
display: 'block',
marginBottom: 2,
};
return (
<Modal
title={
<Space>
<Text strong style={{ fontSize: 16 }}>
{summary.nome}
</Text>
<CustomerStatusBadge status={summary.activityStatus} />
</Space>
}
open={!!summary}
onCancel={onClose}
centered
width={isMobile ? '95vw' : 860}
destroyOnHidden
styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
}}
footer={
<Space wrap>
<Button onClick={onClose}>Fechar</Button>
<Button
icon={<WhatsAppOutlined />}
style={{
background: '#25D366',
borderColor: '#25D366',
color: '#fff',
fontWeight: 600,
}}
onClick={() => window.open(`https://wa.me/55${phone.replace(/\D/g, '')}`, '_blank')}
>
WhatsApp
</Button>
<Button icon={<BarChartOutlined />} onClick={onAnalyze}>
Analisar
</Button>
<Button
icon={<UserOutlined />}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(summary.idCliente) } })
}
>
Abrir ficha
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
style={{ backgroundColor: '#389e0d', borderColor: '#389e0d' }}
onClick={() =>
void navigate({
to: '/pedidos/novo',
search: { clientId: String(summary.idCliente) },
})
}
>
Criar Pedido
</Button>
</Space>
}
>
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
{/* Identificação */}
<Card
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
styles={{ body: { padding: '14px 16px' } }}
>
<Row gutter={[16, 8]}>
<Col span={16}>
<Text strong style={{ fontSize: 18, color: '#1F2937', display: 'block' }}>
{summary.nome}
</Text>
{summary.razao && (
<Text style={{ fontSize: 13, color: '#64748B', display: 'block', marginTop: 2 }}>
{summary.razao}
</Text>
)}
{summary.cgcpf && (
<Text style={{ fontSize: 13, color: '#94A3B8', fontFamily: 'monospace' }}>
{summary.cgcpf}
</Text>
)}
</Col>
<Col span={8} style={{ textAlign: 'right' }}>
<CustomerStatusBadge status={summary.activityStatus} />
</Col>
</Row>
</Card>
{/* Dados cadastrais + comerciais em 2 colunas */}
<Row gutter={[24, 16]}>
<Col span={12}>
<Text
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: '#64748B',
display: 'block',
marginBottom: 10,
}}
>
Dados Cadastrais
</Text>
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
<div>
<span style={label}>Telefone</span>
<Space size={4}>
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 13 }}>{phone}</Text>
</Space>
</div>
<div>
<span style={label}>E-mail</span>
<Space size={4}>
<MailOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 13, wordBreak: 'break-all' }}>
{summary.email ?? '—'}
</Text>
</Space>
</div>
{loadingDetail ? (
<Spin size="small" />
) : (
<>
{endereco !== '—' && (
<div>
<span style={label}>Endereço</span>
<Text style={{ fontSize: 13 }}>{endereco}</Text>
</div>
)}
{d?.inscricaoEstadual && (
<div>
<span style={label}>Insc. Estadual</span>
<Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text>
</div>
)}
</>
)}
</Space>
</Col>
<Col span={12}>
<Text
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: '#64748B',
display: 'block',
marginBottom: 10,
}}
>
Dados Comerciais
</Text>
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
{limiteFormatado !== '—' && (
<div>
<span style={label}>Limite de Crédito</span>
<Text strong style={{ fontSize: 13, color: '#003B8E' }}>
{limiteFormatado}
</Text>
</div>
)}
<div>
<span style={label}>Representante</span>
<Text style={{ fontSize: 13 }}>
{summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`}
{summary.nomeVendedor && (
<Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>
(cód. {summary.codVendedor})
</Text>
)}
</Text>
</div>
{summary.dtUltimaCompra && (
<div>
<span style={label}>Última Compra</span>
<Space size={4}>
<CalendarOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 13 }}>
{fmtDate(summary.dtUltimaCompra)}
{dias !== null && (
<Text style={{ fontSize: 11, color: '#94A3B8', marginLeft: 4 }}>
({dias} dias)
</Text>
)}
</Text>
</Space>
</div>
)}
</Space>
</Col>
</Row>
{/* Últimos pedidos */}
{(loadingOrders || orders.length > 0) && (
<>
<Divider style={{ margin: '4px 0' }} />
<div>
<Text
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: '#64748B',
display: 'block',
marginBottom: 10,
}}
>
Últimos Pedidos
</Text>
{loadingOrders ? (
<Spin size="small" />
) : (
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
{orders.slice(0, 5).map((p) => (
<div
key={p.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
borderRadius: 8,
background: '#F8FAFC',
border: '1px solid #EBF0F5',
}}
>
<Space size={8}>
<Text strong style={{ fontSize: 13, color: '#003B8E' }}>
{p.numPedSar}
</Text>
<Text style={{ fontSize: 12, color: '#64748B' }}>
{new Date(p.dtPedido).toLocaleDateString('pt-BR')}
</Text>
</Space>
<Space size={8}>
<Tag
color={
p.situa === 3
? 'error'
: p.situa === 4
? 'geekblue'
: p.situa === 2
? 'success'
: 'warning'
}
style={{ borderRadius: 12, fontSize: 10, margin: 0 }}
>
{SITUA_LABEL[p.situa] ?? String(p.situa)}
</Tag>
<Text strong style={{ fontSize: 13 }}>
{Number(p.total).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</Text>
</Space>
</div>
))}
</Space>
)}
</div>
</>
)}
{/* Observações */}
{d?.obs && (
<>
<Divider style={{ margin: '4px 0' }} />
<div>
<span style={label}>Observações</span>
<div
style={{
padding: '10px 12px',
background: '#FFFBEB',
border: '1px solid #FDE68A',
borderRadius: 8,
marginTop: 6,
}}
>
<Text style={{ fontSize: 13, color: '#78350F', fontStyle: 'italic' }}>{d.obs}</Text>
</div>
</div>
</>
)}
</Space>
</Modal>
);
}
// ─── CustomerAnalysisModal ───────────────────────────────────────────────────
function CustomerAnalysisModal({
summary,
onClose,
}: {
summary: ClientSummary | null;
onClose: () => void;
}) {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: detail } = useClientDetail(summary?.idCliente);
if (!summary) return null;
const dias = diasSemComprar(summary.dtUltimaCompra);
const sugestao = getSugestao(summary.activityStatus, summary.dtUltimaCompra);
const urgencyColor = ACTIVITY_CFG[summary.activityStatus].color;
const limiteFormatado = fmt(detail?.limiteCreditoStr ?? summary.limiteCreditoStr);
const label: React.CSSProperties = {
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: '#94A3B8',
display: 'block',
marginBottom: 2,
};
return (
<Modal
title={
<Space>
<BarChartOutlined />
<Text strong>Análise Comercial {summary.nome}</Text>
</Space>
}
open={!!summary}
onCancel={onClose}
centered
width={isMobile ? '95vw' : 720}
destroyOnHidden
styles={{ body: { padding: '20px 24px' } }}
footer={
<Space>
<Button
icon={<WhatsAppOutlined />}
style={{
background: '#25D366',
borderColor: '#25D366',
color: '#fff',
fontWeight: 600,
borderRadius: 8,
}}
onClick={() =>
window.open(
`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`,
'_blank',
)
}
>
WhatsApp
</Button>
<Button
icon={<MailOutlined />}
style={{ borderRadius: 8 }}
onClick={() => window.open(`mailto:${summary.email ?? ''}`, '_blank')}
>
E-mail
</Button>
<Button onClick={onClose} style={{ borderRadius: 8 }}>
Fechar
</Button>
</Space>
}
>
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
<div>
<Space size={8}>
<CustomerStatusBadge status={summary.activityStatus} />
{summary.cgcpf && (
<Text style={{ fontSize: 12, color: '#64748B' }}>{summary.cgcpf}</Text>
)}
</Space>
</div>
<Row gutter={[12, 12]}>
{[
{
label: 'Limite de Crédito',
value: limiteFormatado,
icon: <DollarOutlined />,
color: '#003B8E',
},
{
label: 'Última Compra',
value: fmtDate(summary.dtUltimaCompra),
icon: <CalendarOutlined />,
color: '#d46b08',
},
{
label: 'Dias s/ Comprar',
value: dias !== null ? `${dias} dias` : '—',
icon: <CalendarOutlined />,
color: urgencyColor,
},
{
label: 'Representante',
value: summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`,
icon: <UserOutlined />,
color: '#64748B',
},
].map((m) => (
<Col span={12} key={m.label}>
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5', background: '#F8FAFC' }}
styles={{ body: { padding: '12px 14px' } }}
>
<Space size={8} align="center">
<span style={{ fontSize: 18, color: m.color }}>{m.icon}</span>
<div>
<span style={label}>{m.label}</span>
<Text strong style={{ fontSize: 14, color: '#1F2937' }}>
{m.value}
</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
<Divider style={{ margin: '4px 0' }} />
<div>
<Text
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: '#64748B',
display: 'block',
marginBottom: 8,
}}
>
Sugestão de Ação Comercial
</Text>
<div
style={{
padding: '14px 16px',
borderRadius: 10,
background: `${urgencyColor}10`,
border: `1px solid ${urgencyColor}40`,
}}
>
<Text style={{ fontSize: 14, color: urgencyColor, fontWeight: 500, lineHeight: 1.5 }}>
{sugestao}
</Text>
</div>
</div>
{detail?.obs && (
<div>
<span style={label}>Observações</span>
<div
style={{
padding: '10px 12px',
background: '#FFFBEB',
border: '1px solid #FDE68A',
borderRadius: 8,
marginTop: 6,
}}
>
<Text style={{ fontSize: 13, color: '#78350F', fontStyle: 'italic' }}>
{detail.obs}
</Text>
</div>
</div>
)}
</Space>
</Modal>
);
}
// ─── CustomerActionsMenu ──────────────────────────────────────────────────────
function CustomerActionsMenu({
summary,
onView,
onAnalyze,
}: {
summary: ClientSummary;
onView: () => void;
onAnalyze: () => void;
}) {
const navigate = useNavigate();
const items: MenuProps['items'] = [
{ key: 'view', icon: <EyeOutlined />, label: 'Ver detalhes', onClick: onView },
{ key: 'analyze', icon: <BarChartOutlined />, label: 'Analisar', onClick: onAnalyze },
{ type: 'divider' },
{
key: 'orders',
icon: <ShoppingCartOutlined />,
label: 'Ver pedidos',
onClick: () => void navigate({ to: '/pedidos' }),
},
{
key: 'new-order',
icon: <PlusOutlined style={{ color: '#389e0d' }} />,
label: <span style={{ color: '#389e0d', fontWeight: 600 }}>Criar pedido</span>,
onClick: () =>
void navigate({ to: '/pedidos/novo', search: { clientId: String(summary.idCliente) } }),
},
{ type: 'divider' },
{
key: 'whatsapp',
icon: <WhatsAppOutlined />,
label: 'Enviar WhatsApp',
onClick: () =>
window.open(`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`, '_blank'),
},
{
key: 'email',
icon: <MailOutlined />,
label: 'Enviar e-mail',
onClick: () => window.open(`mailto:${summary.email ?? ''}`, '_blank'),
},
];
return (
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
<Button type="text" icon={<EllipsisOutlined />} size="small" />
</Dropdown>
);
}
// ─── MobileCustomerCard ───────────────────────────────────────────────────────
function MobileCustomerCard({ summary, onView }: { summary: ClientSummary; onView: () => void }) {
const navigate = useNavigate();
return (
<Card
style={{
borderRadius: 10,
marginBottom: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
}}
styles={{ body: { padding: '14px 16px' } }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
}}
>
<div style={{ flex: 1, marginRight: 8 }}>
<Text strong style={{ fontSize: 15, color: '#1F2937', display: 'block' }}>
{summary.nome}
</Text>
{summary.razao && (
<Text style={{ fontSize: 12, color: '#64748B', display: 'block' }}>
{summary.razao}
</Text>
)}
{summary.cgcpf && (
<Text style={{ fontSize: 11, color: '#94A3B8', fontFamily: 'monospace' }}>
{summary.cgcpf}
</Text>
)}
</div>
<CustomerStatusBadge status={summary.activityStatus} />
</div>
<Space size={12} wrap style={{ marginBottom: 10 }}>
{summary.telefone && (
<Space size={4}>
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 12, color: '#64748B' }}>{summary.telefone}</Text>
</Space>
)}
{summary.email && (
<Space size={4}>
<MailOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 12, color: '#64748B' }}>{summary.email}</Text>
</Space>
)}
</Space>
<Space>
<Button size="small" icon={<EyeOutlined />} onClick={onView} style={{ borderRadius: 6 }}>
Ver
</Button>
<Button
size="small"
icon={<PlusOutlined />}
style={{ borderRadius: 6 }}
onClick={() =>
void navigate({ to: '/pedidos/novo', search: { clientId: String(summary.idCliente) } })
}
>
Pedido
</Button>
<Button
size="small"
icon={<WhatsAppOutlined />}
style={{ borderRadius: 6, background: '#25D366', borderColor: '#25D366', color: '#fff' }}
onClick={() =>
window.open(`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`, '_blank')
}
>
WhatsApp
</Button>
</Space>
</Card>
);
}
// ─── ClientsPage ──────────────────────────────────────────────────────────────
export function ClientsPage() {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { message: msg } = App.useApp();
const navigate = useNavigate();
const stats = usePortfolioStats();
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<ActivityStatus | undefined>();
const [sortBy, setSortBy] = useState('nome_az');
const [page, setPage] = useState(1);
const limit = 50;
const [detailSummary, setDetailSummary] = useState<ClientSummary | null>(null);
const [analysisSummary, setAnalysisSummary] = useState<ClientSummary | null>(null);
const { data, isLoading, isFetching } = useClientList({
q: query || undefined,
status: statusFilter,
page,
limit,
});
const rows = useMemo(() => data?.data ?? [], [data]);
const sorted = useMemo(() => {
const r = [...rows];
if (sortBy === 'nome_az') r.sort((a, b) => a.nome.localeCompare(b.nome, 'pt-BR'));
if (sortBy === 'nome_za') r.sort((a, b) => b.nome.localeCompare(a.nome, 'pt-BR'));
if (sortBy === 'ultima_compra') {
r.sort((a, b) => {
const da = a.dtUltimaCompra ? new Date(a.dtUltimaCompra).getTime() : 0;
const db = b.dtUltimaCompra ? new Date(b.dtUltimaCompra).getTime() : 0;
return db - da;
});
}
return r;
}, [rows, sortBy]);
const hasFilters = !!query || !!statusFilter || sortBy !== 'nome_az';
function commitSearch() {
setQuery(search.trim());
setPage(1);
}
function clearFilters() {
setSearch('');
setQuery('');
setStatusFilter(undefined);
setSortBy('nome_az');
setPage(1);
}
const expandable = {
expandedRowRender: (s: ClientSummary) => <CustomerExpandedDetail summary={s} />,
rowExpandable: () => true,
};
const columns: TableColumnsType<ClientSummary> = [
{
title: 'Cliente',
key: 'cliente',
minWidth: 280,
render: (_: unknown, c: ClientSummary) => (
<div>
<Text
strong
style={{
fontSize: 15,
color: '#003B8E',
cursor: 'pointer',
display: 'block',
lineHeight: 1.3,
}}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
}
>
{c.nome}
</Text>
{c.razao && (
<Text
style={{
fontSize: 12,
color: '#475569',
display: 'block',
marginTop: 1,
lineHeight: 1.3,
}}
>
{c.razao}
</Text>
)}
{c.cgcpf && (
<Text
style={{
fontSize: 11,
color: '#94A3B8',
fontFamily: 'monospace',
letterSpacing: '0.03em',
}}
>
{c.cgcpf}
</Text>
)}
</div>
),
},
{
title: 'Status',
dataIndex: 'activityStatus',
key: 'status',
width: 120,
render: (s: ActivityStatus) => <CustomerStatusBadge status={s} />,
},
{
title: 'Contato',
key: 'contato',
width: 195,
render: (_: unknown, c: ClientSummary) => (
<div>
{c.telefone && (
<Space size={5} style={{ marginBottom: c.email ? 3 : 0 }}>
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 11, flexShrink: 0 }} />
<Text style={{ fontSize: 12, fontWeight: 500, color: '#1F2937' }}>{c.telefone}</Text>
</Space>
)}
{c.email && (
<>
{c.telefone && <br />}
<Space size={5}>
<MailOutlined style={{ color: '#94A3B8', fontSize: 11, flexShrink: 0 }} />
<Text
style={{
fontSize: 11,
color: '#64748B',
maxWidth: 150,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'inline-block',
}}
title={c.email}
>
{c.email}
</Text>
</Space>
</>
)}
{!c.telefone && !c.email && (
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
)}
</div>
),
},
{
title: 'Limite de Crédito',
dataIndex: 'limiteCreditoStr',
key: 'limiteCredito',
width: 130,
align: 'right' as const,
render: (v: string | null) => {
const f = fmt(v);
return f !== '—' ? (
<Text strong style={{ fontSize: 13, color: '#003B8E' }} className="tabular-nums">
{f}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
);
},
},
{
title: 'Última compra',
dataIndex: 'dtUltimaCompra',
key: 'ultimaCompra',
width: 118,
render: (v: string | null) =>
v ? (
<div>
<Text
style={{ fontSize: 13, fontWeight: 500, display: 'block', color: '#1F2937' }}
className="tabular-nums"
>
{fmtDate(v)}
</Text>
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{diasSemComprar(v)} dias atrás</Text>
</div>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
Sem compras
</Text>
),
},
{
title: 'Ações',
key: 'actions',
width: 120,
render: (_: unknown, c: ClientSummary) => (
<Space size={4}>
<Button
size="small"
icon={<BarChartOutlined />}
style={{ borderRadius: 6 }}
title="Analisar"
onClick={() => setAnalysisSummary(c)}
/>
<Button
size="small"
icon={<EyeOutlined />}
type="primary"
style={{ borderRadius: 6 }}
title="Ver detalhes"
onClick={() => setDetailSummary(c)}
/>
<CustomerActionsMenu
summary={c}
onView={() => setDetailSummary(c)}
onAnalyze={() => setAnalysisSummary(c)}
/>
</Space>
),
},
];
return (
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
{/* ── Cabeçalho ─────────────────────────────────────────────────── */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 20,
}}
>
<div>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Clientes
</Title>
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
Gerencie sua carteira de clientes, vínculos comerciais e oportunidades.
</p>
</div>
{!isMobile && (
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
style={{ borderRadius: 8, fontWeight: 600 }}
onClick={() => void navigate({ to: '/clientes/novo' })}
>
Cadastrar Cliente
</Button>
)}
</div>
{/* ── Métricas ──────────────────────────────────────────────────── */}
<CustomerMetrics stats={stats} />
{/* ── Barra de ações ────────────────────────────────────────────── */}
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
marginBottom: 16,
}}
styles={{ body: { padding: '14px 20px' } }}
>
<Row gutter={[12, 12]} align="middle">
<Col xs={24} md="auto">
<Space wrap>
<Button
type="primary"
icon={<PlusOutlined />}
style={{ borderRadius: 8, fontWeight: 600 }}
onClick={() => void navigate({ to: '/clientes/novo' })}
>
Cadastrar Cliente
</Button>
<Button
icon={<ImportOutlined />}
style={{ borderRadius: 8 }}
onClick={() => void msg.info('Importação em breve.')}
>
Importar
</Button>
<Button
icon={<TeamOutlined />}
style={{ borderRadius: 8 }}
onClick={() => void msg.info('Vínculos e permissões em breve.')}
>
Vínculos e permissões
</Button>
</Space>
</Col>
<Col xs={24} md="auto" style={{ marginLeft: 'auto' }}>
<div style={{ position: 'relative', width: isMobile ? '100%' : 360 }}>
<SearchOutlined
style={{
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: '#94A3B8',
zIndex: 1,
}}
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitSearch();
}}
onBlur={commitSearch}
placeholder="Pesquisar por nome, razão social ou CNPJ..."
style={{
width: '100%',
height: 34,
padding: '0 11px 0 32px',
border: '1px solid #d9d9d9',
borderRadius: 8,
fontSize: 13,
outline: 'none',
color: '#1F2937',
boxSizing: 'border-box',
}}
/>
</div>
</Col>
</Row>
</Card>
{/* ── Filtros ───────────────────────────────────────────────────── */}
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
marginBottom: 20,
}}
styles={{ body: { padding: '12px 20px' } }}
>
<Row gutter={[10, 10]} align="middle">
<Col xs={12} sm={6} md={5}>
<Select
style={{ width: '100%' }}
placeholder="Atividade"
allowClear
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPage(1);
}}
options={[
{ value: 'active', label: 'Ativos' },
{ value: 'alert', label: 'Em alerta' },
{ value: 'inactive', label: 'Inativos' },
]}
/>
</Col>
<Col xs={12} sm={6} md={5}>
<Select
style={{ width: '100%' }}
value={sortBy}
onChange={setSortBy}
options={[
{ value: 'nome_az', label: 'Nome AZ' },
{ value: 'nome_za', label: 'Nome ZA' },
{ value: 'ultima_compra', label: 'Última compra' },
]}
/>
</Col>
<Col xs={12} sm={6} md={4}>
<Button
style={{ width: '100%', borderRadius: 6 }}
icon={<ClearOutlined />}
disabled={!hasFilters}
onClick={clearFilters}
>
Limpar filtros
</Button>
</Col>
<Col>
<Text style={{ fontSize: 12, color: '#94A3B8' }}>
{data?.total !== undefined ? `${data.total.toLocaleString('pt-BR')} clientes` : '…'}
</Text>
</Col>
</Row>
</Card>
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
{isMobile && (
<>
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
<div style={{ marginBottom: 16 }} />
</>
)}
{/* ── Área principal ────────────────────────────────────────────── */}
<Row gutter={[20, 20]}>
{/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com
scroll x estoura a coluna e engole o card vizinho. */}
<Col xs={24} lg={17} style={{ minWidth: 0 }}>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" />
</div>
) : sorted.length === 0 ? (
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<UserOutlined
style={{ fontSize: 56, color: '#D9E2EC', display: 'block', marginBottom: 16 }}
/>
<Text strong style={{ fontSize: 15, display: 'block' }}>
Nenhum cliente encontrado
</Text>
<Text type="secondary">Tente ajustar os filtros ou a busca.</Text>
</div>
</Card>
) : isMobile ? (
<div>
{sorted.map((c) => (
<MobileCustomerCard
key={c.idCliente}
summary={c}
onView={() => setDetailSummary(c)}
/>
))}
<Text
style={{
fontSize: 12,
color: '#94A3B8',
display: 'block',
textAlign: 'center',
padding: '8px 0 16px',
}}
>
Mostrando {sorted.length} de {data?.total ?? 0} clientes
</Text>
</div>
) : (
<Card
style={{
borderRadius: 10,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 6px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: 0 } }}
>
<Table<ClientSummary>
rowKey="idCliente"
columns={columns}
dataSource={sorted}
expandable={expandable}
size="middle"
loading={isFetching}
scroll={{ x: 1000 }}
pagination={{
current: page,
pageSize: limit,
total: data?.total ?? 0,
showSizeChanger: false,
showTotal: (t, [s, e]) => `Mostrando ${s}${e} de ${t} clientes`,
onChange: (p) => setPage(p),
style: { padding: '12px 24px' },
}}
onRow={() => ({ style: { cursor: 'default', verticalAlign: 'top' } })}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
)}
</Col>
{!isMobile && (
<Col lg={7}>
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
</Col>
)}
</Row>
{/* ── Modais ────────────────────────────────────────────────────── */}
{detailSummary && (
<CustomerDetailsModal
summary={detailSummary}
onClose={() => setDetailSummary(null)}
onAnalyze={() => {
setAnalysisSummary(detailSummary);
setDetailSummary(null);
}}
/>
)}
{analysisSummary && (
<CustomerAnalysisModal summary={analysisSummary} onClose={() => setAnalysisSummary(null)} />
)}
{/* FAB mobile */}
{isMobile && (
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
size="large"
onClick={() => void navigate({ to: '/clientes/novo' })}
style={{
position: 'fixed',
bottom: 24,
right: 24,
width: 52,
height: 52,
fontSize: 22,
backgroundColor: '#003B8E',
borderColor: '#003B8E',
boxShadow: '0 4px 16px rgba(0,59,142,0.40)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
)}
<style>{`
.ant-table-row:hover td { background: #F8FAFC !important; }
.ant-table-expanded-row > td { padding: 0 !important; }
`}</style>
</div>
);
}