mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-07-09 03:16:01 +00:00
feat(lançamentos): ações de converter transações existentes em parceladas e recorrentes
This commit is contained in:
committed by
Felipe Coutinho
parent
558197e870
commit
4b5cdf81b8
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
49
src/features/transactions/lib/installment-detection.ts
Normal file
49
src/features/transactions/lib/installment-detection.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user