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:
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