Files
sar/apps/web/src/cockpits/rep/ClientsPage.tsx
julian a3c68f9f05 feat(mvp-rep): formas de pagamento do ERP + suporte offline completo
Formas de pagamento:
- Endpoint GET /catalog/payment-methods lendo vw_formas_pagamento
  filtrado por ativa=1 e integrar_sar=1
- FormaPagamento schema/type no shared api-interface
- Hook useFormasPagamento (staleTime 1h) substituindo lista hardcoded

Offline (FR-4.2 / NFR-2.1–2.4):
- IndexedDB queue: lib/offline/idb.ts + order-queue.ts sem deps externos
- NewOrderPage detecta !navigator.onLine → enqueueOrder() → toast + reset
- useOfflineSync: auto-sync ao reconectar (POST orders + PATCH transmit)
- usePendingOrders: fila reativa via CustomEvents
- AppShell: banner offline + useOfflineSync() global
- OrdersPage: seção de pedidos pendentes com retry/descartar
- sw.js: network-first para API GETs cacheáveis + stale-while-revalidate
  para assets + app shell navigate fallback

Docs:
- architecture.md: documento de decisões de arquitetura do SAR MVP

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 21:30:23 +00:00

1578 lines
50 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,
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<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 }: { 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 (
<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 direction="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
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>
);
}
// ─── 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 (
<Drawer
title={
<Space>
<Text strong style={{ fontSize: 16 }}>
{summary.nome}
</Text>
<CustomerStatusBadge status={summary.activityStatus} />
</Space>
}
open
onClose={onClose}
width={520}
placement="right"
styles={{ body: { padding: '16px 24px' } }}
footer={
<Space wrap>
<Button onClick={onClose}>Fechar</Button>
<Button icon={<BarChartOutlined />} onClick={onAnalyze}>
Analisar
</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 direction="vertical" size={20} style={{ width: '100%' }}>
{/* Identificação */}
<Card
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
styles={{ body: { padding: '14px 16px' } }}
>
<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>
)}
</Card>
{/* Dados cadastrais */}
<div>
<Text
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: '#64748B',
display: 'block',
marginBottom: 10,
}}
>
Dados Cadastrais
</Text>
<Row gutter={[12, 12]}>
<Col span={12}>
<span style={label}>Telefone</span>
<Space size={4}>
<PhoneOutlined style={{ color: '#94A3B8', fontSize: 12 }} />
<Text style={{ fontSize: 13 }}>{phone}</Text>
</Space>
</Col>
<Col span={12}>
<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>
</Col>
{loadingDetail ? (
<Col span={24}>
<Spin size="small" />
</Col>
) : (
<>
{endereco !== '—' && (
<Col span={24}>
<span style={label}>Endereço</span>
<Text style={{ fontSize: 13 }}>{endereco}</Text>
</Col>
)}
{d?.inscricaoEstadual && (
<Col span={12}>
<span style={label}>Insc. Estadual</span>
<Text style={{ fontSize: 13 }}>{d.inscricaoEstadual}</Text>
</Col>
)}
</>
)}
</Row>
</div>
<Divider style={{ margin: '4px 0' }} />
{/* Dados comerciais */}
<div>
<Text
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
color: '#64748B',
display: 'block',
marginBottom: 10,
}}
>
Dados Comerciais
</Text>
<Row gutter={[12, 12]}>
{limiteFormatado !== '—' && (
<Col span={12}>
<span style={label}>Limite de Crédito</span>
<Text strong style={{ fontSize: 13, color: '#003B8E' }}>
{limiteFormatado}
</Text>
</Col>
)}
<Col span={12}>
<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>
</Col>
{summary.dtUltimaCompra && (
<Col span={12}>
<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>
</Col>
)}
</Row>
</div>
{/* Ú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 direction="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>
</>
)}
<Button
block
icon={<WhatsAppOutlined />}
style={{
borderRadius: 8,
background: '#25D366',
borderColor: '#25D366',
color: '#fff',
fontWeight: 600,
}}
onClick={() => window.open(`https://wa.me/55${phone.replace(/\D/g, '')}`, '_blank')}
>
Enviar WhatsApp
</Button>
</Space>
</Drawer>
);
}
// ─── 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 (
<Drawer
title={
<Space>
<BarChartOutlined />
<Text strong>Análise Comercial</Text>
</Space>
}
open
onClose={onClose}
width={480}
placement="right"
styles={{ body: { padding: '16px 24px' } }}
>
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<div>
<Text strong style={{ fontSize: 17, color: '#1F2937', display: 'block' }}>
{summary.nome}
</Text>
<Space size={8} style={{ marginTop: 4 }}>
<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>
<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>
</Drawer>
);
}
// ─── 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 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 = 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={() => setDetailSummary(c)}
>
{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 msg.info('Cadastro de clientes em breve.')}
>
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 msg.info('Cadastro de clientes em breve.')}
>
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} />
<div style={{ marginBottom: 16 }} />
</>
)}
{/* ── Área principal ────────────────────────────────────────────── */}
<Row gutter={[20, 20]}>
<Col xs={24} lg={17}>
{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} />
</Col>
)}
</Row>
{/* ── Drawers ───────────────────────────────────────────────────── */}
{detailSummary && (
<CustomerDetailsDrawer
summary={detailSummary}
onClose={() => setDetailSummary(null)}
onAnalyze={() => {
setAnalysisSummary(detailSummary);
setDetailSummary(null);
}}
/>
)}
{analysisSummary && (
<CustomerAnalysisDrawer
summary={analysisSummary}
onClose={() => setAnalysisSummary(null)}
/>
)}
{/* FAB mobile */}
{isMobile && (
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
size="large"
onClick={() => void msg.info('Cadastro de clientes em breve.')}
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>
);
}