From 264305386d4065847cdcdbd9ab10cc8041f0d9d6 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 20 Jul 2026 18:15:40 +0000 Subject: [PATCH] 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) --- .../migration.sql | 22 + apps/api/prisma/schema.prisma | 26 + apps/api/src/app/app.module.ts | 2 + apps/api/src/app/funil/funil.controller.ts | 48 ++ apps/api/src/app/funil/funil.module.ts | 9 + apps/api/src/app/funil/funil.service.ts | 141 +++++ apps/web/src/cockpits/rep/FunilPage.tsx | 550 ++++++++++++++++++ apps/web/src/components/layout/Sidebar.tsx | 10 +- apps/web/src/lib/queries/funil.ts | 46 ++ apps/web/src/lib/router.tsx | 8 + libs/shared/api-interface/src/index.ts | 1 + .../api-interface/src/lib/funil.contract.ts | 45 ++ 12 files changed, 903 insertions(+), 5 deletions(-) create mode 100644 apps/api/prisma/migrations/20260629000000_add_oportunidades/migration.sql create mode 100644 apps/api/src/app/funil/funil.controller.ts create mode 100644 apps/api/src/app/funil/funil.module.ts create mode 100644 apps/api/src/app/funil/funil.service.ts create mode 100644 apps/web/src/cockpits/rep/FunilPage.tsx create mode 100644 apps/web/src/lib/queries/funil.ts create mode 100644 libs/shared/api-interface/src/lib/funil.contract.ts diff --git a/apps/api/prisma/migrations/20260629000000_add_oportunidades/migration.sql b/apps/api/prisma/migrations/20260629000000_add_oportunidades/migration.sql new file mode 100644 index 0000000..38ef0d4 --- /dev/null +++ b/apps/api/prisma/migrations/20260629000000_add_oportunidades/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable: sar.oportunidades (Funil de Vendas) +-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL +-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente. +-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui. +CREATE TABLE IF NOT EXISTS sar.oportunidades ( + id SERIAL PRIMARY KEY, + id_empresa INTEGER NOT NULL, + cod_vendedor INTEGER NOT NULL, + id_cliente INTEGER, + nome_prospect VARCHAR(200), + empresa_prospect VARCHAR(200), + titulo VARCHAR(200) NOT NULL, + etapa VARCHAR(20) NOT NULL DEFAULT 'lead', + valor_estimado NUMERIC(15,2), + observacoes TEXT, + id_pedido UUID REFERENCES sar.pedidos(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor); +CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e78f21c..7d0a66d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -171,6 +171,32 @@ model Promocao { @@map("promocoes") } +// ─── Oportunidade (Funil de Vendas) ────────────────────────────────────────── +// +// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente) +// ou ser um prospect livre (nome_prospect / empresa_prospect). +// etapa: lead | proposta | negociacao | ganho | perdido + +model Oportunidade { + id Int @id @default(autoincrement()) + idEmpresa Int @map("id_empresa") + codVendedor Int @map("cod_vendedor") + idCliente Int? @map("id_cliente") + nomeProspect String? @map("nome_prospect") @db.VarChar(200) + empresaProspect String? @map("empresa_prospect") @db.VarChar(200) + titulo String @db.VarChar(200) + etapa String @default("lead") @db.VarChar(20) + valorEstimado Decimal? @db.Decimal(15, 2) @map("valor_estimado") + observacoes String? + idPedido String? @db.Uuid @map("id_pedido") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([idEmpresa, codVendedor]) + @@index([etapa]) + @@map("oportunidades") +} + // ─── PushSubscription (C6) ─────────────────────────────────────────────────── // // Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser. diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index ba116dd..15e883e 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -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 }, diff --git a/apps/api/src/app/funil/funil.controller.ts b/apps/api/src/app/funil/funil.controller.ts new file mode 100644 index 0000000..e284dba --- /dev/null +++ b/apps/api/src/app/funil/funil.controller.ts @@ -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 { + return this.funil.listar(); + } + + @Post() + criar(@Body() dto: CreateDto): Promise { + return this.funil.criar(CreateOportunidadeSchema.parse(dto)); + } + + @Patch(':id') + atualizar(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateDto): Promise { + return this.funil.atualizar(id, UpdateOportunidadeSchema.parse(dto)); + } + + @Delete(':id') + @HttpCode(204) + deletar(@Param('id', ParseIntPipe) id: number): Promise { + return this.funil.deletar(id); + } +} diff --git a/apps/api/src/app/funil/funil.module.ts b/apps/api/src/app/funil/funil.module.ts new file mode 100644 index 0000000..3ea67dd --- /dev/null +++ b/apps/api/src/app/funil/funil.module.ts @@ -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 {} diff --git a/apps/api/src/app/funil/funil.service.ts b/apps/api/src/app/funil/funil.service.ts new file mode 100644 index 0000000..de6c888 --- /dev/null +++ b/apps/api/src/app/funil/funil.service.ts @@ -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; + create: (args: object) => Promise; + update: (args: object) => Promise; + delete: (args: object) => Promise; + }; + $queryRawUnsafe: (sql: string, ...args: unknown[]) => Promise; +}; + +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): 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 { + 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(); + if (idClientes.length > 0) { + const ids = idClientes.join(','); + const clientes = await prisma.$queryRawUnsafe( + `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 { + 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 { + 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 { + const { prisma, idEmpresa, codVendedor } = this.ctx(); + await prisma.oportunidade.delete({ where: { id, idEmpresa, codVendedor } }); + } +} diff --git a/apps/web/src/cockpits/rep/FunilPage.tsx b/apps/web/src/cockpits/rep/FunilPage.tsx new file mode 100644 index 0000000..2f9017d --- /dev/null +++ b/apps/web/src/cockpits/rep/FunilPage.tsx @@ -0,0 +1,550 @@ +import { useState } from 'react'; +import { + Alert, + Badge, + Button, + Card, + Col, + Collapse, + Flex, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + Skeleton, + Space, + Tag, + Tooltip, + Typography, +} from 'antd'; +import { + ArrowRightOutlined, + DeleteOutlined, + EditOutlined, + PlusOutlined, + TrophyOutlined, +} from '@ant-design/icons'; +import type { + Oportunidade, + EtapaFunil, + CreateOportunidadeDto, + UpdateOportunidadeDto, +} from '@sar/api-interface'; +import { ETAPAS_FUNIL } from '@sar/api-interface'; +import { + useFunil, + useCreateOportunidade, + useUpdateOportunidade, + useDeleteOportunidade, +} from '../../lib/queries/funil'; + +const { Title, Text } = Typography; + +const ETAPA_CONFIG: Record = { + lead: { label: 'Lead', cor: '#64748b', corBg: '#F8FAFC' }, + proposta: { label: 'Proposta', cor: '#2563EB', corBg: '#EFF6FF' }, + negociacao: { label: 'Negociação', cor: '#D97706', corBg: '#FFFBEB' }, + ganho: { label: 'Ganho', cor: '#16A34A', corBg: '#F0FDF4' }, + perdido: { label: 'Perdido', cor: '#DC2626', corBg: '#FEF2F2' }, +}; + +const PROXIMA: Partial> = { + lead: 'proposta', + proposta: 'negociacao', + negociacao: 'ganho', +}; + +const ANTERIOR: Partial> = { + proposta: 'lead', + negociacao: 'proposta', + ganho: 'negociacao', +}; + +function fmt(v: string | null | undefined): string { + if (!v) return ''; + return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); +} + +function diasDesde(iso: string): number { + return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000); +} + +function nomePrincipal(op: Oportunidade): string { + return op.nomeCliente ?? op.nomeProspect ?? op.empresaProspect ?? `Oport. #${op.id}`; +} + +// ─── Card ──────────────────────────────────────────────────────────────────── + +function OportCard({ + op, + onEdit, + onMover, + onDeletar, +}: { + op: Oportunidade; + onEdit: () => void; + onMover: (etapa: EtapaFunil) => void; + onDeletar: () => void; +}) { + const dias = diasDesde(op.updatedAt); + const prox = PROXIMA[op.etapa as EtapaFunil]; + const ant = ANTERIOR[op.etapa as EtapaFunil]; + + return ( + + +
+ + {nomePrincipal(op)} + + {op.titulo && ( + + {op.titulo} + + )} +
+ + + + )} +
+ {op.etapa !== 'perdido' && op.etapa !== 'ganho' && ( + + + + )} + {prox && ( + + )} + {op.etapa === 'ganho' && ( + + Ganho + + )} + + + ); +} + +// ─── Modal criar/editar ─────────────────────────────────────────────────────── + +type FormValues = { + titulo: string; + etapa: EtapaFunil; + nomeProspect?: string; + empresaProspect?: string; + valorEstimado?: number; + observacoes?: string; +}; + +function OportModal({ + open, + initial, + etapaInicial, + onClose, + onSave, +}: { + open: boolean; + initial?: Oportunidade | null; + etapaInicial: EtapaFunil; + onClose: () => void; + onSave: (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => Promise; +}) { + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + + const handleOk = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + await onSave({ + titulo: values.titulo, + etapa: values.etapa, + nomeProspect: values.nomeProspect ?? null, + empresaProspect: values.empresaProspect ?? null, + valorEstimado: values.valorEstimado ?? null, + observacoes: values.observacoes ?? null, + }); + onClose(); + } finally { + setSaving(false); + } + }; + + return ( + { + if (vis) { + form.setFieldsValue( + initial + ? { + titulo: initial.titulo, + etapa: initial.etapa as EtapaFunil, + nomeProspect: initial.nomeProspect ?? undefined, + empresaProspect: initial.empresaProspect ?? undefined, + valorEstimado: initial.valorEstimado ? Number(initial.valorEstimado) : undefined, + observacoes: initial.observacoes ?? undefined, + } + : { etapa: etapaInicial }, + ); + } else { + form.resetFields(); + } + }} + > +
+ + + + + + + + + + + + + + + + + + + String(v).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} + parser={(v) => Number((v ?? '').replace(/\./g, '').replace(',', '.'))} + placeholder="0,00" + /> + + + + +