feat(lançamentos): ações de converter transações existentes em parceladas e recorrentes

This commit is contained in:
Yuri Argolo
2026-06-13 13:00:44 -03:00
committed by Felipe Coutinho
parent 558197e870
commit 4b5cdf81b8
9 changed files with 629 additions and 0 deletions

View File

@@ -4,6 +4,8 @@ import { RiAddFill } from "@remixicon/react";
import { useState } from "react";
import { toast } from "sonner";
import {
convertTransactionToInstallmentAction,
convertTransactionToRecurringAction,
createMassTransactionsAction,
deleteMultipleTransactionsAction,
deleteTransactionAction,
@@ -18,8 +20,19 @@ import {
detachAttachmentBulkAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import { detectInstallmentFromName } from "@/features/transactions/lib/installment-detection";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import type {
TransactionsExportContext,
TransactionsPaginationState,
@@ -190,6 +203,14 @@ export function TransactionsPage({
const [refundOpen, setRefundOpen] = useState(false);
const [transactionToRefund, setTransactionToRefund] =
useState<TransactionItem | null>(null);
const [convertInstallmentOpen, setConvertInstallmentOpen] = useState(false);
const [transactionToConvert, setTransactionToConvert] =
useState<TransactionItem | null>(null);
const [convertRecurringOpen, setConvertRecurringOpen] = useState(false);
const [transactionToConvertRecurring, setTransactionToConvertRecurring] =
useState<TransactionItem | null>(null);
const [recurrenceCount, setRecurrenceCount] = useState("12");
const [recurrencePending, setRecurrencePending] = useState(false);
const handleToggleSettlement = async (item: TransactionItem) => {
if (item.paymentMethod === "Cartão de crédito") {
@@ -542,6 +563,71 @@ export function TransactionsPage({
setRefundOpen(true);
};
const handleConvertToInstallment = (item: TransactionItem) => {
setTransactionToConvert(item);
setConvertInstallmentOpen(true);
};
const confirmConvertToInstallment = async () => {
if (!transactionToConvert) {
return;
}
const result = await convertTransactionToInstallmentAction({
id: transactionToConvert.id,
});
if (!result.success) {
toast.error(result.error);
throw new Error(result.error);
}
toast.success(result.message);
setConvertInstallmentOpen(false);
setTransactionToConvert(null);
};
const conversionPreview = transactionToConvert
? detectInstallmentFromName(transactionToConvert.name)
: null;
const handleConvertToRecurring = (item: TransactionItem) => {
setTransactionToConvertRecurring(item);
setRecurrenceCount("12");
setConvertRecurringOpen(true);
};
const confirmConvertToRecurring = async () => {
if (!transactionToConvertRecurring) {
return;
}
const count = Number(recurrenceCount);
if (!Number.isInteger(count) || count < 2 || count > 60) {
toast.error("Informe uma recorrência entre 2 e 60 meses.");
return;
}
try {
setRecurrencePending(true);
const result = await convertTransactionToRecurringAction({
id: transactionToConvertRecurring.id,
recurrenceCount: count,
});
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setConvertRecurringOpen(false);
setTransactionToConvertRecurring(null);
} finally {
setRecurrencePending(false);
}
};
const handleAnticipate = (item: TransactionItem) => {
setSelectedForAnticipation(item);
setAnticipateOpen(true);
@@ -628,6 +714,8 @@ export function TransactionsPage({
onBulkImport={handleBulkImport}
onViewDetails={handleViewDetails}
onRefund={handleRefund}
onConvertToInstallment={handleConvertToInstallment}
onConvertToRecurring={handleConvertToRecurring}
onToggleSettlement={handleToggleSettlement}
onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory}
@@ -749,6 +837,79 @@ export function TransactionsPage({
disabled={!transactionToDelete}
/>
<ConfirmActionDialog
open={convertInstallmentOpen && !!transactionToConvert}
onOpenChange={(open) => {
setConvertInstallmentOpen(open);
if (!open) {
setTransactionToConvert(null);
}
}}
title="Converter em parcelamento?"
description={
conversionPreview
? `Este lançamento será convertido para "${conversionPreview.name}" como parcela ${conversionPreview.currentInstallment} de ${conversionPreview.installmentCount}. As próximas parcelas serão criadas automaticamente.`
: "Este lançamento será convertido em uma série parcelada."
}
confirmLabel="Converter"
pendingLabel="Convertendo..."
onConfirm={confirmConvertToInstallment}
disabled={!transactionToConvert}
/>
<Dialog
open={convertRecurringOpen && !!transactionToConvertRecurring}
onOpenChange={(open) => {
setConvertRecurringOpen(open);
if (!open) {
setTransactionToConvertRecurring(null);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Converter em recorrente?</DialogTitle>
<DialogDescription>
O lançamento atual será mantido como a primeira recorrência e os
próximos meses serão criados automaticamente.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="recurrenceCount">Repetir por</Label>
<Input
id="recurrenceCount"
type="number"
min={2}
max={60}
value={recurrenceCount}
onChange={(event) => setRecurrenceCount(event.target.value)}
/>
<p className="text-muted-foreground text-sm">
Use o total de meses da série, incluindo este lançamento.
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setConvertRecurringOpen(false)}
disabled={recurrencePending}
>
Cancelar
</Button>
<Button
type="button"
onClick={confirmConvertToRecurring}
disabled={recurrencePending}
>
{recurrencePending ? "Convertendo..." : "Converter"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<BulkActionDialog
open={bulkDeleteOpen && !!pendingDeleteData}
onOpenChange={setBulkDeleteOpen}

View File

@@ -20,6 +20,9 @@ import {
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { CREDIT_CARD_PAYMENT_METHOD } from "@/features/transactions/lib/constants";
import { detectInstallmentFromName } from "@/features/transactions/lib/installment-detection";
import { getConditionIcon } from "@/shared/utils/icons";
import type { TransactionItem } from "../types";
type TransactionActionsMenuProps = {
@@ -33,6 +36,8 @@ type TransactionActionsMenuProps = {
onRefund?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
onConvertToInstallment?: (item: TransactionItem) => void;
onConvertToRecurring?: (item: TransactionItem) => void;
};
export function TransactionActionsMenu({
@@ -46,6 +51,8 @@ export function TransactionActionsMenu({
onRefund,
onAnticipate,
onViewAnticipationHistory,
onConvertToInstallment,
onConvertToRecurring,
}: TransactionActionsMenuProps) {
const isOwnData = item.userId === currentUserId;
const canRefund =
@@ -55,9 +62,29 @@ export function TransactionActionsMenu({
!item.splitGroupId &&
!item.readonly &&
!item.note?.startsWith(REFUND_NOTE_PREFIX);
const showInstallmentActions =
isOwnData && item.condition === "Parcelado" && item.seriesId;
const detectedInstallment = detectInstallmentFromName(item.name);
const canConvertToInstallment =
isOwnData &&
item.paymentMethod === CREDIT_CARD_PAYMENT_METHOD &&
item.condition === "À vista" &&
!item.splitGroupId &&
!item.isDivided &&
!item.readonly &&
Boolean(detectedInstallment) &&
Boolean(onConvertToInstallment);
const canConvertToRecurring =
isOwnData &&
item.condition === "À vista" &&
!item.splitGroupId &&
!item.isDivided &&
!item.readonly &&
Boolean(onConvertToRecurring);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -112,6 +139,24 @@ export function TransactionActionsMenu({
</DropdownMenuItem>
) : null}
{canConvertToInstallment ? (
<DropdownMenuItem
onSelect={() => onConvertToInstallment?.(item)}
>
{getConditionIcon("Parcelado")}
Converter em Parcelamento
</DropdownMenuItem>
) : null}
{canConvertToRecurring ? (
<DropdownMenuItem
onSelect={() => onConvertToRecurring?.(item)}
>
{getConditionIcon("Recorrente")}
Converter em Recorrente
</DropdownMenuItem>
) : null}
{isOwnData ? (
<DropdownMenuItem
variant="destructive"

View File

@@ -47,6 +47,8 @@ type BuildColumnsArgs = {
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
onConvertToInstallment?: (item: TransactionItem) => void;
onConvertToRecurring?: (item: TransactionItem) => void;
isSettlementLoading: (id: string) => boolean;
showActions: boolean;
columnOrder?: string[] | null;
@@ -109,6 +111,8 @@ function buildColumns({
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
onConvertToInstallment,
onConvertToRecurring,
isSettlementLoading,
showActions,
}: BuildColumnsArgs): ColumnDef<TransactionItem>[] {
@@ -122,6 +126,8 @@ function buildColumns({
const handleToggleSettlement = onToggleSettlement ?? noop;
const handleAnticipate = onAnticipate ?? noop;
const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop;
const handleConvertToInstallment = onConvertToInstallment ?? noop;
const handleConvertToRecurring = onConvertToRecurring ?? noop;
const columns: ColumnDef<TransactionItem>[] = [
{
@@ -545,6 +551,8 @@ function buildColumns({
onRefund={handleRefund}
onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment ? handleConvertToInstallment : undefined}
onConvertToRecurring={onConvertToRecurring ? handleConvertToRecurring : undefined}
/>
</div>
),

View File

@@ -39,6 +39,8 @@ type TransactionsMobileListProps = {
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
onConvertToInstallment?: (item: TransactionItem) => void;
onConvertToRecurring?: (item: TransactionItem) => void;
isSettlementLoading: (id: string) => boolean;
showActions?: boolean;
};
@@ -55,6 +57,8 @@ export function TransactionsMobileList({
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
onConvertToInstallment,
onConvertToRecurring,
isSettlementLoading,
showActions = true,
}: TransactionsMobileListProps) {
@@ -74,6 +78,8 @@ export function TransactionsMobileList({
onToggleSettlement={onToggleSettlement}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment}
onConvertToRecurring={onConvertToRecurring}
isSettlementLoading={isSettlementLoading}
showActions={showActions}
/>
@@ -98,6 +104,8 @@ function TransactionMobileCard({
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
onConvertToInstallment,
onConvertToRecurring,
isSettlementLoading,
showActions = true,
}: TransactionMobileCardProps) {
@@ -261,6 +269,8 @@ function TransactionMobileCard({
onRefund={onRefund}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment}
onConvertToRecurring={onConvertToRecurring}
/>
</div>
) : null}

View File

@@ -71,6 +71,8 @@ type TransactionsTableProps = {
onBulkImport?: (items: TransactionItem[]) => void;
onViewDetails?: (item: TransactionItem) => void;
onRefund?: (item: TransactionItem) => void;
onConvertToInstallment?: (item: TransactionItem) => void;
onConvertToRecurring?: (item: TransactionItem) => void;
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
@@ -100,6 +102,8 @@ export function TransactionsTable({
onBulkImport,
onViewDetails,
onRefund,
onConvertToInstallment,
onConvertToRecurring,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -134,6 +138,8 @@ export function TransactionsTable({
onConfirmDelete,
onViewDetails,
onRefund,
onConvertToInstallment,
onConvertToRecurring,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -151,6 +157,8 @@ export function TransactionsTable({
onConfirmDelete,
onViewDetails,
onRefund,
onConvertToInstallment,
onConvertToRecurring,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,