Files
sar/apps/web/src/cockpits/rep/FunilPage.tsx
julian 264305386d 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>
2026-07-20 18:15:40 +00:00

551 lines
16 KiB
TypeScript

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>
);
}