Files
SeConectai_Vendas/frontend/app/app/recebimentos/page.tsx
T
tiagoandClaude Sonnet 5 323912748f Frontend completo (Etapas 11-18) e extensões de backend (Etapas 13 e 18)
Adiciona o frontend Next.js em frontend/ (setup, design system,
autenticação/tema, layout/navegação, área master, área tenant completa:
clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas,
equipe e perfil). Estende o backend de forma aditiva e autorizada:
cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe +
edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo
e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:41:13 -03:00

300 lines
11 KiB
TypeScript

'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import type { DateRange } from 'react-day-picker';
import {
atualizarRecebimentosAtrasados,
listRecebimentos,
marcarRecebimentoPago,
type FormaPagamento,
type Recebimento,
} from '@/lib/api/sale-payments';
import { listCustomers } from '@/lib/api/customers';
import type { ParcelaStatus } from '@/lib/api/sales';
import { ApiError } from '@/lib/api/client';
import { formatCurrency, formatDate } from '@/lib/format';
import { toast } from '@/lib/toast';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { DateRangePicker } from '@/components/ui/date-picker';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { StatusPill } from '@/components/ui/status-pill';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
import { MarcarPagoDialog } from '@/components/domain/marcar-pago-dialog';
const STATUS_OPTIONS: { value: ParcelaStatus | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os status' },
{ value: 'pendente', label: 'Pendente' },
{ value: 'atrasado', label: 'Atrasado' },
{ value: 'pago', label: 'Pago' },
{ value: 'cancelado', label: 'Cancelado' },
];
const FORMAS: { value: FormaPagamento; label: string }[] = [
{ value: 'pix', label: 'Pix' },
{ value: 'boleto', label: 'Boleto' },
{ value: 'cartao', label: 'Cartão' },
{ value: 'dinheiro', label: 'Dinheiro' },
];
const LIMIT = 20;
function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ['tenant', 'recebimentos'] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'vendas'] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'dashboard'] });
}
export default function RecebimentosPage() {
const queryClient = useQueryClient();
const [status, setStatus] = React.useState<ParcelaStatus | 'todos'>('todos');
const [customerId, setCustomerId] = React.useState('');
const [periodo, setPeriodo] = React.useState<DateRange | undefined>();
const [page, setPage] = React.useState(1);
const [payingParcela, setPayingParcela] = React.useState<{ id: string; valor: string } | null>(null);
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const [bulkForma, setBulkForma] = React.useState<FormaPagamento | ''>('');
const [bulkOpen, setBulkOpen] = React.useState(false);
const [bulkLoading, setBulkLoading] = React.useState(false);
const [atualizarOpen, setAtualizarOpen] = React.useState(false);
const { data: customersData } = useQuery({
queryKey: ['tenant', 'customers', 'combobox'],
queryFn: () => listCustomers({ limit: 100 }),
});
const { data, isLoading } = useQuery({
queryKey: [
'tenant',
'recebimentos',
{ status, customerId, de: periodo?.from?.toISOString(), ate: periodo?.to?.toISOString(), page },
],
queryFn: () =>
listRecebimentos({
status: status === 'todos' ? undefined : status,
customer_id: customerId || undefined,
vencimento_inicio: periodo?.from?.toISOString().slice(0, 10),
vencimento_fim: periodo?.to?.toISOString().slice(0, 10),
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const atualizarMutation = useMutation({
mutationFn: () => atualizarRecebimentosAtrasados(),
onSuccess: (result) => {
invalidateAll(queryClient);
toast.success(`${result.atualizadas} parcela(s) marcadas como atrasadas.`);
setAtualizarOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível atualizar as parcelas atrasadas.');
setAtualizarOpen(false);
},
});
function toggleSelected(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function marcarSelecionadasPagas() {
if (!bulkForma) return;
setBulkLoading(true);
const ids = Array.from(selected);
const results = await Promise.allSettled(ids.map((id) => marcarRecebimentoPago(id, bulkForma)));
const falhas = results.filter((r) => r.status === 'rejected').length;
setBulkLoading(false);
setBulkOpen(false);
setSelected(new Set());
setBulkForma('');
invalidateAll(queryClient);
if (falhas === 0) {
toast.success(`${ids.length} parcela(s) marcadas como pagas.`);
} else {
toast.error(`${ids.length - falhas} de ${ids.length} marcadas como pagas — ${falhas} falharam.`);
}
}
const customerOptions = [
{ value: '', label: 'Todos os clientes' },
...(customersData?.data ?? []).map((c) => ({ value: c.id, label: c.nome_fantasia || c.razao_social })),
];
const columns: DataTableColumn<Recebimento>[] = [
{
id: 'select',
header: '',
hideOnMobileCard: true,
cell: (row) =>
row.status === 'pendente' || row.status === 'atrasado' ? (
<input
type="checkbox"
className="size-4 accent-primary"
checked={selected.has(row.id)}
onChange={() => toggleSelected(row.id)}
aria-label={`Selecionar parcela de ${row.sale.customer.razao_social}`}
/>
) : null,
className: 'w-px',
},
{ id: 'cliente', header: 'Cliente', primary: true, cell: (row) => row.sale.customer.razao_social },
{
id: 'venda',
header: 'Venda',
cell: (row) => `#${row.sale.numero_venda} · parcela ${row.numero_parcela}`,
},
{ id: 'valor', header: 'Valor', primary: true, cell: (row) => formatCurrency(row.valor) },
{
id: 'vencimento',
header: 'Vencimento',
cell: (row) => (
<span className={cn(row.status === 'atrasado' && 'font-medium text-danger')}>{formatDate(row.vencimento)}</span>
),
},
{ id: 'status', header: 'Status', cell: (row) => <StatusPill status={row.status} /> },
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Recebimentos</h1>
<p className="text-sm text-muted-foreground">Parcelas de vendas a receber</p>
</div>
<Button variant="outline" onClick={() => setAtualizarOpen(true)}>
<RefreshCw /> Atualizar atrasadas
</Button>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Select
value={status}
onValueChange={(value) => {
setStatus(value as ParcelaStatus | 'todos');
setPage(1);
}}
>
<SelectTrigger className="w-full sm:w-48" aria-label="Filtrar por status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
options={customerOptions}
value={customerId}
onChange={(value) => {
setCustomerId(value);
setPage(1);
}}
placeholder="Todos os clientes"
searchPlaceholder="Buscar cliente..."
className="w-full sm:w-56"
/>
<DateRangePicker
value={periodo}
onChange={(range) => {
setPeriodo(range);
setPage(1);
}}
placeholder="Período de vencimento"
className="w-full sm:w-64"
/>
</div>
{selected.size > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-primary/30 bg-primary/5 p-3">
<span className="text-sm font-medium text-foreground">{selected.size} selecionada(s)</span>
<Button size="sm" onClick={() => setBulkOpen(true)}>
Marcar selecionadas como pagas
</Button>
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
Limpar seleção
</Button>
</div>
)}
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhum recebimento encontrado"
emptyDescription="Ajuste os filtros de status/cliente/período."
rowActions={(row) =>
row.status === 'pendente' || row.status === 'atrasado' ? (
<Button variant="ghost" size="sm" onClick={() => setPayingParcela(row)}>
Marcar como paga
</Button>
) : null
}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<MarcarPagoDialog
parcela={payingParcela}
onOpenChange={(open) => !open && setPayingParcela(null)}
onSuccess={() => invalidateAll(queryClient)}
/>
<ConfirmDialog
open={atualizarOpen}
onOpenChange={setAtualizarOpen}
title="Atualizar parcelas atrasadas?"
description="Marca como atrasada toda parcela pendente já vencida."
confirmLabel="Atualizar"
loading={atualizarMutation.isPending}
onConfirm={() => atualizarMutation.mutate()}
/>
<ConfirmDialog
open={bulkOpen}
onOpenChange={(open) => {
setBulkOpen(open);
if (!open) setBulkForma('');
}}
title={`Marcar ${selected.size} parcela(s) como pagas?`}
description="Selecione a forma de pagamento usada em todas as parcelas selecionadas."
confirmLabel="Confirmar"
confirmDisabled={!bulkForma}
loading={bulkLoading}
onConfirm={marcarSelecionadasPagas}
>
<div className="px-4 pb-4 sm:px-0 sm:pb-0">
<Select value={bulkForma} onValueChange={(value) => setBulkForma(value as FormaPagamento)}>
<SelectTrigger className="w-full" aria-label="Forma de pagamento">
<SelectValue placeholder="Selecione a forma de pagamento" />
</SelectTrigger>
<SelectContent>
{FORMAS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</ConfirmDialog>
</div>
);
}