Auditoria de segurança + robustez da área do representante antes de produção. API (autorização e injeção): - IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos, histórico, contatos) validam carteira do rep via assertClienteDaCarteira; cliente alheio retorna 404. orders.create idem antes de aceitar idCliente. - Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403). - Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação); dtAniversario de contato validado e escapado. - id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded. - Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela pauta>promo>base + promoção ativa), não só o desconto global. - Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono. Web (erros nunca silenciosos): - Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e politicas — corrige criar oportunidade, abrir chamado e editar políticas. - Handler global de erro no QueryClient (query e mutation viram toast). - Error boundary por rota (payload divergente não derruba o app em branco). - Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis. Qualidade: - Corrige ping.contract.spec (idEmpresa) e zera erros de lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
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,
|
|
};
|
|
|
|
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
|
try {
|
|
result = await createMutation.mutateAsync(payload);
|
|
} catch {
|
|
return; // erro já notificado pelo handler global do QueryClient
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|