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>
219 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|