Representante abre chamado a partir de um pedido e conversa com a empresa por thread de mensagens; o gerente responde e resolve. - API: `SacModule` com 5 rotas do rep (listar, detalhe, criar, responder, cancelar) e 4 da empresa (/ger/chamados: listar, detalhe, responder, resolver). Escopo do rep e por cod_vendedor; o da empresa, por id_empresa. - Modelos `Chamado` + `ChamadoMensagem` e migrations correspondentes. - O chamado pode referenciar tanto um pedido nascido no SAR (id_pedido + num_ped_sar) quanto um pedido historico do ERP (num_ped_erp) — dai id_pedido e num_ped_sar serem nullable. `nome_cliente` fica desnormalizado para evitar join em toda listagem. - Web: `ChamadosPage` (rep), `GerChamadosPage` (gerente) e `AbrirChamadoModal`, acessivel tambem pelo detalhe do pedido. Entradas "SAC" no menu do rep e do gerente. - `useOrderErpConsulta` ganha flag `enabled` para a busca de pedido ERP so disparar quando o modal esta aberto. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import { Badge, Button, Modal, Input, Space, Spin, Table, Tag, Typography, Tabs, Form } from 'antd';
|
|
import { CustomerServiceOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
|
import type { TableColumnsType } from 'antd';
|
|
import {
|
|
STATUS_CHAMADO_LABEL,
|
|
TIPO_CHAMADO_LABEL,
|
|
RESOLUCAO_CHAMADO_LABEL,
|
|
type Chamado,
|
|
type ItemAfetado,
|
|
type ChamadoMensagem,
|
|
type PedidoErpConsultaItem,
|
|
} from '@sar/api-interface';
|
|
import {
|
|
useChamados,
|
|
useAddMensagemRep,
|
|
useCancelChamado,
|
|
useChamado,
|
|
} from '../../lib/queries/sac';
|
|
import { useOrderErpConsulta, useOrderDetail } from '../../lib/queries/orders';
|
|
import { AbrirChamadoModal } from './AbrirChamadoModal';
|
|
|
|
const { Title, Text } = Typography;
|
|
const { TextArea } = Input;
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
aberto: 'blue',
|
|
em_analise: 'orange',
|
|
aguardando_rep: 'purple',
|
|
resolvido: 'green',
|
|
cancelado: 'default',
|
|
};
|
|
|
|
function pedidoLabel(chamado: Chamado) {
|
|
if (chamado.numPedErp) return `ERP #${chamado.numPedErp}`;
|
|
if (chamado.numPedSar) return `SAR-${String(chamado.numPedSar).padStart(5, '0')}`;
|
|
return '—';
|
|
}
|
|
|
|
// Busca pedido ERP por texto livre (cliente, número, etc.) e abre chamado
|
|
function BuscarPedidoModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [searchAtivo, setSearchAtivo] = useState('');
|
|
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoErpConsultaItem | null>(null);
|
|
|
|
// idPedido é o ID interno do ERP (usado no endpoint /orders/erp/:id)
|
|
// numeroPedido é o número externo visível ao usuário
|
|
const erp_id = pedidoSelecionado?.idPedido ?? null;
|
|
const consulta = useOrderErpConsulta({ search: searchAtivo, limit: 20 }, !!searchAtivo);
|
|
const detalhe = useOrderDetail(erp_id ? `erp-${erp_id}` : undefined);
|
|
|
|
function buscar() {
|
|
const trimmed = searchInput.trim();
|
|
if (!trimmed) return;
|
|
setSearchAtivo(trimmed);
|
|
setPedidoSelecionado(null);
|
|
}
|
|
|
|
function fechar() {
|
|
setSearchInput('');
|
|
setSearchAtivo('');
|
|
setPedidoSelecionado(null);
|
|
onClose();
|
|
}
|
|
|
|
// Detalhe carregado — abre o formulário de chamado
|
|
if (detalhe.data && pedidoSelecionado) {
|
|
return (
|
|
<AbrirChamadoModal
|
|
open={true}
|
|
onClose={fechar}
|
|
numPedErp={pedidoSelecionado.numeroPedido}
|
|
nomeCliente={pedidoSelecionado.razaoCliente ?? pedidoSelecionado.nomeCliente}
|
|
idCliente={pedidoSelecionado.idCliente}
|
|
itens={detalhe.data.itens}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const resultados = consulta.data?.data ?? [];
|
|
const carregandoDetalhe = detalhe.isLoading;
|
|
|
|
const colunas: TableColumnsType<PedidoErpConsultaItem> = [
|
|
{ title: 'Pedido', dataIndex: 'numeroPedido', width: 90 },
|
|
{
|
|
title: 'Cliente',
|
|
render: (_: unknown, r: PedidoErpConsultaItem) => r.razaoCliente ?? r.nomeCliente ?? '—',
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: 'Data',
|
|
dataIndex: 'data',
|
|
width: 95,
|
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
|
},
|
|
{
|
|
title: 'Total',
|
|
dataIndex: 'total',
|
|
width: 100,
|
|
render: (v: string) =>
|
|
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Modal
|
|
title="Novo Chamado — Selecionar Pedido"
|
|
open={open}
|
|
onCancel={fechar}
|
|
footer={null}
|
|
width={680}
|
|
centered
|
|
>
|
|
<Space orientation="vertical" style={{ width: '100%', marginTop: 16 }} size="middle">
|
|
<Space.Compact style={{ width: '100%' }}>
|
|
<Input
|
|
prefix={<SearchOutlined />}
|
|
placeholder="Buscar por cliente, número do pedido..."
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
onPressEnter={buscar}
|
|
autoFocus
|
|
/>
|
|
<Button
|
|
type="primary"
|
|
onClick={buscar}
|
|
loading={consulta.isLoading}
|
|
disabled={!searchInput.trim()}
|
|
>
|
|
Buscar
|
|
</Button>
|
|
</Space.Compact>
|
|
|
|
{searchAtivo && (
|
|
<Table<PedidoErpConsultaItem>
|
|
rowKey="numeroPedido"
|
|
columns={colunas}
|
|
dataSource={resultados}
|
|
loading={consulta.isLoading || carregandoDetalhe}
|
|
pagination={false}
|
|
size="small"
|
|
scroll={{ y: 320 }}
|
|
onRow={(r) => ({
|
|
onClick: () => (r.idPedido ? setPedidoSelecionado(r) : undefined),
|
|
style: {
|
|
cursor: r.idPedido ? 'pointer' : 'not-allowed',
|
|
opacity: r.idPedido ? 1 : 0.4,
|
|
},
|
|
})}
|
|
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
|
|
/>
|
|
)}
|
|
</Space>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function ChamadoDetalheModal({
|
|
id,
|
|
open,
|
|
onClose,
|
|
}: {
|
|
id: number;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const [texto, setTexto] = useState('');
|
|
const { data: chamado, isLoading } = useChamado(id);
|
|
const addMsg = useAddMensagemRep(id);
|
|
const cancel = useCancelChamado();
|
|
|
|
function enviar() {
|
|
if (!texto.trim()) return;
|
|
addMsg.mutate({ texto: texto.trim() }, { onSuccess: () => setTexto('') });
|
|
}
|
|
|
|
const fechado = chamado && ['resolvido', 'cancelado'].includes(chamado.status);
|
|
|
|
return (
|
|
<Modal
|
|
title={chamado ? `Chamado #${chamado.id} — ${chamado.assunto}` : 'Chamado'}
|
|
open={open}
|
|
onCancel={onClose}
|
|
footer={null}
|
|
width={700}
|
|
centered
|
|
>
|
|
{isLoading || !chamado ? (
|
|
<Spin />
|
|
) : (
|
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
|
<Space wrap>
|
|
<Tag color={STATUS_COLOR[chamado.status]}>{STATUS_CHAMADO_LABEL[chamado.status]}</Tag>
|
|
<Tag>{TIPO_CHAMADO_LABEL[chamado.tipo] ?? chamado.tipo}</Tag>
|
|
{chamado.resolucao && (
|
|
<Tag color="green">
|
|
{RESOLUCAO_CHAMADO_LABEL[chamado.resolucao] ?? chamado.resolucao}
|
|
</Tag>
|
|
)}
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{pedidoLabel(chamado)}
|
|
{chamado.nomeCliente ? ` · ${chamado.nomeCliente}` : ''}
|
|
</Text>
|
|
</Space>
|
|
|
|
<Text type="secondary">{chamado.descricao}</Text>
|
|
|
|
{chamado.notaResolucao && (
|
|
<div
|
|
style={{
|
|
background: '#f6ffed',
|
|
border: '1px solid #b7eb8f',
|
|
borderRadius: 6,
|
|
padding: '8px 12px',
|
|
}}
|
|
>
|
|
<Text strong>Resolução: </Text>
|
|
<Text>{chamado.notaResolucao}</Text>
|
|
</div>
|
|
)}
|
|
|
|
{chamado.itensAfetados && chamado.itensAfetados.length > 0 && (
|
|
<div>
|
|
<Text strong style={{ fontSize: 12 }}>
|
|
Produtos afetados:{' '}
|
|
</Text>
|
|
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
|
|
<Tag key={`${it.codProduto}-${i}`}>
|
|
{it.codProduto} — {it.nomeProduto} ({it.qtd} un)
|
|
</Tag>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
style={{
|
|
border: '1px solid #f0f0f0',
|
|
borderRadius: 8,
|
|
padding: 12,
|
|
maxHeight: 320,
|
|
overflowY: 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 8,
|
|
}}
|
|
>
|
|
{(chamado.mensagens ?? []).length === 0 ? (
|
|
<Text
|
|
type="secondary"
|
|
style={{ textAlign: 'center', display: 'block', padding: '16px 0' }}
|
|
>
|
|
Sem mensagens ainda.
|
|
</Text>
|
|
) : (
|
|
((chamado.mensagens as ChamadoMensagem[]) ?? []).map((m) => (
|
|
<div
|
|
key={m.id}
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: m.autor === 'rep' ? 'flex-end' : 'flex-start',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
maxWidth: '72%',
|
|
background: m.autor === 'rep' ? '#1677ff' : '#f5f5f5',
|
|
color: m.autor === 'rep' ? '#fff' : '#000',
|
|
borderRadius: 10,
|
|
padding: '8px 12px',
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
|
|
{m.autor === 'rep' ? 'Você' : 'Empresa'}
|
|
</div>
|
|
<div>{m.texto}</div>
|
|
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4, textAlign: 'right' }}>
|
|
{new Date(m.createdAt).toLocaleString('pt-BR')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{!fechado && (
|
|
<Form layout="vertical">
|
|
<Form.Item label="Nova mensagem">
|
|
<TextArea
|
|
rows={2}
|
|
value={texto}
|
|
onChange={(e) => setTexto(e.target.value)}
|
|
maxLength={2000}
|
|
showCount
|
|
/>
|
|
</Form.Item>
|
|
<Space>
|
|
<Button
|
|
type="primary"
|
|
onClick={enviar}
|
|
loading={addMsg.isPending}
|
|
disabled={!texto.trim()}
|
|
>
|
|
Enviar
|
|
</Button>
|
|
<Button
|
|
danger
|
|
onClick={() => cancel.mutate(id, { onSuccess: onClose })}
|
|
loading={cancel.isPending}
|
|
>
|
|
Cancelar Chamado
|
|
</Button>
|
|
</Space>
|
|
</Form>
|
|
)}
|
|
</Space>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const columns: TableColumnsType<Chamado> = [
|
|
{ title: '#', dataIndex: 'id', width: 60 },
|
|
{
|
|
title: 'Status',
|
|
dataIndex: 'status',
|
|
width: 140,
|
|
render: (s: string) => (
|
|
<Tag color={STATUS_COLOR[s] ?? 'default'}>{STATUS_CHAMADO_LABEL[s] ?? s}</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: 'Tipo',
|
|
dataIndex: 'tipo',
|
|
width: 160,
|
|
render: (t: string) => TIPO_CHAMADO_LABEL[t] ?? t,
|
|
},
|
|
{ title: 'Assunto', dataIndex: 'assunto', ellipsis: true },
|
|
{
|
|
title: 'Pedido',
|
|
width: 100,
|
|
render: (_: unknown, r: Chamado) => pedidoLabel(r),
|
|
},
|
|
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160, ellipsis: true },
|
|
{
|
|
title: 'Data',
|
|
dataIndex: 'createdAt',
|
|
width: 110,
|
|
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
|
},
|
|
];
|
|
|
|
export function ChamadosPage() {
|
|
const { data, isLoading } = useChamados();
|
|
const [detalheId, setDetalheId] = useState<number | null>(null);
|
|
const [novoOpen, setNovoOpen] = useState(false);
|
|
|
|
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
|
|
|
const abertos = data?.abertos ?? [];
|
|
const fechados = data?.fechados ?? [];
|
|
const totalAbertos = abertos.length;
|
|
|
|
return (
|
|
<div style={{ padding: 24, maxWidth: 1100, margin: '0 auto' }}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
<Space align="center">
|
|
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
|
<Title level={3} style={{ margin: 0 }}>
|
|
SAC — Chamados
|
|
</Title>
|
|
{totalAbertos > 0 && <Badge count={totalAbertos} />}
|
|
</Space>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNovoOpen(true)}>
|
|
Novo Chamado
|
|
</Button>
|
|
</div>
|
|
|
|
<Tabs
|
|
items={[
|
|
{
|
|
key: 'abertos',
|
|
label: `Abertos (${abertos.length})`,
|
|
children: (
|
|
<Table<Chamado>
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={abertos}
|
|
pagination={false}
|
|
size="small"
|
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
|
locale={{ emptyText: 'Nenhum chamado aberto.' }}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'fechados',
|
|
label: `Fechados (${fechados.length})`,
|
|
children: (
|
|
<Table<Chamado>
|
|
rowKey="id"
|
|
columns={columns}
|
|
dataSource={fechados}
|
|
pagination={{ pageSize: 20 }}
|
|
size="small"
|
|
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
|
locale={{ emptyText: 'Nenhum chamado fechado.' }}
|
|
/>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{detalheId !== null && (
|
|
<ChamadoDetalheModal
|
|
id={detalheId}
|
|
open={detalheId !== null}
|
|
onClose={() => setDetalheId(null)}
|
|
/>
|
|
)}
|
|
|
|
<BuscarPedidoModal open={novoOpen} onClose={() => setNovoOpen(false)} />
|
|
</div>
|
|
);
|
|
}
|