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>
142 lines
4.8 KiB
TypeScript
142 lines
4.8 KiB
TypeScript
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 } });
|
|
}
|
|
}
|