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>
1578 lines
50 KiB
TypeScript
1578 lines
50 KiB
TypeScript
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 A–Z' },
|
||
{ value: 'nome_za', label: 'Nome Z–A' },
|
||
{ 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>
|
||
);
|
||
}
|