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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user