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:
@@ -3,16 +3,21 @@ import { createZodDto } from 'nestjs-zod';
|
|||||||
import {
|
import {
|
||||||
ClientListQuerySchema,
|
ClientListQuerySchema,
|
||||||
CreateClientNovoSchema,
|
CreateClientNovoSchema,
|
||||||
|
CreateContatoSchema,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
type ClientListQuery,
|
type ClientListQuery,
|
||||||
type ClientListResponse,
|
type ClientListResponse,
|
||||||
type ClientNovoResult,
|
type ClientNovoResult,
|
||||||
|
type Contato,
|
||||||
|
type ContatoResult,
|
||||||
type CreateClientNovo,
|
type CreateClientNovo,
|
||||||
|
type CreateContato,
|
||||||
} 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) {}
|
class CreateClientNovoDto extends createZodDto(CreateClientNovoSchema) {}
|
||||||
|
class CreateContatoDto extends createZodDto(CreateContatoSchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'clients' })
|
@Controller({ path: 'clients' })
|
||||||
export class ClientsController {
|
export class ClientsController {
|
||||||
@@ -30,6 +35,20 @@ export class ClientsController {
|
|||||||
return this.clients.createNovo(parsed);
|
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')
|
@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);
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import type {
|
|||||||
ClientListResponse,
|
ClientListResponse,
|
||||||
ClientNovoResult,
|
ClientNovoResult,
|
||||||
ClientSummary,
|
ClientSummary,
|
||||||
|
Contato,
|
||||||
|
ContatoResult,
|
||||||
CreateClientNovo,
|
CreateClientNovo,
|
||||||
|
CreateContato,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
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> {
|
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');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { PedidoSummary } from '@sar/api-interface';
|
|||||||
import { SITUA_LABEL } from '@sar/api-interface';
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useClientDetail } from '../../lib/queries/clients';
|
import { useClientDetail } from '../../lib/queries/clients';
|
||||||
import { useClientOrders } from '../../lib/queries/orders';
|
import { useClientOrders } from '../../lib/queries/orders';
|
||||||
|
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
@@ -120,6 +121,8 @@ export function ClientDetailPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
|
<ClientContacts idCliente={idNum} />
|
||||||
|
|
||||||
<Divider orientation="left">Últimos Pedidos</Divider>
|
<Divider orientation="left">Últimos Pedidos</Divider>
|
||||||
|
|
||||||
<Table<PedidoSummary>
|
<Table<PedidoSummary>
|
||||||
|
|||||||
232
apps/web/src/components/contacts/ClientContacts.tsx
Normal file
232
apps/web/src/components/contacts/ClientContacts.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,18 @@ import {
|
|||||||
ClientDetailSchema,
|
ClientDetailSchema,
|
||||||
ClientListResponseSchema,
|
ClientListResponseSchema,
|
||||||
ClientNovoResultSchema,
|
ClientNovoResultSchema,
|
||||||
|
ContatoResultSchema,
|
||||||
|
ContatoSchema,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
type ClientListQuery,
|
type ClientListQuery,
|
||||||
type ClientListResponse,
|
type ClientListResponse,
|
||||||
type ClientNovoResult,
|
type ClientNovoResult,
|
||||||
|
type Contato,
|
||||||
|
type ContatoResult,
|
||||||
type CreateClientNovo,
|
type CreateClientNovo,
|
||||||
|
type CreateContato,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
|
import { z } from 'zod';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
export const CLIENT_KEYS = {
|
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() {
|
export function useCreateClientNovo() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation<ClientNovoResult, Error, CreateClientNovo>({
|
return useMutation<ClientNovoResult, Error, CreateClientNovo>({
|
||||||
|
|||||||
@@ -63,6 +63,54 @@ export const ClientListResponseSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ClientListResponse = z.infer<typeof ClientListResponseSchema>;
|
export type ClientListResponse = z.infer<typeof ClientListResponseSchema>;
|
||||||
|
|
||||||
|
// ─── Contato ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ContatoSchema = z.object({
|
||||||
|
idContato: z.number().int(),
|
||||||
|
idEmpresa: z.number().int(),
|
||||||
|
idCorrent: z.number().int(),
|
||||||
|
idChatwoot: z.number().int().nullable(),
|
||||||
|
nome: z.string(),
|
||||||
|
empresa: z.string().nullable(),
|
||||||
|
cargo: z.string().nullable(),
|
||||||
|
departamento: z.string().nullable(),
|
||||||
|
dtAniversario: z.string().nullable(),
|
||||||
|
telefone: z.string().nullable(),
|
||||||
|
ramal: z.string().nullable(),
|
||||||
|
celular: z.string().nullable(),
|
||||||
|
whatsapp: z.string().nullable(),
|
||||||
|
email: z.string().nullable(),
|
||||||
|
ativo: z.number().int(),
|
||||||
|
anotacoes: z.string().nullable(),
|
||||||
|
nomeCliente: z.string().nullable(),
|
||||||
|
razaoCliente: z.string().nullable(),
|
||||||
|
codVendedor: z.number().int().nullable(),
|
||||||
|
});
|
||||||
|
export type Contato = z.infer<typeof ContatoSchema>;
|
||||||
|
|
||||||
|
export const CreateContatoSchema = z.object({
|
||||||
|
nome: z.string().min(1).max(100),
|
||||||
|
empresa: z.string().max(100).optional(),
|
||||||
|
cargo: z.string().max(100).optional(),
|
||||||
|
departamento: z.string().max(100).optional(),
|
||||||
|
dtAniversario: z.string().optional(),
|
||||||
|
telefone: z.string().max(20).optional(),
|
||||||
|
ramal: z.string().max(10).optional(),
|
||||||
|
celular: z.string().max(20).optional(),
|
||||||
|
whatsapp: z.string().max(20).optional(),
|
||||||
|
email: z.string().max(150).optional(),
|
||||||
|
anotacoes: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type CreateContato = z.infer<typeof CreateContatoSchema>;
|
||||||
|
|
||||||
|
export const ContatoResultSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
idContatoErp: z.number().int().nullable(),
|
||||||
|
sincronizado: z.boolean(),
|
||||||
|
erroSync: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type ContatoResult = z.infer<typeof ContatoResultSchema>;
|
||||||
|
|
||||||
// ─── Municipio ───────────────────────────────────────────────────────────────
|
// ─── Municipio ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const MunicipioSchema = z.object({
|
export const MunicipioSchema = z.object({
|
||||||
|
|||||||
@@ -503,6 +503,37 @@ SELECT
|
|||||||
nome
|
nome
|
||||||
FROM gestao.marca;
|
FROM gestao.marca;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Contatos vinculados a clientes (gestao.contato)
|
||||||
|
-- id_entidade = id_corrent::text (CHAR 20, auto-pad)
|
||||||
|
-- Usado para envio via WhatsApp / ChatWoot futuramente
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW sar.vw_contatos AS
|
||||||
|
SELECT
|
||||||
|
c.id_contato,
|
||||||
|
c.id_empresa,
|
||||||
|
TRIM(c.id_entidade)::integer AS id_corrent,
|
||||||
|
c.id_chatwoot,
|
||||||
|
TRIM(c.nome) AS nome,
|
||||||
|
TRIM(c.empresa) AS empresa,
|
||||||
|
TRIM(c.cargo) AS cargo,
|
||||||
|
TRIM(c.departamento) AS departamento,
|
||||||
|
c.dt_aniversario,
|
||||||
|
TRIM(c.telefone) AS telefone,
|
||||||
|
TRIM(c.ramal) AS ramal,
|
||||||
|
TRIM(c.celular) AS celular,
|
||||||
|
TRIM(c.whatsapp) AS whatsapp,
|
||||||
|
TRIM(c.email) AS email,
|
||||||
|
COALESCE(c.ativo, 1) AS ativo,
|
||||||
|
c.anotacoes,
|
||||||
|
TRIM(cr.nome) AS nome_cliente,
|
||||||
|
TRIM(cr.razao::text) AS razao_cliente,
|
||||||
|
cr.cod_vendedor
|
||||||
|
FROM gestao.contato c
|
||||||
|
LEFT JOIN sig.corrent cr
|
||||||
|
ON cr.id_corrent = TRIM(c.id_entidade)::integer
|
||||||
|
WHERE TRIM(c.id_entidade) ~ '^\d+$';
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- PARTE 2 — TABELAS DE ESCRITA DO SAR
|
-- PARTE 2 — TABELAS DE ESCRITA DO SAR
|
||||||
-- Dados gerados pelo SAR — não existem no ERP.
|
-- Dados gerados pelo SAR — não existem no ERP.
|
||||||
|
|||||||
@@ -448,6 +448,97 @@ CREATE TRIGGER trg_cliente_novo
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION sar.fn_sync_cliente_to_erp();
|
EXECUTE FUNCTION sar.fn_sync_cliente_to_erp();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 7. Tabela staging de contatos criados pelo rep
|
||||||
|
--
|
||||||
|
-- id_entidade em gestao.contato é CHAR(20) — recebe id_corrent::text (auto-pad).
|
||||||
|
-- whatsapp aqui é o número que será usado para envio via ChatWoot.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.contatos_novos (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
id_empresa INTEGER NOT NULL,
|
||||||
|
cod_vendedor INTEGER NOT NULL,
|
||||||
|
id_corrent INTEGER NOT NULL,
|
||||||
|
|
||||||
|
nome VARCHAR(100) NOT NULL,
|
||||||
|
empresa VARCHAR(100),
|
||||||
|
cargo VARCHAR(100),
|
||||||
|
departamento VARCHAR(100),
|
||||||
|
dt_aniversario DATE,
|
||||||
|
telefone VARCHAR(20),
|
||||||
|
ramal VARCHAR(10),
|
||||||
|
celular VARCHAR(20),
|
||||||
|
whatsapp VARCHAR(20),
|
||||||
|
email VARCHAR(150),
|
||||||
|
anotacoes TEXT,
|
||||||
|
|
||||||
|
id_contato_erp INTEGER,
|
||||||
|
sincronizado BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
erro_sync TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_ctnov_corrent ON sar.contatos_novos(id_corrent);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_ctnov_empresa ON sar.contatos_novos(id_empresa);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_ctnov_vend ON sar.contatos_novos(cod_vendedor);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sar_ctnov_sync ON sar.contatos_novos(sincronizado);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 8. Função: replicar contato → gestao.contato
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION sar.fn_sync_contato_to_erp()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
||||||
|
DECLARE
|
||||||
|
v_id_contato INTEGER;
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO gestao.contato (
|
||||||
|
id_empresa, id_entidade,
|
||||||
|
nome, empresa, cargo, departamento,
|
||||||
|
dt_aniversario,
|
||||||
|
telefone, ramal, celular, whatsapp, email,
|
||||||
|
ativo, anotacoes
|
||||||
|
) VALUES (
|
||||||
|
NEW.id_empresa,
|
||||||
|
NEW.id_corrent::text,
|
||||||
|
COALESCE(LEFT(NEW.nome, 100), ''),
|
||||||
|
LEFT(NEW.empresa, 100),
|
||||||
|
LEFT(NEW.cargo, 100),
|
||||||
|
LEFT(NEW.departamento, 100),
|
||||||
|
NEW.dt_aniversario,
|
||||||
|
LEFT(NEW.telefone, 20),
|
||||||
|
LEFT(NEW.ramal, 10),
|
||||||
|
LEFT(NEW.celular, 20),
|
||||||
|
LEFT(NEW.whatsapp, 20),
|
||||||
|
LEFT(NEW.email, 150),
|
||||||
|
1,
|
||||||
|
NEW.anotacoes
|
||||||
|
)
|
||||||
|
RETURNING id_contato INTO v_id_contato;
|
||||||
|
|
||||||
|
UPDATE sar.contatos_novos
|
||||||
|
SET id_contato_erp = v_id_contato,
|
||||||
|
sincronizado = TRUE,
|
||||||
|
erro_sync = NULL
|
||||||
|
WHERE id = NEW.id;
|
||||||
|
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
UPDATE sar.contatos_novos
|
||||||
|
SET sincronizado = FALSE,
|
||||||
|
erro_sync = SQLERRM
|
||||||
|
WHERE id = NEW.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
-- 9. Trigger
|
||||||
|
DROP TRIGGER IF EXISTS trg_contato_novo ON sar.contatos_novos;
|
||||||
|
CREATE TRIGGER trg_contato_novo
|
||||||
|
AFTER INSERT ON sar.contatos_novos
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION sar.fn_sync_contato_to_erp();
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Verificação final
|
-- Verificação final
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user