Files
sar/apps/web/src/cockpits/rep/NewClientPage.tsx
julian 518769218f fix(api,web): corrige autorização da área do rep e falhas silenciosas no front
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>
2026-07-21 20:40:19 +00:00

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>
);
}