diff --git a/apps/api/prisma/migrations/20260629010000_add_chamados/migration.sql b/apps/api/prisma/migrations/20260629010000_add_chamados/migration.sql new file mode 100644 index 0000000..d48698d --- /dev/null +++ b/apps/api/prisma/migrations/20260629010000_add_chamados/migration.sql @@ -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); diff --git a/apps/api/prisma/migrations/20260630000000_chamados_add_erp_ref/migration.sql b/apps/api/prisma/migrations/20260630000000_chamados_add_erp_ref/migration.sql new file mode 100644 index 0000000..254d8bd --- /dev/null +++ b/apps/api/prisma/migrations/20260630000000_chamados_add_erp_ref/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7d0a66d..a1899e0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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. diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 15e883e..b97faf3 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -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 }, diff --git a/apps/api/src/app/sac/sac.controller.ts b/apps/api/src/app/sac/sac.controller.ts new file mode 100644 index 0000000..5333f11 --- /dev/null +++ b/apps/api/src/app/sac/sac.controller.ts @@ -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)); + } +} diff --git a/apps/api/src/app/sac/sac.module.ts b/apps/api/src/app/sac/sac.module.ts new file mode 100644 index 0000000..69a6c29 --- /dev/null +++ b/apps/api/src/app/sac/sac.module.ts @@ -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 {} diff --git a/apps/api/src/app/sac/sac.service.ts b/apps/api/src/app/sac/sac.service.ts new file mode 100644 index 0000000..de654d7 --- /dev/null +++ b/apps/api/src/app/sac/sac.service.ts @@ -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; + findFirst: (args: object) => Promise; + findUnique: (args: object) => Promise; + create: (args: object) => Promise; + update: (args: object) => Promise; + }; + chamadoMensagem: { create: (args: object) => Promise }; + $queryRawUnsafe: (sql: string, ...args: unknown[]) => Promise; +}; + +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> { + const ids = [...new Set(rows.map((r) => r.idCliente))]; + if (!ids.length) return new Map(); + const clientes = await prisma.$queryRawUnsafe( + `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(); + 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'); + } +} diff --git a/apps/web/src/cockpits/ger/GerChamadosPage.tsx b/apps/web/src/cockpits/ger/GerChamadosPage.tsx new file mode 100644 index 0000000..11a7d47 --- /dev/null +++ b/apps/web/src/cockpits/ger/GerChamadosPage.tsx @@ -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 = { + 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(); + const resolver = useResolverChamado(id); + + function handleOk() { + form.validateFields().then((values) => { + resolver.mutate(values, { + onSuccess: () => { + form.resetFields(); + onClose(); + }, + }); + }); + } + + return ( + { + form.resetFields(); + onClose(); + }} + okText="Confirmar Resolução" + cancelText="Voltar" + confirmLoading={resolver.isPending} + width={560} + centered + > +
+ + + + +