BREAKING CHANGE: Remove feature de seleção de período das preferências do usuário
Alterações principais:
- Adiciona sistema completo de relatórios por categoria
- Cria página /relatorios/categorias com filtros e visualizações
- Implementa tabela e gráfico de evolução mensal
- Adiciona funcionalidade de exportação de dados
- Cria skeleton otimizado para melhor UX de loading
- Remove feature de seleção de período das preferências
- Deleta lib/user-preferences/period.ts
- Remove colunas periodMonthsBefore e periodMonthsAfter do schema
- Remove todas as referências em 16+ arquivos
- Atualiza database schema via Drizzle
- Substitui Select de período por MonthPicker visual
- Implementa componente PeriodPicker reutilizável
- Integra shadcn MonthPicker customizado (português, Remix icons)
- Substitui createMonthOptions em todos os formulários
- Mantém formato "YYYY-MM" no banco de dados
- Melhora design da tabela de relatórios
- Mescla colunas Categoria e Tipo em uma única coluna
- Substitui badge de tipo por dot colorido discreto
- Reduz largura da tabela em ~120px
- Atualiza skeleton para refletir nova estrutura
- Melhorias gerais de UI
- Reduz espaçamento entre títulos da sidebar (p-2 → px-2 py-1)
- Adiciona MonthNavigation para navegação entre períodos
- Otimiza loading states com skeletons detalhados
533 lines
16 KiB
TypeScript
533 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createMassLancamentosAction,
|
|
deleteLancamentoAction,
|
|
deleteLancamentoBulkAction,
|
|
deleteMultipleLancamentosAction,
|
|
toggleLancamentoSettlementAction,
|
|
updateLancamentoBulkAction,
|
|
} from "@/app/(dashboard)/lancamentos/actions";
|
|
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
|
import { useCallback, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
|
|
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
|
|
import { BulkActionDialog, type BulkActionScope } from "../dialogs/bulk-action-dialog";
|
|
import { LancamentoDetailsDialog } from "../dialogs/lancamento-details-dialog";
|
|
import { LancamentoDialog } from "../dialogs/lancamento-dialog/lancamento-dialog";
|
|
import { LancamentosTable } from "../table/lancamentos-table";
|
|
import { MassAddDialog, type MassAddFormData } from "../dialogs/mass-add-dialog";
|
|
import type {
|
|
ContaCartaoFilterOption,
|
|
LancamentoFilterOption,
|
|
LancamentoItem,
|
|
SelectOption,
|
|
} from "../types";
|
|
|
|
interface LancamentosPageProps {
|
|
lancamentos: LancamentoItem[];
|
|
pagadorOptions: SelectOption[];
|
|
splitPagadorOptions: SelectOption[];
|
|
defaultPagadorId: string | null;
|
|
contaOptions: SelectOption[];
|
|
cartaoOptions: SelectOption[];
|
|
categoriaOptions: SelectOption[];
|
|
pagadorFilterOptions: LancamentoFilterOption[];
|
|
categoriaFilterOptions: LancamentoFilterOption[];
|
|
contaCartaoFilterOptions: ContaCartaoFilterOption[];
|
|
selectedPeriod: string;
|
|
estabelecimentos: string[];
|
|
allowCreate?: boolean;
|
|
defaultCartaoId?: string | null;
|
|
defaultPaymentMethod?: string | null;
|
|
lockCartaoSelection?: boolean;
|
|
lockPaymentMethod?: boolean;
|
|
}
|
|
|
|
export function LancamentosPage({
|
|
lancamentos,
|
|
pagadorOptions,
|
|
splitPagadorOptions,
|
|
defaultPagadorId,
|
|
contaOptions,
|
|
cartaoOptions,
|
|
categoriaOptions,
|
|
pagadorFilterOptions,
|
|
categoriaFilterOptions,
|
|
contaCartaoFilterOptions,
|
|
selectedPeriod,
|
|
estabelecimentos,
|
|
allowCreate = true,
|
|
defaultCartaoId,
|
|
defaultPaymentMethod,
|
|
lockCartaoSelection,
|
|
lockPaymentMethod,
|
|
}: LancamentosPageProps) {
|
|
const [selectedLancamento, setSelectedLancamento] =
|
|
useState<LancamentoItem | null>(null);
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [copyOpen, setCopyOpen] = useState(false);
|
|
const [lancamentoToCopy, setLancamentoToCopy] =
|
|
useState<LancamentoItem | null>(null);
|
|
const [massAddOpen, setMassAddOpen] = useState(false);
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
const [lancamentoToDelete, setLancamentoToDelete] =
|
|
useState<LancamentoItem | null>(null);
|
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
const [settlementLoadingId, setSettlementLoadingId] = useState<string | null>(
|
|
null
|
|
);
|
|
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
|
const [pendingEditData, setPendingEditData] = useState<{
|
|
id: string;
|
|
name: string;
|
|
categoriaId: string | undefined;
|
|
note: string;
|
|
pagadorId: string | undefined;
|
|
contaId: string | undefined;
|
|
cartaoId: string | undefined;
|
|
amount: number;
|
|
dueDate: string | null;
|
|
boletoPaymentDate: string | null;
|
|
lancamento: LancamentoItem;
|
|
} | null>(null);
|
|
const [pendingDeleteData, setPendingDeleteData] =
|
|
useState<LancamentoItem | null>(null);
|
|
const [multipleBulkDeleteOpen, setMultipleBulkDeleteOpen] = useState(false);
|
|
const [pendingMultipleDeleteData, setPendingMultipleDeleteData] = useState<
|
|
LancamentoItem[]
|
|
>([]);
|
|
const [anticipateOpen, setAnticipateOpen] = useState(false);
|
|
const [anticipationHistoryOpen, setAnticipationHistoryOpen] = useState(false);
|
|
const [selectedForAnticipation, setSelectedForAnticipation] =
|
|
useState<LancamentoItem | null>(null);
|
|
|
|
const handleToggleSettlement = useCallback(async (item: LancamentoItem) => {
|
|
if (item.paymentMethod === "Cartão de crédito") {
|
|
toast.info(
|
|
"Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const supportedMethods = ["Pix", "Boleto", "Dinheiro", "Cartão de débito", "Pré-Pago | VR/VA", "Transferência bancária"];
|
|
if (!supportedMethods.includes(item.paymentMethod)) {
|
|
return;
|
|
}
|
|
|
|
const nextValue = !Boolean(item.isSettled);
|
|
|
|
try {
|
|
setSettlementLoadingId(item.id);
|
|
const result = await toggleLancamentoSettlementAction({
|
|
id: item.id,
|
|
value: nextValue,
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error);
|
|
}
|
|
|
|
toast.success(result.message);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "Não foi possível atualizar o pagamento.";
|
|
toast.error(message);
|
|
} finally {
|
|
setSettlementLoadingId(null);
|
|
}
|
|
}, []);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (!lancamentoToDelete) {
|
|
return;
|
|
}
|
|
|
|
const result = await deleteLancamentoAction({
|
|
id: lancamentoToDelete.id,
|
|
});
|
|
|
|
if (!result.success) {
|
|
toast.error(result.error);
|
|
throw new Error(result.error);
|
|
}
|
|
|
|
toast.success(result.message);
|
|
setDeleteOpen(false);
|
|
}, [lancamentoToDelete]);
|
|
|
|
const handleBulkDelete = useCallback(
|
|
async (scope: BulkActionScope) => {
|
|
if (!pendingDeleteData) {
|
|
return;
|
|
}
|
|
|
|
const result = await deleteLancamentoBulkAction({
|
|
id: pendingDeleteData.id,
|
|
scope,
|
|
});
|
|
|
|
if (!result.success) {
|
|
toast.error(result.error);
|
|
throw new Error(result.error);
|
|
}
|
|
|
|
toast.success(result.message);
|
|
setBulkDeleteOpen(false);
|
|
setPendingDeleteData(null);
|
|
},
|
|
[pendingDeleteData]
|
|
);
|
|
|
|
const handleBulkEditRequest = useCallback(
|
|
(data: {
|
|
id: string;
|
|
name: string;
|
|
categoriaId: string | undefined;
|
|
note: string;
|
|
pagadorId: string | undefined;
|
|
contaId: string | undefined;
|
|
cartaoId: string | undefined;
|
|
amount: number;
|
|
dueDate: string | null;
|
|
boletoPaymentDate: string | null;
|
|
}) => {
|
|
if (!selectedLancamento) {
|
|
return;
|
|
}
|
|
|
|
setPendingEditData({
|
|
...data,
|
|
lancamento: selectedLancamento,
|
|
});
|
|
setEditOpen(false);
|
|
setBulkEditOpen(true);
|
|
},
|
|
[selectedLancamento]
|
|
);
|
|
|
|
const handleBulkEdit = useCallback(
|
|
async (scope: BulkActionScope) => {
|
|
if (!pendingEditData) {
|
|
return;
|
|
}
|
|
|
|
const result = await updateLancamentoBulkAction({
|
|
id: pendingEditData.id,
|
|
scope,
|
|
name: pendingEditData.name,
|
|
categoriaId: pendingEditData.categoriaId,
|
|
note: pendingEditData.note,
|
|
pagadorId: pendingEditData.pagadorId,
|
|
contaId: pendingEditData.contaId,
|
|
cartaoId: pendingEditData.cartaoId,
|
|
amount: pendingEditData.amount,
|
|
dueDate: pendingEditData.dueDate,
|
|
boletoPaymentDate: pendingEditData.boletoPaymentDate,
|
|
});
|
|
|
|
if (!result.success) {
|
|
toast.error(result.error);
|
|
throw new Error(result.error);
|
|
}
|
|
|
|
toast.success(result.message);
|
|
setBulkEditOpen(false);
|
|
setPendingEditData(null);
|
|
},
|
|
[pendingEditData]
|
|
);
|
|
|
|
const handleMassAddSubmit = useCallback(async (data: MassAddFormData) => {
|
|
const result = await createMassLancamentosAction(data);
|
|
|
|
if (!result.success) {
|
|
toast.error(result.error);
|
|
throw new Error(result.error);
|
|
}
|
|
|
|
toast.success(result.message);
|
|
}, []);
|
|
|
|
const handleMultipleBulkDelete = useCallback((items: LancamentoItem[]) => {
|
|
setPendingMultipleDeleteData(items);
|
|
setMultipleBulkDeleteOpen(true);
|
|
}, []);
|
|
|
|
const confirmMultipleBulkDelete = useCallback(async () => {
|
|
if (pendingMultipleDeleteData.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const ids = pendingMultipleDeleteData.map((item) => item.id);
|
|
const result = await deleteMultipleLancamentosAction({ ids });
|
|
|
|
if (!result.success) {
|
|
toast.error(result.error);
|
|
throw new Error(result.error);
|
|
}
|
|
|
|
toast.success(result.message);
|
|
setMultipleBulkDeleteOpen(false);
|
|
setPendingMultipleDeleteData([]);
|
|
}, [pendingMultipleDeleteData]);
|
|
|
|
const handleCreate = useCallback(() => {
|
|
setCreateOpen(true);
|
|
}, []);
|
|
|
|
const handleMassAdd = useCallback(() => {
|
|
setMassAddOpen(true);
|
|
}, []);
|
|
|
|
const handleEdit = useCallback((item: LancamentoItem) => {
|
|
setSelectedLancamento(item);
|
|
setEditOpen(true);
|
|
}, []);
|
|
|
|
const handleCopy = useCallback((item: LancamentoItem) => {
|
|
setLancamentoToCopy(item);
|
|
setCopyOpen(true);
|
|
}, []);
|
|
|
|
const handleConfirmDelete = useCallback((item: LancamentoItem) => {
|
|
if (item.seriesId) {
|
|
setPendingDeleteData(item);
|
|
setBulkDeleteOpen(true);
|
|
} else {
|
|
setLancamentoToDelete(item);
|
|
setDeleteOpen(true);
|
|
}
|
|
}, []);
|
|
|
|
const handleViewDetails = useCallback((item: LancamentoItem) => {
|
|
setSelectedLancamento(item);
|
|
setDetailsOpen(true);
|
|
}, []);
|
|
|
|
const handleAnticipate = useCallback((item: LancamentoItem) => {
|
|
setSelectedForAnticipation(item);
|
|
setAnticipateOpen(true);
|
|
}, []);
|
|
|
|
const handleViewAnticipationHistory = useCallback((item: LancamentoItem) => {
|
|
setSelectedForAnticipation(item);
|
|
setAnticipationHistoryOpen(true);
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<LancamentosTable
|
|
data={lancamentos}
|
|
pagadorFilterOptions={pagadorFilterOptions}
|
|
categoriaFilterOptions={categoriaFilterOptions}
|
|
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
|
onCreate={allowCreate ? handleCreate : undefined}
|
|
onMassAdd={allowCreate ? handleMassAdd : undefined}
|
|
onEdit={handleEdit}
|
|
onCopy={handleCopy}
|
|
onConfirmDelete={handleConfirmDelete}
|
|
onBulkDelete={handleMultipleBulkDelete}
|
|
onViewDetails={handleViewDetails}
|
|
onToggleSettlement={handleToggleSettlement}
|
|
onAnticipate={handleAnticipate}
|
|
onViewAnticipationHistory={handleViewAnticipationHistory}
|
|
isSettlementLoading={(id) => settlementLoadingId === id}
|
|
/>
|
|
|
|
{allowCreate ? (
|
|
<LancamentoDialog
|
|
mode="create"
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
pagadorOptions={pagadorOptions}
|
|
splitPagadorOptions={splitPagadorOptions}
|
|
defaultPagadorId={defaultPagadorId}
|
|
contaOptions={contaOptions}
|
|
cartaoOptions={cartaoOptions}
|
|
categoriaOptions={categoriaOptions}
|
|
estabelecimentos={estabelecimentos}
|
|
defaultPeriod={selectedPeriod}
|
|
defaultCartaoId={defaultCartaoId}
|
|
defaultPaymentMethod={defaultPaymentMethod}
|
|
lockCartaoSelection={lockCartaoSelection}
|
|
lockPaymentMethod={lockPaymentMethod}
|
|
/>
|
|
) : null}
|
|
|
|
<LancamentoDialog
|
|
mode="create"
|
|
open={copyOpen && !!lancamentoToCopy}
|
|
onOpenChange={(open) => {
|
|
setCopyOpen(open);
|
|
if (!open) {
|
|
setLancamentoToCopy(null);
|
|
}
|
|
}}
|
|
pagadorOptions={pagadorOptions}
|
|
splitPagadorOptions={splitPagadorOptions}
|
|
defaultPagadorId={defaultPagadorId}
|
|
contaOptions={contaOptions}
|
|
cartaoOptions={cartaoOptions}
|
|
categoriaOptions={categoriaOptions}
|
|
estabelecimentos={estabelecimentos}
|
|
lancamento={lancamentoToCopy ?? undefined}
|
|
defaultPeriod={selectedPeriod}
|
|
/>
|
|
|
|
<LancamentoDialog
|
|
mode="update"
|
|
open={editOpen && !!selectedLancamento}
|
|
onOpenChange={setEditOpen}
|
|
pagadorOptions={pagadorOptions}
|
|
splitPagadorOptions={splitPagadorOptions}
|
|
defaultPagadorId={defaultPagadorId}
|
|
contaOptions={contaOptions}
|
|
cartaoOptions={cartaoOptions}
|
|
categoriaOptions={categoriaOptions}
|
|
estabelecimentos={estabelecimentos}
|
|
lancamento={selectedLancamento ?? undefined}
|
|
defaultPeriod={selectedPeriod}
|
|
onBulkEditRequest={handleBulkEditRequest}
|
|
/>
|
|
|
|
<LancamentoDetailsDialog
|
|
open={detailsOpen && !!selectedLancamento}
|
|
onOpenChange={(open) => {
|
|
setDetailsOpen(open);
|
|
if (!open) {
|
|
setSelectedLancamento(null);
|
|
}
|
|
}}
|
|
lancamento={detailsOpen ? selectedLancamento : null}
|
|
/>
|
|
|
|
<ConfirmActionDialog
|
|
open={deleteOpen && !!lancamentoToDelete}
|
|
onOpenChange={setDeleteOpen}
|
|
title={
|
|
lancamentoToDelete
|
|
? `Remover lançamento "${lancamentoToDelete.name}"?`
|
|
: "Remover lançamento?"
|
|
}
|
|
description="Essa ação é irreversível e removerá o lançamento de forma permanente."
|
|
confirmLabel="Remover"
|
|
pendingLabel="Removendo..."
|
|
confirmVariant="destructive"
|
|
onConfirm={handleDelete}
|
|
disabled={!lancamentoToDelete}
|
|
/>
|
|
|
|
<BulkActionDialog
|
|
open={bulkDeleteOpen && !!pendingDeleteData}
|
|
onOpenChange={setBulkDeleteOpen}
|
|
actionType="delete"
|
|
seriesType={
|
|
pendingDeleteData?.condition === "Parcelado"
|
|
? "installment"
|
|
: "recurring"
|
|
}
|
|
currentNumber={pendingDeleteData?.currentInstallment ?? undefined}
|
|
totalCount={
|
|
pendingDeleteData?.installmentCount ??
|
|
pendingDeleteData?.recurrenceCount ??
|
|
undefined
|
|
}
|
|
onConfirm={handleBulkDelete}
|
|
/>
|
|
|
|
<BulkActionDialog
|
|
open={bulkEditOpen && !!pendingEditData}
|
|
onOpenChange={setBulkEditOpen}
|
|
actionType="edit"
|
|
seriesType={
|
|
pendingEditData?.lancamento.condition === "Parcelado"
|
|
? "installment"
|
|
: "recurring"
|
|
}
|
|
currentNumber={
|
|
pendingEditData?.lancamento.currentInstallment ?? undefined
|
|
}
|
|
totalCount={
|
|
pendingEditData?.lancamento.installmentCount ??
|
|
pendingEditData?.lancamento.recurrenceCount ??
|
|
undefined
|
|
}
|
|
onConfirm={handleBulkEdit}
|
|
/>
|
|
|
|
{allowCreate ? (
|
|
<MassAddDialog
|
|
open={massAddOpen}
|
|
onOpenChange={setMassAddOpen}
|
|
onSubmit={handleMassAddSubmit}
|
|
pagadorOptions={pagadorOptions}
|
|
contaOptions={contaOptions}
|
|
cartaoOptions={cartaoOptions}
|
|
categoriaOptions={categoriaOptions}
|
|
estabelecimentos={estabelecimentos}
|
|
selectedPeriod={selectedPeriod}
|
|
defaultPagadorId={defaultPagadorId}
|
|
/>
|
|
) : null}
|
|
|
|
<ConfirmActionDialog
|
|
open={multipleBulkDeleteOpen && pendingMultipleDeleteData.length > 0}
|
|
onOpenChange={setMultipleBulkDeleteOpen}
|
|
title={`Remover ${pendingMultipleDeleteData.length} ${
|
|
pendingMultipleDeleteData.length === 1 ? "lançamento" : "lançamentos"
|
|
}?`}
|
|
description="Essa ação é irreversível e removerá os lançamentos selecionados de forma permanente."
|
|
confirmLabel="Remover"
|
|
pendingLabel="Removendo..."
|
|
confirmVariant="destructive"
|
|
onConfirm={confirmMultipleBulkDelete}
|
|
disabled={pendingMultipleDeleteData.length === 0}
|
|
/>
|
|
|
|
{/* Dialogs de Antecipação */}
|
|
{selectedForAnticipation && (
|
|
<AnticipateInstallmentsDialog
|
|
open={anticipateOpen}
|
|
onOpenChange={setAnticipateOpen}
|
|
seriesId={selectedForAnticipation.seriesId!}
|
|
lancamentoName={selectedForAnticipation.name}
|
|
categorias={categoriaOptions.map((c) => ({
|
|
id: c.value,
|
|
name: c.label,
|
|
icon: c.icon ?? null,
|
|
}))}
|
|
pagadores={pagadorOptions.map((p) => ({
|
|
id: p.value,
|
|
name: p.label,
|
|
}))}
|
|
defaultPeriod={selectedPeriod}
|
|
/>
|
|
)}
|
|
|
|
{selectedForAnticipation && (
|
|
<AnticipationHistoryDialog
|
|
open={anticipationHistoryOpen}
|
|
onOpenChange={setAnticipationHistoryOpen}
|
|
seriesId={selectedForAnticipation.seriesId!}
|
|
lancamentoName={selectedForAnticipation.name}
|
|
onViewLancamento={(lancamentoId) => {
|
|
const lancamento = lancamentos.find((l) => l.id === lancamentoId);
|
|
if (lancamento) {
|
|
setSelectedLancamento(lancamento);
|
|
setDetailsOpen(true);
|
|
setAnticipationHistoryOpen(false);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|