feat(lancamentos): melhorar modal de adicionar múltiplos lançamentos

- Remover campo "condição" desabilitado e informar que lançamentos são sempre à vista
- Mover botão de adicionar linha para ao lado de cada transação (estilo compacto)
- Unificar select de conta/cartão com grupos separados
- Adicionar suporte a defaultCartaoId para contexto de fatura de cartão

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-20 13:40:04 +00:00
parent af36e5474d
commit 524865f55e
2 changed files with 148 additions and 117 deletions

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { PeriodPicker } from "@/components/period-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input"; import { CurrencyInput } from "@/components/ui/currency-input";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
@@ -23,7 +24,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { PeriodPicker } from "@/components/period-picker";
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers"; import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants"; import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import { getTodayDateString } from "@/lib/utils/date"; import { getTodayDateString } from "@/lib/utils/date";
@@ -33,7 +33,6 @@ import { toast } from "sonner";
import type { SelectOption } from "../../types"; import type { SelectOption } from "../../types";
import { import {
CategoriaSelectContent, CategoriaSelectContent,
ConditionSelectContent,
ContaCartaoSelectContent, ContaCartaoSelectContent,
PagadorSelectContent, PagadorSelectContent,
PaymentMethodSelectContent, PaymentMethodSelectContent,
@@ -52,6 +51,7 @@ interface MassAddDialogProps {
estabelecimentos: string[]; estabelecimentos: string[];
selectedPeriod: string; selectedPeriod: string;
defaultPagadorId?: string | null; defaultPagadorId?: string | null;
defaultCartaoId?: string | null;
} }
export interface MassAddFormData { export interface MassAddFormData {
@@ -92,18 +92,32 @@ export function MassAddDialog({
estabelecimentos, estabelecimentos,
selectedPeriod, selectedPeriod,
defaultPagadorId, defaultPagadorId,
defaultCartaoId,
}: MassAddDialogProps) { }: MassAddDialogProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Fixed fields state (sempre ativos, sem checkboxes) // Fixed fields state (sempre ativos, sem checkboxes)
const [transactionType, setTransactionType] = useState<string>("Despesa"); const [transactionType, setTransactionType] = useState<string>("Despesa");
const [paymentMethod, setPaymentMethod] = useState<string>( const [paymentMethod, setPaymentMethod] = useState<string>(
LANCAMENTO_PAYMENT_METHODS[0] LANCAMENTO_PAYMENT_METHODS[0],
); );
const [condition, setCondition] = useState<string>("À vista");
const [period, setPeriod] = useState<string>(selectedPeriod); const [period, setPeriod] = useState<string>(selectedPeriod);
const [contaId, setContaId] = useState<string | undefined>(); // Formato: "conta:uuid" ou "cartao:uuid"
const [cartaoId, setCartaoId] = useState<string | undefined>(); const [contaCartaoId, setContaCartaoId] = useState<string | undefined>(
defaultCartaoId ? `cartao:${defaultCartaoId}` : undefined,
);
// Quando defaultCartaoId está definido, exibe apenas o cartão específico
const isLockedToCartao = !!defaultCartaoId;
// Deriva contaId e cartaoId do valor selecionado
const isCartaoSelected = contaCartaoId?.startsWith("cartao:");
const contaId = contaCartaoId?.startsWith("conta:")
? contaCartaoId.replace("conta:", "")
: undefined;
const cartaoId = contaCartaoId?.startsWith("cartao:")
? contaCartaoId.replace("cartao:", "")
: undefined;
// Transaction rows // Transaction rows
const [transactions, setTransactions] = useState<TransactionRow[]>([ const [transactions, setTransactions] = useState<TransactionRow[]>([
@@ -120,7 +134,7 @@ export function MassAddDialog({
// Categorias agrupadas e filtradas por tipo de transação // Categorias agrupadas e filtradas por tipo de transação
const groupedCategorias = useMemo(() => { const groupedCategorias = useMemo(() => {
const filtered = categoriaOptions.filter( const filtered = categoriaOptions.filter(
(option) => option.group?.toLowerCase() === transactionType.toLowerCase() (option) => option.group?.toLowerCase() === transactionType.toLowerCase(),
); );
return groupAndSortCategorias(filtered); return groupAndSortCategorias(filtered);
}, [categoriaOptions, transactionType]); }, [categoriaOptions, transactionType]);
@@ -150,33 +164,28 @@ export function MassAddDialog({
const updateTransaction = ( const updateTransaction = (
id: string, id: string,
field: keyof TransactionRow, field: keyof TransactionRow,
value: string | undefined value: string | undefined,
) => { ) => {
setTransactions( setTransactions(
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)) transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
); );
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
// Validate conta/cartao selection // Validate conta/cartao selection
if (paymentMethod === "Cartão de crédito" && !cartaoId) { if (!contaCartaoId) {
toast.error("Selecione um cartão para continuar"); toast.error("Selecione uma conta ou cartão para continuar");
return;
}
if (paymentMethod !== "Cartão de crédito" && !contaId) {
toast.error("Selecione uma conta para continuar");
return; return;
} }
// Validate transactions // Validate transactions
const invalidTransactions = transactions.filter( const invalidTransactions = transactions.filter(
(t) => !t.name.trim() || !t.amount.trim() || !t.purchaseDate (t) => !t.name.trim() || !t.amount.trim() || !t.purchaseDate,
); );
if (invalidTransactions.length > 0) { if (invalidTransactions.length > 0) {
toast.error( toast.error(
"Preencha todos os campos obrigatórios das transações (data, estabelecimento e valor)" "Preencha todos os campos obrigatórios das transações (data, estabelecimento e valor)",
); );
return; return;
} }
@@ -185,11 +194,11 @@ export function MassAddDialog({
const formData: MassAddFormData = { const formData: MassAddFormData = {
fixedFields: { fixedFields: {
transactionType, transactionType,
paymentMethod, paymentMethod: isCartaoSelected ? "Cartão de crédito" : paymentMethod,
condition, condition: "À vista",
period, period,
contaId: paymentMethod !== "Cartão de crédito" ? contaId : undefined, contaId: contaId,
cartaoId: paymentMethod === "Cartão de crédito" ? cartaoId : undefined, cartaoId: cartaoId,
}, },
transactions: transactions.map((t) => ({ transactions: transactions.map((t) => ({
purchaseDate: t.purchaseDate, purchaseDate: t.purchaseDate,
@@ -207,10 +216,10 @@ export function MassAddDialog({
// Reset form // Reset form
setTransactionType("Despesa"); setTransactionType("Despesa");
setPaymentMethod(LANCAMENTO_PAYMENT_METHODS[0]); setPaymentMethod(LANCAMENTO_PAYMENT_METHODS[0]);
setCondition("À vista");
setPeriod(selectedPeriod); setPeriod(selectedPeriod);
setContaId(undefined); setContaCartaoId(
setCartaoId(undefined); defaultCartaoId ? `cartao:${defaultCartaoId}` : undefined,
);
setTransactions([ setTransactions([
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -235,6 +244,8 @@ export function MassAddDialog({
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle> <DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
<DialogDescription> <DialogDescription>
Configure os valores padrão e adicione várias transações de uma vez. Configure os valores padrão e adicione várias transações de uma vez.
Todos os lançamentos adicionados aqui são{" "}
<span className="font-bold">sempre à vista</span>.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -242,7 +253,7 @@ export function MassAddDialog({
{/* Fixed Fields Section */} {/* Fixed Fields Section */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold">Valores Padrão</h3> <h3 className="text-sm font-semibold">Valores Padrão</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{/* Transaction Type */} {/* Transaction Type */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="transaction-type">Tipo de Transação</Label> <Label htmlFor="transaction-type">Tipo de Transação</Label>
@@ -300,25 +311,6 @@ export function MassAddDialog({
</Select> </Select>
</div> </div>
{/* Condition */}
<div className="space-y-2">
<Label htmlFor="condition">Condição</Label>
<Select value={condition} onValueChange={setCondition} disabled>
<SelectTrigger id="condition" className="w-full">
<SelectValue>
{condition && (
<ConditionSelectContent label={condition} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="À vista">
<ConditionSelectContent label="À vista" />
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Period */} {/* Period */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="period">Período</Label> <Label htmlFor="period">Período</Label>
@@ -332,16 +324,20 @@ export function MassAddDialog({
{/* Conta/Cartao */} {/* Conta/Cartao */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="conta-cartao"> <Label htmlFor="conta-cartao">
{paymentMethod === "Cartão de crédito" ? "Cartão" : "Conta"} {isLockedToCartao ? "Cartão" : "Conta/Cartão"}
</Label> </Label>
{paymentMethod === "Cartão de crédito" ? ( <Select
<Select value={cartaoId} onValueChange={setCartaoId}> value={contaCartaoId}
<SelectTrigger id="conta-cartao" className="w-full"> onValueChange={setContaCartaoId}
<SelectValue placeholder="Selecione o cartão"> disabled={isLockedToCartao}
{cartaoId && >
(() => { <SelectTrigger id="conta-cartao" className="w-full">
<SelectValue placeholder="Selecione">
{contaCartaoId &&
(() => {
if (isCartaoSelected) {
const selectedOption = cartaoOptions.find( const selectedOption = cartaoOptions.find(
(opt) => opt.value === cartaoId (opt) => opt.value === cartaoId,
); );
return selectedOption ? ( return selectedOption ? (
<ContaCartaoSelectContent <ContaCartaoSelectContent
@@ -350,29 +346,9 @@ export function MassAddDialog({
isCartao={true} isCartao={true}
/> />
) : null; ) : null;
})()} } else {
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select value={contaId} onValueChange={setContaId}>
<SelectTrigger id="conta-cartao" className="w-full">
<SelectValue placeholder="Selecione a conta">
{contaId &&
(() => {
const selectedOption = contaOptions.find( const selectedOption = contaOptions.find(
(opt) => opt.value === contaId (opt) => opt.value === contaId,
); );
return selectedOption ? ( return selectedOption ? (
<ContaCartaoSelectContent <ContaCartaoSelectContent
@@ -381,22 +357,55 @@ export function MassAddDialog({
isCartao={false} isCartao={false}
/> />
) : null; ) : null;
})()} }
</SelectValue> })()}
</SelectTrigger> </SelectValue>
<SelectContent> </SelectTrigger>
{contaOptions.map((option) => ( <SelectContent>
<SelectItem key={option.value} value={option.value}> {cartaoOptions.length > 0 && (
<ContaCartaoSelectContent <SelectGroup>
label={option.label} {!isLockedToCartao && (
logo={option.logo} <SelectLabel>Cartões</SelectLabel>
isCartao={false} )}
/> {cartaoOptions
</SelectItem> .filter(
))} (option) =>
</SelectContent> !isLockedToCartao ||
</Select> option.value === defaultCartaoId,
)} )
.map((option) => (
<SelectItem
key={option.value}
value={`cartao:${option.value}`}
>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))}
</SelectGroup>
)}
{!isLockedToCartao && contaOptions.length > 0 && (
<SelectGroup>
<SelectLabel>Contas</SelectLabel>
{contaOptions.map((option) => (
<SelectItem
key={option.value}
value={`conta:${option.value}`}
>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div> </div>
</div> </div>
</div> </div>
@@ -405,18 +414,7 @@ export function MassAddDialog({
{/* Transactions Section */} {/* Transactions Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <h3 className="text-sm font-semibold">Lançamentos</h3>
<h3 className="text-sm font-semibold">Transações</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={addTransaction}
>
<RiAddLine className="size-4" />
Adicionar linha
</Button>
</div>
<div className="space-y-3"> <div className="space-y-3">
{transactions.map((transaction, index) => ( {transactions.map((transaction, index) => (
@@ -439,7 +437,7 @@ export function MassAddDialog({
updateTransaction( updateTransaction(
transaction.id, transaction.id,
"purchaseDate", "purchaseDate",
value value,
) )
} }
placeholder="Data" placeholder="Data"
@@ -505,7 +503,7 @@ export function MassAddDialog({
{transaction.pagadorId && {transaction.pagadorId &&
(() => { (() => {
const selectedOption = pagadorOptions.find( const selectedOption = pagadorOptions.find(
(opt) => opt.value === transaction.pagadorId (opt) => opt.value === transaction.pagadorId,
); );
return selectedOption ? ( return selectedOption ? (
<PagadorSelectContent <PagadorSelectContent
@@ -542,7 +540,7 @@ export function MassAddDialog({
updateTransaction( updateTransaction(
transaction.id, transaction.id,
"categoriaId", "categoriaId",
value value,
) )
} }
> >
@@ -576,10 +574,21 @@ export function MassAddDialog({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7 shrink-0"
onClick={addTransaction}
>
<RiAddLine className="size-3.5" />
<span className="sr-only">Adicionar transação</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() => removeTransaction(transaction.id)} onClick={() => removeTransaction(transaction.id)}
disabled={transactions.length === 1} disabled={transactions.length === 1}
> >
<RiDeleteBinLine className="size-4" /> <RiDeleteBinLine className="size-3.5" />
<span className="sr-only">Remover transação</span> <span className="sr-only">Remover transação</span>
</Button> </Button>
</div> </div>

View File

@@ -14,12 +14,18 @@ import { toast } from "sonner";
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog"; import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog"; import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
import { BulkActionDialog, type BulkActionScope } from "../dialogs/bulk-action-dialog"; import {
BulkActionDialog,
type BulkActionScope,
} from "../dialogs/bulk-action-dialog";
import { BulkImportDialog } from "../dialogs/bulk-import-dialog"; import { BulkImportDialog } from "../dialogs/bulk-import-dialog";
import { LancamentoDetailsDialog } from "../dialogs/lancamento-details-dialog"; import { LancamentoDetailsDialog } from "../dialogs/lancamento-details-dialog";
import { LancamentoDialog } from "../dialogs/lancamento-dialog/lancamento-dialog"; import { LancamentoDialog } from "../dialogs/lancamento-dialog/lancamento-dialog";
import { LancamentosTable } from "../table/lancamentos-table"; import { LancamentosTable } from "../table/lancamentos-table";
import { MassAddDialog, type MassAddFormData } from "../dialogs/mass-add-dialog"; import {
MassAddDialog,
type MassAddFormData,
} from "../dialogs/mass-add-dialog";
import type { import type {
ContaCartaoFilterOption, ContaCartaoFilterOption,
LancamentoFilterOption, LancamentoFilterOption,
@@ -97,7 +103,7 @@ export function LancamentosPage({
useState<LancamentoItem | null>(null); useState<LancamentoItem | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
const [settlementLoadingId, setSettlementLoadingId] = useState<string | null>( const [settlementLoadingId, setSettlementLoadingId] = useState<string | null>(
null null,
); );
const [bulkEditOpen, setBulkEditOpen] = useState(false); const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
@@ -125,17 +131,26 @@ export function LancamentosPage({
const [selectedForAnticipation, setSelectedForAnticipation] = const [selectedForAnticipation, setSelectedForAnticipation] =
useState<LancamentoItem | null>(null); useState<LancamentoItem | null>(null);
const [bulkImportOpen, setBulkImportOpen] = useState(false); const [bulkImportOpen, setBulkImportOpen] = useState(false);
const [lancamentosToImport, setLancamentosToImport] = useState<LancamentoItem[]>([]); const [lancamentosToImport, setLancamentosToImport] = useState<
LancamentoItem[]
>([]);
const handleToggleSettlement = useCallback(async (item: LancamentoItem) => { const handleToggleSettlement = useCallback(async (item: LancamentoItem) => {
if (item.paymentMethod === "Cartão de crédito") { if (item.paymentMethod === "Cartão de crédito") {
toast.info( toast.info(
"Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão." "Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão.",
); );
return; return;
} }
const supportedMethods = ["Pix", "Boleto", "Dinheiro", "Cartão de débito", "Pré-Pago | VR/VA", "Transferência bancária"]; const supportedMethods = [
"Pix",
"Boleto",
"Dinheiro",
"Cartão de débito",
"Pré-Pago | VR/VA",
"Transferência bancária",
];
if (!supportedMethods.includes(item.paymentMethod)) { if (!supportedMethods.includes(item.paymentMethod)) {
return; return;
} }
@@ -203,7 +218,7 @@ export function LancamentosPage({
setBulkDeleteOpen(false); setBulkDeleteOpen(false);
setPendingDeleteData(null); setPendingDeleteData(null);
}, },
[pendingDeleteData] [pendingDeleteData],
); );
const handleBulkEditRequest = useCallback( const handleBulkEditRequest = useCallback(
@@ -230,7 +245,7 @@ export function LancamentosPage({
setEditOpen(false); setEditOpen(false);
setBulkEditOpen(true); setBulkEditOpen(true);
}, },
[selectedLancamento] [selectedLancamento],
); );
const handleBulkEdit = useCallback( const handleBulkEdit = useCallback(
@@ -262,7 +277,7 @@ export function LancamentosPage({
setBulkEditOpen(false); setBulkEditOpen(false);
setPendingEditData(null); setPendingEditData(null);
}, },
[pendingEditData] [pendingEditData],
); );
const handleMassAddSubmit = useCallback(async (data: MassAddFormData) => { const handleMassAddSubmit = useCallback(async (data: MassAddFormData) => {
@@ -299,7 +314,12 @@ export function LancamentosPage({
setPendingMultipleDeleteData([]); setPendingMultipleDeleteData([]);
}, [pendingMultipleDeleteData]); }, [pendingMultipleDeleteData]);
const handleCreate = useCallback(() => { const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
"Despesa" | "Receita" | null
>(null);
const handleCreate = useCallback((type: "Despesa" | "Receita") => {
setTransactionTypeForCreate(type);
setCreateOpen(true); setCreateOpen(true);
}, []); }, []);
@@ -393,6 +413,7 @@ export function LancamentosPage({
defaultPaymentMethod={defaultPaymentMethod} defaultPaymentMethod={defaultPaymentMethod}
lockCartaoSelection={lockCartaoSelection} lockCartaoSelection={lockCartaoSelection}
lockPaymentMethod={lockPaymentMethod} lockPaymentMethod={lockPaymentMethod}
defaultTransactionType={transactionTypeForCreate ?? undefined}
/> />
) : null} ) : null}
@@ -541,6 +562,7 @@ export function LancamentosPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
defaultPagadorId={defaultPagadorId} defaultPagadorId={defaultPagadorId}
defaultCartaoId={defaultCartaoId}
/> />
) : null} ) : null}