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:
@@ -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);
|
||||||
@@ -171,6 +171,32 @@ model Promocao {
|
|||||||
@@map("promocoes")
|
@@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) ───────────────────────────────────────────────────
|
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { NotificationsModule } from './notifications/notifications.module';
|
|||||||
import { ReportsModule } from './reports/reports.module';
|
import { ReportsModule } from './reports/reports.module';
|
||||||
import { PoliticasModule } from './politicas/politicas.module';
|
import { PoliticasModule } from './politicas/politicas.module';
|
||||||
import { EquipeModule } from './equipe/equipe.module';
|
import { EquipeModule } from './equipe/equipe.module';
|
||||||
|
import { FunilModule } from './funil/funil.module';
|
||||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -35,6 +36,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
|||||||
ReportsModule,
|
ReportsModule,
|
||||||
PoliticasModule,
|
PoliticasModule,
|
||||||
EquipeModule,
|
EquipeModule,
|
||||||
|
FunilModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
{ 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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
550
apps/web/src/cockpits/rep/FunilPage.tsx
Normal file
550
apps/web/src/cockpits/rep/FunilPage.tsx
Normal file
@@ -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<EtapaFunil, { label: string; cor: string; corBg: string }> = {
|
||||||
|
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<Record<EtapaFunil, EtapaFunil>> = {
|
||||||
|
lead: 'proposta',
|
||||||
|
proposta: 'negociacao',
|
||||||
|
negociacao: 'ganho',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANTERIOR: Partial<Record<EtapaFunil, EtapaFunil>> = {
|
||||||
|
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 (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 10, borderRadius: 8, border: '1px solid #E2E8F0' }}
|
||||||
|
styles={{ body: { padding: '10px 12px' } }}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="flex-start" gap={8}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text strong style={{ fontSize: 13, color: '#1e293b', display: 'block' }} ellipsis>
|
||||||
|
{nomePrincipal(op)}
|
||||||
|
</Text>
|
||||||
|
{op.titulo && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }} ellipsis>
|
||||||
|
{op.titulo}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Space size={2}>
|
||||||
|
<Tooltip title="Editar">
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Excluir">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Excluir oportunidade?',
|
||||||
|
content: 'Esta ação não pode ser desfeita.',
|
||||||
|
okText: 'Excluir',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: onDeletar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginTop: 6 }}>
|
||||||
|
<Text style={{ fontSize: 12, color: '#003B8E', fontWeight: 600 }}>
|
||||||
|
{op.valorEstimado ? fmt(op.valorEstimado) : '—'}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{dias === 0 ? 'hoje' : `${dias}d`}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex gap={4} style={{ marginTop: 8 }}>
|
||||||
|
{ant && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{ fontSize: 11, padding: '0 6px', color: '#64748b', borderColor: '#CBD5E1' }}
|
||||||
|
onClick={() => onMover(ant)}
|
||||||
|
>
|
||||||
|
← {ETAPA_CONFIG[ant].label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{op.etapa !== 'perdido' && op.etapa !== 'ganho' && (
|
||||||
|
<Tooltip title="Marcar como perdido">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
ghost
|
||||||
|
style={{ fontSize: 11, padding: '0 6px' }}
|
||||||
|
onClick={() => onMover('perdido')}
|
||||||
|
>
|
||||||
|
Perdido
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{prox && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '0 6px',
|
||||||
|
background: ETAPA_CONFIG[prox].cor,
|
||||||
|
borderColor: ETAPA_CONFIG[prox].cor,
|
||||||
|
}}
|
||||||
|
onClick={() => onMover(prox)}
|
||||||
|
>
|
||||||
|
{ETAPA_CONFIG[prox].label} <ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{op.etapa === 'ganho' && (
|
||||||
|
<Tag color="green" style={{ fontSize: 11, margin: 0 }}>
|
||||||
|
<TrophyOutlined /> Ganho
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<void>;
|
||||||
|
}) {
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={initial ? 'Editar Oportunidade' : 'Nova Oportunidade'}
|
||||||
|
width={520}
|
||||||
|
centered
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={handleOk}
|
||||||
|
okText={initial ? 'Salvar' : 'Criar'}
|
||||||
|
confirmLoading={saving}
|
||||||
|
afterOpenChange={(vis) => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="titulo"
|
||||||
|
label="Título / produto de interesse"
|
||||||
|
rules={[{ required: true, message: 'Informe o título' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Ex: Reposição de estoque Q3" />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="nomeProspect" label="Nome do contato">
|
||||||
|
<Input placeholder="João Silva" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="empresaProspect" label="Empresa">
|
||||||
|
<Input placeholder="Empresa XYZ" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="valorEstimado" label="Valor estimado (R$)">
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
formatter={(v) => String(v).replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
|
||||||
|
parser={(v) => Number((v ?? '').replace(/\./g, '').replace(',', '.'))}
|
||||||
|
placeholder="0,00"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="etapa" label="Estágio">
|
||||||
|
<Select
|
||||||
|
options={ETAPAS_FUNIL.map((e) => ({
|
||||||
|
value: e,
|
||||||
|
label: ETAPA_CONFIG[e].label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="observacoes" label="Observações">
|
||||||
|
<Input.TextArea rows={3} placeholder="Anotações sobre o lead..." />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Coluna kanban ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function KanbanColuna({
|
||||||
|
etapa,
|
||||||
|
ops,
|
||||||
|
onEdit,
|
||||||
|
onMover,
|
||||||
|
onDeletar,
|
||||||
|
onNovo,
|
||||||
|
}: {
|
||||||
|
etapa: EtapaFunil;
|
||||||
|
ops: Oportunidade[];
|
||||||
|
onEdit: (op: Oportunidade) => void;
|
||||||
|
onMover: (id: number, etapa: EtapaFunil) => void;
|
||||||
|
onDeletar: (id: number) => void;
|
||||||
|
onNovo: (etapa: EtapaFunil) => void;
|
||||||
|
}) {
|
||||||
|
const cfg = ETAPA_CONFIG[etapa];
|
||||||
|
const total = ops.reduce((s, o) => s + (o.valorEstimado ? Number(o.valorEstimado) : 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: 240,
|
||||||
|
flex: 1,
|
||||||
|
background: cfg.corBg,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${cfg.cor}33`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px 8px',
|
||||||
|
borderBottom: `2px solid ${cfg.cor}`,
|
||||||
|
borderRadius: '10px 10px 0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Space size={6}>
|
||||||
|
<Text strong style={{ color: cfg.cor, fontSize: 13 }}>
|
||||||
|
{cfg.label}
|
||||||
|
</Text>
|
||||||
|
<Badge count={ops.length} color={cfg.cor} style={{ fontSize: 11 }} />
|
||||||
|
</Space>
|
||||||
|
{total > 0 && (
|
||||||
|
<Text style={{ fontSize: 11, color: cfg.cor, fontWeight: 600 }}>
|
||||||
|
{total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 10px 4px',
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
maxHeight: 'calc(100vh - 260px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ops.map((op) => (
|
||||||
|
<OportCard
|
||||||
|
key={op.id}
|
||||||
|
op={op}
|
||||||
|
onEdit={() => onEdit(op)}
|
||||||
|
onMover={(e) => onMover(op.id, e)}
|
||||||
|
onDeletar={() => onDeletar(op.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '4px 10px 10px' }}>
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
style={{ borderColor: cfg.cor, color: cfg.cor, fontSize: 12 }}
|
||||||
|
onClick={() => onNovo(etapa)}
|
||||||
|
>
|
||||||
|
Adicionar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FunilPage ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function FunilPage() {
|
||||||
|
const { data, isLoading, isError, error } = useFunil();
|
||||||
|
const criar = useCreateOportunidade();
|
||||||
|
const atualizar = useUpdateOportunidade();
|
||||||
|
const deletar = useDeleteOportunidade();
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editOp, setEditOp] = useState<Oportunidade | null>(null);
|
||||||
|
const [etapaModal, setEtapaModal] = useState<EtapaFunil>('lead');
|
||||||
|
|
||||||
|
const abrirNovo = (etapa: EtapaFunil) => {
|
||||||
|
setEditOp(null);
|
||||||
|
setEtapaModal(etapa);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirEditar = (op: Oportunidade) => {
|
||||||
|
setEditOp(op);
|
||||||
|
setEtapaModal(op.etapa as EtapaFunil);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => {
|
||||||
|
if (editOp) {
|
||||||
|
await atualizar.mutateAsync({ id: editOp.id, dto });
|
||||||
|
} else {
|
||||||
|
await criar.mutateAsync(dto as CreateOportunidadeDto);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMover = (id: number, etapa: EtapaFunil) => {
|
||||||
|
atualizar.mutate({ id, dto: { etapa } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletar = (id: number) => {
|
||||||
|
deletar.mutate(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const etapasPrincipais: EtapaFunil[] = ['lead', 'proposta', 'negociacao', 'ganho'];
|
||||||
|
const perdidos = data?.perdido ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 'calc(100vh - var(--layout-topbar-height) - 48px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex align="center" justify="space-between" style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
|
Funil de Vendas
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Acompanhe seus leads até o fechamento do pedido
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => abrirNovo('lead')}
|
||||||
|
style={{ background: '#003B8E' }}
|
||||||
|
>
|
||||||
|
Novo Lead
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
title="Erro ao carregar funil"
|
||||||
|
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||||
|
style={{ marginBottom: 16, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 8 }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flex: 1, overflowX: 'auto', paddingBottom: 8 }}>
|
||||||
|
{etapasPrincipais.map((etapa) => (
|
||||||
|
<KanbanColuna
|
||||||
|
key={etapa}
|
||||||
|
etapa={etapa}
|
||||||
|
ops={data?.[etapa] ?? []}
|
||||||
|
onEdit={abrirEditar}
|
||||||
|
onMover={handleMover}
|
||||||
|
onDeletar={handleDeletar}
|
||||||
|
onNovo={abrirNovo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{perdidos.length > 0 && (
|
||||||
|
<div style={{ marginTop: 12, flexShrink: 0 }}>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'perdidos',
|
||||||
|
label: (
|
||||||
|
<Text style={{ color: '#DC2626', fontSize: 13 }}>
|
||||||
|
Perdidos ({perdidos.length})
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<Row gutter={[10, 10]}>
|
||||||
|
{perdidos.map((op) => (
|
||||||
|
<Col key={op.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<OportCard
|
||||||
|
op={op}
|
||||||
|
onEdit={() => abrirEditar(op)}
|
||||||
|
onMover={(e) => handleMover(op.id, e)}
|
||||||
|
onDeletar={() => handleDeletar(op.id)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OportModal
|
||||||
|
open={modalOpen}
|
||||||
|
initial={editOp}
|
||||||
|
etapaInicial={etapaModal}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,17 +28,17 @@ function getRole(): string {
|
|||||||
|
|
||||||
const REP_ITEMS: ItemType[] = [
|
const REP_ITEMS: ItemType[] = [
|
||||||
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
||||||
|
{
|
||||||
|
key: '/funil',
|
||||||
|
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
|
||||||
|
label: 'Funil de Vendas',
|
||||||
|
},
|
||||||
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
|
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
|
||||||
{
|
{
|
||||||
key: '/catalogo',
|
key: '/catalogo',
|
||||||
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||||
label: 'Catálogo',
|
label: 'Catálogo',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: '/funil',
|
|
||||||
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
|
|
||||||
label: 'Funil de Vendas',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: '/pedidos',
|
key: '/pedidos',
|
||||||
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
||||||
|
|||||||
46
apps/web/src/lib/queries/funil.ts
Normal file
46
apps/web/src/lib/queries/funil.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
FunilResponseSchema,
|
||||||
|
OportunidadeSchema,
|
||||||
|
type CreateOportunidadeDto,
|
||||||
|
type UpdateOportunidadeDto,
|
||||||
|
type Oportunidade,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
export function useFunil() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['funil'],
|
||||||
|
queryFn: async () => FunilResponseSchema.parse(await apiFetch('/funil')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateOportunidade() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
|
||||||
|
apiFetch('/funil', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
|
||||||
|
OportunidadeSchema.parse(r),
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateOportunidade() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, dto }: { id: number; dto: UpdateOportunidadeDto }): Promise<Oportunidade> =>
|
||||||
|
apiFetch(`/funil/${id}`, { method: 'PATCH', body: JSON.stringify(dto) }).then((r) =>
|
||||||
|
OportunidadeSchema.parse(r),
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteOportunidade() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number): Promise<void> => apiFetch(`/funil/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
|||||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||||
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||||
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||||
|
import { FunilPage } from '../cockpits/rep/FunilPage';
|
||||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||||
import { GerPainel } from '../cockpits/ger/GerPainel';
|
import { GerPainel } from '../cockpits/ger/GerPainel';
|
||||||
@@ -137,6 +138,12 @@ const relatoriosRoute = createRoute({
|
|||||||
component: RelatoriosPage,
|
component: RelatoriosPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const funilRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/funil',
|
||||||
|
component: FunilPage,
|
||||||
|
});
|
||||||
|
|
||||||
const aprovacoes = createRoute({
|
const aprovacoes = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/aprovacoes',
|
path: '/aprovacoes',
|
||||||
@@ -174,6 +181,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
catalogoRoute,
|
catalogoRoute,
|
||||||
pedidosErpRoute,
|
pedidosErpRoute,
|
||||||
relatoriosRoute,
|
relatoriosRoute,
|
||||||
|
funilRoute,
|
||||||
aprovacoes,
|
aprovacoes,
|
||||||
gerPainelRoute,
|
gerPainelRoute,
|
||||||
equipeRoute,
|
equipeRoute,
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from './lib/company.contract';
|
|||||||
export * from './lib/report.contract';
|
export * from './lib/report.contract';
|
||||||
export * from './lib/politicas.contract';
|
export * from './lib/politicas.contract';
|
||||||
export * from './lib/equipe.contract';
|
export * from './lib/equipe.contract';
|
||||||
|
export * from './lib/funil.contract';
|
||||||
|
|||||||
45
libs/shared/api-interface/src/lib/funil.contract.ts
Normal file
45
libs/shared/api-interface/src/lib/funil.contract.ts
Normal 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>;
|
||||||
Reference in New Issue
Block a user