feat(api,web): funil de vendas do representante

Kanban de oportunidades por etapa (lead, proposta, negociacao, ganho,
perdido) com CRUD completo.

- API: `FunilModule` com GET/POST/PATCH/DELETE em /funil, escopado por
  id_empresa + cod_vendedor.
- Modelo `Oportunidade` + migration `sar.oportunidades`. Uma oportunidade
  referencia um cliente do ERP (id_cliente) ou e um prospect livre
  (nome_prospect / empresa_prospect).
- Contrato Zod compartilhado em `funil.contract.ts`.
- Web: `FunilPage` com colunas por etapa, entrada no menu do rep e rota
  /funil.

Nota: a UI ainda nao expoe seletor de cliente, entao toda oportunidade
criada pela tela nasce como prospect livre; `idCliente` e `idPedido` ja
sao suportados no backend, mas ficam inalcancaveis pela interface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:15:40 +00:00
parent bf6e21ce47
commit 264305386d
12 changed files with 903 additions and 5 deletions

View File

@@ -16,6 +16,7 @@ import { NotificationsModule } from './notifications/notifications.module';
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 { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -35,6 +36,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
ReportsModule,
PoliticasModule,
EquipeModule,
FunilModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -0,0 +1,48 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
CreateOportunidadeSchema,
UpdateOportunidadeSchema,
type FunilResponse,
type Oportunidade,
} from '@sar/api-interface';
import { FunilService } from './funil.service';
class CreateDto extends createZodDto(CreateOportunidadeSchema) {}
class UpdateDto extends createZodDto(UpdateOportunidadeSchema) {}
@Controller('funil')
export class FunilController {
constructor(private readonly funil: FunilService) {}
@Get()
listar(): Promise<FunilResponse> {
return this.funil.listar();
}
@Post()
criar(@Body() dto: CreateDto): Promise<Oportunidade> {
return this.funil.criar(CreateOportunidadeSchema.parse(dto));
}
@Patch(':id')
atualizar(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateDto): Promise<Oportunidade> {
return this.funil.atualizar(id, UpdateOportunidadeSchema.parse(dto));
}
@Delete(':id')
@HttpCode(204)
deletar(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.funil.deletar(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { FunilController } from './funil.controller';
import { FunilService } from './funil.service';
@Module({
controllers: [FunilController],
providers: [FunilService],
})
export class FunilModule {}

View File

@@ -0,0 +1,141 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
CreateOportunidadeDto,
UpdateOportunidadeDto,
FunilResponse,
EtapaFunil,
Oportunidade,
} from '@sar/api-interface';
type OportRow = {
id: number;
idCliente: number | null;
nomeProspect: string | null;
empresaProspect: string | null;
titulo: string;
etapa: string;
valorEstimado: unknown;
observacoes: string | null;
idPedido: string | null;
createdAt: Date;
updatedAt: Date;
};
type PrismaCtx = {
oportunidade: {
findMany: (args: object) => Promise<OportRow[]>;
create: (args: object) => Promise<OportRow>;
update: (args: object) => Promise<OportRow>;
delete: (args: object) => Promise<void>;
};
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
};
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
@Injectable()
export class FunilService {
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 toOp(r: OportRow, nomeMap: Map<number, string>): Oportunidade {
return {
id: r.id,
idCliente: r.idCliente,
nomeProspect: r.nomeProspect,
empresaProspect: r.empresaProspect,
titulo: r.titulo,
etapa: r.etapa as EtapaFunil,
valorEstimado: r.valorEstimado != null ? String(r.valorEstimado) : null,
observacoes: r.observacoes,
idPedido: r.idPedido,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
nomeCliente: r.idCliente ? (nomeMap.get(r.idCliente) ?? null) : null,
};
}
async listar(): Promise<FunilResponse> {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const rows = await prisma.oportunidade.findMany({
where: { idEmpresa, codVendedor },
orderBy: { updatedAt: 'desc' },
});
const idClientes = [
...new Set(rows.filter((r) => r.idCliente).map((r) => r.idCliente as number)),
];
const nomeMap = new Map<number, string>();
if (idClientes.length > 0) {
const ids = idClientes.join(',');
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}) AND id_empresa = ${idEmpresa}`,
);
for (const c of clientes) {
nomeMap.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
}
}
const byEtapa = (etapa: EtapaFunil) =>
rows.filter((r) => r.etapa === etapa).map((r) => this.toOp(r, nomeMap));
return {
lead: byEtapa('lead'),
proposta: byEtapa('proposta'),
negociacao: byEtapa('negociacao'),
ganho: byEtapa('ganho'),
perdido: byEtapa('perdido'),
};
}
async criar(dto: CreateOportunidadeDto): Promise<Oportunidade> {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const row = await prisma.oportunidade.create({
data: {
idEmpresa,
codVendedor,
idCliente: dto.idCliente ?? null,
nomeProspect: dto.nomeProspect ?? null,
empresaProspect: dto.empresaProspect ?? null,
titulo: dto.titulo,
etapa: dto.etapa ?? 'lead',
valorEstimado: dto.valorEstimado ?? null,
observacoes: dto.observacoes ?? null,
},
});
return this.toOp(row, new Map());
}
async atualizar(id: number, dto: UpdateOportunidadeDto): Promise<Oportunidade> {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const row = await prisma.oportunidade.update({
where: { id, idEmpresa, codVendedor },
data: {
...(dto.idCliente !== undefined && { idCliente: dto.idCliente }),
...(dto.nomeProspect !== undefined && { nomeProspect: dto.nomeProspect }),
...(dto.empresaProspect !== undefined && { empresaProspect: dto.empresaProspect }),
...(dto.titulo !== undefined && { titulo: dto.titulo }),
...(dto.etapa !== undefined && { etapa: dto.etapa }),
...(dto.valorEstimado !== undefined && { valorEstimado: dto.valorEstimado }),
...(dto.observacoes !== undefined && { observacoes: dto.observacoes }),
...(dto.idPedido !== undefined && { idPedido: dto.idPedido }),
},
});
return this.toOp(row, new Map());
}
async deletar(id: number): Promise<void> {
const { prisma, idEmpresa, codVendedor } = this.ctx();
await prisma.oportunidade.delete({ where: { id, idEmpresa, codVendedor } });
}
}