feat(web+api): ficha do cliente — contatos, CTR, NF-e, pedidos e produtos

- Contatos: grid de cards 3 colunas, botão "Novo Contato" no topo da página
- Sync ERP↔SAR corrigido: vw_contatos aceita formato COR#<id>, trigger normaliza id_empresa (9001→1)
- CTR + NF-e: layout 50/50 — lista de títulos abertos com badge vencido/a vencer e lista de notas com botão copiar chave NF-e
- Histórico de pedidos: UNION SAR+ERP, top 5 + modal "Ver todos"
- Produtos mais comprados: top 5 com último preço + modal "Ver todos"
- Novos endpoints: ctr-list, notas, orders-history, top-produtos
- AppShell: overflow-x travado, sem scroll horizontal na aplicação

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 14:13:21 +00:00
parent a2bab75bad
commit 400fcb3360
23 changed files with 2532 additions and 356 deletions

View File

@@ -1,13 +1,35 @@
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
import {
Button,
Descriptions,
Tag,
Table,
Typography,
Spin,
Alert,
Space,
Divider,
Modal,
Tooltip,
message,
Badge,
} from 'antd';
import { useState } from 'react';
import type { TableColumnsType } from 'antd';
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import type { CtrTitulo, NotaFiscal, PedidoSummary, TopProduto } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
import {
useClientDetail,
useClientCtr,
useClientCtrList,
useClientNotasFiscais,
useClientOrdersHistory,
useClientTopProdutos,
} from '../../lib/queries/clients';
import { ClientContacts } from '../../components/contacts/ClientContacts';
const { Title } = Typography;
const { Title, Text } = Typography;
const ACTIVITY_COLOR: Record<string, string> = {
active: 'success',
@@ -20,60 +42,182 @@ const ACTIVITY_LABEL: Record<string, string> = {
inactive: 'Inativo',
};
const orderColumns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Status',
dataIndex: 'situa',
width: 140,
render: (s: number) => {
const colorMap: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
const SITUA_COLOR: Record<number, string> = {
0: 'default',
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
function orderColumns(withFonte: boolean): TableColumnsType<PedidoSummary> {
const cols: TableColumnsType<PedidoSummary> = [
{
title: 'Nº Pedido',
dataIndex: 'numPedSar',
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Data',
dataIndex: 'dtPedido',
width: 130,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
{
title: 'Status',
dataIndex: 'situa',
width: 130,
render: (s: number) => (
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
),
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right' as const,
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Data',
dataIndex: 'dtPedido',
width: 110,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
if (withFonte) {
cols.push({
title: 'Origem',
dataIndex: 'fonte',
width: 70,
render: (f: string) => (
<Tag color={f === 'sar' ? 'blue' : 'default'} style={{ fontSize: 11 }}>
{f === 'sar' ? 'SAR' : 'ERP'}
</Tag>
),
});
}
return cols;
}
export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' });
const idNum = Number(id);
const navigate = useNavigate();
const [addContactOpen, setAddContactOpen] = useState(false);
const [ordersModalOpen, setOrdersModalOpen] = useState(false);
const [produtosModalOpen, setProdutosModalOpen] = useState(false);
const [notasModalOpen, setNotasModalOpen] = useState(false);
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum);
const { data: ctr } = useClientCtr(idNum);
const { data: ctrList = [], isLoading: ctrListLoading } = useClientCtrList(idNum, 60);
const { data: orders = [], isLoading: ordersLoading } = useClientOrdersHistory(idNum, 50);
const { data: topProdutos = [], isLoading: produtosLoading } = useClientTopProdutos(idNum, 30);
const { data: notas = [], isLoading: notasLoading } = useClientNotasFiscais(idNum, 30);
const [msgApi, msgCtx] = message.useMessage();
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (clientError || !client)
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
return <Alert type="error" title="Cliente não encontrado." style={{ margin: 24 }} />;
const today: string = new Date().toISOString().slice(0, 10);
const ordersPreview = orders.slice(0, 5);
const produtosPreview = topProdutos.slice(0, 5);
const notasPreview = notas.slice(0, 5);
const ctrPreview = ctrList.slice(0, 8);
const notaColumns: TableColumnsType<NotaFiscal> = [
{
title: 'NF',
dataIndex: 'numero',
width: 90,
render: (v: number, r: NotaFiscal) => `${v}-${r.serie}`,
},
{
title: 'Emissão',
dataIndex: 'dtEmissao',
width: 100,
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
},
{
title: 'Valor',
dataIndex: 'vlNf',
width: 130,
align: 'right' as const,
render: (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Chave NF-e',
dataIndex: 'chaveAcessoNfe',
render: (chave: string | null) =>
chave ? (
<Space size={4}>
<Text
style={{ fontSize: 11, fontFamily: 'monospace', color: '#666' }}
ellipsis={{ tooltip: chave }}
>
{chave}
</Text>
<Tooltip title="Copiar chave">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() =>
void navigator.clipboard
.writeText(chave)
.then(() => void msgApi.success('Chave copiada!'))
}
/>
</Tooltip>
</Space>
) : (
'—'
),
},
];
const produtoColumns: TableColumnsType<TopProduto> = [
{
title: 'Produto',
dataIndex: 'descricao',
ellipsis: true,
},
{
title: 'Qtd Total',
dataIndex: 'qtdTotal',
width: 100,
align: 'right' as const,
render: (v: number, r: TopProduto) =>
`${v.toLocaleString('pt-BR', { maximumFractionDigits: 0 })} ${r.unidade ?? ''}`.trim(),
},
{
title: 'Pedidos',
dataIndex: 'numPedidos',
width: 80,
align: 'center' as const,
},
{
title: 'Último Preço',
dataIndex: 'ultimoPreco',
width: 120,
align: 'right' as const,
render: (v: number) =>
v > 0 ? v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—',
},
{
title: 'Última Compra',
dataIndex: 'ultimaCompra',
width: 120,
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ padding: 24, maxWidth: '100%', overflowX: 'hidden' }}>
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/clientes"> Clientes</Link>
<Title level={3} style={{ margin: 0 }}>
@@ -82,6 +226,9 @@ export function ClientDetailPage() {
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
{ACTIVITY_LABEL[client.activityStatus]}
</Tag>
<Button icon={<UserAddOutlined />} onClick={() => setAddContactOpen(true)}>
Novo Contato
</Button>
<Button
type="primary"
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
@@ -121,20 +268,306 @@ export function ClientDetailPage() {
</Descriptions.Item>
</Descriptions>
<ClientContacts idCliente={idNum} />
{/* ── CTR + NF-e lado a lado ── */}
{msgCtx}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
{/* ── Contas a Receber ── */}
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Space size={8}>
<Text strong style={{ fontSize: 14 }}>
Contas a Receber
</Text>
{ctr && ctr.qtdAberto > 0 && (
<Tag color={ctr.qtdVencido > 0 ? 'error' : 'warning'}>
{ctr.qtdAberto} título{ctr.qtdAberto > 1 ? 's' : ''} {' '}
{ctr.totalAberto.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
</Tag>
)}
</Space>
</div>
<Divider style={{ margin: '0 0 10px' }} />
<Table<CtrTitulo>
rowKey="idCtr"
size="small"
pagination={false}
loading={ctrListLoading}
dataSource={ctrPreview}
locale={{ emptyText: 'Nenhum título em aberto.' }}
scroll={{ x: 'max-content', y: ctrList.length > 8 ? 260 : undefined }}
style={{ maxWidth: '100%' }}
columns={[
{
title: 'Título / Parcela',
dataIndex: 'numero',
render: (num: string, r: CtrTitulo) => (
<Space size={4}>
<Text style={{ fontSize: 12 }}>
{r.prefixo}-{num}
</Text>
{r.dtVencimento < today ? (
<Badge
status="error"
text={
<Text type="danger" style={{ fontSize: 11 }}>
{Math.ceil(
(new Date(today).getTime() - new Date(r.dtVencimento).getTime()) /
86400000,
)}
d atraso
</Text>
}
/>
) : (
<Badge
status="warning"
text={
<Text type="warning" style={{ fontSize: 11 }}>
a vencer
</Text>
}
/>
)}
</Space>
),
},
{
title: 'Vencimento',
dataIndex: 'dtVencimento',
width: 100,
render: (v: string) => (
<Text type={v < today ? 'danger' : undefined} style={{ fontSize: 12 }}>
{new Date(v + 'T12:00:00').toLocaleDateString('pt-BR')}
</Text>
),
},
{
title: 'Saldo',
dataIndex: 'saldo',
width: 110,
align: 'right' as const,
render: (v: number) => (
<Text strong style={{ fontSize: 12 }}>
{v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
</Text>
),
},
]}
footer={
ctrList.length > 8
? () => (
<Text type="secondary" style={{ fontSize: 12 }}>
Exibindo 8 de {ctrList.length}
{ctrList.length === 60 ? '+' : ''} títulos total em aberto:{' '}
{ctr?.totalAberto.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</Text>
)
: undefined
}
/>
</div>
<Divider orientation="left">Últimos Pedidos</Divider>
{/* ── Notas Fiscais ── */}
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text strong style={{ fontSize: 14 }}>
Notas Fiscais
</Text>
{notas.length > 5 && (
<Button size="small" type="link" onClick={() => setNotasModalOpen(true)}>
Ver todas ({notas.length}
{notas.length === 30 ? '+' : ''})
</Button>
)}
</div>
<Divider style={{ margin: '0 0 10px' }} />
<Table<NotaFiscal>
rowKey="idNf"
size="small"
pagination={false}
loading={notasLoading}
dataSource={notasPreview}
locale={{ emptyText: 'Nenhuma nota fiscal.' }}
style={{ maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
columns={notaColumns}
/>
</div>
</div>
<Modal
title={
<Space>
<span>Notas Fiscais {client.razao ?? client.nome}</span>
<Text type="secondary" style={{ fontSize: 13 }}>
({notas.length}
{notas.length === 30 ? '+' : ''} notas)
</Text>
</Space>
}
open={notasModalOpen}
onCancel={() => setNotasModalOpen(false)}
footer={null}
width={900}
centered
destroyOnHidden
>
<Table<NotaFiscal>
rowKey="idNf"
columns={notaColumns}
dataSource={notas}
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ y: 460, x: 'max-content' }}
/>
</Modal>
<ClientContacts
idCliente={idNum}
modalOpen={addContactOpen}
onModalClose={() => setAddContactOpen(false)}
/>
{/* ── Produtos Mais Comprados ── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text strong style={{ fontSize: 14 }}>
Produtos Mais Comprados
</Text>
{topProdutos.length > 5 && (
<Button size="small" type="link" onClick={() => setProdutosModalOpen(true)}>
Ver todos ({topProdutos.length}
{topProdutos.length === 30 ? '+' : ''})
</Button>
)}
</div>
<Divider style={{ margin: '0 0 12px' }} />
<Table<TopProduto>
rowKey="idProduto"
columns={produtoColumns}
dataSource={produtosPreview}
loading={produtosLoading}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum produto encontrado.' }}
style={{ marginBottom: 24, maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
/>
<Modal
title={
<Space>
<span>Produtos {client.razao ?? client.nome}</span>
<Text type="secondary" style={{ fontSize: 13 }}>
({topProdutos.length}
{topProdutos.length === 30 ? '+' : ''} produtos)
</Text>
</Space>
}
open={produtosModalOpen}
onCancel={() => setProdutosModalOpen(false)}
footer={null}
width={820}
centered
destroyOnHidden
>
<Table<TopProduto>
rowKey="idProduto"
columns={produtoColumns}
dataSource={topProdutos}
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ y: 460 }}
/>
</Modal>
{/* ── Últimos Pedidos ── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text strong style={{ fontSize: 14 }}>
Últimos Pedidos
</Text>
{orders.length > 5 && (
<Button size="small" type="link" onClick={() => setOrdersModalOpen(true)}>
Ver todos ({orders.length}
{orders.length === 50 ? '+' : ''})
</Button>
)}
</div>
<Divider style={{ margin: '0 0 12px' }} />
<Table<PedidoSummary>
rowKey="id"
columns={orderColumns}
dataSource={orders ?? []}
columns={orderColumns(false)}
dataSource={ordersPreview}
loading={ordersLoading}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
style={{ marginBottom: 24, maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
/>
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
{/* ── Modal Ver Todos Pedidos ── */}
<Modal
title={
<Space>
<span>Pedidos {client.razao ?? client.nome}</span>
<Text type="secondary" style={{ fontSize: 13 }}>
({orders.length}
{orders.length === 50 ? '+' : ''} registros)
</Text>
</Space>
}
open={ordersModalOpen}
onCancel={() => setOrdersModalOpen(false)}
footer={null}
width={780}
centered
destroyOnHidden
>
<Table<PedidoSummary>
rowKey="id"
columns={orderColumns(true)}
dataSource={orders}
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ y: 460 }}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
/>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,557 @@
import { useState } from 'react';
import {
Badge,
Card,
Col,
DatePicker,
Progress,
Row,
Select,
Space,
Spin,
Statistic,
Table,
Tag,
Tabs,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface';
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports';
const { Title, Text } = Typography;
const { Option } = Select;
function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '—';
return new Date(v).toLocaleDateString('pt-BR');
}
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
function TabMeta() {
const now = dayjs();
const [mes, setMes] = useState(now.month() + 1);
const [ano, setAno] = useState(now.year());
const { data, isLoading } = useReportMeta({ mes, ano });
const realizado = Number(data?.realizado ?? 0);
const meta = Number(data?.meta ?? 0);
const percentual = Number(data?.percentual ?? 0);
const comissao = Number(data?.comissaoEstimada ?? 0);
const bateuMeta = meta > 0 && realizado >= meta;
const progressColor = percentual >= 100 ? '#52c41a' : percentual >= 70 ? '#faad14' : '#003B8E';
const meses = [
'Jan',
'Fev',
'Mar',
'Abr',
'Mai',
'Jun',
'Jul',
'Ago',
'Set',
'Out',
'Nov',
'Dez',
];
return (
<div>
<Space style={{ marginBottom: 20 }}>
<Select value={mes} onChange={setMes} style={{ width: 90 }}>
{meses.map((m, i) => (
<Option key={i + 1} value={i + 1}>
{m}
</Option>
))}
</Select>
<DatePicker
picker="year"
value={dayjs().year(ano)}
onChange={(d) => d && setAno(d.year())}
style={{ width: 100 }}
allowClear={false}
/>
</Space>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
) : (
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: '24px 28px' } }}
>
<Row gutter={[32, 24]} align="middle">
<Col xs={24} md={12}>
<Text
type="secondary"
style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em' }}
>
Realizado {meses[mes - 1]}/{ano}
</Text>
<div style={{ marginTop: 4 }}>
<Text
strong
style={{
fontSize: 32,
color: bateuMeta ? '#52c41a' : '#003B8E',
lineHeight: 1,
}}
>
{fmt(realizado)}
</Text>
{bateuMeta && (
<Tag
color="success"
style={{ marginLeft: 10, fontWeight: 700, borderRadius: 20 }}
>
Meta batida!
</Tag>
)}
</div>
<Text type="secondary" style={{ fontSize: 13 }}>
de {fmt(meta)} de meta
</Text>
<Progress
percent={Math.min(percentual, 100)}
style={{ marginTop: 12 }}
strokeColor={progressColor}
format={() => `${percentual.toFixed(1)}%`}
/>
</Col>
<Col xs={24} md={12}>
<Row gutter={[16, 16]}>
<Col span={12}>
<Statistic
title="Pedidos Transmitidos"
value={data?.totalPedidos ?? 0}
valueStyle={{ color: '#003B8E' }}
/>
</Col>
<Col span={12}>
<Statistic
title="Comissão Estimada"
value={fmt(comissao)}
valueStyle={{ color: '#52c41a', fontSize: 20 }}
/>
</Col>
<Col span={12}>
<Statistic
title="Falta para Meta"
value={meta > realizado ? fmt(meta - realizado) : 'Atingida'}
valueStyle={{ color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 }}
/>
</Col>
<Col span={12}>
<Statistic
title="% Atingido"
value={`${percentual.toFixed(1)}%`}
valueStyle={{ color: progressColor, fontSize: 18 }}
/>
</Col>
</Row>
</Col>
</Row>
</Card>
{meta === 0 && (
<Card
style={{ borderRadius: 8, border: '1px solid #fde68a', background: '#fffbeb' }}
styles={{ body: { padding: '12px 16px' } }}
>
<Space>
<AlertOutlined style={{ color: '#d97706' }} />
<Text style={{ color: '#92400e' }}>
Nenhuma meta cadastrada para {meses[mes - 1]}/{ano}. Fale com seu supervisor.
</Text>
</Space>
</Card>
)}
</Space>
)}
</div>
);
}
// ─── Aba 2: Carteira de Clientes ─────────────────────────────────────────────
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
function TabCarteira() {
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
const { data, isLoading } = useReportCarteira();
const clientes = data?.data ?? [];
const filtered = clientes.filter((c: CarteiraCliente) => {
if (filtro === 'inativos30') return c.diasSemPedido != null && c.diasSemPedido >= 30;
if (filtro === 'inativos60') return c.diasSemPedido != null && c.diasSemPedido >= 60;
if (filtro === 'semPedido') return c.totalPedidos === 0;
return true;
});
function statusBadge(c: CarteiraCliente) {
if (c.totalPedidos === 0)
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedidos</Text>} />;
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
return (
<Badge
color="#ef4444"
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
/>
);
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
return (
<Badge
color="#f59e0b"
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Inativo 30d+</Text>}
/>
);
return (
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
);
}
const columns: TableColumnsType<CarteiraCliente> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space direction="vertical" size={0}>
<Text strong style={{ fontSize: 13 }}>
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
</Text>
{c.razao && c.nome && (
<Text type="secondary" style={{ fontSize: 11 }}>
{c.nome}
</Text>
)}
</Space>
),
},
{
title: 'Status',
key: 'status',
width: 140,
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
},
{
title: 'Último Pedido',
dataIndex: 'ultimoPedido',
width: 130,
render: (v: string | null) => (
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
),
},
{
title: 'Dias Sem Pedido',
dataIndex: 'diasSemPedido',
width: 140,
align: 'right' as const,
render: (v: number | null) =>
v != null ? (
<Text
strong
className="tabular-nums"
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
>
{v}d
</Text>
) : (
<Text type="secondary"></Text>
),
},
{
title: 'Pedidos',
dataIndex: 'totalPedidos',
width: 90,
align: 'right' as const,
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
},
{
title: 'Ticket Médio',
dataIndex: 'ticketMedio',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
),
},
];
return (
<div>
{data && (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{[
{ key: 'todos', label: 'Total', value: data.total, color: '#003B8E' },
{ key: 'inativos30', label: 'Inativos 30d+', value: data.inativos30, color: '#f59e0b' },
{ key: 'inativos60', label: 'Inativos 60d+', value: data.inativos60, color: '#ef4444' },
{ key: 'semPedido', label: 'Sem Pedido', value: data.semPedido, color: '#94A3B8' },
].map((item) => (
<Col key={item.key} xs={12} md={6}>
<Card
hoverable
onClick={() => setFiltro(item.key as FiltroCarteira)}
style={{
borderRadius: 8,
border: filtro === item.key ? `2px solid ${item.color}` : '1px solid #EBF0F5',
cursor: 'pointer',
}}
styles={{ body: { padding: '12px 16px' } }}
>
<Statistic
title={item.label}
value={item.value}
valueStyle={{ color: item.color, fontSize: 24 }}
/>
</Card>
</Col>
))}
</Row>
)}
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<CarteiraCliente>
rowKey="idCliente"
columns={columns}
dataSource={filtered}
loading={isLoading}
size="middle"
pagination={{ pageSize: 15, showSizeChanger: false }}
scroll={{ x: 700 }}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
</div>
);
}
// ─── Aba 3: Curva ABC ─────────────────────────────────────────────────────────
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
function TabAbc() {
const now = dayjs();
const [mes, setMes] = useState<number | undefined>(now.month() + 1);
const [ano, setAno] = useState<number | undefined>(now.year());
const { data, isLoading } = useReportAbc({ mes, ano });
const meses = [
'Jan',
'Fev',
'Mar',
'Abr',
'Mai',
'Jun',
'Jul',
'Ago',
'Set',
'Out',
'Nov',
'Dez',
];
const columns: TableColumnsType<AbcProduto> = [
{
title: 'Curva',
dataIndex: 'curva',
width: 70,
align: 'center' as const,
render: (v: string) => (
<Tag
color={CURVA_COLOR[v]}
style={{ fontWeight: 800, borderRadius: 20, fontSize: 13, padding: '0 10px' }}
>
{v}
</Tag>
),
},
{
title: 'Produto',
key: 'produto',
render: (_: unknown, p: AbcProduto) => (
<Space direction="vertical" size={0}>
{p.codProduto && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{p.codProduto}</Text>}
<Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text>
</Space>
),
},
{
title: 'Total Faturado',
dataIndex: 'totalFaturado',
width: 150,
align: 'right' as const,
render: (v: string, p: AbcProduto) => (
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{Number(p.participacao).toFixed(1)}% do total
</Text>
</Space>
),
},
{
title: 'Qtd. Vendida',
dataIndex: 'qtdVendida',
width: 110,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums">
{Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}
</Text>
),
},
{
title: 'Desc. Médio',
dataIndex: 'descontoMedio',
width: 110,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: Number(v) > 5 ? '#f59e0b' : '#475569' }}>
{Number(v).toFixed(1)}%
</Text>
),
},
{
title: 'Comissão',
dataIndex: 'comissaoTotal',
width: 120,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: '#22c55e', fontWeight: 600 }}>
{fmt(v)}
</Text>
),
},
];
return (
<div>
<Space style={{ marginBottom: 20 }}>
<Select
value={mes}
onChange={(v) => setMes(v)}
allowClear
placeholder="Mês"
style={{ width: 100 }}
onClear={() => setMes(undefined)}
>
{meses.map((m, i) => (
<Option key={i + 1} value={i + 1}>
{m}
</Option>
))}
</Select>
<DatePicker
picker="year"
value={ano ? dayjs().year(ano) : null}
onChange={(d) => setAno(d?.year())}
style={{ width: 100 }}
placeholder="Ano"
allowClear
/>
{data && (
<Text type="secondary" style={{ fontSize: 13 }}>
Período: <strong>{data.periodo}</strong> Total:{' '}
<strong>{fmt(data.totalFaturado)}</strong>
</Text>
)}
</Space>
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<AbcProduto>
rowKey={(r) => r.codProduto ?? r.descProduto}
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
size="middle"
pagination={{ pageSize: 20, showSizeChanger: false }}
scroll={{ x: 800 }}
rowClassName={(r) => (r.curva === 'A' ? 'row-curva-a' : '')}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
<style>{`
.row-curva-a td { background: #f0f5ff !important; }
`}</style>
</div>
);
}
// ─── RelatoriosPage ───────────────────────────────────────────────────────────
export function RelatoriosPage() {
const tabs = [
{
key: 'meta',
label: (
<Space>
<RiseOutlined />
Desempenho vs. Meta
</Space>
),
children: <TabMeta />,
},
{
key: 'carteira',
label: (
<Space>
<TeamOutlined />
Carteira de Clientes
</Space>
),
children: <TabCarteira />,
},
{
key: 'abc',
label: (
<Space>
<BarChartOutlined />
Curva ABC
</Space>
),
children: <TabAbc />,
},
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Relatórios
</Title>
<Text type="secondary" style={{ fontSize: 14 }}>
Análise de desempenho, carteira e mix de produtos.
</Text>
</div>
<Tabs defaultActiveKey="meta" items={tabs} size="large" />
</div>
);
}

View File

@@ -1,4 +1,18 @@
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
import { useState } from 'react';
import {
Button,
Card,
Col,
Flex,
Progress,
Row,
Segmented,
Skeleton,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
@@ -7,8 +21,9 @@ import {
faCircleExclamation,
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { MetaItem, PedidoSummary } from '@sar/api-interface';
import { WhatsAppOutlined } from '@ant-design/icons';
import { Link, useNavigate } from '@tanstack/react-router';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
@@ -34,17 +49,19 @@ function greeting(): string {
}
function today(): string {
return new Date().toLocaleDateString('pt-BR', {
day: 'numeric',
month: 'long',
});
return new Date().toLocaleDateString('pt-BR', { day: 'numeric', month: 'long' });
}
function num(v: number, dec = 0): string {
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec });
}
// Célula "realizado / meta" — realizado em destaque (verde se bateu), meta abaixo.
function waLink(whatsapp: string): string {
const digits = whatsapp.replace(/\D/g, '');
const number = digits.startsWith('55') ? digits : `55${digits}`;
return `https://wa.me/${number}`;
}
function MetaCell({
real,
meta,
@@ -131,6 +148,170 @@ const metaColumns: TableColumnsType<MetaItem> = [
},
];
// ─── Não positivados ──────────────────────────────────────────────────────────
type OrdemNP = 'longe' | 'recentes';
function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[]; total: number }) {
const navigate = useNavigate();
const [ordem, setOrdem] = useState<OrdemNP>('longe');
const [expandido, setExpandido] = useState(false);
const PAGE = 10;
const sorted = [...clientes].sort((a, b) => {
if (ordem === 'longe') {
return (b.diasSemPedido ?? 999) - (a.diasSemPedido ?? 999);
}
// 'recentes': compraram antes primeiro, ordenados por último pedido desc
if (a.comprouAntes && !b.comprouAntes) return -1;
if (!a.comprouAntes && b.comprouAntes) return 1;
const da = a.ultimoPedido ?? '';
const db = b.ultimoPedido ?? '';
return db.localeCompare(da);
});
const visible = expandido ? sorted : sorted.slice(0, PAGE);
const restante = total - PAGE;
const columns: TableColumnsType<ClienteNaoPositivado> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: ClienteNaoPositivado) => (
<Text
strong
style={{ cursor: 'pointer', color: 'var(--jcs-blue)' }}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
}
>
{c.razao ?? c.nome}
</Text>
),
},
{
title: 'Último pedido',
key: 'ultimo',
width: 160,
render: (_: unknown, c: ClienteNaoPositivado) => {
if (!c.comprouAntes) {
return (
<Tag color="default" style={{ borderRadius: 20 }}>
nunca comprou
</Tag>
);
}
const dias = c.diasSemPedido ?? 999;
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
return (
<Space direction="vertical" size={0}>
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
{dias}d sem pedido
</Tag>
{c.ultimoPedido && (
<Text type="secondary" style={{ fontSize: 11 }}>
{new Date(c.ultimoPedido).toLocaleDateString('pt-BR')}
</Text>
)}
</Space>
);
},
},
{
title: '',
key: 'acoes',
width: 96,
render: (_: unknown, c: ClienteNaoPositivado) => (
<Space size={6}>
<Button
size="small"
style={{ borderRadius: 6 }}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
}
>
Ficha
</Button>
{c.whatsapp && (
<Button
size="small"
type="primary"
style={{ borderRadius: 6, background: '#25d366', borderColor: '#25d366' }}
icon={<WhatsAppOutlined />}
href={waLink(c.whatsapp)}
target="_blank"
rel="noopener noreferrer"
/>
)}
</Space>
),
},
];
return (
<Card
title={
<Space>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Não positivados no mês
{total > 0 && (
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
{total}
</Tag>
)}
</Space>
}
extra={
<Segmented<OrdemNP>
size="small"
value={ordem}
onChange={setOrdem}
options={[
{ label: 'Mais longe', value: 'longe' },
{ label: 'Compraram antes', value: 'recentes' },
]}
/>
}
>
{clientes.length === 0 ? (
<Text type="secondary">Todos os clientes positivados este mês. Ótimo trabalho!</Text>
) : (
<Flex vertical gap={12}>
<Table<ClienteNaoPositivado>
rowKey="idCliente"
columns={columns}
dataSource={visible}
size="small"
pagination={false}
showHeader={false}
style={{ borderRadius: 8 }}
/>
{!expandido && total > PAGE && (
<Button
type="link"
style={{ alignSelf: 'flex-start', padding: 0 }}
onClick={() => setExpandido(true)}
>
Ver mais {restante} cliente{restante !== 1 ? 's' : ''}
</Button>
)}
{expandido && total > PAGE && (
<Button
type="link"
style={{ alignSelf: 'flex-start', padding: 0 }}
onClick={() => setExpandido(false)}
>
Recolher
</Button>
)}
</Flex>
)}
</Card>
);
}
// ─── RepPainel ────────────────────────────────────────────────────────────────
export function RepPainel() {
const { data, isLoading } = useRepDashboard();
const { data: user } = useCurrentUser();
@@ -140,13 +321,10 @@ export function RepPainel() {
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 2 }} />
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Col xs={24} md={14}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Col xs={24} md={10}>
<Skeleton active />
</Col>
</Row>
@@ -157,10 +335,10 @@ export function RepPainel() {
const {
meta,
metasPorGrupo = [],
comissao,
pedidosMes,
pedidosRecentes = [],
clientesInativos = [],
naoPositivados = [],
totalNaoPositivados = 0,
syncedAt,
} = data;
@@ -173,21 +351,21 @@ export function RepPainel() {
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}
{clientesInativos.length > 0 && (
{totalNaoPositivados > 0 && (
<>
{' '}
·{' '}
<span style={{ color: 'var(--orange)' }}>
{clientesInativos.length} clientes inativos
{totalNaoPositivados} clientes sem pedido no s
</span>
</>
)}
</Text>
</Flex>
{/* Linha 1 — Meta + KPIs */}
{/* Linha 1 — Meta + Pedidos do mês */}
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Col xs={24} md={14}>
<Card style={{ height: '100%' }}>
<Flex vertical gap={16}>
<Flex justify="space-between" align="flex-start">
@@ -228,8 +406,8 @@ export function RepPainel() {
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Col xs={24} md={10}>
<Card style={{ height: '100%' }}>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO MÊS
@@ -238,32 +416,14 @@ export function RepPainel() {
{pedidosMes}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias
<FontAwesomeIcon icon={faArrowTrendUp} /> mês corrente
</Text>
</Space>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
COMISSÃO ACUMULADA
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(comissao.total)}
</Title>
{comissao.flex > 0 && (
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
FLEX: {fmt(comissao.flex)}
</Text>
)}
</Space>
</Card>
</Col>
</Row>
{/* Metas por Grupo — acompanhamento multi-medida do mês */}
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (
<Card
title={
@@ -339,67 +499,13 @@ export function RepPainel() {
</Card>
)}
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
{/* Linha 2 — Não positivados + Pedidos recentes */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Clientes esfriando
</Space>
}
extra={
clientesInativos.length > 0 ? (
<Text type="secondary">{clientesInativos.length} clientes</Text>
) : null
}
>
{clientesInativos.length === 0 ? (
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
) : (
<Flex vertical gap={12}>
{clientesInativos.map((c) => (
<Flex
key={c.idCliente}
justify="space-between"
align="center"
style={{
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 12,
background: c.diasSemCompra > 60 ? '#fff7e6' : 'var(--bg-surface-alt)',
}}
>
<Space orientation="vertical" size={0}>
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
<Text strong>{c.nome}</Text>
</Link>
{c.ultimaCompraValor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Última compra:{' '}
<span className="tabular-nums">
{Number(c.ultimaCompraValor).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</span>
</Text>
)}
</Space>
<Tag
color={c.diasSemCompra > 60 ? 'orange' : 'default'}
className="tabular-nums"
>
{c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`}
</Tag>
</Flex>
))}
</Flex>
)}
</Card>
<Col xs={24} lg={14}>
<NaoPositivados clientes={naoPositivados} total={totalNaoPositivados} />
</Col>
<Col xs={24} lg={12}>
<Col xs={24} lg={10}>
<Card
title={
<Space>

View File

@@ -1,20 +1,20 @@
import { useState } from 'react';
import {
App,
Button,
Card,
Col,
DatePicker,
Divider,
Form,
Input,
Modal,
Row,
Space,
Table,
Spin,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { MailOutlined, PhoneOutlined, PlusOutlined, WhatsAppOutlined } from '@ant-design/icons';
import { MailOutlined, PhoneOutlined, WhatsAppOutlined } from '@ant-design/icons';
import type { Contato } from '@sar/api-interface';
import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
@@ -26,7 +26,7 @@ function ContactActions({ contato }: { contato: Contato }) {
const mail = contato.email?.trim();
return (
<Space size={6}>
<Space size={4} wrap>
{wa && (
<Tooltip title={`WhatsApp: ${wa}`}>
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer">
@@ -51,7 +51,7 @@ function ContactActions({ contato }: { contato: Contato }) {
<Tooltip title={mail}>
<a href={`mailto:${mail}`}>
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}>
{mail.length > 24 ? mail.slice(0, 22) + '…' : mail}
{mail.length > 22 ? mail.slice(0, 20) + '…' : mail}
</Tag>
</a>
</Tooltip>
@@ -60,45 +60,18 @@ function ContactActions({ contato }: { contato: Contato }) {
);
}
const columns: TableColumnsType<Contato> = [
{
title: 'Nome',
dataIndex: 'nome',
render: (nome: string, r: Contato) => (
<div>
<Text strong style={{ fontSize: 13 }}>
{nome}
</Text>
{(r.cargo || r.departamento) && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{[r.cargo, r.departamento].filter(Boolean).join(' · ')}
</Text>
</div>
)}
</div>
),
},
{
title: 'Contato',
key: 'contato',
render: (_: unknown, r: Contato) => <ContactActions contato={r} />,
},
{
title: 'Aniversário',
dataIndex: 'dtAniversario',
width: 120,
render: (v: string | null) => (v ? new Date(v + 'T12:00:00').toLocaleDateString('pt-BR') : '—'),
},
];
interface Props {
idCliente: number;
modalOpen?: boolean;
onModalClose?: () => void;
}
export function ClientContacts({ idCliente }: Props) {
export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: Props) {
const { message } = App.useApp();
const [open, setOpen] = useState(false);
const open = modalOpen;
const setOpen = (v: boolean) => {
if (!v) onModalClose?.();
};
const [form] = Form.useForm();
const { data: contacts = [], isLoading } = useClientContacts(idCliente);
@@ -134,38 +107,53 @@ export function ClientContacts({ idCliente }: Props) {
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}
>
<Divider titlePlacement="left" style={{ margin: 0, flex: 1 }}>
Contatos
</Divider>
<Button
type="default"
size="small"
icon={<PlusOutlined />}
onClick={() => setOpen(true)}
style={{ marginLeft: 16, borderRadius: 6 }}
>
Adicionar
</Button>
</div>
<Divider titlePlacement="left" style={{ marginBottom: 12 }}>
Contatos
</Divider>
<Table<Contato>
rowKey="idContato"
columns={columns}
dataSource={contacts}
loading={isLoading}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum contato cadastrado.' }}
style={{ marginBottom: 24 }}
/>
{isLoading ? (
<Spin style={{ display: 'block', margin: '16px auto' }} />
) : contacts.length === 0 ? (
<Typography.Text
type="secondary"
style={{ display: 'block', marginBottom: 24, textAlign: 'center' }}
>
Nenhum contato cadastrado.
</Typography.Text>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 12,
marginBottom: 24,
}}
>
{contacts.map((c: Contato) => (
<Card
key={c.idContato}
size="small"
styles={{ body: { padding: '10px 12px' } }}
style={{ borderRadius: 8 }}
>
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 2 }}>
{c.nome}
</Text>
{(c.cargo || c.departamento) && (
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>
{[c.cargo, c.departamento].filter(Boolean).join(' · ')}
</Text>
)}
<ContactActions contato={c} />
{c.dtAniversario && (
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
Aniversário: {new Date(c.dtAniversario + 'T12:00:00').toLocaleDateString('pt-BR')}
</Text>
)}
</Card>
))}
</div>
)}
<Modal
title="Novo Contato"
@@ -178,7 +166,7 @@ export function ClientContacts({ idCliente }: Props) {
confirmLoading={createMutation.isPending}
okText="Cadastrar"
cancelText="Cancelar"
width={560}
width={580}
destroyOnHidden
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional">
@@ -190,40 +178,63 @@ export function ClientContacts({ idCliente }: Props) {
<Input placeholder="Nome do contato" maxLength={100} />
</Form.Item>
<Form.Item name="cargo" label="Cargo">
<Input placeholder="Comprador, Gerente..." maxLength={100} />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="cargo" label="Cargo">
<Input placeholder="Comprador, Gerente..." maxLength={100} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="departamento" label="Departamento">
<Input placeholder="Compras, Financeiro..." maxLength={100} />
</Form.Item>
</Col>
</Row>
<Form.Item name="departamento" label="Departamento">
<Input placeholder="Compras, Financeiro..." maxLength={100} />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="whatsapp" label="WhatsApp">
<Input
placeholder="48999990000"
maxLength={20}
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="celular" label="Celular">
<Input placeholder="48999990000" maxLength={20} />
</Form.Item>
</Col>
</Row>
<Form.Item name="whatsapp" label="WhatsApp">
<Input
placeholder="48999990000"
maxLength={20}
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
/>
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="telefone" label="Telefone fixo">
<Input placeholder="4833330000" maxLength={20} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="email" label="E-mail">
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
</Form.Item>
</Col>
</Row>
<Form.Item name="celular" label="Celular">
<Input placeholder="48999990000" maxLength={20} />
</Form.Item>
<Form.Item name="telefone" label="Telefone fixo">
<Input placeholder="4833330000" maxLength={20} />
</Form.Item>
<Form.Item name="email" label="E-mail">
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
</Form.Item>
<Form.Item name="dtAniversario" label="Aniversário">
<DatePicker format="DD/MM/YYYY" style={{ width: '100%' }} placeholder="dd/mm/aaaa" />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="dtAniversario" label="Aniversário">
<DatePicker
format="DD/MM/YYYY"
style={{ width: '100%' }}
placeholder="dd/mm/aaaa"
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="anotacoes" label="Anotações">
<Input.TextArea rows={3} placeholder="Observações sobre o contato..." />
<Input.TextArea rows={2} placeholder="Observações sobre o contato..." />
</Form.Item>
</Form>
</Modal>

View File

@@ -23,7 +23,7 @@ export function AppShell({ children }: AppShellProps) {
useOfflineSync();
return (
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
{!isOnline && (
<Alert
type="warning"
@@ -35,14 +35,15 @@ export function AppShell({ children }: AppShellProps) {
/>
)}
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<Flex flex={1}>
<Flex flex={1} style={{ overflow: 'hidden' }}>
<Sidebar />
<main
style={{
flex: 1,
padding: 'var(--space-xl)',
overflow: 'auto',
maxWidth: '100%',
overflowY: 'auto',
overflowX: 'hidden',
minWidth: 0,
}}
>
{children}

View File

@@ -6,11 +6,8 @@ import {
faClipboardList,
faClipboardCheck,
faFunnelDollar,
faCalendarDays,
faUsers,
faBoxesStacked,
faGear,
faPercent,
faFileInvoiceDollar,
} from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface';
@@ -28,11 +25,6 @@ export function Sidebar() {
icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
label: 'Painel',
},
{
key: '/agenda',
icon: <FontAwesomeIcon icon={faCalendarDays} fixedWidth />,
label: 'Agenda e Rotas',
},
{
key: '/clientes',
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
@@ -58,24 +50,11 @@ export function Sidebar() {
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Consulta ERP',
},
{
key: '/comissao',
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
label: 'Comissão / FLEX',
},
{
type: 'divider',
},
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
label: 'Relatórios',
},
{
key: '/configuracoes',
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
label: 'Configurações',
},
];
return (

View File

@@ -5,14 +5,24 @@ import {
ClientNovoResultSchema,
ContatoResultSchema,
ContatoSchema,
CtrSummarySchema,
CtrTituloSchema,
NotaFiscalSchema,
PedidoSummarySchema,
TopProdutoSchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type ClientNovoResult,
type Contato,
type ContatoResult,
type CtrSummary,
type CtrTitulo,
type CreateClientNovo,
type CreateContato,
type NotaFiscal,
type PedidoSummary,
type TopProduto,
} from '@sar/api-interface';
import { z } from 'zod';
import { apiFetch } from '../api-client';
@@ -52,6 +62,61 @@ export function useClientDetail(id: number | string | undefined) {
});
}
export function useClientTopProdutos(idCliente: number | undefined, limit = 30) {
return useQuery<TopProduto[]>({
queryKey: ['clients', 'top-produtos', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/top-produtos?limit=${limit}`);
return z.array(TopProdutoSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientOrdersHistory(idCliente: number | undefined, limit = 50) {
return useQuery<PedidoSummary[]>({
queryKey: ['clients', 'orders-history', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/orders-history?limit=${limit}`);
return z.array(PedidoSummarySchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientCtrList(idCliente: number | undefined, limit = 60) {
return useQuery<CtrTitulo[]>({
queryKey: ['clients', 'ctr-list', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/ctr-list?limit=${limit}`);
return z.array(CtrTituloSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientNotasFiscais(idCliente: number | undefined, limit = 30) {
return useQuery<NotaFiscal[]>({
queryKey: ['clients', 'notas', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/notas?limit=${limit}`);
return z.array(NotaFiscalSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientCtr(idCliente: number | undefined) {
return useQuery<CtrSummary>({
queryKey: ['clients', 'ctr', idCliente],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/ctr`);
return CtrSummarySchema.parse(res);
},
enabled: !!idCliente,
});
}
export function useClientContacts(idCliente: number | undefined) {
return useQuery<Contato[]>({
queryKey: ['clients', 'contacts', idCliente],

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import {
ReportMetaResponseSchema,
ReportCarteiraResponseSchema,
ReportAbcResponseSchema,
type ReportMetaQuery,
type ReportAbcQuery,
type ReportMetaResponse,
type ReportCarteiraResponse,
type ReportAbcResponse,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useReportMeta(params: ReportMetaQuery) {
const qs = new URLSearchParams({ mes: String(params.mes), ano: String(params.ano) });
return useQuery<ReportMetaResponse>({
queryKey: ['reports', 'meta', params],
queryFn: async () => ReportMetaResponseSchema.parse(await apiFetch(`/reports/meta?${qs}`)),
});
}
export function useReportCarteira() {
return useQuery<ReportCarteiraResponse>({
queryKey: ['reports', 'carteira'],
queryFn: async () => ReportCarteiraResponseSchema.parse(await apiFetch('/reports/carteira')),
});
}
export function useReportAbc(params: ReportAbcQuery) {
const qs = new URLSearchParams();
if (params.mes != null) qs.set('mes', String(params.mes));
if (params.ano != null) qs.set('ano', String(params.ano));
return useQuery<ReportAbcResponse>({
queryKey: ['reports', 'abc', params],
queryFn: async () => ReportAbcResponseSchema.parse(await apiFetch(`/reports/abc?${qs}`)),
});
}

View File

@@ -17,6 +17,7 @@ import { NewClientPage } from '../cockpits/rep/NewClientPage';
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { authStore } from './auth-store';
@@ -125,6 +126,12 @@ const pedidosErpRoute = createRoute({
component: OrdersErpPage,
});
const relatoriosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/relatorios',
component: RelatoriosPage,
});
const aprovacoes = createRoute({
getParentRoute: () => rootRoute,
path: '/aprovacoes',
@@ -143,6 +150,7 @@ const routeTree = rootRoute.addChildren([
pedidoPrintRoute,
catalogoRoute,
pedidosErpRoute,
relatoriosRoute,
aprovacoes,
]);