feat(web+api): tela de cadastro de cliente novo (CNPJ/CPF) com sync ERP

Web (NewClientPage):
- Segmentado PJ/CPF com campos específicos por categoria
- PJ: razão social, nome fantasia, CNPJ, IE, indicador IE, consumidor final
- PF: nome completo, CPF
- Endereço com auto-preenchimento via ViaCEP (CEP → rua/bairro/município)
- Município: Select com busca server-side (/catalog/municipios)
- Contato: DDD, telefone, email
- Comercial: forma pagamento, tabela de preços, limite crédito
- Feedback: código ERP exibido no sucesso; alerta de erro de sync sem bloquear

API:
- GET /catalog/municipios?q= — busca municipios da vw_municipios
- POST /clients/novo — INSERT em sar.clientes_novos; trigger sincroniza para sig.corrent
- Retorna { id, idCorrentErp, sincronizado, erroSync } após o trigger disparar
- Fix trigger fn_sync_cliente_to_erp: NULLIF para cgcpf vazio (evita violação
  do unique index key_corrent_cgcpf quando cliente não tem CPF/CNPJ)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:06:27 +00:00
parent ec0b7c1611
commit 236dd69eba
11 changed files with 638 additions and 9 deletions

View File

@@ -1291,7 +1291,7 @@ export function ClientsPage() {
icon={<PlusOutlined />}
size="large"
style={{ borderRadius: 8, fontWeight: 600 }}
onClick={() => void msg.info('Cadastro de clientes em breve.')}
onClick={() => void navigate({ to: '/clientes/novo' })}
>
Cadastrar Cliente
</Button>
@@ -1318,7 +1318,7 @@ export function ClientsPage() {
type="primary"
icon={<PlusOutlined />}
style={{ borderRadius: 8, fontWeight: 600 }}
onClick={() => void msg.info('Cadastro de clientes em breve.')}
onClick={() => void navigate({ to: '/clientes/novo' })}
>
Cadastrar Cliente
</Button>
@@ -1549,7 +1549,7 @@ export function ClientsPage() {
shape="circle"
icon={<PlusOutlined />}
size="large"
onClick={() => void msg.info('Cadastro de clientes em breve.')}
onClick={() => void navigate({ to: '/clientes/novo' })}
style={{
position: 'fixed',
bottom: 24,

View File

@@ -0,0 +1,430 @@
import { useState } from 'react';
import {
Alert,
App,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Row,
Segmented,
Select,
Spin,
Switch,
Typography,
} from 'antd';
import {
ArrowLeftOutlined,
BankOutlined,
CheckCircleOutlined,
EnvironmentOutlined,
PhoneOutlined,
ShopOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import type { CreateClientNovo } from '@sar/api-interface';
import { useMunicipios, usePautas, useFormasPagamento } from '../../lib/queries/catalog';
import { useCreateClientNovo } from '../../lib/queries/clients';
const { Title, Text } = Typography;
type Pessoa = 'PJ' | 'PF';
function Section({
icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<div style={{ marginBottom: 24 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 16,
paddingBottom: 8,
borderBottom: '1px solid #EBF0F5',
}}
>
<span style={{ color: '#003B8E', fontSize: 16 }}>{icon}</span>
<Text strong style={{ fontSize: 14, color: '#003B8E' }}>
{title}
</Text>
</div>
{children}
</div>
);
}
export function NewClientPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const [form] = Form.useForm();
const [pessoa, setPessoa] = useState<Pessoa>('PJ');
const [municipioSearch, setMunicipioSearch] = useState('');
const [syncError, setSyncError] = useState<string | null>(null);
const { data: municipios = [], isFetching: loadingMun } = useMunicipios(
municipioSearch || undefined,
);
const { data: pautas = [] } = usePautas();
const { data: formas = [] } = useFormasPagamento();
const createMutation = useCreateClientNovo();
async function handleCepBlur(cep: string) {
const digits = cep.replace(/\D/g, '');
if (digits.length !== 8) return;
try {
const res = await fetch(`https://viacep.com.br/ws/${digits}/json/`);
const data = (await res.json()) as {
erro?: boolean;
logradouro?: string;
bairro?: string;
localidade?: string;
uf?: string;
};
if (data.erro) return;
if (data.logradouro) form.setFieldValue('endereco', data.logradouro);
if (data.bairro) form.setFieldValue('bairro', data.bairro);
if (data.localidade && data.uf) {
setMunicipioSearch(data.localidade);
}
} catch {
// silencioso — CEP inválido não bloqueia
}
}
async function onFinish(values: Record<string, unknown>) {
setSyncError(null);
const payload: CreateClientNovo = {
pesso: pessoa === 'PJ' ? 0 : 1,
consfinal: values.consfinal ? 1 : 0,
nome: String(values.nome ?? '').slice(0, 40),
razao: String(values.razao ?? values.nome ?? '').slice(0, 65),
cgcpf: values.cgcpf ? String(values.cgcpf) : undefined,
sufCgcpf: '',
inscr: values.inscr ? String(values.inscr) : '',
indicadorIe: values.indicadorIe as 1 | 2 | 9 | undefined,
endereco: String(values.endereco ?? '').slice(0, 60),
numEndereco: String(values.numEndereco ?? '').slice(0, 10),
bairro: String(values.bairro ?? '').slice(0, 60),
idMunicipio: Number(values.idMunicipio),
cep: String(values.cep ?? '')
.replace(/\D/g, '')
.slice(0, 9),
ddd: String(values.ddd ?? '').slice(0, 4),
telefone: String(values.telefone ?? '').slice(0, 35),
email: String(values.email ?? '').slice(0, 80),
limiteCredito: Number(values.limiteCredito ?? 0),
codFormapag: values.codFormapag ? Number(values.codFormapag) : undefined,
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
};
const result = await createMutation.mutateAsync(payload);
if (!result.sincronizado && result.erroSync) {
setSyncError(result.erroSync);
void message.warning('Cliente cadastrado, mas houve erro na sincronização com o ERP.');
} else {
void message.success(
`Cliente cadastrado com sucesso! Código ERP: ${result.idCorrentErp ?? '—'}`,
);
void navigate({ to: '/clientes' });
}
}
const isPJ = pessoa === 'PJ';
return (
<div style={{ maxWidth: 860, margin: '0 auto', padding: '24px 16px' }}>
{/* Cabeçalho */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 24 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => void navigate({ to: '/clientes' })}
style={{ borderRadius: 8 }}
>
Voltar
</Button>
<div>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Cadastrar Cliente
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
Após salvar, o cliente é criado automaticamente no ERP.
</Text>
</div>
</div>
{/* Seletor PJ / PF */}
<div style={{ marginBottom: 24 }}>
<Segmented
size="large"
value={pessoa}
onChange={(v) => {
setPessoa(v as Pessoa);
form.resetFields(['cgcpf', 'inscr', 'indicadorIe', 'consfinal', 'razao']);
}}
options={[
{ value: 'PJ', icon: <BankOutlined />, label: 'CNPJ — Pessoa Jurídica' },
{ value: 'PF', icon: <UserOutlined />, label: 'CPF — Pessoa Física' },
]}
style={{ borderRadius: 10 }}
/>
</div>
<Form
form={form}
layout="vertical"
onFinish={(v) => void onFinish(v as Record<string, unknown>)}
requiredMark="optional"
initialValues={{ consfinal: false, limiteCredito: 0 }}
>
<Card
style={{ borderRadius: 12, border: '1px solid #EBF0F5', marginBottom: 16 }}
styles={{ body: { padding: '20px 24px' } }}
>
{/* ── Identificação ─────────────────────────────────────────── */}
<Section icon={isPJ ? <ShopOutlined /> : <UserOutlined />} title="Identificação">
<Row gutter={[16, 0]}>
{isPJ ? (
<>
<Col xs={24} md={12}>
<Form.Item
name="razao"
label="Razão Social"
rules={[{ required: true, message: 'Informe a razão social' }]}
>
<Input placeholder="Razão Social Ltda" maxLength={65} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="nome"
label="Nome Fantasia"
rules={[{ required: true, message: 'Informe o nome fantasia' }]}
>
<Input placeholder="Nome fantasia" maxLength={40} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="cgcpf" label="CNPJ">
<Input placeholder="00.000.000/0000-00" maxLength={18} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="inscr" label="Inscrição Estadual">
<Input placeholder="000.000.000" maxLength={18} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="indicadorIe" label="Indicador IE">
<Select placeholder="Selecione" allowClear>
<Select.Option value={1}>Contribuinte</Select.Option>
<Select.Option value={2}>Isento</Select.Option>
<Select.Option value={9}>Não contribuinte</Select.Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="consfinal" label="Consumidor Final" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</>
) : (
<>
<Col xs={24} md={16}>
<Form.Item
name="nome"
label="Nome Completo"
rules={[{ required: true, message: 'Informe o nome' }]}
>
<Input placeholder="Nome completo" maxLength={40} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="cgcpf" label="CPF">
<Input placeholder="000.000.000-00" maxLength={14} />
</Form.Item>
</Col>
</>
)}
</Row>
</Section>
{/* ── Endereço ──────────────────────────────────────────────── */}
<Section icon={<EnvironmentOutlined />} title="Endereço">
<Row gutter={[16, 0]}>
<Col xs={24} md={6}>
<Form.Item name="cep" label="CEP">
<Input
placeholder="00000-000"
maxLength={9}
onBlur={(e) => void handleCepBlur(e.target.value)}
/>
</Form.Item>
</Col>
<Col xs={24} md={14}>
<Form.Item name="endereco" label="Endereço">
<Input placeholder="Rua / Av." maxLength={60} />
</Form.Item>
</Col>
<Col xs={24} md={4}>
<Form.Item name="numEndereco" label="Número">
<Input placeholder="123" maxLength={10} />
</Form.Item>
</Col>
<Col xs={24} md={10}>
<Form.Item name="bairro" label="Bairro">
<Input placeholder="Bairro" maxLength={60} />
</Form.Item>
</Col>
<Col xs={24} md={14}>
<Form.Item
name="idMunicipio"
label="Município"
rules={[{ required: true, message: 'Selecione o município' }]}
>
<Select
showSearch
placeholder="Digite para buscar..."
filterOption={false}
onSearch={setMunicipioSearch}
notFoundContent={loadingMun ? <Spin size="small" /> : 'Nenhum resultado'}
optionLabelProp="label"
>
{municipios.map((m) => (
<Select.Option
key={m.idMunicipio}
value={m.idMunicipio}
label={`${m.nome}${m.uf}`}
>
{m.nome}{' '}
<Text type="secondary" style={{ fontSize: 12 }}>
{m.uf}
</Text>
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
</Section>
{/* ── Contato ───────────────────────────────────────────────── */}
<Section icon={<PhoneOutlined />} title="Contato">
<Row gutter={[16, 0]}>
<Col xs={8} md={4}>
<Form.Item name="ddd" label="DDD">
<Input placeholder="48" maxLength={4} />
</Form.Item>
</Col>
<Col xs={16} md={8}>
<Form.Item name="telefone" label="Telefone">
<Input placeholder="99999-9999" maxLength={35} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="email" label="E-mail">
<Input placeholder="contato@empresa.com.br" maxLength={80} type="email" />
</Form.Item>
</Col>
</Row>
</Section>
{/* ── Comercial ─────────────────────────────────────────────── */}
<Section icon={<BankOutlined />} title="Comercial">
<Row gutter={[16, 0]}>
<Col xs={24} md={8}>
<Form.Item name="codFormapag" label="Forma de Pagamento">
<Select placeholder="Selecione" allowClear>
{formas.map((f) => (
<Select.Option key={f.codigo} value={f.codigo}>
{f.descricao}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="codPauta" label="Tabela de Preços">
<Select placeholder="Selecione" allowClear>
{pautas.map((p) => (
<Select.Option key={p.idPauta} value={p.idPauta}>
{p.descricao}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="limiteCredito" label="Limite de Crédito">
<InputNumber
style={{ width: '100%' }}
min={0}
step={100}
formatter={(v) => `R$ ${v ?? 0}`.replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
parser={(v) => Number((v ?? '').replace(/R\$\s?\.*/g, '').replace(',', '.'))}
placeholder="0,00"
/>
</Form.Item>
</Col>
</Row>
</Section>
{syncError && (
<Alert
type="warning"
showIcon
message="Falha na sincronização com o ERP"
description={syncError}
style={{ marginBottom: 16 }}
/>
)}
</Card>
{/* Rodapé sticky */}
<div
style={{
position: 'sticky',
bottom: 0,
background: '#fff',
borderTop: '1px solid #EBF0F5',
padding: '12px 24px',
display: 'flex',
justifyContent: 'flex-end',
gap: 12,
borderRadius: '0 0 12px 12px',
}}
>
<Button
onClick={() => void navigate({ to: '/clientes' })}
style={{ borderRadius: 8 }}
disabled={createMutation.isPending}
>
Cancelar
</Button>
<Button
type="primary"
htmlType="submit"
icon={<CheckCircleOutlined />}
loading={createMutation.isPending}
style={{ borderRadius: 8, fontWeight: 600, minWidth: 160 }}
>
{createMutation.isPending ? 'Cadastrando...' : 'Cadastrar Cliente'}
</Button>
</div>
</Form>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import {
FormaPagamentoSchema,
MunicipioSchema,
PautaSchema,
ProdutoListResponseSchema,
ProdutoDetailSchema,
type FormaPagamento,
type Municipio,
type ProdutoListQuery,
type ProdutoListResponse,
type ProdutoDetail,
@@ -13,6 +15,19 @@ import {
import { z } from 'zod';
import { apiFetch } from '../api-client';
export function useMunicipios(q?: string) {
return useQuery<Municipio[]>({
queryKey: ['catalog', 'municipios', q ?? ''],
queryFn: async () => {
const qs = q ? `?q=${encodeURIComponent(q)}` : '';
const res = await apiFetch(`/catalog/municipios${qs}`);
return z.array(MunicipioSchema).parse(res);
},
staleTime: 24 * 60 * 60 * 1000,
enabled: !q || q.length >= 2,
});
}
export function usePautas() {
return useQuery<Pauta[]>({
queryKey: ['catalog', 'pautas'],

View File

@@ -1,10 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ClientListResponseSchema,
ClientDetailSchema,
ClientListResponseSchema,
ClientNovoResultSchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type ClientDetail,
type ClientNovoResult,
type CreateClientNovo,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
@@ -42,3 +45,20 @@ export function useClientDetail(id: number | string | undefined) {
enabled: !!id,
});
}
export function useCreateClientNovo() {
const queryClient = useQueryClient();
return useMutation<ClientNovoResult, Error, CreateClientNovo>({
mutationFn: async (data) => {
const res = await apiFetch('/clients/novo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return ClientNovoResultSchema.parse(res);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: CLIENT_KEYS.all });
},
});
}

View File

@@ -13,6 +13,7 @@ import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
import { OrdersPage } from '../cockpits/rep/OrdersPage';
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
import { NewClientPage } from '../cockpits/rep/NewClientPage';
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
@@ -75,6 +76,12 @@ const clientesRoute = createRoute({
component: ClientsPage,
});
const novoClienteRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/novo',
component: NewClientPage,
});
const clienteDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/$id',
@@ -121,6 +128,7 @@ const routeTree = rootRoute.addChildren([
indexRoute,
rafaelRoute,
clientesRoute,
novoClienteRoute,
clienteDetailRoute,
pedidosRoute,
novoOrderRoute,