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

@@ -6,6 +6,8 @@ export {
} from "./actions/bulk-actions"; } from "./actions/bulk-actions";
export { exportTransactionsDataAction } from "./actions/export-actions"; export { exportTransactionsDataAction } from "./actions/export-actions";
export { export {
convertTransactionToInstallmentAction,
convertTransactionToRecurringAction,
createTransactionAction, createTransactionAction,
deleteTransactionAction, deleteTransactionAction,
toggleTransactionSettlementAction, toggleTransactionSettlementAction,

View File

@@ -513,11 +513,28 @@ export const toggleSettlementSchema = z.object({
.optional(), .optional(),
}); });
export const convertToInstallmentSchema = z.object({
id: uuidSchema("Lançamento"),
});
export const convertToRecurringSchema = z.object({
id: uuidSchema("Lançamento"),
recurrenceCount: z.coerce
.number({ message: "Informe por quantos meses repetir." })
.int()
.min(2, "A recorrência deve ter ao menos dois meses.")
.max(60, "Selecione até 60 meses."),
});
type BaseInput = z.infer<typeof baseFields>; type BaseInput = z.infer<typeof baseFields>;
export type CreateInput = z.infer<typeof createSchema>; export type CreateInput = z.infer<typeof createSchema>;
export type UpdateInput = z.infer<typeof updateSchema>; export type UpdateInput = z.infer<typeof updateSchema>;
export type DeleteInput = z.infer<typeof deleteSchema>; export type DeleteInput = z.infer<typeof deleteSchema>;
export type ToggleSettlementInput = z.infer<typeof toggleSettlementSchema>; export type ToggleSettlementInput = z.infer<typeof toggleSettlementSchema>;
export type ConvertToInstallmentInput = z.infer<
typeof convertToInstallmentSchema
>;
export type ConvertToRecurringInput = z.infer<typeof convertToRecurringSchema>;
export const revalidate = (userId: string) => export const revalidate = (userId: string) =>
revalidateForEntity("transactions", userId); revalidateForEntity("transactions", userId);

View File

@@ -23,12 +23,17 @@ import {
parseLocalDateString, parseLocalDateString,
} from "@/shared/utils/date"; } from "@/shared/utils/date";
import { copyAttachmentsForImport } from "../lib/attachment-copy"; import { copyAttachmentsForImport } from "../lib/attachment-copy";
import { detectInstallmentFromName } from "../lib/installment-detection";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments"; import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import { import {
buildShares, buildShares,
buildTransactionRecords, buildTransactionRecords,
type ConvertToInstallmentInput,
type ConvertToRecurringInput,
type CreateInput, type CreateInput,
centsToDecimalString, centsToDecimalString,
convertToInstallmentSchema,
convertToRecurringSchema,
createSchema, createSchema,
type DeleteInput, type DeleteInput,
deleteSchema, deleteSchema,
@@ -471,6 +476,330 @@ export async function deleteTransactionAction(
} }
} }
export async function convertTransactionToInstallmentAction(
input: ConvertToInstallmentInput,
): Promise<ActionResult<{ createdCount: number }>> {
try {
const user = await getUser();
const data = convertToInstallmentSchema.parse(input);
const existing = await db.query.transactions.findFirst({
where: and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
return {
success: false,
error: "Pagamentos automáticos de fatura não podem ser convertidos.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser convertidos.",
};
}
if (
existing.paymentMethod !== "Cartão de crédito" ||
!existing.cardId ||
existing.condition !== "À vista"
) {
return {
success: false,
error: "Apenas lançamentos à vista de cartão de crédito podem ser convertidos.",
};
}
if (existing.splitGroupId || existing.isDivided) {
return {
success: false,
error: "Lançamentos divididos ainda não podem ser convertidos em parcelamento.",
};
}
const detected = detectInstallmentFromName(existing.name);
if (!detected) {
return {
success: false,
error:
"Não encontrei um padrão de parcela no nome deste lançamento, como 2/10 ou Parcela 2 de 10.",
};
}
const amountSign: 1 | -1 =
existing.transactionType === "Despesa" ? -1 : 1;
const totalCents =
Math.round(Math.abs(Number(existing.amount)) * 100) *
detected.installmentCount;
const seriesId = randomUUID();
const records = buildTransactionRecords({
data: {
purchaseDate: existing.purchaseDate.toISOString().slice(0, 10),
period: existing.period,
name: detected.name,
transactionType: existing.transactionType as "Receita" | "Despesa",
amount: totalCents / 100,
condition: "Parcelado",
paymentMethod: "Cartão de crédito",
payerId: existing.payerId,
isSplit: false,
accountId: null,
cardId: existing.cardId,
categoryId: existing.categoryId,
note: existing.note,
installmentCount: detected.installmentCount,
startInstallment: detected.currentInstallment,
dueDate: existing.dueDate?.toISOString().slice(0, 10),
isSettled: null,
},
userId: user.id,
period: existing.period,
purchaseDate: existing.purchaseDate,
dueDate: existing.dueDate,
boletoPaymentDate: null,
shares: [{ payerId: existing.payerId, amountCents: totalCents }],
amountSign,
shouldNullifySettled: true,
seriesId,
}).map((record) => ({
...record,
importBatchId: existing.importBatchId,
}));
const currentRow = records[0];
const rowsToInsert = records.slice(1);
if (!currentRow) {
throw new Error("Não foi possível montar o parcelamento.");
}
const periodsToUpdate = records
.map((row) => row.period)
.filter((period): period is string => Boolean(period));
const paidPeriods = await getPaidInvoicePeriods(
user.id,
existing.cardId,
periodsToUpdate,
);
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de converter este lançamento.`,
};
}
if (existing.transactionType === "Despesa") {
const limitCheck = await validateCardLimit({
userId: user.id,
cardId: existing.cardId,
addAmount: records.reduce((acc, row) => acc + Math.abs(Number(row.amount)), 0),
excludeTransactionIds: [existing.id],
});
if (!limitCheck.ok) {
return { success: false, error: limitCheck.error };
}
}
await db.transaction(async (tx: typeof db) => {
await tx
.update(transactions)
.set({
condition: currentRow.condition,
name: currentRow.name,
amount: currentRow.amount,
installmentCount: currentRow.installmentCount,
currentInstallment: currentRow.currentInstallment,
recurrenceCount: null,
period: currentRow.period,
dueDate: currentRow.dueDate,
isSettled: null,
seriesId,
})
.where(
and(eq(transactions.id, existing.id), eq(transactions.userId, user.id)),
);
if (rowsToInsert.length > 0) {
await tx.insert(transactions).values(rowsToInsert);
}
});
revalidate(user.id);
return {
success: true,
message: `Lançamento convertido em ${detected.installmentCount} parcelas.`,
data: { createdCount: rowsToInsert.length },
};
} catch (error) {
return handleActionError(error) as ActionResult<{ createdCount: number }>;
}
}
export async function convertTransactionToRecurringAction(
input: ConvertToRecurringInput,
): Promise<ActionResult<{ createdCount: number }>> {
try {
const user = await getUser();
const data = convertToRecurringSchema.parse(input);
const existing = await db.query.transactions.findFirst({
where: and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
return {
success: false,
error: "Pagamentos automáticos de fatura não podem ser convertidos.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser convertidos.",
};
}
if (existing.condition !== "À vista") {
return {
success: false,
error: "Apenas lançamentos à vista podem ser convertidos em recorrência.",
};
}
if (existing.splitGroupId || existing.isDivided) {
return {
success: false,
error: "Lançamentos divididos ainda não podem ser convertidos em recorrência.",
};
}
const amountSign: 1 | -1 =
existing.transactionType === "Despesa" ? -1 : 1;
const totalCents = Math.round(Math.abs(Number(existing.amount)) * 100);
const seriesId = randomUUID();
const isCreditCard = existing.paymentMethod === "Cartão de crédito";
const records = buildTransactionRecords({
data: {
purchaseDate: existing.purchaseDate.toISOString().slice(0, 10),
period: existing.period,
name: existing.name,
transactionType: existing.transactionType as "Receita" | "Despesa",
amount: totalCents / 100,
condition: "Recorrente",
paymentMethod: existing.paymentMethod as
| "Pix"
| "Boleto"
| "Dinheiro"
| "Cartão de débito"
| "Cartão de crédito"
| "Pré-Pago | VR/VA"
| "Transferência bancária",
payerId: existing.payerId,
isSplit: false,
accountId: isCreditCard ? null : existing.accountId,
cardId: isCreditCard ? existing.cardId : null,
categoryId: existing.categoryId,
note: existing.note,
recurrenceCount: data.recurrenceCount,
dueDate: existing.dueDate?.toISOString().slice(0, 10),
boletoPaymentDate: existing.boletoPaymentDate
?.toISOString()
.slice(0, 10),
isSettled: existing.isSettled,
},
userId: user.id,
period: existing.period,
purchaseDate: existing.purchaseDate,
dueDate: existing.dueDate,
boletoPaymentDate: existing.boletoPaymentDate,
shares: [{ payerId: existing.payerId, amountCents: totalCents }],
amountSign,
shouldNullifySettled: isCreditCard,
seriesId,
}).map((record) => ({
...record,
importBatchId: existing.importBatchId,
}));
const currentRow = records[0];
const rowsToInsert = records.slice(1);
if (!currentRow) {
throw new Error("Não foi possível montar a recorrência.");
}
if (isCreditCard && existing.cardId) {
const periodsToUpdate = records
.map((row) => row.period)
.filter((period): period is string => Boolean(period));
const paidPeriods = await getPaidInvoicePeriods(
user.id,
existing.cardId,
periodsToUpdate,
);
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de converter este lançamento.`,
};
}
}
await db.transaction(async (tx: typeof db) => {
await tx
.update(transactions)
.set({
condition: currentRow.condition,
name: currentRow.name,
amount: currentRow.amount,
recurrenceCount: currentRow.recurrenceCount,
installmentCount: null,
currentInstallment: null,
period: currentRow.period,
purchaseDate: currentRow.purchaseDate,
dueDate: currentRow.dueDate,
isSettled: currentRow.isSettled,
boletoPaymentDate: currentRow.boletoPaymentDate,
seriesId,
})
.where(
and(eq(transactions.id, existing.id), eq(transactions.userId, user.id)),
);
if (rowsToInsert.length > 0) {
await tx.insert(transactions).values(rowsToInsert);
}
});
revalidate(user.id);
return {
success: true,
message: `Lançamento convertido em recorrência de ${data.recurrenceCount} meses.`,
data: { createdCount: rowsToInsert.length },
};
} catch (error) {
return handleActionError(error) as ActionResult<{ createdCount: number }>;
}
}
export async function updateTransactionSplitPairAction( export async function updateTransactionSplitPairAction(
input: UpdateInput, input: UpdateInput,
): Promise<ActionResult> { ): Promise<ActionResult> {

View File

@@ -4,6 +4,8 @@ import { RiAddFill } from "@remixicon/react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
convertTransactionToInstallmentAction,
convertTransactionToRecurringAction,
createMassTransactionsAction, createMassTransactionsAction,
deleteMultipleTransactionsAction, deleteMultipleTransactionsAction,
deleteTransactionAction, deleteTransactionAction,
@@ -18,8 +20,19 @@ import {
detachAttachmentBulkAction, detachAttachmentBulkAction,
getPresignedUploadUrlAction, getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments"; } from "@/features/transactions/actions/attachments";
import { detectInstallmentFromName } from "@/features/transactions/lib/installment-detection";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button"; 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 { import type {
TransactionsExportContext, TransactionsExportContext,
TransactionsPaginationState, TransactionsPaginationState,
@@ -190,6 +203,14 @@ export function TransactionsPage({
const [refundOpen, setRefundOpen] = useState(false); const [refundOpen, setRefundOpen] = useState(false);
const [transactionToRefund, setTransactionToRefund] = const [transactionToRefund, setTransactionToRefund] =
useState<TransactionItem | null>(null); 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) => { const handleToggleSettlement = async (item: TransactionItem) => {
if (item.paymentMethod === "Cartão de crédito") { if (item.paymentMethod === "Cartão de crédito") {
@@ -542,6 +563,71 @@ export function TransactionsPage({
setRefundOpen(true); 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) => { const handleAnticipate = (item: TransactionItem) => {
setSelectedForAnticipation(item); setSelectedForAnticipation(item);
setAnticipateOpen(true); setAnticipateOpen(true);
@@ -628,6 +714,8 @@ export function TransactionsPage({
onBulkImport={handleBulkImport} onBulkImport={handleBulkImport}
onViewDetails={handleViewDetails} onViewDetails={handleViewDetails}
onRefund={handleRefund} onRefund={handleRefund}
onConvertToInstallment={handleConvertToInstallment}
onConvertToRecurring={handleConvertToRecurring}
onToggleSettlement={handleToggleSettlement} onToggleSettlement={handleToggleSettlement}
onAnticipate={handleAnticipate} onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory} onViewAnticipationHistory={handleViewAnticipationHistory}
@@ -749,6 +837,79 @@ export function TransactionsPage({
disabled={!transactionToDelete} 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 <BulkActionDialog
open={bulkDeleteOpen && !!pendingDeleteData} open={bulkDeleteOpen && !!pendingDeleteData}
onOpenChange={setBulkDeleteOpen} onOpenChange={setBulkDeleteOpen}

View File

@@ -20,6 +20,9 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"; } from "@/shared/components/ui/dropdown-menu";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants"; 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"; import type { TransactionItem } from "../types";
type TransactionActionsMenuProps = { type TransactionActionsMenuProps = {
@@ -33,6 +36,8 @@ type TransactionActionsMenuProps = {
onRefund?: (item: TransactionItem) => void; onRefund?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void; onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void; onViewAnticipationHistory?: (item: TransactionItem) => void;
onConvertToInstallment?: (item: TransactionItem) => void;
onConvertToRecurring?: (item: TransactionItem) => void;
}; };
export function TransactionActionsMenu({ export function TransactionActionsMenu({
@@ -46,6 +51,8 @@ export function TransactionActionsMenu({
onRefund, onRefund,
onAnticipate, onAnticipate,
onViewAnticipationHistory, onViewAnticipationHistory,
onConvertToInstallment,
onConvertToRecurring,
}: TransactionActionsMenuProps) { }: TransactionActionsMenuProps) {
const isOwnData = item.userId === currentUserId; const isOwnData = item.userId === currentUserId;
const canRefund = const canRefund =
@@ -55,9 +62,29 @@ export function TransactionActionsMenu({
!item.splitGroupId && !item.splitGroupId &&
!item.readonly && !item.readonly &&
!item.note?.startsWith(REFUND_NOTE_PREFIX); !item.note?.startsWith(REFUND_NOTE_PREFIX);
const showInstallmentActions = const showInstallmentActions =
isOwnData && item.condition === "Parcelado" && item.seriesId; 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 ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -112,6 +139,24 @@ export function TransactionActionsMenu({
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : 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 ? ( {isOwnData ? (
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
export type InstallmentDetection = {
name: string;
currentInstallment: number;
installmentCount: number;
};
const INSTALLMENT_SUFFIX_PATTERNS = [
/^(?<name>.+?)\s*[-]?\s*parcela\s+(?<current>\d{1,2})\s+de\s+(?<total>\d{1,2})\s*$/iu,
/^(?<name>.+?)\s*[-]?\s*parcela\s+(?<current>\d{1,2})\s*\/\s*(?<total>\d{1,2})\s*$/iu,
/^(?<name>.+?)\s*\((?<current>\d{1,2})\s*[/?]\s*(?<total>\d{1,2})\)\s*$/u,
/^(?<name>.+?)\s+(?<current>\d{1,2})\s*[/?]\s*(?<total>\d{1,2})\s*$/u,
];
const normalizeDetectedName = (value: string) =>
value
.trim()
.replace(/\s+[-:]\s*$/u, "")
.trim();
export function detectInstallmentFromName(
value: string | null | undefined,
): InstallmentDetection | null {
const text = value?.trim();
if (!text) return null;
for (const pattern of INSTALLMENT_SUFFIX_PATTERNS) {
const match = pattern.exec(text);
const groups = match?.groups;
if (!groups) continue;
const currentInstallment = Number(groups.current);
const installmentCount = Number(groups.total);
const name = normalizeDetectedName(groups.name ?? "");
if (
name.length > 0 &&
Number.isInteger(currentInstallment) &&
Number.isInteger(installmentCount) &&
currentInstallment >= 1 &&
installmentCount >= 2 &&
currentInstallment <= installmentCount &&
installmentCount <= 60
) {
return { name, currentInstallment, installmentCount };
}
}
return null;
}