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:
@@ -4,6 +4,7 @@ import {
|
||||
ProdutoListQuerySchema,
|
||||
type EmpresaInfo,
|
||||
type FormaPagamento,
|
||||
type Municipio,
|
||||
type Pauta,
|
||||
type ProdutoDetail,
|
||||
type ProdutoListQuery,
|
||||
@@ -19,6 +20,11 @@ class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
|
||||
export class CatalogController {
|
||||
constructor(private readonly catalog: CatalogService) {}
|
||||
|
||||
@Get('municipios')
|
||||
municipios(@Query('q') q?: string): Promise<Municipio[]> {
|
||||
return this.catalog.municipios(q);
|
||||
}
|
||||
|
||||
@Get('pautas')
|
||||
pautas(): Promise<Pauta[]> {
|
||||
return this.catalog.pautas();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
EmpresaInfo,
|
||||
FormaPagamento,
|
||||
Municipio,
|
||||
Pauta,
|
||||
ProdutoDetail,
|
||||
ProdutoListQuery,
|
||||
@@ -122,6 +123,35 @@ export class CatalogService {
|
||||
};
|
||||
}
|
||||
|
||||
async municipios(q?: string): Promise<Municipio[]> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
interface Row {
|
||||
id_municipio: number;
|
||||
nome: string;
|
||||
uf: string;
|
||||
codigo_ibge: number | null;
|
||||
}
|
||||
|
||||
const searchFilter = q ? `WHERE UPPER(nome) LIKE UPPER('%${escSql(q)}%')` : '';
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||
SELECT id_municipio, TRIM(nome) AS nome, uf::text AS uf, codigo_ibge
|
||||
FROM sar.vw_municipios
|
||||
${searchFilter}
|
||||
ORDER BY nome
|
||||
LIMIT 100
|
||||
`);
|
||||
|
||||
return rows.map((r) => ({
|
||||
idMunicipio: Number(r.id_municipio),
|
||||
nome: r.nome,
|
||||
uf: r.uf,
|
||||
codigoIbge: r.codigo_ibge !== null ? Number(r.codigo_ibge) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async pautas(): Promise<Pauta[]> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
ClientListQuerySchema,
|
||||
CreateClientNovoSchema,
|
||||
type ClientDetail,
|
||||
type ClientListQuery,
|
||||
type ClientListResponse,
|
||||
type ClientNovoResult,
|
||||
type CreateClientNovo,
|
||||
} from '@sar/api-interface';
|
||||
import { ClientsService } from './clients.service';
|
||||
|
||||
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
|
||||
class CreateClientNovoDto extends createZodDto(CreateClientNovoSchema) {}
|
||||
|
||||
@Controller({ path: 'clients' })
|
||||
export class ClientsController {
|
||||
@@ -20,6 +24,12 @@ export class ClientsController {
|
||||
return this.clients.list(parsed);
|
||||
}
|
||||
|
||||
@Post('novo')
|
||||
createNovo(@Body() body: CreateClientNovoDto): Promise<ClientNovoResult> {
|
||||
const parsed = CreateClientNovoSchema.parse(body) as CreateClientNovo;
|
||||
return this.clients.createNovo(parsed);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
|
||||
return this.clients.findOne(id);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
ActivityStatus,
|
||||
ClientDetail,
|
||||
ClientListQuery,
|
||||
ClientListResponse,
|
||||
ClientNovoResult,
|
||||
ClientSummary,
|
||||
ActivityStatus,
|
||||
CreateClientNovo,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
|
||||
@@ -187,6 +189,65 @@ export class ClientsService {
|
||||
return { data: mapped, total, page, limit };
|
||||
}
|
||||
|
||||
async createNovo(dto: CreateClientNovo): Promise<ClientNovoResult> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
id_corrent_erp: number | null;
|
||||
sincronizado: boolean;
|
||||
erro_sync: string | null;
|
||||
}
|
||||
|
||||
const esc = (s: string) => s.replace(/'/g, "''");
|
||||
const opt = (v: string | undefined) => (v != null ? `'${esc(v)}'` : 'NULL');
|
||||
const optN = (v: number | undefined) => (v != null ? String(v) : 'NULL');
|
||||
|
||||
// INSERT retorna id — RETURNING lê valores pré-trigger, então
|
||||
// fazemos SELECT separado para capturar o que o trigger gravou
|
||||
const insertRows = await prisma.$queryRawUnsafe<[{ id: string }]>(`
|
||||
INSERT INTO sar.clientes_novos (
|
||||
id_empresa, cod_vendedor, pesso, consfinal,
|
||||
nome, razao, cgcpf, suf_cgcpf, inscr, indicador_ie,
|
||||
endereco, num_endereco, bairro, id_municipio, cep,
|
||||
ddd, telefone, email, limite_credito, cod_formapag, cod_pauta
|
||||
) VALUES (
|
||||
${idEmpresa}, ${codVendedor}, ${dto.pesso}, ${dto.consfinal},
|
||||
'${esc(dto.nome)}', '${esc(dto.razao)}',
|
||||
${opt(dto.cgcpf)}, '${esc(dto.sufCgcpf ?? '')}',
|
||||
'${esc(dto.inscr ?? '')}', ${optN(dto.indicadorIe)},
|
||||
'${esc(dto.endereco ?? '')}', '${esc(dto.numEndereco ?? '')}',
|
||||
'${esc(dto.bairro ?? '')}', ${dto.idMunicipio}, '${esc(dto.cep ?? '')}',
|
||||
'${esc(dto.ddd ?? '')}', '${esc(dto.telefone ?? '')}',
|
||||
'${esc(dto.email ?? '')}', ${dto.limiteCredito ?? 0},
|
||||
${optN(dto.codFormapag)}, ${optN(dto.codPauta)}
|
||||
)
|
||||
RETURNING id::text
|
||||
`);
|
||||
|
||||
const id = insertRows[0]?.id;
|
||||
if (!id) throw new Error('Falha ao inserir cliente');
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||
SELECT id::text, id_corrent_erp, sincronizado, erro_sync
|
||||
FROM sar.clientes_novos WHERE id = '${id}'
|
||||
`);
|
||||
|
||||
const r = rows[0];
|
||||
if (!r) throw new Error('Falha ao recuperar cliente inserido');
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
idCorrentErp: r.id_corrent_erp !== null ? Number(r.id_corrent_erp) : null,
|
||||
sincronizado: r.sincronizado,
|
||||
erroSync: r.erro_sync,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
@@ -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,
|
||||
|
||||
430
apps/web/src/cockpits/rep/NewClientPage.tsx
Normal file
430
apps/web/src/cockpits/rep/NewClientPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -62,3 +62,48 @@ export const ClientListResponseSchema = z.object({
|
||||
limit: z.number().int().positive(),
|
||||
});
|
||||
export type ClientListResponse = z.infer<typeof ClientListResponseSchema>;
|
||||
|
||||
// ─── Municipio ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const MunicipioSchema = z.object({
|
||||
idMunicipio: z.number().int(),
|
||||
nome: z.string(),
|
||||
uf: z.string(),
|
||||
codigoIbge: z.number().int().nullable(),
|
||||
});
|
||||
export type Municipio = z.infer<typeof MunicipioSchema>;
|
||||
|
||||
// ─── Criar Cliente Novo ───────────────────────────────────────────────────────
|
||||
// pesso: 0=PJ (CNPJ), 1=PF (CPF) — campo do ERP, não renomear
|
||||
// indicadorIe: 1=contribuinte, 2=isento, 9=não contribuinte
|
||||
|
||||
export const CreateClientNovoSchema = z.object({
|
||||
pesso: z.union([z.literal(0), z.literal(1)]),
|
||||
consfinal: z.union([z.literal(0), z.literal(1)]).default(0),
|
||||
nome: z.string().min(1).max(40),
|
||||
razao: z.string().min(1).max(65),
|
||||
cgcpf: z.string().max(18).optional(),
|
||||
sufCgcpf: z.string().max(3).default(''),
|
||||
inscr: z.string().max(18).default(''),
|
||||
indicadorIe: z.union([z.literal(1), z.literal(2), z.literal(9)]).optional(),
|
||||
endereco: z.string().max(60).default(''),
|
||||
numEndereco: z.string().max(10).default(''),
|
||||
bairro: z.string().max(60).default(''),
|
||||
idMunicipio: z.number().int().positive(),
|
||||
cep: z.string().max(9).default(''),
|
||||
ddd: z.string().max(4).default(''),
|
||||
telefone: z.string().max(35).default(''),
|
||||
email: z.string().max(80).default(''),
|
||||
limiteCredito: z.number().nonnegative().default(0),
|
||||
codFormapag: z.number().int().optional(),
|
||||
codPauta: z.number().int().optional(),
|
||||
});
|
||||
export type CreateClientNovo = z.infer<typeof CreateClientNovoSchema>;
|
||||
|
||||
export const ClientNovoResultSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
idCorrentErp: z.number().int().nullable(),
|
||||
sincronizado: z.boolean(),
|
||||
erroSync: z.string().nullable(),
|
||||
});
|
||||
export type ClientNovoResult = z.infer<typeof ClientNovoResultSchema>;
|
||||
|
||||
@@ -366,8 +366,12 @@ CREATE OR REPLACE FUNCTION sar.fn_sync_cliente_to_erp()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_id_corrent INTEGER;
|
||||
v_cgcpf CHAR(18);
|
||||
BEGIN
|
||||
BEGIN
|
||||
-- NULLIF evita conflito no unique index key_corrent_cgcpf quando cgcpf é vazio
|
||||
v_cgcpf := NULLIF(TRIM(COALESCE(NEW.cgcpf, '')), '');
|
||||
|
||||
INSERT INTO sig.corrent (
|
||||
ativo, pesso, consfinal, cgcpf, suf_cgcpf, inscr, nome, razao,
|
||||
endereco, cxpostal, compl, bairr, id_municipio, cep,
|
||||
@@ -391,7 +395,7 @@ BEGIN
|
||||
limcred, indicador_ie, cod_formapag, cod_pauta
|
||||
) VALUES (
|
||||
1, NEW.pesso, NEW.consfinal,
|
||||
COALESCE(NEW.cgcpf, ''), COALESCE(NEW.suf_cgcpf, ''), COALESCE(NEW.inscr, ''),
|
||||
v_cgcpf, COALESCE(NEW.suf_cgcpf, ''), COALESCE(NEW.inscr, ''),
|
||||
COALESCE(LEFT(NEW.nome, 40), ''), LEFT(NEW.razao, 65),
|
||||
COALESCE(LEFT(NEW.endereco, 60), ''), 0, '',
|
||||
COALESCE(LEFT(NEW.bairro, 60), ''), NEW.id_municipio, COALESCE(NEW.cep, ''),
|
||||
|
||||
Reference in New Issue
Block a user