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,
|
ProdutoListQuerySchema,
|
||||||
type EmpresaInfo,
|
type EmpresaInfo,
|
||||||
type FormaPagamento,
|
type FormaPagamento,
|
||||||
|
type Municipio,
|
||||||
type Pauta,
|
type Pauta,
|
||||||
type ProdutoDetail,
|
type ProdutoDetail,
|
||||||
type ProdutoListQuery,
|
type ProdutoListQuery,
|
||||||
@@ -19,6 +20,11 @@ class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
|
|||||||
export class CatalogController {
|
export class CatalogController {
|
||||||
constructor(private readonly catalog: CatalogService) {}
|
constructor(private readonly catalog: CatalogService) {}
|
||||||
|
|
||||||
|
@Get('municipios')
|
||||||
|
municipios(@Query('q') q?: string): Promise<Municipio[]> {
|
||||||
|
return this.catalog.municipios(q);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('pautas')
|
@Get('pautas')
|
||||||
pautas(): Promise<Pauta[]> {
|
pautas(): Promise<Pauta[]> {
|
||||||
return this.catalog.pautas();
|
return this.catalog.pautas();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ClsService } from 'nestjs-cls';
|
|||||||
import type {
|
import type {
|
||||||
EmpresaInfo,
|
EmpresaInfo,
|
||||||
FormaPagamento,
|
FormaPagamento,
|
||||||
|
Municipio,
|
||||||
Pauta,
|
Pauta,
|
||||||
ProdutoDetail,
|
ProdutoDetail,
|
||||||
ProdutoListQuery,
|
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[]> {
|
async pautas(): Promise<Pauta[]> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
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 { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
ClientListQuerySchema,
|
ClientListQuerySchema,
|
||||||
|
CreateClientNovoSchema,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
type ClientListQuery,
|
type ClientListQuery,
|
||||||
type ClientListResponse,
|
type ClientListResponse,
|
||||||
|
type ClientNovoResult,
|
||||||
|
type CreateClientNovo,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { ClientsService } from './clients.service';
|
import { ClientsService } from './clients.service';
|
||||||
|
|
||||||
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
|
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
|
||||||
|
class CreateClientNovoDto extends createZodDto(CreateClientNovoSchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'clients' })
|
@Controller({ path: 'clients' })
|
||||||
export class ClientsController {
|
export class ClientsController {
|
||||||
@@ -20,6 +24,12 @@ export class ClientsController {
|
|||||||
return this.clients.list(parsed);
|
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')
|
@Get(':id')
|
||||||
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
|
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
|
||||||
return this.clients.findOne(id);
|
return this.clients.findOne(id);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type {
|
import type {
|
||||||
|
ActivityStatus,
|
||||||
ClientDetail,
|
ClientDetail,
|
||||||
ClientListQuery,
|
ClientListQuery,
|
||||||
ClientListResponse,
|
ClientListResponse,
|
||||||
|
ClientNovoResult,
|
||||||
ClientSummary,
|
ClientSummary,
|
||||||
ActivityStatus,
|
CreateClientNovo,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
@@ -187,6 +189,65 @@ export class ClientsService {
|
|||||||
return { data: mapped, total, page, limit };
|
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> {
|
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|||||||
@@ -1291,7 +1291,7 @@ export function ClientsPage() {
|
|||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
size="large"
|
size="large"
|
||||||
style={{ borderRadius: 8, fontWeight: 600 }}
|
style={{ borderRadius: 8, fontWeight: 600 }}
|
||||||
onClick={() => void msg.info('Cadastro de clientes em breve.')}
|
onClick={() => void navigate({ to: '/clientes/novo' })}
|
||||||
>
|
>
|
||||||
Cadastrar Cliente
|
Cadastrar Cliente
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1318,7 +1318,7 @@ export function ClientsPage() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
style={{ borderRadius: 8, fontWeight: 600 }}
|
style={{ borderRadius: 8, fontWeight: 600 }}
|
||||||
onClick={() => void msg.info('Cadastro de clientes em breve.')}
|
onClick={() => void navigate({ to: '/clientes/novo' })}
|
||||||
>
|
>
|
||||||
Cadastrar Cliente
|
Cadastrar Cliente
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1549,7 +1549,7 @@ export function ClientsPage() {
|
|||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => void msg.info('Cadastro de clientes em breve.')}
|
onClick={() => void navigate({ to: '/clientes/novo' })}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 24,
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
FormaPagamentoSchema,
|
FormaPagamentoSchema,
|
||||||
|
MunicipioSchema,
|
||||||
PautaSchema,
|
PautaSchema,
|
||||||
ProdutoListResponseSchema,
|
ProdutoListResponseSchema,
|
||||||
ProdutoDetailSchema,
|
ProdutoDetailSchema,
|
||||||
type FormaPagamento,
|
type FormaPagamento,
|
||||||
|
type Municipio,
|
||||||
type ProdutoListQuery,
|
type ProdutoListQuery,
|
||||||
type ProdutoListResponse,
|
type ProdutoListResponse,
|
||||||
type ProdutoDetail,
|
type ProdutoDetail,
|
||||||
@@ -13,6 +15,19 @@ import {
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { apiFetch } from '../api-client';
|
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() {
|
export function usePautas() {
|
||||||
return useQuery<Pauta[]>({
|
return useQuery<Pauta[]>({
|
||||||
queryKey: ['catalog', 'pautas'],
|
queryKey: ['catalog', 'pautas'],
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ClientListResponseSchema,
|
|
||||||
ClientDetailSchema,
|
ClientDetailSchema,
|
||||||
|
ClientListResponseSchema,
|
||||||
|
ClientNovoResultSchema,
|
||||||
|
type ClientDetail,
|
||||||
type ClientListQuery,
|
type ClientListQuery,
|
||||||
type ClientListResponse,
|
type ClientListResponse,
|
||||||
type ClientDetail,
|
type ClientNovoResult,
|
||||||
|
type CreateClientNovo,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
@@ -42,3 +45,20 @@ export function useClientDetail(id: number | string | undefined) {
|
|||||||
enabled: !!id,
|
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 { OrdersPage } from '../cockpits/rep/OrdersPage';
|
||||||
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
|
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
|
||||||
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
|
import { OrderPrintPage } from '../cockpits/rep/OrderPrintPage';
|
||||||
|
import { NewClientPage } from '../cockpits/rep/NewClientPage';
|
||||||
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
||||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||||
@@ -75,6 +76,12 @@ const clientesRoute = createRoute({
|
|||||||
component: ClientsPage,
|
component: ClientsPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const novoClienteRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/clientes/novo',
|
||||||
|
component: NewClientPage,
|
||||||
|
});
|
||||||
|
|
||||||
const clienteDetailRoute = createRoute({
|
const clienteDetailRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/clientes/$id',
|
path: '/clientes/$id',
|
||||||
@@ -121,6 +128,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
indexRoute,
|
indexRoute,
|
||||||
rafaelRoute,
|
rafaelRoute,
|
||||||
clientesRoute,
|
clientesRoute,
|
||||||
|
novoClienteRoute,
|
||||||
clienteDetailRoute,
|
clienteDetailRoute,
|
||||||
pedidosRoute,
|
pedidosRoute,
|
||||||
novoOrderRoute,
|
novoOrderRoute,
|
||||||
|
|||||||
@@ -62,3 +62,48 @@ export const ClientListResponseSchema = z.object({
|
|||||||
limit: z.number().int().positive(),
|
limit: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
export type ClientListResponse = z.infer<typeof ClientListResponseSchema>;
|
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 $$
|
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_id_corrent INTEGER;
|
v_id_corrent INTEGER;
|
||||||
|
v_cgcpf CHAR(18);
|
||||||
BEGIN
|
BEGIN
|
||||||
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 (
|
INSERT INTO sig.corrent (
|
||||||
ativo, pesso, consfinal, cgcpf, suf_cgcpf, inscr, nome, razao,
|
ativo, pesso, consfinal, cgcpf, suf_cgcpf, inscr, nome, razao,
|
||||||
endereco, cxpostal, compl, bairr, id_municipio, cep,
|
endereco, cxpostal, compl, bairr, id_municipio, cep,
|
||||||
@@ -391,7 +395,7 @@ BEGIN
|
|||||||
limcred, indicador_ie, cod_formapag, cod_pauta
|
limcred, indicador_ie, cod_formapag, cod_pauta
|
||||||
) VALUES (
|
) VALUES (
|
||||||
1, NEW.pesso, NEW.consfinal,
|
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.nome, 40), ''), LEFT(NEW.razao, 65),
|
||||||
COALESCE(LEFT(NEW.endereco, 60), ''), 0, '',
|
COALESCE(LEFT(NEW.endereco, 60), ''), 0, '',
|
||||||
COALESCE(LEFT(NEW.bairro, 60), ''), NEW.id_municipio, COALESCE(NEW.cep, ''),
|
COALESCE(LEFT(NEW.bairro, 60), ''), NEW.id_municipio, COALESCE(NEW.cep, ''),
|
||||||
|
|||||||
Reference in New Issue
Block a user