import { useState, useMemo } from 'react'; import { App, Button, Card, Col, Drawer, 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 = { 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 ( {cfg.label} ); } // ─── 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; // ─── CustomerMetrics ────────────────────────────────────────────────────────── function CustomerMetrics({ stats }: { stats: PortfolioStats }) { const metrics = [ { label: 'Total de Clientes', value: stats.total, icon: , color: '#003B8E' }, { label: 'Ativos', value: stats.ativos, icon: , color: '#389e0d' }, { label: 'Em alerta', value: stats.emAlerta, icon: , color: '#d46b08' }, { label: 'Inativos', value: stats.inativos, icon: , color: '#cf1322' }, ]; return ( {metrics.map((m) => (
{m.icon}
{m.label} {stats.loaded ? m.value.toLocaleString('pt-BR') : }
))}
); } // ─── CustomerPortfolioCard ──────────────────────────────────────────────────── function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) { 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 (
Carteira de Clientes {mesAtual}
{stats.loaded && total > 0 ? ( <>
{stats.total.toLocaleString('pt-BR')} Clientes
) : (
)}
{legendItems.map((item) => { const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0'; return (
{item.label} {item.value.toLocaleString('pt-BR')} {pct}%
); })}
); } // ─── 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 (
); } 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 (
{endereco !== '—' && ( Endereço {endereco} )} Telefone {phone} {d?.inscricaoEstadual && ( Insc. Estadual {d.inscricaoEstadual} )} {limiteFormatado !== '—' && ( Limite de Crédito {limiteFormatado} )} {d?.obs && ( Observações {d.obs} )}
); } // ─── CustomerDetailsDrawer ──────────────────────────────────────────────────── function CustomerDetailsDrawer({ summary, onClose, onAnalyze, }: { summary: ClientSummary | null; onClose: () => void; onAnalyze: () => void; }) { const navigate = useNavigate(); 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 ( {summary.nome} } open onClose={onClose} width={520} placement="right" styles={{ body: { padding: '16px 24px' } }} footer={ } > {/* Identificação */} {summary.nome} {summary.razao && ( {summary.razao} )} {summary.cgcpf && ( {summary.cgcpf} )} {/* Dados cadastrais */}
Dados Cadastrais Telefone {phone} E-mail {summary.email ?? '—'} {loadingDetail ? ( ) : ( <> {endereco !== '—' && ( Endereço {endereco} )} {d?.inscricaoEstadual && ( Insc. Estadual {d.inscricaoEstadual} )} )}
{/* Dados comerciais */}
Dados Comerciais {limiteFormatado !== '—' && ( Limite de Crédito {limiteFormatado} )} Representante {summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`} {summary.nomeVendedor && ( (cód. {summary.codVendedor}) )} {summary.dtUltimaCompra && ( Última Compra {fmtDate(summary.dtUltimaCompra)} {dias !== null && ( ({dias} dias) )} )}
{/* Últimos pedidos */} {(loadingOrders || orders.length > 0) && ( <>
Últimos Pedidos {loadingOrders ? ( ) : ( {orders.slice(0, 5).map((p) => (
{p.numPedSar} {new Date(p.dtPedido).toLocaleDateString('pt-BR')} {SITUA_LABEL[p.situa] ?? String(p.situa)} {Number(p.total).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', })}
))}
)}
)} {/* Observações */} {d?.obs && ( <>
Observações
{d.obs}
)}
); } // ─── CustomerAnalysisDrawer ─────────────────────────────────────────────────── function CustomerAnalysisDrawer({ summary, onClose, }: { summary: ClientSummary | null; onClose: () => void; }) { 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 ( Análise Comercial } open onClose={onClose} width={480} placement="right" styles={{ body: { padding: '16px 24px' } }} >
{summary.nome} {summary.cgcpf && ( {summary.cgcpf} )}
{[ { label: 'Limite de Crédito', value: limiteFormatado, icon: , color: '#003B8E', }, { label: 'Última Compra', value: fmtDate(summary.dtUltimaCompra), icon: , color: '#d46b08', }, { label: 'Dias s/ Comprar', value: dias !== null ? `${dias} dias` : '—', icon: , color: urgencyColor, }, { label: 'Representante', value: summary.nomeVendedor ?? `Cód. ${summary.codVendedor}`, icon: , color: '#64748B', }, ].map((m) => ( {m.icon}
{m.label} {m.value}
))}
Sugestão de Ação Comercial
{sugestao}
{detail?.obs && (
Observações
{detail.obs}
)}
); } // ─── CustomerActionsMenu ────────────────────────────────────────────────────── function CustomerActionsMenu({ summary, onView, onAnalyze, }: { summary: ClientSummary; onView: () => void; onAnalyze: () => void; }) { const navigate = useNavigate(); const items: MenuProps['items'] = [ { key: 'view', icon: , label: 'Ver detalhes', onClick: onView }, { key: 'analyze', icon: , label: 'Analisar', onClick: onAnalyze }, { type: 'divider' }, { key: 'orders', icon: , label: 'Ver pedidos', onClick: () => void navigate({ to: '/pedidos' }), }, { key: 'new-order', icon: , label: Criar pedido, onClick: () => void navigate({ to: '/pedidos/novo', search: { clientId: String(summary.idCliente) } }), }, { type: 'divider' }, { key: 'whatsapp', icon: , label: 'Enviar WhatsApp', onClick: () => window.open(`https://wa.me/55${(summary.telefone ?? '').replace(/\D/g, '')}`, '_blank'), }, { key: 'email', icon: , label: 'Enviar e-mail', onClick: () => window.open(`mailto:${summary.email ?? ''}`, '_blank'), }, ]; return ( ); } // ─── ClientsPage ────────────────────────────────────────────────────────────── export function ClientsPage() { const screens = useBreakpoint(); const isMobile = !screens.md; const { message: msg } = App.useApp(); const stats = usePortfolioStats(); const [search, setSearch] = useState(''); const [query, setQuery] = useState(''); const [statusFilter, setStatusFilter] = useState(); const [sortBy, setSortBy] = useState('nome_az'); const [page, setPage] = useState(1); const limit = 50; const [detailSummary, setDetailSummary] = useState(null); const [analysisSummary, setAnalysisSummary] = useState(null); const { data, isLoading, isFetching } = useClientList({ q: query || undefined, status: statusFilter, page, limit, }); const rows = 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) => , rowExpandable: () => true, }; const columns: TableColumnsType = [ { title: 'Cliente', key: 'cliente', minWidth: 280, render: (_: unknown, c: ClientSummary) => (
setDetailSummary(c)} > {c.nome} {c.razao && ( {c.razao} )} {c.cgcpf && ( {c.cgcpf} )}
), }, { title: 'Status', dataIndex: 'activityStatus', key: 'status', width: 120, render: (s: ActivityStatus) => , }, { title: 'Contato', key: 'contato', width: 195, render: (_: unknown, c: ClientSummary) => (
{c.telefone && ( {c.telefone} )} {c.email && ( <> {c.telefone &&
} {c.email} )} {!c.telefone && !c.email && ( )}
), }, { 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 !== '—' ? ( {f} ) : ( ); }, }, { title: 'Última compra', dataIndex: 'dtUltimaCompra', key: 'ultimaCompra', width: 118, render: (v: string | null) => v ? (
{fmtDate(v)} {diasSemComprar(v)} dias atrás
) : ( Sem compras ), }, { title: 'Ações', key: 'actions', width: 120, render: (_: unknown, c: ClientSummary) => ( )}
{/* ── Métricas ──────────────────────────────────────────────────── */} {/* ── Barra de ações ────────────────────────────────────────────── */}
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', }} />
{/* ── Filtros ───────────────────────────────────────────────────── */} {data?.total !== undefined ? `${data.total.toLocaleString('pt-BR')} clientes` : '…'} {/* ── Portfolio mobile (antes da lista) ─────────────────────────── */} {isMobile && ( <>
)} {/* ── Área principal ────────────────────────────────────────────── */} {isLoading ? (
) : sorted.length === 0 ? (
Nenhum cliente encontrado Tente ajustar os filtros ou a busca.
) : isMobile ? (
{sorted.map((c) => ( setDetailSummary(c)} /> ))} Mostrando {sorted.length} de {data?.total ?? 0} clientes
) : ( 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' }} /> )} {!isMobile && ( )}
{/* ── Drawers ───────────────────────────────────────────────────── */} {detailSummary && ( setDetailSummary(null)} onAnalyze={() => { setAnalysisSummary(detailSummary); setDetailSummary(null); }} /> )} {analysisSummary && ( setAnalysisSummary(null)} /> )} {/* FAB mobile */} {isMobile && (
); }