chore: prepara versao 2.7.7

This commit is contained in:
Felipe Coutinho
2026-06-20 14:57:18 -03:00
parent 4b5cdf81b8
commit 129295d2e2
27 changed files with 414 additions and 268 deletions

View File

@@ -515,6 +515,11 @@ export const toggleSettlementSchema = z.object({
export const convertToInstallmentSchema = z.object({
id: uuidSchema("Lançamento"),
installmentCount: z.coerce
.number({ message: "Informe em quantas parcelas dividir." })
.int()
.min(2, "O parcelamento deve ter ao menos duas parcelas.")
.max(60, "Selecione até 60 parcelas."),
});
export const convertToRecurringSchema = z.object({

View File

@@ -484,7 +484,10 @@ export async function convertTransactionToInstallmentAction(
const data = convertToInstallmentSchema.parse(input);
const existing = await db.query.transactions.findFirst({
where: and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
});
if (!existing) {
@@ -512,37 +515,32 @@ export async function convertTransactionToInstallmentAction(
) {
return {
success: false,
error: "Apenas lançamentos à vista de cartão de crédito podem ser convertidos.",
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.",
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 transactionName =
detected?.installmentCount === data.installmentCount
? detected.name
: existing.name;
const amountSign: 1 | -1 = existing.transactionType === "Despesa" ? -1 : 1;
const totalCents = Math.round(Math.abs(Number(existing.amount)) * 100);
const seriesId = randomUUID();
const records = buildTransactionRecords({
data: {
purchaseDate: existing.purchaseDate.toISOString().slice(0, 10),
period: existing.period,
name: detected.name,
name: transactionName,
transactionType: existing.transactionType as "Receita" | "Despesa",
amount: totalCents / 100,
condition: "Parcelado",
@@ -553,8 +551,8 @@ export async function convertTransactionToInstallmentAction(
cardId: existing.cardId,
categoryId: existing.categoryId,
note: existing.note,
installmentCount: detected.installmentCount,
startInstallment: detected.currentInstallment,
installmentCount: data.installmentCount,
startInstallment: 1,
dueDate: existing.dueDate?.toISOString().slice(0, 10),
isSettled: null,
},
@@ -600,7 +598,10 @@ export async function convertTransactionToInstallmentAction(
const limitCheck = await validateCardLimit({
userId: user.id,
cardId: existing.cardId,
addAmount: records.reduce((acc, row) => acc + Math.abs(Number(row.amount)), 0),
addAmount: records.reduce(
(acc, row) => acc + Math.abs(Number(row.amount)),
0,
),
excludeTransactionIds: [existing.id],
});
@@ -625,7 +626,10 @@ export async function convertTransactionToInstallmentAction(
seriesId,
})
.where(
and(eq(transactions.id, existing.id), eq(transactions.userId, user.id)),
and(
eq(transactions.id, existing.id),
eq(transactions.userId, user.id),
),
);
if (rowsToInsert.length > 0) {
@@ -637,7 +641,7 @@ export async function convertTransactionToInstallmentAction(
return {
success: true,
message: `Lançamento convertido em ${detected.installmentCount} parcelas.`,
message: `Lançamento convertido em ${data.installmentCount} parcelas.`,
data: { createdCount: rowsToInsert.length },
};
} catch (error) {
@@ -653,7 +657,10 @@ export async function convertTransactionToRecurringAction(
const data = convertToRecurringSchema.parse(input);
const existing = await db.query.transactions.findFirst({
where: and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
});
if (!existing) {
@@ -677,19 +684,20 @@ export async function convertTransactionToRecurringAction(
if (existing.condition !== "À vista") {
return {
success: false,
error: "Apenas lançamentos à vista podem ser convertidos em recorrência.",
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.",
error:
"Lançamentos divididos ainda não podem ser convertidos em recorrência.",
};
}
const amountSign: 1 | -1 =
existing.transactionType === "Despesa" ? -1 : 1;
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";
@@ -760,6 +768,22 @@ export async function convertTransactionToRecurringAction(
)} 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) => {
@@ -780,7 +804,10 @@ export async function convertTransactionToRecurringAction(
seriesId,
})
.where(
and(eq(transactions.id, existing.id), eq(transactions.userId, user.id)),
and(
eq(transactions.id, existing.id),
eq(transactions.userId, user.id),
),
);
if (rowsToInsert.length > 0) {

View File

@@ -49,6 +49,8 @@ const categoryGroupByTransactionType = {
income: "receita",
} as const;
const normalizeCategoryName = (value: string) => value.trim().toLowerCase();
interface ImportPageProps {
payerOptions: SelectOption[];
accountOptions: SelectOption[];
@@ -112,8 +114,9 @@ export function ImportPage({
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
if (t.categoryRaw) {
const categoryRaw = normalizeCategoryName(t.categoryRaw);
const matchedOption = categoryOptions.find(
(opt) => opt.label.toLowerCase() === t.categoryRaw?.toLowerCase()
(opt) => normalizeCategoryName(opt.label) === categoryRaw,
);
if (matchedOption) {
mappedCategoryId = matchedOption.value;

View File

@@ -33,6 +33,7 @@ import {
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
import type {
TransactionsExportContext,
TransactionsPaginationState,
@@ -97,6 +98,9 @@ interface TransactionsPageProps {
importCategoryOptions?: SelectOption[];
}
const pluralize = (count: number, singular: string, plural: string) =>
count === 1 ? singular : plural;
export function TransactionsPage({
currentUserId,
transactions: transactionList,
@@ -206,6 +210,8 @@ export function TransactionsPage({
const [convertInstallmentOpen, setConvertInstallmentOpen] = useState(false);
const [transactionToConvert, setTransactionToConvert] =
useState<TransactionItem | null>(null);
const [installmentCount, setInstallmentCount] = useState("2");
const [installmentPending, setInstallmentPending] = useState(false);
const [convertRecurringOpen, setConvertRecurringOpen] = useState(false);
const [transactionToConvertRecurring, setTransactionToConvertRecurring] =
useState<TransactionItem | null>(null);
@@ -564,7 +570,9 @@ export function TransactionsPage({
};
const handleConvertToInstallment = (item: TransactionItem) => {
const detectedInstallment = detectInstallmentFromName(item.name);
setTransactionToConvert(item);
setInstallmentCount(String(detectedInstallment?.installmentCount ?? 2));
setConvertInstallmentOpen(true);
};
@@ -573,23 +581,31 @@ export function TransactionsPage({
return;
}
const result = await convertTransactionToInstallmentAction({
id: transactionToConvert.id,
});
if (!result.success) {
toast.error(result.error);
throw new Error(result.error);
const count = Number(installmentCount);
if (!Number.isInteger(count) || count < 2 || count > 60) {
toast.error("Informe um parcelamento entre 2 e 60 parcelas.");
return;
}
toast.success(result.message);
setConvertInstallmentOpen(false);
setTransactionToConvert(null);
};
try {
setInstallmentPending(true);
const result = await convertTransactionToInstallmentAction({
id: transactionToConvert.id,
installmentCount: count,
});
const conversionPreview = transactionToConvert
? detectInstallmentFromName(transactionToConvert.name)
: null;
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setConvertInstallmentOpen(false);
setTransactionToConvert(null);
} finally {
setInstallmentPending(false);
}
};
const handleConvertToRecurring = (item: TransactionItem) => {
setTransactionToConvertRecurring(item);
@@ -628,6 +644,37 @@ export function TransactionsPage({
}
};
const parsedInstallmentCount = Number(installmentCount);
const installmentSummary =
transactionToConvert &&
Number.isInteger(parsedInstallmentCount) &&
parsedInstallmentCount >= 2 &&
parsedInstallmentCount <= 60
? {
total: formatCurrency(Math.abs(transactionToConvert.amount)),
installmentValue: formatCurrency(
Math.abs(transactionToConvert.amount) / parsedInstallmentCount,
),
count: parsedInstallmentCount,
createdCount: parsedInstallmentCount - 1,
}
: null;
const parsedRecurrenceCount = Number(recurrenceCount);
const recurringSummary =
transactionToConvertRecurring &&
Number.isInteger(parsedRecurrenceCount) &&
parsedRecurrenceCount >= 2 &&
parsedRecurrenceCount <= 60
? {
amount: formatCurrency(
Math.abs(transactionToConvertRecurring.amount),
),
count: parsedRecurrenceCount,
createdCount: parsedRecurrenceCount - 1,
}
: null;
const handleAnticipate = (item: TransactionItem) => {
setSelectedForAnticipation(item);
setAnticipateOpen(true);
@@ -837,7 +884,7 @@ export function TransactionsPage({
disabled={!transactionToDelete}
/>
<ConfirmActionDialog
<Dialog
open={convertInstallmentOpen && !!transactionToConvert}
onOpenChange={(open) => {
setConvertInstallmentOpen(open);
@@ -845,17 +892,64 @@ export function TransactionsPage({
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}
/>
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Converter em parcelamento?</DialogTitle>
<DialogDescription>
O lançamento atual será mantido como a primeira parcela e os
próximos meses serão criados automaticamente.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="installmentCount">Parcelar em</Label>
<Input
id="installmentCount"
type="number"
min={2}
max={60}
value={installmentCount}
onChange={(event) => setInstallmentCount(event.target.value)}
/>
<p className="text-muted-foreground text-sm">
Use o total de parcelas da série, incluindo este lançamento.
</p>
{installmentSummary ? (
<p className="rounded-md border bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
Resumo: {installmentSummary.total} será dividido em{" "}
{installmentSummary.count} parcelas de aproximadamente{" "}
{installmentSummary.installmentValue}. Este lançamento vira a
primeira parcela e {installmentSummary.createdCount}{" "}
{pluralize(
installmentSummary.createdCount,
"nova parcela será criada",
"novas parcelas serão criadas",
)}
.
</p>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setConvertInstallmentOpen(false)}
disabled={installmentPending}
>
Cancelar
</Button>
<Button
type="button"
onClick={confirmConvertToInstallment}
disabled={installmentPending}
>
{installmentPending ? "Convertendo..." : "Converter"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={convertRecurringOpen && !!transactionToConvertRecurring}
@@ -888,6 +982,18 @@ export function TransactionsPage({
<p className="text-muted-foreground text-sm">
Use o total de meses da série, incluindo este lançamento.
</p>
{recurringSummary ? (
<p className="rounded-md border bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
Resumo: este lançamento vira a primeira recorrência e{" "}
{recurringSummary.createdCount}{" "}
{pluralize(
recurringSummary.createdCount,
"novo lançamento mensal será criado",
"novos lançamentos mensais serão criados",
)}{" "}
com valor de {recurringSummary.amount}.
</p>
) : null}
</div>
<DialogFooter>

View File

@@ -11,6 +11,7 @@ import {
RiRefundLine,
RiTimeLine,
} from "@remixicon/react";
import { CREDIT_CARD_PAYMENT_METHOD } from "@/features/transactions/lib/constants";
import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
@@ -20,8 +21,6 @@ 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";
@@ -66,7 +65,6 @@ export function TransactionActionsMenu({
const showInstallmentActions =
isOwnData && item.condition === "Parcelado" && item.seriesId;
const detectedInstallment = detectInstallmentFromName(item.name);
const canConvertToInstallment =
isOwnData &&
item.paymentMethod === CREDIT_CARD_PAYMENT_METHOD &&
@@ -74,7 +72,6 @@ export function TransactionActionsMenu({
!item.splitGroupId &&
!item.isDivided &&
!item.readonly &&
Boolean(detectedInstallment) &&
Boolean(onConvertToInstallment);
const canConvertToRecurring =
@@ -140,18 +137,14 @@ export function TransactionActionsMenu({
) : null}
{canConvertToInstallment ? (
<DropdownMenuItem
onSelect={() => onConvertToInstallment?.(item)}
>
<DropdownMenuItem onSelect={() => onConvertToInstallment?.(item)}>
{getConditionIcon("Parcelado")}
Converter em Parcelamento
</DropdownMenuItem>
) : null}
{canConvertToRecurring ? (
<DropdownMenuItem
onSelect={() => onConvertToRecurring?.(item)}
>
<DropdownMenuItem onSelect={() => onConvertToRecurring?.(item)}>
{getConditionIcon("Recorrente")}
Converter em Recorrente
</DropdownMenuItem>

View File

@@ -551,8 +551,12 @@ function buildColumns({
onRefund={handleRefund}
onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment ? handleConvertToInstallment : undefined}
onConvertToRecurring={onConvertToRecurring ? handleConvertToRecurring : undefined}
onConvertToInstallment={
onConvertToInstallment ? handleConvertToInstallment : undefined
}
onConvertToRecurring={
onConvertToRecurring ? handleConvertToRecurring : undefined
}
/>
</div>
),