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:
@@ -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 },
|
||||
|
||||
48
apps/api/src/app/funil/funil.controller.ts
Normal file
48
apps/api/src/app/funil/funil.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/funil/funil.module.ts
Normal file
9
apps/api/src/app/funil/funil.module.ts
Normal 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 {}
|
||||
141
apps/api/src/app/funil/funil.service.ts
Normal file
141
apps/api/src/app/funil/funil.service.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user