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>
300 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|