refactor: reformular modal de múltiplos lançamentos

- Separar selects de conta e cartão por forma de pagamento
- Remover opção Boleto do modal
- Usar InlinePeriodPicker ao selecionar cartão de crédito
- Grid full-width (sm:grid-cols-3) e DatePicker compact
- Reduzir espaçamento geral do modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-27 15:24:35 +00:00
parent 656fbaed54
commit d93ef77d15

View File

@@ -3,7 +3,6 @@
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react"; import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
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";
@@ -16,6 +15,12 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { MonthPicker } from "@/components/ui/monthpicker";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -30,6 +35,7 @@ import { Spinner } from "@/components/ui/spinner";
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";
import { displayPeriod } from "@/lib/utils/period";
import type { SelectOption } from "../../types"; import type { SelectOption } from "../../types";
import { import {
CategoriaSelectContent, CategoriaSelectContent,
@@ -40,6 +46,57 @@ import {
} from "../select-items"; } from "../select-items";
import { EstabelecimentoInput } from "../shared/estabelecimento-input"; import { EstabelecimentoInput } from "../shared/estabelecimento-input";
/** Payment methods sem Boleto para este modal */
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}`;
}
function InlinePeriodPicker({
period,
onPeriodChange,
}: {
period: string;
onPeriodChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="-mt-1">
<span className="text-xs text-muted-foreground">Fatura de </span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="text-xs text-primary underline-offset-2 hover:underline cursor-pointer lowercase"
>
{displayPeriod(period)}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<MonthPicker
selectedMonth={periodToDate(period)}
onMonthSelect={(date) => {
onPeriodChange(dateToPeriod(date));
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
);
}
interface MassAddDialogProps { interface MassAddDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -102,22 +159,15 @@ export function MassAddDialog({
LANCAMENTO_PAYMENT_METHODS[0], LANCAMENTO_PAYMENT_METHODS[0],
); );
const [period, setPeriod] = useState<string>(selectedPeriod); const [period, setPeriod] = useState<string>(selectedPeriod);
// Formato: "conta:uuid" ou "cartao:uuid" const [contaId, setContaId] = useState<string | undefined>(undefined);
const [contaCartaoId, setContaCartaoId] = useState<string | undefined>( const [cartaoId, setCartaoId] = useState<string | undefined>(
defaultCartaoId ? `cartao:${defaultCartaoId}` : undefined, defaultCartaoId ?? undefined,
); );
// Quando defaultCartaoId está definido, exibe apenas o cartão específico // Quando defaultCartaoId está definido, exibe apenas o cartão específico
const isLockedToCartao = !!defaultCartaoId; const isLockedToCartao = !!defaultCartaoId;
// Deriva contaId e cartaoId do valor selecionado const isCartaoSelected = paymentMethod === "Cartão de crédito";
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[]>([
@@ -173,8 +223,12 @@ export function MassAddDialog({
const handleSubmit = async () => { const handleSubmit = async () => {
// Validate conta/cartao selection // Validate conta/cartao selection
if (!contaCartaoId) { if (isCartaoSelected && !cartaoId) {
toast.error("Selecione uma conta ou cartão para continuar"); toast.error("Selecione um cartão para continuar");
return;
}
if (!isCartaoSelected && !contaId) {
toast.error("Selecione uma conta para continuar");
return; return;
} }
@@ -194,11 +248,11 @@ export function MassAddDialog({
const formData: MassAddFormData = { const formData: MassAddFormData = {
fixedFields: { fixedFields: {
transactionType, transactionType,
paymentMethod: isCartaoSelected ? "Cartão de crédito" : paymentMethod, paymentMethod,
condition: "À vista", condition: "À vista",
period, period,
contaId: contaId, contaId,
cartaoId: cartaoId, cartaoId,
}, },
transactions: transactions.map((t) => ({ transactions: transactions.map((t) => ({
purchaseDate: t.purchaseDate, purchaseDate: t.purchaseDate,
@@ -217,9 +271,8 @@ export function MassAddDialog({
setTransactionType("Despesa"); setTransactionType("Despesa");
setPaymentMethod(LANCAMENTO_PAYMENT_METHODS[0]); setPaymentMethod(LANCAMENTO_PAYMENT_METHODS[0]);
setPeriod(selectedPeriod); setPeriod(selectedPeriod);
setContaCartaoId( setContaId(undefined);
defaultCartaoId ? `cartao:${defaultCartaoId}` : undefined, setCartaoId(defaultCartaoId ?? undefined);
);
setTransactions([ setTransactions([
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -237,9 +290,6 @@ export function MassAddDialog({
} }
}; };
// Show period picker only for credit card
const showPeriodPicker = isCartaoSelected;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8"> <DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
@@ -252,11 +302,11 @@ export function MassAddDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6"> <div className="space-y-4">
{/* 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-4"> <div className="grid gap-3 grid-cols-1 sm:grid-cols-3">
{/* 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>
@@ -291,9 +341,9 @@ export function MassAddDialog({
setPaymentMethod(value); setPaymentMethod(value);
// Reset conta/cartao when changing payment method // Reset conta/cartao when changing payment method
if (value === "Cartão de crédito") { if (value === "Cartão de crédito") {
setContaCartaoId(undefined); setContaId(undefined);
} else { } else {
setContaCartaoId(undefined); setCartaoId(undefined);
} }
}} }}
> >
@@ -305,7 +355,7 @@ export function MassAddDialog({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{LANCAMENTO_PAYMENT_METHODS.map((method) => ( {MASS_ADD_PAYMENT_METHODS.map((method) => (
<SelectItem key={method} value={method}> <SelectItem key={method} value={method}>
<PaymentMethodSelectContent label={method} /> <PaymentMethodSelectContent label={method} />
</SelectItem> </SelectItem>
@@ -314,33 +364,19 @@ export function MassAddDialog({
</Select> </Select>
</div> </div>
{/* Period - only for credit card */} {/* Cartão (only for credit card) */}
{showPeriodPicker ? ( {isCartaoSelected ? (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="period">Fatura</Label> <Label htmlFor="cartao">Cartão</Label>
<PeriodPicker <Select
value={period} value={cartaoId}
onChange={setPeriod} onValueChange={setCartaoId}
className="w-full truncate" disabled={isLockedToCartao}
/> >
</div> <SelectTrigger id="cartao" className="w-full">
) : null} <SelectValue placeholder="Selecione">
{cartaoId &&
{/* Conta/Cartao */} (() => {
<div className="space-y-2">
<Label htmlFor="conta-cartao">
{isLockedToCartao ? "Cartão" : "Conta/Cartão"}
</Label>
<Select
value={contaCartaoId}
onValueChange={setContaCartaoId}
disabled={isLockedToCartao}
>
<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,
); );
@@ -351,7 +387,53 @@ export function MassAddDialog({
isCartao={true} isCartao={true}
/> />
) : null; ) : null;
} else { })()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhum cartão cadastrado
</p>
</div>
) : (
cartaoOptions
.filter(
(option) =>
!isLockedToCartao ||
option.value === defaultCartaoId,
)
.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
{cartaoId ? (
<InlinePeriodPicker
period={period}
onPeriodChange={setPeriod}
/>
) : null}
</div>
) : null}
{/* Conta (for non-credit-card methods) */}
{!isCartaoSelected ? (
<div className="space-y-2">
<Label htmlFor="conta">Conta</Label>
<Select value={contaId} onValueChange={setContaId}>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione">
{contaId &&
(() => {
const selectedOption = contaOptions.find( const selectedOption = contaOptions.find(
(opt) => opt.value === contaId, (opt) => opt.value === contaId,
); );
@@ -362,56 +444,31 @@ export function MassAddDialog({
isCartao={false} isCartao={false}
/> />
) : null; ) : null;
} })()}
})()} </SelectValue>
</SelectValue> </SelectTrigger>
</SelectTrigger> <SelectContent>
<SelectContent> {contaOptions.length === 0 ? (
{cartaoOptions.length > 0 && ( <div className="px-2 py-6 text-center">
<SelectGroup> <p className="text-sm text-muted-foreground">
{!isLockedToCartao && ( Nenhuma conta cadastrada
<SelectLabel>Cartões</SelectLabel> </p>
)} </div>
{cartaoOptions ) : (
.filter( contaOptions.map((option) => (
(option) => <SelectItem key={option.value} value={option.value}>
!isLockedToCartao ||
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 <ContaCartaoSelectContent
label={option.label} label={option.label}
logo={option.logo} logo={option.logo}
isCartao={false} isCartao={false}
/> />
</SelectItem> </SelectItem>
))} ))
</SelectGroup> )}
)} </SelectContent>
</SelectContent> </Select>
</Select> </div>
</div> ) : null}
</div> </div>
</div> </div>
@@ -428,7 +485,7 @@ export function MassAddDialog({
className="grid gap-2 border-b pb-3 border-dashed last:border-0" className="grid gap-2 border-b pb-3 border-dashed last:border-0"
> >
<div className="flex gap-2 w-full"> <div className="flex gap-2 w-full">
<div className="w-full"> <div className="w-24 shrink-0">
<Label <Label
htmlFor={`date-${transaction.id}`} htmlFor={`date-${transaction.id}`}
className="sr-only" className="sr-only"
@@ -446,7 +503,7 @@ export function MassAddDialog({
) )
} }
placeholder="Data" placeholder="Data"
className="w-32 truncate" compact
required required
/> />
</div> </div>