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:
@@ -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>
|
||||
|
||||
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,
|
||||
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>({
|
||||
|
||||
Reference in New Issue
Block a user