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>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user