feat(finance): refina fluxos de transacoes e pagadores

This commit is contained in:
Felipe Coutinho
2026-03-09 17:13:44 +00:00
parent 69da27276c
commit ada1377640
58 changed files with 1288 additions and 1559 deletions

View File

@@ -2,7 +2,7 @@
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import MoneyValues from "@/components/money-values";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/table";
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
import { formatCurrentInstallment } from "@/lib/installments/utils";
import { formatShortPeriodLabel } from "@/lib/utils/period";
import { cn } from "@/lib/utils/ui";
interface InstallmentSelectionTableProps {
@@ -43,12 +44,6 @@ export function InstallmentSelectionTable({
}
};
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
const date = new Date(Number(year), Number(month) - 1);
return format(date, "MMM/yyyy", { locale: ptBR });
};
const formatDate = (date: Date | null) => {
if (!date) return "—";
return format(date, "dd/MM/yyyy", { locale: ptBR });
@@ -116,7 +111,7 @@ export function InstallmentSelectionTable({
</Badge>
</TableCell>
<TableCell className="font-medium">
{formatPeriod(inst.period)}
{formatShortPeriodLabel(inst.period)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(inst.dueDate)}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions";
import { Button } from "@/components/ui/button";
@@ -58,20 +58,18 @@ export function BulkImportDialog({
const [contaId, setContaId] = useState<string | undefined>(undefined);
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
const [isPending, startTransition] = useTransition();
type CreateLancamentoInput = Parameters<typeof createLancamentoAction>[0];
// Reset form when dialog opens/closes
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
setPagadorId(defaultPagadorId ?? undefined);
setCategoriaId(undefined);
setContaId(undefined);
setCartaoId(undefined);
}
onOpenChange(newOpen);
},
[onOpenChange, defaultPagadorId],
);
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setPagadorId(defaultPagadorId ?? undefined);
setCategoriaId(undefined);
setContaId(undefined);
setCartaoId(undefined);
}
onOpenChange(newOpen);
};
const categoriaGroups = useMemo(() => {
// Get unique transaction types from items
@@ -88,111 +86,100 @@ export function BulkImportDialog({
return groupAndSortCategorias(filtered);
}, [categoriaOptions, items]);
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pagadorId) {
toast.error("Selecione o pagador.");
return;
}
if (!pagadorId) {
toast.error("Selecione o pagador.");
return;
}
if (!categoriaId) {
toast.error("Selecione a categoria.");
return;
}
if (!categoriaId) {
toast.error("Selecione a categoria.");
return;
}
startTransition(async () => {
let successCount = 0;
let errorCount = 0;
startTransition(async () => {
let successCount = 0;
let errorCount = 0;
for (const item of items) {
const sanitizedAmount = Math.abs(item.amount);
for (const item of items) {
const sanitizedAmount = Math.abs(item.amount);
// Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito";
// Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito";
// Validate payment method fields
if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito.");
return;
}
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
const payload = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType: item.transactionType as
| "Despesa"
| "Receita"
| "Transferência",
amount: sanitizedAmount,
condition: item.condition as "À vista" | "Parcelado" | "Recorrente",
paymentMethod: item.paymentMethod as
| "Cartão de crédito"
| "Cartão de débito"
| "Pix"
| "Dinheiro"
| "Boleto"
| "Pré-Pago | VR/VA"
| "Transferência bancária",
pagadorId,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? undefined : contaId,
cartaoId: isCredit ? cartaoId : undefined,
categoriaId,
note: item.note || undefined,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const result = await createLancamentoAction(payload);
if (result.success) {
successCount++;
} else {
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
// Validate payment method fields
if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito.");
return;
}
if (errorCount === 0) {
toast.success(
`${successCount} ${
successCount === 1
? "lançamento importado"
: "lançamentos importados"
} com sucesso!`,
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
);
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
const payload: CreateLancamentoInput = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType:
item.transactionType as CreateLancamentoInput["transactionType"],
amount: sanitizedAmount,
condition: item.condition as CreateLancamentoInput["condition"],
paymentMethod:
item.paymentMethod as CreateLancamentoInput["paymentMethod"],
pagadorId: pagadorId ?? null,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? null : (contaId ?? null),
cartaoId: isCredit ? (cartaoId ?? null) : null,
categoriaId: categoriaId ?? null,
note: item.note ?? null,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const result = await createLancamentoAction(payload);
if (result.success) {
successCount++;
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
});
},
[items, pagadorId, categoriaId, contaId, cartaoId, handleOpenChange],
);
}
if (errorCount === 0) {
toast.success(
`${successCount} ${
successCount === 1
? "lançamento importado"
: "lançamentos importados"
} com sucesso!`,
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
);
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
}
});
};
const itemCount = items.length;
const hasCredit = items.some(

View File

@@ -1,6 +1,5 @@
"use client";
import { useCallback, useMemo } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -10,73 +9,48 @@ import {
SelectValue,
} from "@/components/ui/select";
import { LANCAMENTO_CONDITIONS } from "@/lib/lancamentos/constants";
import { formatCurrency } from "@/lib/utils/currency";
import { cn } from "@/lib/utils/ui";
import { ConditionSelectContent } from "../../select-items";
import type { ConditionSectionProps } from "./lancamento-dialog-types";
function formatCurrency(value: number): string {
return value.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function ConditionSection({
formState,
onFieldChange,
showInstallments,
showRecurrence,
}: ConditionSectionProps) {
const amount = useMemo(() => {
const value = Number(formState.amount);
return Number.isNaN(value) || value <= 0 ? null : value;
}, [formState.amount]);
const parsedAmount = Number(formState.amount);
const amount =
Number.isNaN(parsedAmount) || parsedAmount <= 0 ? null : parsedAmount;
const getInstallmentLabel = useCallback(
(count: number) => {
if (amount) {
const installmentValue = amount / count;
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
}
return `${count}x`;
},
[amount],
);
const getInstallmentLabel = (count: number) => {
if (amount) {
const installmentValue = amount / count;
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
}
const _getRecurrenceLabel = (count: number) => {
return `${count} meses`;
return `${count}x`;
};
const installmentSummary = useMemo(() => {
if (!showInstallments || !formState.installmentCount || !amount) {
return null;
}
const installmentCount = Number(formState.installmentCount);
const installmentSummary =
showInstallments &&
formState.installmentCount &&
amount &&
!Number.isNaN(installmentCount) &&
installmentCount > 0
? getInstallmentLabel(installmentCount)
: null;
const count = Number(formState.installmentCount);
if (Number.isNaN(count) || count <= 0) {
return null;
}
return getInstallmentLabel(count);
}, [
showInstallments,
formState.installmentCount,
amount,
getInstallmentLabel,
]);
const recurrenceSummary = useMemo(() => {
if (!showRecurrence || !formState.recurrenceCount) {
return null;
}
const count = Number(formState.recurrenceCount);
if (Number.isNaN(count) || count <= 0) {
return null;
}
return `Por ${count} meses`;
}, [showRecurrence, formState.recurrenceCount]);
const recurrenceCount = Number(formState.recurrenceCount);
const recurrenceSummary =
showRecurrence &&
formState.recurrenceCount &&
!Number.isNaN(recurrenceCount) &&
recurrenceCount > 0
? `Por ${recurrenceCount} meses`
: null;
return (
<div className="flex w-full flex-col gap-2 md:flex-row">

View File

@@ -1,6 +1,5 @@
"use client";
import { useCallback } from "react";
import { CurrencyInput } from "@/components/ui/currency-input";
import { Label } from "@/components/ui/label";
import {
@@ -20,25 +19,19 @@ export function PagadorSection({
secondaryPagadorOptions,
totalAmount,
}: PagadorSectionProps) {
const handlePrimaryAmountChange = useCallback(
(value: string) => {
onFieldChange("primarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
},
[totalAmount, onFieldChange],
);
const handlePrimaryAmountChange = (value: string) => {
onFieldChange("primarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
};
const handleSecondaryAmountChange = useCallback(
(value: string) => {
onFieldChange("secondarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("primarySplitAmount", remaining.toFixed(2));
},
[totalAmount, onFieldChange],
);
const handleSecondaryAmountChange = (value: string) => {
onFieldChange("secondarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("primarySplitAmount", remaining.toFixed(2));
};
return (
<div className="flex w-full flex-col gap-2 md:flex-row">

View File

@@ -16,7 +16,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import { displayPeriod } from "@/lib/utils/period";
import { dateToPeriod, displayPeriod, periodToDate } from "@/lib/utils/period";
import { cn } from "@/lib/utils/ui";
import {
ContaCartaoSelectContent,
@@ -24,17 +24,6 @@ import {
} from "../../select-items";
import type { PaymentMethodSectionProps } from "./lancamento-dialog-types";
function periodToDate(period: string): Date {
const [year, month] = period.split("-").map(Number);
return new Date(year, month - 1, 1);
}
function dateToPeriod(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function InlinePeriodPicker({
period,
onPeriodChange,

View File

@@ -33,9 +33,12 @@ import {
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import {
LANCAMENTO_PAYMENT_METHODS,
type LANCAMENTO_TRANSACTION_TYPES,
} from "@/lib/lancamentos/constants";
import { getTodayDateString } from "@/lib/utils/date";
import { displayPeriod } from "@/lib/utils/period";
import { dateToPeriod, displayPeriod, periodToDate } from "@/lib/utils/period";
import {
CategoriaSelectContent,
ContaCartaoSelectContent,
@@ -50,17 +53,8 @@ import type { SelectOption } from "../types";
const MASS_ADD_PAYMENT_METHODS = LANCAMENTO_PAYMENT_METHODS.filter(
(m) => m !== "Boleto",
);
function periodToDate(period: string): Date {
const [year, month] = period.split("-").map(Number);
return new Date(year, month - 1, 1);
}
function dateToPeriod(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
type MassAddTransactionType = (typeof LANCAMENTO_TRANSACTION_TYPES)[number];
type MassAddPaymentMethod = (typeof LANCAMENTO_PAYMENT_METHODS)[number];
function InlinePeriodPicker({
period,
@@ -111,23 +105,9 @@ interface MassAddDialogProps {
defaultCartaoId?: string | null;
}
export interface MassAddFormData {
fixedFields: {
transactionType?: string;
paymentMethod?: string;
condition?: string;
period?: string;
contaId?: string;
cartaoId?: string;
};
transactions: Array<{
purchaseDate: string;
name: string;
amount: string;
categoriaId?: string;
pagadorId?: string;
}>;
}
export type MassAddFormData = Parameters<
typeof import("@/app/(dashboard)/lancamentos/actions").createMassLancamentosAction
>[0];
interface TransactionRow {
id: string;
@@ -154,8 +134,9 @@ export function MassAddDialog({
const [loading, setLoading] = useState(false);
// Fixed fields state (sempre ativos, sem checkboxes)
const [transactionType, setTransactionType] = useState<string>("Despesa");
const [paymentMethod, setPaymentMethod] = useState<string>(
const [transactionType, setTransactionType] =
useState<MassAddTransactionType>("Despesa");
const [paymentMethod, setPaymentMethod] = useState<MassAddPaymentMethod>(
LANCAMENTO_PAYMENT_METHODS[0],
);
const [period, setPeriod] = useState<string>(selectedPeriod);
@@ -257,7 +238,7 @@ export function MassAddDialog({
transactions: transactions.map((t) => ({
purchaseDate: t.purchaseDate,
name: t.name.trim(),
amount: t.amount.trim(),
amount: Number(t.amount.trim()),
categoriaId: t.categoriaId,
pagadorId: t.pagadorId,
})),
@@ -312,7 +293,9 @@ export function MassAddDialog({
<Label htmlFor="transaction-type">Tipo de Transação</Label>
<Select
value={transactionType}
onValueChange={setTransactionType}
onValueChange={(value) =>
setTransactionType(value as MassAddTransactionType)
}
>
<SelectTrigger id="transaction-type" className="w-full">
<SelectValue>
@@ -338,7 +321,7 @@ export function MassAddDialog({
<Select
value={paymentMethod}
onValueChange={(value) => {
setPaymentMethod(value);
setPaymentMethod(value as MassAddPaymentMethod);
// Reset conta/cartao when changing payment method
if (value === "Cartão de crédito") {
setContaId(undefined);