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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
557
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal file
557
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 já 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 mê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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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],
|
||||
|
||||
37
apps/web/src/lib/queries/reports.ts
Normal file
37
apps/web/src/lib/queries/reports.ts
Normal 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}`)),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user