From 34368e80064537f34bd673c3afd0b64fbebd2bd0 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 24 Jun 2026 17:25:48 +0000 Subject: [PATCH] feat(web+api): contatos vinculados a clientes com sync ERP e base WhatsApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../api/src/app/clients/clients.controller.ts | 19 ++ apps/api/src/app/clients/clients.service.ts | 116 +++++++++ .../web/src/cockpits/rep/ClientDetailPage.tsx | 3 + .../components/contacts/ClientContacts.tsx | 232 ++++++++++++++++++ apps/web/src/lib/queries/clients.ts | 34 +++ .../api-interface/src/lib/client.contract.ts | 48 ++++ scripts/sar-erp-schema.sql | 31 +++ scripts/sar-triggers.sql | 91 +++++++ 8 files changed, 574 insertions(+) create mode 100644 apps/web/src/components/contacts/ClientContacts.tsx diff --git a/apps/api/src/app/clients/clients.controller.ts b/apps/api/src/app/clients/clients.controller.ts index 52172c6..fb19822 100644 --- a/apps/api/src/app/clients/clients.controller.ts +++ b/apps/api/src/app/clients/clients.controller.ts @@ -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 { + return this.clients.listContacts(id); + } + + @Post(':id/contacts') + createContato( + @Param('id', ParseIntPipe) id: number, + @Body() body: CreateContatoDto, + ): Promise { + const parsed = CreateContatoSchema.parse(body) as CreateContato; + return this.clients.createContato(id, parsed); + } + @Get(':id') findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.clients.findOne(id); diff --git a/apps/api/src/app/clients/clients.service.ts b/apps/api/src/app/clients/clients.service.ts index 758aa01..8e689ff 100644 --- a/apps/api/src/app/clients/clients.service.ts +++ b/apps/api/src/app/clients/clients.service.ts @@ -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 { + 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(` + 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 { + 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(` + 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(` + 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 { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); diff --git a/apps/web/src/cockpits/rep/ClientDetailPage.tsx b/apps/web/src/cockpits/rep/ClientDetailPage.tsx index 35ea2a5..46922d2 100644 --- a/apps/web/src/cockpits/rep/ClientDetailPage.tsx +++ b/apps/web/src/cockpits/rep/ClientDetailPage.tsx @@ -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() { + + Últimos Pedidos diff --git a/apps/web/src/components/contacts/ClientContacts.tsx b/apps/web/src/components/contacts/ClientContacts.tsx new file mode 100644 index 0000000..3ca576d --- /dev/null +++ b/apps/web/src/components/contacts/ClientContacts.tsx @@ -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 ( + + {wa && ( + + + } + color="#25D366" + style={{ cursor: 'pointer', margin: 0 }} + > + {wa} + + + + )} + {tel && !wa && ( + + } color="blue" style={{ margin: 0 }}> + {tel} + + + )} + {mail && ( + + + } color="default" style={{ margin: 0 }}> + {mail.length > 24 ? mail.slice(0, 22) + '…' : mail} + + + + )} + + ); +} + +const columns: TableColumnsType = [ + { + title: 'Nome', + dataIndex: 'nome', + render: (nome: string, r: Contato) => ( +
+ + {nome} + + {(r.cargo || r.departamento) && ( +
+ + {[r.cargo, r.departamento].filter(Boolean).join(' · ')} + +
+ )} +
+ ), + }, + { + title: 'Contato', + key: 'contato', + render: (_: unknown, r: Contato) => , + }, + { + 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 ( + <> +
+ + Contatos + + +
+ + + rowKey="idContato" + columns={columns} + dataSource={contacts} + loading={isLoading} + pagination={false} + size="small" + locale={{ emptyText: 'Nenhum contato cadastrado.' }} + style={{ marginBottom: 24 }} + /> + + void handleSave()} + onCancel={() => { + setOpen(false); + form.resetFields(); + }} + confirmLoading={createMutation.isPending} + okText="Cadastrar" + cancelText="Cancelar" + width={560} + destroyOnHidden + > +
+ + + + + + + + + + + + + + } + /> + + + + + + + + + + + + + + + + + + + + + +
+
+ + ); +} diff --git a/apps/web/src/lib/queries/clients.ts b/apps/web/src/lib/queries/clients.ts index 9ab89f1..abbd5a9 100644 --- a/apps/web/src/lib/queries/clients.ts +++ b/apps/web/src/lib/queries/clients.ts @@ -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({ + 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({ + 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({ diff --git a/libs/shared/api-interface/src/lib/client.contract.ts b/libs/shared/api-interface/src/lib/client.contract.ts index 0d09841..1df341f 100644 --- a/libs/shared/api-interface/src/lib/client.contract.ts +++ b/libs/shared/api-interface/src/lib/client.contract.ts @@ -63,6 +63,54 @@ export const ClientListResponseSchema = z.object({ }); export type ClientListResponse = z.infer; +// ─── 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; + +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; + +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; + // ─── Municipio ─────────────────────────────────────────────────────────────── export const MunicipioSchema = z.object({ diff --git a/scripts/sar-erp-schema.sql b/scripts/sar-erp-schema.sql index 9a4aaee..4b0dd68 100644 --- a/scripts/sar-erp-schema.sql +++ b/scripts/sar-erp-schema.sql @@ -503,6 +503,37 @@ SELECT nome 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 -- Dados gerados pelo SAR — não existem no ERP. diff --git a/scripts/sar-triggers.sql b/scripts/sar-triggers.sql index 0a64e92..1b5e2c6 100644 --- a/scripts/sar-triggers.sql +++ b/scripts/sar-triggers.sql @@ -448,6 +448,97 @@ CREATE TRIGGER trg_cliente_novo FOR EACH ROW 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 -- ---------------------------------------------------------------------------