feat(api,web): SAC de chamados sobre pedidos

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>
This commit is contained in:
2026-07-20 18:16:16 +00:00
parent 264305386d
commit 2649bc9e94
17 changed files with 1553 additions and 6 deletions

View File

@@ -0,0 +1,33 @@
-- CreateTable chamados
CREATE TABLE IF NOT EXISTS sar.chamados (
id SERIAL PRIMARY KEY,
id_empresa INTEGER NOT NULL,
cod_vendedor INTEGER NOT NULL,
id_pedido UUID NOT NULL,
num_ped_sar INTEGER NOT NULL,
id_cliente INTEGER NOT NULL,
tipo VARCHAR(50) NOT NULL,
assunto VARCHAR(200) NOT NULL,
descricao TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'aberto',
resolucao VARCHAR(50),
nota_resolucao TEXT,
itens_afetados JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_status ON sar.chamados(id_empresa, status);
CREATE INDEX IF NOT EXISTS idx_chamados_pedido ON sar.chamados(id_pedido);
-- CreateTable chamado_mensagens
CREATE TABLE IF NOT EXISTS sar.chamado_mensagens (
id SERIAL PRIMARY KEY,
id_chamado INTEGER NOT NULL REFERENCES sar.chamados(id) ON DELETE CASCADE,
autor VARCHAR(10) NOT NULL,
texto TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagens(id_chamado);

View File

@@ -0,0 +1,12 @@
-- Torna id_pedido e num_ped_sar opcionais (chamados podem referenciar pedidos ERP)
ALTER TABLE sar.chamados ALTER COLUMN id_pedido DROP NOT NULL;
ALTER TABLE sar.chamados ALTER COLUMN num_ped_sar DROP NOT NULL;
-- Referência ao pedido ERP (numero inteiro do SIG)
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS num_ped_erp INTEGER;
-- Nome do cliente desnormalizado (evita join em toda consulta)
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS nome_cliente VARCHAR(200);
-- Remove index por id_pedido (agora nullable, menos útil como index único)
DROP INDEX IF EXISTS sar.idx_chamados_pedido;

View File

@@ -197,6 +197,50 @@ model Oportunidade {
@@map("oportunidades")
}
// ─── Chamados SAC ────────────────────────────────────────────────────────────
//
// Chamado aberto pelo representante vinculado a um pedido SAR.
// tipo: avaria | quantidade_divergente | produto_errado | prazo_entrega | outro
// status: aberto | em_analise | aguardando_rep | resolvido | cancelado
// resolucao: reposicao | desconto | cancelamento_item | sem_acao | outro
model Chamado {
id Int @id @default(autoincrement())
idEmpresa Int @map("id_empresa")
codVendedor Int @map("cod_vendedor")
idPedido String? @db.Uuid @map("id_pedido")
numPedSar Int? @map("num_ped_sar")
numPedErp Int? @map("num_ped_erp")
idCliente Int @map("id_cliente")
nomeCliente String? @db.VarChar(200) @map("nome_cliente")
tipo String @db.VarChar(50)
assunto String @db.VarChar(200)
descricao String
status String @default("aberto") @db.VarChar(30)
resolucao String? @db.VarChar(50)
notaResolucao String? @map("nota_resolucao")
itensAfetados Json? @map("itens_afetados")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
mensagens ChamadoMensagem[]
@@index([idEmpresa, codVendedor])
@@index([idEmpresa, status])
@@map("chamados")
}
model ChamadoMensagem {
id Int @id @default(autoincrement())
idChamado Int @map("id_chamado")
autor String @db.VarChar(10)
texto String
createdAt DateTime @default(now()) @map("created_at")
chamado Chamado @relation(fields: [idChamado], references: [id], onDelete: Cascade)
@@index([idChamado])
@@map("chamado_mensagens")
}
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
//
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.

View File

@@ -17,6 +17,7 @@ import { ReportsModule } from './reports/reports.module';
import { PoliticasModule } from './politicas/politicas.module';
import { EquipeModule } from './equipe/equipe.module';
import { FunilModule } from './funil/funil.module';
import { SacModule } from './sac/sac.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -37,6 +38,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
PoliticasModule,
EquipeModule,
FunilModule,
SacModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -0,0 +1,63 @@
import { Controller, Get, Post, Patch, Param, ParseIntPipe, Body } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
import { SacService } from './sac.service';
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
class AddMensagemDto extends createZodDto(AddMensagemSchema) {}
class ResolverDto extends createZodDto(ResolverChamadoSchema) {}
@Controller('chamados')
export class SacRepController {
constructor(private readonly sac: SacService) {}
@Get()
listar() {
return this.sac.listarRep();
}
@Get(':id')
detalhe(@Param('id', ParseIntPipe) id: number) {
return this.sac.detalhe(id, 'rep');
}
@Post()
criar(@Body() dto: CreateChamadoDto) {
return this.sac.criar(CreateChamadoSchema.parse(dto));
}
@Post(':id/mensagens')
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
return this.sac.addMensagemRep(id, AddMensagemSchema.parse(dto));
}
@Patch(':id/cancelar')
cancelar(@Param('id', ParseIntPipe) id: number) {
return this.sac.cancelarRep(id);
}
}
@Controller('ger/chamados')
export class SacGerController {
constructor(private readonly sac: SacService) {}
@Get()
listar() {
return this.sac.listarEmpresa();
}
@Get(':id')
detalhe(@Param('id', ParseIntPipe) id: number) {
return this.sac.detalhe(id, 'empresa');
}
@Post(':id/mensagens')
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
}
@Patch(':id/resolver')
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SacService } from './sac.service';
import { SacRepController, SacGerController } from './sac.controller';
@Module({
controllers: [SacRepController, SacGerController],
providers: [SacService],
})
export class SacModule {}

View File

@@ -0,0 +1,177 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { CreateChamadoDto, AddMensagemDto, ResolverChamadoDto } from '@sar/api-interface';
type PrismaCtx = {
chamado: {
findMany: (args: object) => Promise<unknown[]>;
findFirst: (args: object) => Promise<unknown | null>;
findUnique: (args: object) => Promise<unknown | null>;
create: (args: object) => Promise<unknown>;
update: (args: object) => Promise<unknown>;
};
chamadoMensagem: { create: (args: object) => Promise<unknown> };
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
};
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
@Injectable()
export class SacService {
constructor(private readonly cls: ClsService) {}
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
const prisma = this.cls.get('prisma') as PrismaCtx;
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa') as number;
const userId = this.cls.get('userId') as string | undefined;
const codVendedor = userId ? parseInt(userId, 10) : 0;
return { prisma, idEmpresa, codVendedor };
}
private async enrichNomes(
prisma: PrismaCtx,
idEmpresa: number,
rows: { idCliente: number }[],
): Promise<Map<number, string>> {
const ids = [...new Set(rows.map((r) => r.idCliente))];
if (!ids.length) return new Map();
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids.join(',')}) AND id_empresa = ${idEmpresa}`,
);
const map = new Map<number, string>();
for (const c of clientes) {
map.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
}
return map;
}
async listarRep() {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const all = (await prisma.chamado.findMany({
where: { idEmpresa, codVendedor },
orderBy: { updatedAt: 'desc' },
})) as { idCliente: number; status: string; [k: string]: unknown }[];
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
const enrich = (row: (typeof all)[0]) => ({
...row,
nomeCliente:
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
});
return {
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
};
}
async listarEmpresa() {
const { prisma, idEmpresa } = this.ctx();
const all = (await prisma.chamado.findMany({
where: { idEmpresa },
orderBy: { updatedAt: 'desc' },
})) as { idCliente: number; status: string; [k: string]: unknown }[];
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
const enrich = (row: (typeof all)[0]) => ({
...row,
nomeCliente:
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
});
return {
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
};
}
async detalhe(id: number, role: 'rep' | 'empresa') {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const chamado = (await prisma.chamado.findFirst({
where: { id, idEmpresa },
include: { mensagens: { orderBy: { createdAt: 'asc' } } },
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
if (!chamado) throw new NotFoundException('Chamado não encontrado');
if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException();
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
}
async criar(dto: CreateChamadoDto) {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const chamado = (await prisma.chamado.create({
data: {
idEmpresa,
codVendedor,
idPedido: dto.idPedido ?? null,
numPedSar: dto.numPedSar ?? null,
numPedErp: dto.numPedErp ?? null,
nomeCliente: dto.nomeCliente ?? null,
idCliente: dto.idCliente,
tipo: dto.tipo,
assunto: dto.assunto,
descricao: dto.descricao,
itensAfetados: dto.itensAfetados ?? [],
},
include: { mensagens: true },
})) as { idCliente: number; nomeCliente: string | null; [k: string]: unknown };
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
return { ...chamado, nomeCliente: chamado.nomeCliente ?? nomes.get(chamado.idCliente) ?? null };
}
async addMensagemRep(id: number, dto: AddMensagemDto) {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
codVendedor: number;
} | null;
if (!c) throw new NotFoundException();
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
await prisma.chamadoMensagem.create({
data: { idChamado: id, autor: 'rep', texto: dto.texto },
});
await prisma.chamado.update({ where: { id }, data: { status: 'em_analise' } });
return this.detalhe(id, 'rep');
}
async addMensagemEmpresa(id: number, dto: AddMensagemDto) {
const { prisma, idEmpresa } = this.ctx();
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
if (!c) throw new NotFoundException();
await prisma.chamadoMensagem.create({
data: { idChamado: id, autor: 'empresa', texto: dto.texto },
});
await prisma.chamado.update({ where: { id }, data: { status: 'aguardando_rep' } });
return this.detalhe(id, 'empresa');
}
async resolver(id: number, dto: ResolverChamadoDto) {
const { prisma, idEmpresa } = this.ctx();
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
if (!c) throw new NotFoundException();
if (dto.mensagemFinal) {
await prisma.chamadoMensagem.create({
data: { idChamado: id, autor: 'empresa', texto: dto.mensagemFinal },
});
}
await prisma.chamado.update({
where: { id },
data: {
status: 'resolvido',
resolucao: dto.resolucao,
notaResolucao: dto.notaResolucao ?? null,
},
});
return this.detalhe(id, 'empresa');
}
async cancelarRep(id: number) {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
codVendedor: number;
status: string;
} | null;
if (!c) throw new NotFoundException();
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
if (['resolvido', 'cancelado'].includes(c.status))
throw new ForbiddenException('Chamado já encerrado');
await prisma.chamado.update({ where: { id }, data: { status: 'cancelado' } });
return this.detalhe(id, 'rep');
}
}

View File

@@ -0,0 +1,346 @@
import { useState } from 'react';
import {
Badge,
Button,
Form,
Input,
Modal,
Select,
Space,
Spin,
Table,
Tag,
Typography,
Tabs,
} from 'antd';
import { CustomerServiceOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd';
import {
STATUS_CHAMADO_LABEL,
TIPO_CHAMADO_LABEL,
RESOLUCAO_CHAMADO,
RESOLUCAO_CHAMADO_LABEL,
type Chamado,
type ChamadoMensagem,
type ItemAfetado,
type ResolverChamadoDto,
} from '@sar/api-interface';
import {
useChamadosGer,
useChamadoGer,
useAddMensagemEmpresa,
useResolverChamado,
} from '../../lib/queries/sac';
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 ResolverModal({ id, open, onClose }: { id: number; open: boolean; onClose: () => void }) {
const [form] = Form.useForm<ResolverChamadoDto>();
const resolver = useResolverChamado(id);
function handleOk() {
form.validateFields().then((values) => {
resolver.mutate(values, {
onSuccess: () => {
form.resetFields();
onClose();
},
});
});
}
return (
<Modal
title="Resolver Chamado"
open={open}
onOk={handleOk}
onCancel={() => {
form.resetFields();
onClose();
}}
okText="Confirmar Resolução"
cancelText="Voltar"
confirmLoading={resolver.isPending}
width={560}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="resolucao" label="Tipo de Resolução" rules={[{ required: true }]}>
<Select placeholder="Selecione...">
{RESOLUCAO_CHAMADO.map((r) => (
<Select.Option key={r} value={r}>
{RESOLUCAO_CHAMADO_LABEL[r]}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="notaResolucao" label="Nota / Detalhes">
<TextArea rows={3} maxLength={1000} showCount />
</Form.Item>
<Form.Item name="mensagemFinal" label="Mensagem final para o representante (opcional)">
<TextArea rows={2} maxLength={500} showCount />
</Form.Item>
</Form>
</Modal>
);
}
function ChamadoDetalheModal({
id,
open,
onClose,
}: {
id: number;
open: boolean;
onClose: () => void;
}) {
const [texto, setTexto] = useState('');
const [resolverOpen, setResolverOpen] = useState(false);
const { data: chamado, isLoading } = useChamadoGer(id);
const addMsg = useAddMensagemEmpresa(id);
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={720}
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>
<Text type="secondary">Rep: {chamado.codVendedor}</Text>
<Text type="secondary">Cliente: {chamado.nomeCliente ?? chamado.idCliente}</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>Itens afetados: </Text>
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
<Tag key={`${it.codProduto}-${i}`}>
{it.codProduto} {it.nomeProduto} (Qtd: {it.qtd})
</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 === 'empresa' ? 'flex-end' : 'flex-start',
}}
>
<div
style={{
maxWidth: '72%',
background: m.autor === 'empresa' ? '#1677ff' : '#f5f5f5',
color: m.autor === 'empresa' ? '#fff' : '#000',
borderRadius: 10,
padding: '8px 12px',
fontSize: 13,
}}
>
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
{m.autor === 'empresa' ? 'Empresa' : 'Representante'}
</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="Resposta">
<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()}
>
Responder
</Button>
<Button
type="primary"
style={{ backgroundColor: '#52c41a', borderColor: '#52c41a' }}
onClick={() => setResolverOpen(true)}
>
Resolver
</Button>
</Space>
</Form>
)}
</Space>
)}
</Modal>
<ResolverModal id={id} open={resolverOpen} onClose={() => setResolverOpen(false)} />
</>
);
}
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: 'Rep', dataIndex: 'codVendedor', width: 70 },
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160 },
{
title: 'Atualizado',
dataIndex: 'updatedAt',
width: 110,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
export function GerChamadosPage() {
const { data, isLoading } = useChamadosGer();
const [detalheId, setDetalheId] = useState<number | null>(null);
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
const abertos = data?.abertos ?? [];
const fechados = data?.fechados ?? [];
return (
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
<Space align="center" style={{ marginBottom: 24 }}>
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
<Title level={3} style={{ margin: 0 }}>
SAC Chamados da Empresa
</Title>
{abertos.length > 0 && <Badge count={abertos.length} />}
</Space>
<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)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react';
import { Modal, Form, Select, Input, Checkbox, Typography, Space } from 'antd';
import { TIPOS_CHAMADO, TIPO_CHAMADO_LABEL, type PedidoItem } from '@sar/api-interface';
import { useCreateChamado } from '../../lib/queries/sac';
const { TextArea } = Input;
const { Text } = Typography;
interface Props {
open: boolean;
onClose: () => void;
idPedido?: string;
numPedSar?: number;
numPedErp?: number;
nomeCliente?: string | null;
idCliente: number;
itens: PedidoItem[];
}
export function AbrirChamadoModal({
open,
onClose,
idPedido,
numPedSar,
numPedErp,
nomeCliente,
idCliente,
itens,
}: Props) {
const [form] = Form.useForm();
const [itensSelecionados, setItensSelecionados] = useState<string[]>([]);
const criar = useCreateChamado();
const pedidoLabel = numPedErp
? `ERP #${numPedErp}`
: numPedSar
? `SAR-${String(numPedSar).padStart(5, '0')}`
: 'Pedido';
function handleOk() {
form.validateFields().then((values) => {
const itensAfetados = itens
.filter((it) => itensSelecionados.includes(it.id))
.map((it) => ({
codProduto: it.codProduto ?? '',
nomeProduto: it.descProduto ?? it.codProduto ?? '',
qtd: Number(it.qtd),
}));
criar.mutate(
{
idPedido,
numPedSar,
numPedErp,
nomeCliente: nomeCliente ?? undefined,
idCliente,
tipo: values.tipo,
assunto: values.assunto,
descricao: values.descricao,
itensAfetados,
},
{
onSuccess: () => {
form.resetFields();
setItensSelecionados([]);
onClose();
},
},
);
});
}
function handleCancel() {
form.resetFields();
setItensSelecionados([]);
onClose();
}
return (
<Modal
title={
<span>
Abrir Chamado {pedidoLabel}
{nomeCliente && (
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 8 }}>
· {nomeCliente}
</Text>
)}
</span>
}
open={open}
onOk={handleOk}
onCancel={handleCancel}
okText="Abrir Chamado"
cancelText="Cancelar"
confirmLoading={criar.isPending}
width={640}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="tipo"
label="Tipo de Ocorrência"
rules={[{ required: true, message: 'Selecione o tipo' }]}
>
<Select placeholder="Selecione...">
{TIPOS_CHAMADO.map((t) => (
<Select.Option key={t} value={t}>
{TIPO_CHAMADO_LABEL[t]}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="assunto"
label="Assunto"
rules={[{ required: true, min: 5, message: 'Mínimo 5 caracteres' }]}
>
<Input maxLength={200} showCount placeholder="Resumo breve do problema..." />
</Form.Item>
<Form.Item
name="descricao"
label="Descrição"
rules={[{ required: true, min: 10, message: 'Mínimo 10 caracteres' }]}
>
<TextArea
rows={4}
maxLength={2000}
showCount
placeholder="Descreva detalhadamente o problema..."
/>
</Form.Item>
{itens.length > 0 && (
<Form.Item label="Produtos Afetados (opcional)">
<Space orientation="vertical" style={{ width: '100%' }}>
{itens.map((it) => (
<Checkbox
key={it.id}
checked={itensSelecionados.includes(it.id)}
onChange={(e) => {
if (e.target.checked) {
setItensSelecionados((prev) => [...prev, it.id]);
} else {
setItensSelecionados((prev) => prev.filter((c) => c !== it.id));
}
}}
>
<Text>
{it.codProduto} {it.descProduto ?? it.codProduto}
</Text>
<Text type="secondary"> (Qtd: {it.qtd})</Text>
</Checkbox>
))}
</Space>
</Form.Item>
)}
</Form>
</Modal>
);
}

View File

@@ -0,0 +1,432 @@
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>
);
}

View File

@@ -20,7 +20,8 @@ import {
} from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
import { FilePdfOutlined } from '@ant-design/icons';
import { FilePdfOutlined, CustomerServiceOutlined } from '@ant-design/icons';
import { AbrirChamadoModal } from './AbrirChamadoModal';
import type { TableColumnsType } from 'antd';
import { Link, useParams, useNavigate } from '@tanstack/react-router';
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
@@ -118,7 +119,7 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
: SITUA_COLOR[h.situaNova] === 'error'
? 'red'
: 'blue',
children: (
content: (
<div>
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
{h.situaAnterior != null && (
@@ -263,6 +264,7 @@ export function OrderDetailPage() {
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [chamadoOpen, setChamadoOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const approveMutation = useMutation({
@@ -383,6 +385,11 @@ export function OrderDetailPage() {
Compartilhar
</Button>
)}
{role === 'rep' && !isErp && (order.situa === 2 || order.situa === 4) && (
<Button icon={<CustomerServiceOutlined />} onClick={() => setChamadoOpen(true)}>
Abrir Chamado
</Button>
)}
</Space>
{actionError && (
@@ -447,7 +454,7 @@ export function OrderDetailPage() {
)}
</Descriptions>
<Divider orientation="left">Itens ({order.itens.length})</Divider>
<Divider titlePlacement="left">Itens ({order.itens.length})</Divider>
<Table<PedidoItem>
rowKey="id"
columns={itemColumns}
@@ -459,7 +466,7 @@ export function OrderDetailPage() {
{clientOrders && clientOrders.length > 0 && (
<>
<Divider orientation="left">Outros Pedidos do Cliente</Divider>
<Divider titlePlacement="left">Outros Pedidos do Cliente</Divider>
<Table
rowKey="id"
size="small"
@@ -501,7 +508,7 @@ export function OrderDetailPage() {
</>
)}
<Divider orientation="left">Histórico do Pedido</Divider>
<Divider titlePlacement="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.historico} />
<ApproveModal
@@ -517,6 +524,17 @@ export function OrderDetailPage() {
onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending}
/>
{order && (
<AbrirChamadoModal
open={chamadoOpen}
onClose={() => setChamadoOpen(false)}
idPedido={order.id}
numPedSar={parseInt(order.numPedSar.replace(/\D/g, ''), 10) || undefined}
nomeCliente={order.razaoCliente ?? order.nomeCliente}
idCliente={order.idCliente}
itens={order.itens}
/>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
faBoxesStacked,
faFileInvoiceDollar,
faTags,
faHeadset,
} from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface';
import { authStore } from '../../lib/auth-store';
@@ -49,6 +50,7 @@ const REP_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Consulta ERP',
},
{ key: '/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
@@ -99,6 +101,7 @@ const GERENTE_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
label: 'Políticas Comerciais',
},
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
];
function getItems(role: string): ItemType[] {

View File

@@ -59,7 +59,7 @@ export function useClientOrders(idCliente: number | undefined) {
});
}
export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}) {
export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}, enabled = true) {
const search = new URLSearchParams();
if (params.search) search.set('search', params.search);
if (params.from) search.set('from', params.from);
@@ -70,6 +70,7 @@ export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}
const qs = search.toString();
return useQuery<PedidoErpConsultaResponse>({
queryKey: ['orders-erp-consulta', params],
enabled,
queryFn: async () => {
const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`);
return PedidoErpConsultaResponseSchema.parse(res);

View File

@@ -0,0 +1,104 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ChamadoListResponseSchema,
ChamadoSchema,
type CreateChamadoDto,
type AddMensagemDto,
type ResolverChamadoDto,
type Chamado,
type ChamadoListResponse,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useChamados() {
return useQuery<ChamadoListResponse>({
queryKey: ['chamados'],
queryFn: async () => ChamadoListResponseSchema.parse(await apiFetch('/chamados')),
});
}
export function useChamado(id: number) {
return useQuery<Chamado>({
queryKey: ['chamados', id],
queryFn: async () => ChamadoSchema.parse(await apiFetch(`/chamados/${id}`)),
enabled: id > 0,
});
}
export function useCreateChamado() {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
apiFetch('/chamados', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
ChamadoSchema.parse(r),
),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
});
}
export function useAddMensagemRep(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
(r) => ChamadoSchema.parse(r),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['chamados', id] });
qc.invalidateQueries({ queryKey: ['chamados'] });
},
});
}
export function useCancelChamado() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number): Promise<Chamado> =>
apiFetch(`/chamados/${id}/cancelar`, { method: 'PATCH' }).then((r) => ChamadoSchema.parse(r)),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
});
}
// Gerente
export function useChamadosGer() {
return useQuery<ChamadoListResponse>({
queryKey: ['ger', 'chamados'],
queryFn: async () => ChamadoListResponseSchema.parse(await apiFetch('/ger/chamados')),
});
}
export function useChamadoGer(id: number) {
return useQuery<Chamado>({
queryKey: ['ger', 'chamados', id],
queryFn: async () => ChamadoSchema.parse(await apiFetch(`/ger/chamados/${id}`)),
enabled: id > 0,
});
}
export function useAddMensagemEmpresa(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
(r) => ChamadoSchema.parse(r),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
qc.invalidateQueries({ queryKey: ['ger', 'chamados'] });
},
});
}
export function useResolverChamado(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: JSON.stringify(dto) }).then(
(r) => ChamadoSchema.parse(r),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
qc.invalidateQueries({ queryKey: ['ger', 'chamados'] });
},
});
}

View File

@@ -24,6 +24,8 @@ import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { GerPainel } from '../cockpits/ger/GerPainel';
import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
import { ChamadosPage } from '../cockpits/rep/ChamadosPage';
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
import { authStore } from './auth-store';
function getRoleFromToken(): string {
@@ -168,6 +170,18 @@ const politicasRoute = createRoute({
component: PoliticasPage,
});
const chamadosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/chamados',
component: ChamadosPage,
});
const gerChamadosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/chamados',
component: GerChamadosPage,
});
const routeTree = rootRoute.addChildren([
indexRoute,
rafaelRoute,
@@ -186,6 +200,8 @@ const routeTree = rootRoute.addChildren([
gerPainelRoute,
equipeRoute,
politicasRoute,
chamadosRoute,
gerChamadosRoute,
]);
export const router = createRouter({

View File

@@ -10,3 +10,4 @@ export * from './lib/report.contract';
export * from './lib/politicas.contract';
export * from './lib/equipe.contract';
export * from './lib/funil.contract';
export * from './lib/sac.contract';

View File

@@ -0,0 +1,127 @@
import { z } from 'zod';
export const TIPOS_CHAMADO = [
'avaria',
'quantidade_divergente',
'produto_errado',
'prazo_entrega',
'outro',
] as const;
export const STATUS_CHAMADO = [
'aberto',
'em_analise',
'aguardando_rep',
'resolvido',
'cancelado',
] as const;
export const RESOLUCAO_CHAMADO = [
'reposicao',
'desconto',
'cancelamento_item',
'sem_acao',
'outro',
] as const;
export const TIPO_CHAMADO_LABEL: Record<string, string> = {
avaria: 'Avaria / Dano',
quantidade_divergente: 'Quantidade Divergente',
produto_errado: 'Produto Errado',
prazo_entrega: 'Prazo de Entrega',
outro: 'Outro',
};
export const STATUS_CHAMADO_LABEL: Record<string, string> = {
aberto: 'Aberto',
em_analise: 'Em Análise',
aguardando_rep: 'Aguardando Rep.',
resolvido: 'Resolvido',
cancelado: 'Cancelado',
};
export const RESOLUCAO_CHAMADO_LABEL: Record<string, string> = {
reposicao: 'Reposição do Item',
desconto: 'Desconto / Abatimento',
cancelamento_item: 'Cancelamento do Item',
sem_acao: 'Sem Ação Necessária',
outro: 'Outro',
};
export const ItemAfetadoSchema = z.object({
codProduto: z.string(),
nomeProduto: z.string(),
qtd: z.number(),
});
export const ChamadoMensagemSchema = z.object({
id: z.number(),
idChamado: z.number(),
autor: z.enum(['rep', 'empresa']),
texto: z.string(),
createdAt: z.string(),
});
export const ChamadoSchema = z.object({
id: z.number(),
idEmpresa: z.number(),
codVendedor: z.number(),
idPedido: z.string().nullable().optional(),
numPedSar: z.number().nullable().optional(),
numPedErp: z.number().nullable().optional(),
idCliente: z.number(),
nomeCliente: z.string().nullable().optional(),
tipo: z.string(),
assunto: z.string(),
descricao: z.string(),
status: z.string(),
resolucao: z.string().nullable().optional(),
notaResolucao: z.string().nullable().optional(),
itensAfetados: z.array(ItemAfetadoSchema).nullable().optional(),
createdAt: z.string(),
updatedAt: z.string(),
mensagens: z.array(ChamadoMensagemSchema).optional(),
});
export const CreateChamadoSchema = z.object({
idPedido: z.string().uuid().optional(),
numPedSar: z.number().int().positive().optional(),
numPedErp: z.number().int().positive().optional(),
nomeCliente: z.string().optional(),
idCliente: z.number().int().positive(),
tipo: z.enum(TIPOS_CHAMADO),
assunto: z.string().min(5).max(200),
descricao: z.string().min(10),
itensAfetados: z.array(ItemAfetadoSchema).optional(),
});
export const AddMensagemSchema = z.object({
texto: z.string().min(1).max(2000),
});
export const ResolverChamadoSchema = z.object({
resolucao: z.enum(RESOLUCAO_CHAMADO),
notaResolucao: z.string().optional(),
mensagemFinal: z.string().optional(),
});
export const CancelChamadoSchema = z.object({
motivo: z.string().optional(),
});
export const ChamadoListResponseSchema = z.object({
abertos: z.array(ChamadoSchema),
fechados: z.array(ChamadoSchema),
});
export type TipoChamado = (typeof TIPOS_CHAMADO)[number];
export type StatusChamado = (typeof STATUS_CHAMADO)[number];
export type ResolucaoChamado = (typeof RESOLUCAO_CHAMADO)[number];
export type ItemAfetado = z.infer<typeof ItemAfetadoSchema>;
export type ChamadoMensagem = z.infer<typeof ChamadoMensagemSchema>;
export type Chamado = z.infer<typeof ChamadoSchema>;
export type CreateChamadoDto = z.infer<typeof CreateChamadoSchema>;
export type AddMensagemDto = z.infer<typeof AddMensagemSchema>;
export type ResolverChamadoDto = z.infer<typeof ResolverChamadoSchema>;
export type CancelChamadoDto = z.infer<typeof CancelChamadoSchema>;
export type ChamadoListResponse = z.infer<typeof ChamadoListResponseSchema>;