mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-07-09 19:36:02 +00:00
chore: prepara versao 2.7.7
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user