feat(web+api): contatos vinculados a clientes com sync ERP e base WhatsApp

DB:
- sar.vw_contatos: view sobre gestao.contato com join sig.corrent
  (id_entidade CHAR20 = id_corrent; cod_vendedor para filtro por rep)
- sar.contatos_novos: staging com nome, cargo, departamento, telefone,
  celular, whatsapp, email, dt_aniversario, anotacoes
- trg_contato_novo: AFTER INSERT → gestao.contato; grava id_contato_erp

API:
- GET  /clients/:id/contacts  — lista contatos ativos do cliente
- POST /clients/:id/contacts  — cria via staging; retorna { idContatoErp, sincronizado }

Web:
- ClientContacts: tabela de contatos no detalhe do cliente
- Links diretos WhatsApp (wa.me) + mailto por contato
- Modal "Novo Contato" com todos os campos; feedback de código ERP no sucesso
- whatsapp é o campo principal para envio futuro via ChatWoot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:25:48 +00:00
parent 236dd69eba
commit 34368e8006
8 changed files with 574 additions and 0 deletions

View File

@@ -3,16 +3,21 @@ import { createZodDto } from 'nestjs-zod';
import {
ClientListQuerySchema,
CreateClientNovoSchema,
CreateContatoSchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type ClientNovoResult,
type Contato,
type ContatoResult,
type CreateClientNovo,
type CreateContato,
} from '@sar/api-interface';
import { ClientsService } from './clients.service';
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
class CreateClientNovoDto extends createZodDto(CreateClientNovoSchema) {}
class CreateContatoDto extends createZodDto(CreateContatoSchema) {}
@Controller({ path: 'clients' })
export class ClientsController {
@@ -30,6 +35,20 @@ export class ClientsController {
return this.clients.createNovo(parsed);
}
@Get(':id/contacts')
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
return this.clients.listContacts(id);
}
@Post(':id/contacts')
createContato(
@Param('id', ParseIntPipe) id: number,
@Body() body: CreateContatoDto,
): Promise<ContatoResult> {
const parsed = CreateContatoSchema.parse(body) as CreateContato;
return this.clients.createContato(id, parsed);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
return this.clients.findOne(id);

View File

@@ -7,7 +7,10 @@ import type {
ClientListResponse,
ClientNovoResult,
ClientSummary,
Contato,
ContatoResult,
CreateClientNovo,
CreateContato,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -248,6 +251,119 @@ export class ClientsService {
};
}
async listContacts(idCorrent: number): Promise<Contato[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
interface Row {
id_contato: number;
id_empresa: number;
id_corrent: number;
id_chatwoot: number | null;
nome: string;
empresa: string | null;
cargo: string | null;
departamento: string | null;
dt_aniversario: string | null;
telefone: string | null;
ramal: string | null;
celular: string | null;
whatsapp: string | null;
email: string | null;
ativo: number;
anotacoes: string | null;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number | null;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id_contato, id_empresa, id_corrent, id_chatwoot,
nome, empresa, cargo, departamento,
dt_aniversario::text, telefone, ramal, celular, whatsapp, email,
ativo, anotacoes, nome_cliente, razao_cliente, cod_vendedor
FROM sar.vw_contatos
WHERE id_corrent = ${idCorrent} AND ativo = 1
ORDER BY nome
`);
return rows.map((r) => ({
idContato: Number(r.id_contato),
idEmpresa: Number(r.id_empresa),
idCorrent: Number(r.id_corrent),
idChatwoot: r.id_chatwoot !== null ? Number(r.id_chatwoot) : null,
nome: r.nome,
empresa: r.empresa,
cargo: r.cargo,
departamento: r.departamento,
dtAniversario: r.dt_aniversario,
telefone: r.telefone,
ramal: r.ramal,
celular: r.celular,
whatsapp: r.whatsapp,
email: r.email,
ativo: Number(r.ativo),
anotacoes: r.anotacoes,
nomeCliente: r.nome_cliente,
razaoCliente: r.razao_cliente,
codVendedor: r.cod_vendedor !== null ? Number(r.cod_vendedor) : null,
}));
}
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
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;
}
const esc = (s: string) => s.replace(/'/g, "''");
const opt = (v: string | undefined) => (v ? `'${esc(v)}'` : 'NULL');
const insertRows = await prisma.$queryRawUnsafe<Row[]>(`
INSERT INTO sar.contatos_novos (
id_empresa, cod_vendedor, id_corrent,
nome, empresa, cargo, departamento,
dt_aniversario, telefone, ramal, celular, whatsapp, email, anotacoes
) VALUES (
${idEmpresa}, ${codVendedor}, ${idCorrent},
'${esc(dto.nome)}',
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
${dto.dtAniversario ? `'${dto.dtAniversario}'` : 'NULL'},
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
)
RETURNING id::text
`);
const id = insertRows[0]?.id;
if (!id) throw new Error('Falha ao inserir contato');
interface ResultRow {
id: string;
id_contato_erp: number | null;
sincronizado: boolean;
erro_sync: string | null;
}
const rows = await prisma.$queryRawUnsafe<ResultRow[]>(`
SELECT id::text, id_contato_erp, sincronizado, erro_sync
FROM sar.contatos_novos WHERE id = '${id}'
`);
const r = rows[0];
if (!r) throw new Error('Falha ao recuperar contato inserido');
return {
id: r.id,
idContatoErp: r.id_contato_erp !== null ? Number(r.id_contato_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');

View File

@@ -5,6 +5,7 @@ import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
import { ClientContacts } from '../../components/contacts/ClientContacts';
const { Title } = Typography;
@@ -120,6 +121,8 @@ export function ClientDetailPage() {
</Descriptions.Item>
</Descriptions>
<ClientContacts idCliente={idNum} />
<Divider orientation="left">Últimos Pedidos</Divider>
<Table<PedidoSummary>

View File

@@ -0,0 +1,232 @@
import { useState } from 'react';
import {
App,
Button,
DatePicker,
Divider,
Form,
Input,
Modal,
Space,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { MailOutlined, PhoneOutlined, PlusOutlined, WhatsAppOutlined } from '@ant-design/icons';
import type { Contato } from '@sar/api-interface';
import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
const { Text } = Typography;
function ContactActions({ contato }: { contato: Contato }) {
const wa = contato.whatsapp?.trim() || contato.celular?.trim();
const tel = contato.telefone?.trim();
const mail = contato.email?.trim();
return (
<Space size={6}>
{wa && (
<Tooltip title={`WhatsApp: ${wa}`}>
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer">
<Tag
icon={<WhatsAppOutlined />}
color="#25D366"
style={{ cursor: 'pointer', margin: 0 }}
>
{wa}
</Tag>
</a>
</Tooltip>
)}
{tel && !wa && (
<Tooltip title={`Telefone: ${tel}`}>
<Tag icon={<PhoneOutlined />} color="blue" style={{ margin: 0 }}>
{tel}
</Tag>
</Tooltip>
)}
{mail && (
<Tooltip title={mail}>
<a href={`mailto:${mail}`}>
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}>
{mail.length > 24 ? mail.slice(0, 22) + '…' : mail}
</Tag>
</a>
</Tooltip>
)}
</Space>
);
}
const columns: TableColumnsType<Contato> = [
{
title: 'Nome',
dataIndex: 'nome',
render: (nome: string, r: Contato) => (
<div>
<Text strong style={{ fontSize: 13 }}>
{nome}
</Text>
{(r.cargo || r.departamento) && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{[r.cargo, r.departamento].filter(Boolean).join(' · ')}
</Text>
</div>
)}
</div>
),
},
{
title: 'Contato',
key: 'contato',
render: (_: unknown, r: Contato) => <ContactActions contato={r} />,
},
{
title: 'Aniversário',
dataIndex: 'dtAniversario',
width: 120,
render: (v: string | null) => (v ? new Date(v + 'T12:00:00').toLocaleDateString('pt-BR') : '—'),
},
];
interface Props {
idCliente: number;
}
export function ClientContacts({ idCliente }: Props) {
const { message } = App.useApp();
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const { data: contacts = [], isLoading } = useClientContacts(idCliente);
const createMutation = useCreateContato(idCliente);
async function handleSave() {
const values = await form.validateFields();
const payload = {
nome: String(values.nome),
empresa: values.empresa || undefined,
cargo: values.cargo || undefined,
departamento: values.departamento || undefined,
dtAniversario: values.dtAniversario
? (values.dtAniversario as { format: (f: string) => string }).format('YYYY-MM-DD')
: undefined,
telefone: values.telefone || undefined,
celular: values.celular || undefined,
whatsapp: values.whatsapp || undefined,
email: values.email || undefined,
anotacoes: values.anotacoes || undefined,
};
const result = await createMutation.mutateAsync(payload);
if (!result.sincronizado && result.erroSync) {
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
} else {
void message.success(`Contato cadastrado! Código ERP: ${result.idContatoErp ?? '—'}`);
}
setOpen(false);
form.resetFields();
}
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}
>
<Divider orientation="left" style={{ margin: 0, flex: 1 }}>
Contatos
</Divider>
<Button
type="default"
size="small"
icon={<PlusOutlined />}
onClick={() => setOpen(true)}
style={{ marginLeft: 16, borderRadius: 6 }}
>
Adicionar
</Button>
</div>
<Table<Contato>
rowKey="idContato"
columns={columns}
dataSource={contacts}
loading={isLoading}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum contato cadastrado.' }}
style={{ marginBottom: 24 }}
/>
<Modal
title="Novo Contato"
open={open}
onOk={() => void handleSave()}
onCancel={() => {
setOpen(false);
form.resetFields();
}}
confirmLoading={createMutation.isPending}
okText="Cadastrar"
cancelText="Cancelar"
width={560}
destroyOnHidden
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional">
<Form.Item
name="nome"
label="Nome"
rules={[{ required: true, message: 'Informe o nome' }]}
>
<Input placeholder="Nome do contato" maxLength={100} />
</Form.Item>
<Form.Item name="cargo" label="Cargo">
<Input placeholder="Comprador, Gerente..." maxLength={100} />
</Form.Item>
<Form.Item name="departamento" label="Departamento">
<Input placeholder="Compras, Financeiro..." maxLength={100} />
</Form.Item>
<Form.Item name="whatsapp" label="WhatsApp">
<Input
placeholder="48999990000"
maxLength={20}
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
/>
</Form.Item>
<Form.Item name="celular" label="Celular">
<Input placeholder="48999990000" maxLength={20} />
</Form.Item>
<Form.Item name="telefone" label="Telefone fixo">
<Input placeholder="4833330000" maxLength={20} />
</Form.Item>
<Form.Item name="email" label="E-mail">
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
</Form.Item>
<Form.Item name="dtAniversario" label="Aniversário">
<DatePicker format="DD/MM/YYYY" style={{ width: '100%' }} placeholder="dd/mm/aaaa" />
</Form.Item>
<Form.Item name="anotacoes" label="Anotações">
<Input.TextArea rows={3} placeholder="Observações sobre o contato..." />
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@@ -3,12 +3,18 @@ import {
ClientDetailSchema,
ClientListResponseSchema,
ClientNovoResultSchema,
ContatoResultSchema,
ContatoSchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type ClientNovoResult,
type Contato,
type ContatoResult,
type CreateClientNovo,
type CreateContato,
} from '@sar/api-interface';
import { z } from 'zod';
import { apiFetch } from '../api-client';
export const CLIENT_KEYS = {
@@ -46,6 +52,34 @@ export function useClientDetail(id: number | string | undefined) {
});
}
export function useClientContacts(idCliente: number | undefined) {
return useQuery<Contato[]>({
queryKey: ['clients', 'contacts', idCliente],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/contacts`);
return z.array(ContatoSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useCreateContato(idCliente: number | undefined) {
const queryClient = useQueryClient();
return useMutation<ContatoResult, Error, CreateContato>({
mutationFn: async (data) => {
const res = await apiFetch(`/clients/${idCliente}/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return ContatoResultSchema.parse(res);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['clients', 'contacts', idCliente] });
},
});
}
export function useCreateClientNovo() {
const queryClient = useQueryClient();
return useMutation<ClientNovoResult, Error, CreateClientNovo>({