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:
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -197,6 +197,50 @@ model Oportunidade {
|
|||||||
@@map("oportunidades")
|
@@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) ───────────────────────────────────────────────────
|
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { ReportsModule } from './reports/reports.module';
|
|||||||
import { PoliticasModule } from './politicas/politicas.module';
|
import { PoliticasModule } from './politicas/politicas.module';
|
||||||
import { EquipeModule } from './equipe/equipe.module';
|
import { EquipeModule } from './equipe/equipe.module';
|
||||||
import { FunilModule } from './funil/funil.module';
|
import { FunilModule } from './funil/funil.module';
|
||||||
|
import { SacModule } from './sac/sac.module';
|
||||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -37,6 +38,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
|||||||
PoliticasModule,
|
PoliticasModule,
|
||||||
EquipeModule,
|
EquipeModule,
|
||||||
FunilModule,
|
FunilModule,
|
||||||
|
SacModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||||
|
|||||||
63
apps/api/src/app/sac/sac.controller.ts
Normal file
63
apps/api/src/app/sac/sac.controller.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/app/sac/sac.module.ts
Normal file
9
apps/api/src/app/sac/sac.module.ts
Normal 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 {}
|
||||||
177
apps/api/src/app/sac/sac.service.ts
Normal file
177
apps/api/src/app/sac/sac.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
346
apps/web/src/cockpits/ger/GerChamadosPage.tsx
Normal file
346
apps/web/src/cockpits/ger/GerChamadosPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
apps/web/src/cockpits/rep/AbrirChamadoModal.tsx
Normal file
159
apps/web/src/cockpits/rep/AbrirChamadoModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
apps/web/src/cockpits/rep/ChamadosPage.tsx
Normal file
432
apps/web/src/cockpits/rep/ChamadosPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
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 type { TableColumnsType } from 'antd';
|
||||||
import { Link, useParams, useNavigate } from '@tanstack/react-router';
|
import { Link, useParams, useNavigate } from '@tanstack/react-router';
|
||||||
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
||||||
@@ -118,7 +119,7 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
|
|||||||
: SITUA_COLOR[h.situaNova] === 'error'
|
: SITUA_COLOR[h.situaNova] === 'error'
|
||||||
? 'red'
|
? 'red'
|
||||||
: 'blue',
|
: 'blue',
|
||||||
children: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
|
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
|
||||||
{h.situaAnterior != null && (
|
{h.situaAnterior != null && (
|
||||||
@@ -263,6 +264,7 @@ export function OrderDetailPage() {
|
|||||||
|
|
||||||
const [approveOpen, setApproveOpen] = useState(false);
|
const [approveOpen, setApproveOpen] = useState(false);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [chamadoOpen, setChamadoOpen] = useState(false);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
@@ -383,6 +385,11 @@ export function OrderDetailPage() {
|
|||||||
Compartilhar
|
Compartilhar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{role === 'rep' && !isErp && (order.situa === 2 || order.situa === 4) && (
|
||||||
|
<Button icon={<CustomerServiceOutlined />} onClick={() => setChamadoOpen(true)}>
|
||||||
|
Abrir Chamado
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{actionError && (
|
{actionError && (
|
||||||
@@ -447,7 +454,7 @@ export function OrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Divider orientation="left">Itens ({order.itens.length})</Divider>
|
<Divider titlePlacement="left">Itens ({order.itens.length})</Divider>
|
||||||
<Table<PedidoItem>
|
<Table<PedidoItem>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={itemColumns}
|
columns={itemColumns}
|
||||||
@@ -459,7 +466,7 @@ export function OrderDetailPage() {
|
|||||||
|
|
||||||
{clientOrders && clientOrders.length > 0 && (
|
{clientOrders && clientOrders.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider orientation="left">Outros Pedidos do Cliente</Divider>
|
<Divider titlePlacement="left">Outros Pedidos do Cliente</Divider>
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
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} />
|
<HistoryTimeline history={order.historico} />
|
||||||
|
|
||||||
<ApproveModal
|
<ApproveModal
|
||||||
@@ -517,6 +524,17 @@ export function OrderDetailPage() {
|
|||||||
onCancel={() => setRejectOpen(false)}
|
onCancel={() => setRejectOpen(false)}
|
||||||
loading={rejectMutation.isPending}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
faBoxesStacked,
|
faBoxesStacked,
|
||||||
faFileInvoiceDollar,
|
faFileInvoiceDollar,
|
||||||
faTags,
|
faTags,
|
||||||
|
faHeadset,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import type { ItemType } from 'antd/es/menu/interface';
|
import type { ItemType } from 'antd/es/menu/interface';
|
||||||
import { authStore } from '../../lib/auth-store';
|
import { authStore } from '../../lib/auth-store';
|
||||||
@@ -49,6 +50,7 @@ const REP_ITEMS: ItemType[] = [
|
|||||||
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
||||||
label: 'Consulta ERP',
|
label: 'Consulta ERP',
|
||||||
},
|
},
|
||||||
|
{ key: '/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
|
||||||
{
|
{
|
||||||
key: '/relatorios',
|
key: '/relatorios',
|
||||||
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||||
@@ -99,6 +101,7 @@ const GERENTE_ITEMS: ItemType[] = [
|
|||||||
icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
|
||||||
label: 'Políticas Comerciais',
|
label: 'Políticas Comerciais',
|
||||||
},
|
},
|
||||||
|
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getItems(role: string): ItemType[] {
|
function getItems(role: string): ItemType[] {
|
||||||
|
|||||||
@@ -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();
|
const search = new URLSearchParams();
|
||||||
if (params.search) search.set('search', params.search);
|
if (params.search) search.set('search', params.search);
|
||||||
if (params.from) search.set('from', params.from);
|
if (params.from) search.set('from', params.from);
|
||||||
@@ -70,6 +70,7 @@ export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}
|
|||||||
const qs = search.toString();
|
const qs = search.toString();
|
||||||
return useQuery<PedidoErpConsultaResponse>({
|
return useQuery<PedidoErpConsultaResponse>({
|
||||||
queryKey: ['orders-erp-consulta', params],
|
queryKey: ['orders-erp-consulta', params],
|
||||||
|
enabled,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`);
|
const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`);
|
||||||
return PedidoErpConsultaResponseSchema.parse(res);
|
return PedidoErpConsultaResponseSchema.parse(res);
|
||||||
|
|||||||
104
apps/web/src/lib/queries/sac.ts
Normal file
104
apps/web/src/lib/queries/sac.ts
Normal 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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
|||||||
import { GerPainel } from '../cockpits/ger/GerPainel';
|
import { GerPainel } from '../cockpits/ger/GerPainel';
|
||||||
import { EquipePage } from '../cockpits/ger/EquipePage';
|
import { EquipePage } from '../cockpits/ger/EquipePage';
|
||||||
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
|
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
|
||||||
|
import { ChamadosPage } from '../cockpits/rep/ChamadosPage';
|
||||||
|
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
|
||||||
import { authStore } from './auth-store';
|
import { authStore } from './auth-store';
|
||||||
|
|
||||||
function getRoleFromToken(): string {
|
function getRoleFromToken(): string {
|
||||||
@@ -168,6 +170,18 @@ const politicasRoute = createRoute({
|
|||||||
component: PoliticasPage,
|
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([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
rafaelRoute,
|
rafaelRoute,
|
||||||
@@ -186,6 +200,8 @@ const routeTree = rootRoute.addChildren([
|
|||||||
gerPainelRoute,
|
gerPainelRoute,
|
||||||
equipeRoute,
|
equipeRoute,
|
||||||
politicasRoute,
|
politicasRoute,
|
||||||
|
chamadosRoute,
|
||||||
|
gerChamadosRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from './lib/report.contract';
|
|||||||
export * from './lib/politicas.contract';
|
export * from './lib/politicas.contract';
|
||||||
export * from './lib/equipe.contract';
|
export * from './lib/equipe.contract';
|
||||||
export * from './lib/funil.contract';
|
export * from './lib/funil.contract';
|
||||||
|
export * from './lib/sac.contract';
|
||||||
|
|||||||
127
libs/shared/api-interface/src/lib/sac.contract.ts
Normal file
127
libs/shared/api-interface/src/lib/sac.contract.ts
Normal 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>;
|
||||||
Reference in New Issue
Block a user