Files
SeConectai_Vendas/frontend/app/master/faturas/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

219 lines
8.0 KiB
TypeScript

'use client';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Zap } from 'lucide-react';
import {
atualizarFaturasAtrasadas,
gerarFaturasMes,
listFaturas,
marcarFaturaPaga,
type FaturaListItem,
type FaturaStatus,
} from '@/lib/api/faturas';
import { listTenants } from '@/lib/api/tenants';
import { ApiError } from '@/lib/api/client';
import { formatCurrency, formatDate } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { StatusPill } from '@/components/ui/status-pill';
import { ConfirmDialog } from '@/components/domain/confirm-dialog';
const STATUS_OPTIONS: { value: FaturaStatus | 'todos'; label: string }[] = [
{ value: 'todos', label: 'Todos os status' },
{ value: 'pendente', label: 'Pendente' },
{ value: 'pago', label: 'Pago' },
{ value: 'atrasado', label: 'Atrasado' },
{ value: 'cancelado', label: 'Cancelado' },
];
const LIMIT = 20;
export default function FaturasPage() {
const queryClient = useQueryClient();
const [status, setStatus] = React.useState<FaturaStatus | 'todos'>('todos');
const [tenantId, setTenantId] = React.useState<string>('');
const [referencia, setReferencia] = React.useState('');
const [page, setPage] = React.useState(1);
const [gerarOpen, setGerarOpen] = React.useState(false);
const [atualizarOpen, setAtualizarOpen] = React.useState(false);
const { data: tenantsOptions } = useQuery({
queryKey: ['master', 'tenants', 'combobox'],
queryFn: () => listTenants({ limit: 100 }),
});
const { data, isLoading } = useQuery({
queryKey: ['master', 'faturas', { status, tenantId, referencia, page }],
queryFn: () =>
listFaturas({
status: status === 'todos' ? undefined : status,
tenant_id: tenantId || undefined,
referencia: referencia || undefined,
page,
limit: LIMIT,
}),
placeholderData: (prev) => prev,
});
const marcarPagoMutation = useMutation({
mutationFn: (id: string) => marcarFaturaPaga(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'faturas'] });
queryClient.invalidateQueries({ queryKey: ['master', 'dashboard'] });
toast.success('Fatura marcada como paga.');
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível marcar a fatura como paga.');
},
});
const gerarMutation = useMutation({
mutationFn: () => gerarFaturasMes(),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['master', 'faturas'] });
toast.success(`${result.criadas} fatura(s) geradas para ${result.referencia} (${result.ignoradas} já existiam).`);
setGerarOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível gerar as faturas do mês.');
},
});
const atualizarMutation = useMutation({
mutationFn: () => atualizarFaturasAtrasadas(),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['master', 'faturas'] });
queryClient.invalidateQueries({ queryKey: ['master', 'tenants'] });
toast.success(`${result.atualizadas} fatura(s) marcadas como atrasadas (${result.tenants_afetados} tenant(s) afetados).`);
setAtualizarOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível atualizar as faturas atrasadas.');
},
});
const columns: DataTableColumn<FaturaListItem>[] = [
{ id: 'tenant', header: 'Tenant', primary: true, cell: (row) => row.tenant.razao_social },
{ id: 'referencia', header: 'Referência', cell: (row) => row.referencia },
{ id: 'valor', header: 'Valor', cell: (row) => formatCurrency(row.valor) },
{ id: 'vencimento', header: 'Vencimento', cell: (row) => formatDate(row.vencimento) },
{ id: 'status', header: 'Status', primary: true, cell: (row) => <StatusPill status={row.status} /> },
];
const tenantComboboxOptions = (tenantsOptions?.data ?? []).map((t) => ({
value: t.id,
label: t.nome_fantasia || t.razao_social,
}));
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">Faturas</h1>
<p className="text-sm text-muted-foreground">Mensalidades cobradas dos tenants</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setAtualizarOpen(true)}>
<RefreshCw /> Atualizar atrasadas
</Button>
<Button onClick={() => setGerarOpen(true)}>
<Zap /> Gerar faturas do mês
</Button>
</div>
</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 FaturaStatus | '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={[{ value: '', label: 'Todos os tenants' }, ...tenantComboboxOptions]}
value={tenantId}
onChange={(value) => {
setTenantId(value);
setPage(1);
}}
placeholder="Todos os tenants"
searchPlaceholder="Buscar tenant..."
className="w-full sm:w-56"
/>
<Input
placeholder="Referência (aaaa-mm)"
value={referencia}
onChange={(e) => {
setReferencia(e.target.value);
setPage(1);
}}
className="w-full sm:w-40"
/>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
keyField={(row) => row.id}
loading={isLoading}
emptyTitle="Nenhuma fatura encontrada"
emptyDescription="Ajuste os filtros ou gere as faturas do mês."
rowActions={(row) =>
row.status !== 'pago' && row.status !== 'cancelado' ? (
<Button
variant="ghost"
size="sm"
loading={marcarPagoMutation.isPending && marcarPagoMutation.variables === row.id}
onClick={() => marcarPagoMutation.mutate(row.id)}
>
Marcar como paga
</Button>
) : null
}
pagination={
data ? { page: data.meta.page, limit: data.meta.limit, total: data.meta.total, onPageChange: setPage } : undefined
}
/>
<ConfirmDialog
open={gerarOpen}
onOpenChange={setGerarOpen}
title="Gerar faturas do mês"
description="Cria uma fatura pendente para cada tenant que ainda não tem uma fatura no mês corrente. Tenants que já têm fatura para o mês não são duplicados."
confirmLabel="Gerar faturas"
loading={gerarMutation.isPending}
onConfirm={() => gerarMutation.mutate()}
/>
<ConfirmDialog
open={atualizarOpen}
onOpenChange={setAtualizarOpen}
title="Atualizar faturas atrasadas"
description="Marca como atrasada toda fatura pendente vencida, e move para inadimplente os tenants ativos afetados."
confirmLabel="Atualizar"
loading={atualizarMutation.isPending}
onConfirm={() => atualizarMutation.mutate()}
/>
</div>
);
}