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

@@ -9,3 +9,4 @@ export * from './lib/company.contract';
export * from './lib/report.contract';
export * from './lib/politicas.contract';
export * from './lib/equipe.contract';
export * from './lib/funil.contract';

View File

@@ -0,0 +1,45 @@
import { z } from 'zod';
export const ETAPAS_FUNIL = ['lead', 'proposta', 'negociacao', 'ganho', 'perdido'] as const;
export type EtapaFunil = (typeof ETAPAS_FUNIL)[number];
export const OportunidadeSchema = z.object({
id: z.number(),
idCliente: z.number().nullable(),
nomeProspect: z.string().nullable(),
empresaProspect: z.string().nullable(),
titulo: z.string(),
etapa: z.enum(ETAPAS_FUNIL),
valorEstimado: z.string().nullable(),
observacoes: z.string().nullable(),
idPedido: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
nomeCliente: z.string().nullable(),
});
export type Oportunidade = z.infer<typeof OportunidadeSchema>;
export const CreateOportunidadeSchema = z.object({
idCliente: z.number().int().positive().nullable().optional(),
nomeProspect: z.string().max(200).nullable().optional(),
empresaProspect: z.string().max(200).nullable().optional(),
titulo: z.string().min(1).max(200),
etapa: z.enum(ETAPAS_FUNIL).default('lead'),
valorEstimado: z.number().positive().nullable().optional(),
observacoes: z.string().max(2000).nullable().optional(),
});
export type CreateOportunidadeDto = z.infer<typeof CreateOportunidadeSchema>;
export const UpdateOportunidadeSchema = CreateOportunidadeSchema.partial().extend({
idPedido: z.string().nullable().optional(),
});
export type UpdateOportunidadeDto = z.infer<typeof UpdateOportunidadeSchema>;
export const FunilResponseSchema = z.object({
lead: z.array(OportunidadeSchema),
proposta: z.array(OportunidadeSchema),
negociacao: z.array(OportunidadeSchema),
ganho: z.array(OportunidadeSchema),
perdido: z.array(OportunidadeSchema),
});
export type FunilResponse = z.infer<typeof FunilResponseSchema>;