- 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>
619 lines
22 KiB
TypeScript
619 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { PeriodPicker } from "@/components/period-picker";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CurrencyInput } from "@/components/ui/currency-input";
|
|
import { DatePicker } from "@/components/ui/date-picker";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
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 { getTodayDateString } from "@/lib/utils/date";
|
|
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
|
import { useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import type { SelectOption } from "../../types";
|
|
import {
|
|
CategoriaSelectContent,
|
|
ContaCartaoSelectContent,
|
|
PagadorSelectContent,
|
|
PaymentMethodSelectContent,
|
|
TransactionTypeSelectContent,
|
|
} from "../select-items";
|
|
import { EstabelecimentoInput } from "../shared/estabelecimento-input";
|
|
|
|
interface MassAddDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSubmit: (data: MassAddFormData) => Promise<void>;
|
|
pagadorOptions: SelectOption[];
|
|
contaOptions: SelectOption[];
|
|
cartaoOptions: SelectOption[];
|
|
categoriaOptions: SelectOption[];
|
|
estabelecimentos: string[];
|
|
selectedPeriod: string;
|
|
defaultPagadorId?: string | null;
|
|
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;
|
|
}>;
|
|
}
|
|
|
|
interface TransactionRow {
|
|
id: string;
|
|
purchaseDate: string;
|
|
name: string;
|
|
amount: string;
|
|
categoriaId: string | undefined;
|
|
pagadorId: string | undefined;
|
|
}
|
|
|
|
export function MassAddDialog({
|
|
open,
|
|
onOpenChange,
|
|
onSubmit,
|
|
pagadorOptions,
|
|
contaOptions,
|
|
cartaoOptions,
|
|
categoriaOptions,
|
|
estabelecimentos,
|
|
selectedPeriod,
|
|
defaultPagadorId,
|
|
defaultCartaoId,
|
|
}: MassAddDialogProps) {
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Fixed fields state (sempre ativos, sem checkboxes)
|
|
const [transactionType, setTransactionType] = useState<string>("Despesa");
|
|
const [paymentMethod, setPaymentMethod] = useState<string>(
|
|
LANCAMENTO_PAYMENT_METHODS[0],
|
|
);
|
|
const [period, setPeriod] = useState<string>(selectedPeriod);
|
|
// Formato: "conta:uuid" ou "cartao:uuid"
|
|
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
|
|
const [transactions, setTransactions] = useState<TransactionRow[]>([
|
|
{
|
|
id: crypto.randomUUID(),
|
|
purchaseDate: getTodayDateString(),
|
|
name: "",
|
|
amount: "",
|
|
categoriaId: undefined,
|
|
pagadorId: defaultPagadorId ?? undefined,
|
|
},
|
|
]);
|
|
|
|
// Categorias agrupadas e filtradas por tipo de transação
|
|
const groupedCategorias = useMemo(() => {
|
|
const filtered = categoriaOptions.filter(
|
|
(option) => option.group?.toLowerCase() === transactionType.toLowerCase(),
|
|
);
|
|
return groupAndSortCategorias(filtered);
|
|
}, [categoriaOptions, transactionType]);
|
|
|
|
const addTransaction = () => {
|
|
setTransactions([
|
|
...transactions,
|
|
{
|
|
id: crypto.randomUUID(),
|
|
purchaseDate: getTodayDateString(),
|
|
name: "",
|
|
amount: "",
|
|
categoriaId: undefined,
|
|
pagadorId: defaultPagadorId ?? undefined,
|
|
},
|
|
]);
|
|
};
|
|
|
|
const removeTransaction = (id: string) => {
|
|
if (transactions.length === 1) {
|
|
toast.error("É necessário ter pelo menos uma transação");
|
|
return;
|
|
}
|
|
setTransactions(transactions.filter((t) => t.id !== id));
|
|
};
|
|
|
|
const updateTransaction = (
|
|
id: string,
|
|
field: keyof TransactionRow,
|
|
value: string | undefined,
|
|
) => {
|
|
setTransactions(
|
|
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
|
|
);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
// Validate conta/cartao selection
|
|
if (!contaCartaoId) {
|
|
toast.error("Selecione uma conta ou cartão para continuar");
|
|
return;
|
|
}
|
|
|
|
// Validate transactions
|
|
const invalidTransactions = transactions.filter(
|
|
(t) => !t.name.trim() || !t.amount.trim() || !t.purchaseDate,
|
|
);
|
|
|
|
if (invalidTransactions.length > 0) {
|
|
toast.error(
|
|
"Preencha todos os campos obrigatórios das transações (data, estabelecimento e valor)",
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Build form data
|
|
const formData: MassAddFormData = {
|
|
fixedFields: {
|
|
transactionType,
|
|
paymentMethod: isCartaoSelected ? "Cartão de crédito" : paymentMethod,
|
|
condition: "À vista",
|
|
period,
|
|
contaId: contaId,
|
|
cartaoId: cartaoId,
|
|
},
|
|
transactions: transactions.map((t) => ({
|
|
purchaseDate: t.purchaseDate,
|
|
name: t.name.trim(),
|
|
amount: t.amount.trim(),
|
|
categoriaId: t.categoriaId,
|
|
pagadorId: t.pagadorId,
|
|
})),
|
|
};
|
|
|
|
setLoading(true);
|
|
try {
|
|
await onSubmit(formData);
|
|
onOpenChange(false);
|
|
// Reset form
|
|
setTransactionType("Despesa");
|
|
setPaymentMethod(LANCAMENTO_PAYMENT_METHODS[0]);
|
|
setPeriod(selectedPeriod);
|
|
setContaCartaoId(
|
|
defaultCartaoId ? `cartao:${defaultCartaoId}` : undefined,
|
|
);
|
|
setTransactions([
|
|
{
|
|
id: crypto.randomUUID(),
|
|
purchaseDate: getTodayDateString(),
|
|
name: "",
|
|
amount: "",
|
|
categoriaId: undefined,
|
|
pagadorId: defaultPagadorId ?? undefined,
|
|
},
|
|
]);
|
|
} catch (_error) {
|
|
// Error is handled by the onSubmit function
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
|
|
<DialogHeader>
|
|
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
|
|
<DialogDescription>
|
|
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>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6">
|
|
{/* Fixed Fields Section */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">Valores Padrão</h3>
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
{/* Transaction Type */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
|
<Select
|
|
value={transactionType}
|
|
onValueChange={setTransactionType}
|
|
>
|
|
<SelectTrigger id="transaction-type" className="w-full">
|
|
<SelectValue>
|
|
{transactionType && (
|
|
<TransactionTypeSelectContent label={transactionType} />
|
|
)}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="Despesa">
|
|
<TransactionTypeSelectContent label="Despesa" />
|
|
</SelectItem>
|
|
<SelectItem value="Receita">
|
|
<TransactionTypeSelectContent label="Receita" />
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Payment Method */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="payment-method">Forma de Pagamento</Label>
|
|
<Select
|
|
value={paymentMethod}
|
|
onValueChange={(value) => {
|
|
setPaymentMethod(value);
|
|
// Reset conta/cartao when changing payment method
|
|
if (value === "Cartão de crédito") {
|
|
setContaId(undefined);
|
|
} else {
|
|
setCartaoId(undefined);
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger id="payment-method" className="w-full">
|
|
<SelectValue>
|
|
{paymentMethod && (
|
|
<PaymentMethodSelectContent label={paymentMethod} />
|
|
)}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{LANCAMENTO_PAYMENT_METHODS.map((method) => (
|
|
<SelectItem key={method} value={method}>
|
|
<PaymentMethodSelectContent label={method} />
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Period */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="period">Período</Label>
|
|
<PeriodPicker
|
|
value={period}
|
|
onChange={setPeriod}
|
|
className="w-full truncate"
|
|
/>
|
|
</div>
|
|
|
|
{/* 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(
|
|
(opt) => opt.value === cartaoId,
|
|
);
|
|
return selectedOption ? (
|
|
<ContaCartaoSelectContent
|
|
label={selectedOption.label}
|
|
logo={selectedOption.logo}
|
|
isCartao={true}
|
|
/>
|
|
) : null;
|
|
} else {
|
|
const selectedOption = contaOptions.find(
|
|
(opt) => opt.value === contaId,
|
|
);
|
|
return selectedOption ? (
|
|
<ContaCartaoSelectContent
|
|
label={selectedOption.label}
|
|
logo={selectedOption.logo}
|
|
isCartao={false}
|
|
/>
|
|
) : null;
|
|
}
|
|
})()}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{cartaoOptions.length > 0 && (
|
|
<SelectGroup>
|
|
{!isLockedToCartao && (
|
|
<SelectLabel>Cartões</SelectLabel>
|
|
)}
|
|
{cartaoOptions
|
|
.filter(
|
|
(option) =>
|
|
!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
|
|
label={option.label}
|
|
logo={option.logo}
|
|
isCartao={false}
|
|
/>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Transactions Section */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">Lançamentos</h3>
|
|
|
|
<div className="space-y-3">
|
|
{transactions.map((transaction, index) => (
|
|
<div
|
|
key={transaction.id}
|
|
className="grid gap-2 border-b pb-3 border-dashed last:border-0"
|
|
>
|
|
<div className="flex gap-2 w-full">
|
|
<div className="w-full">
|
|
<Label
|
|
htmlFor={`date-${transaction.id}`}
|
|
className="sr-only"
|
|
>
|
|
Data {index + 1}
|
|
</Label>
|
|
<DatePicker
|
|
id={`date-${transaction.id}`}
|
|
value={transaction.purchaseDate}
|
|
onChange={(value) =>
|
|
updateTransaction(
|
|
transaction.id,
|
|
"purchaseDate",
|
|
value,
|
|
)
|
|
}
|
|
placeholder="Data"
|
|
className="w-32 truncate"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="w-full">
|
|
<Label
|
|
htmlFor={`name-${transaction.id}`}
|
|
className="sr-only"
|
|
>
|
|
Estabelecimento {index + 1}
|
|
</Label>
|
|
<EstabelecimentoInput
|
|
id={`name-${transaction.id}`}
|
|
placeholder="Local"
|
|
value={transaction.name}
|
|
onChange={(value) =>
|
|
updateTransaction(transaction.id, "name", value)
|
|
}
|
|
estabelecimentos={estabelecimentos}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="w-full">
|
|
<Label
|
|
htmlFor={`amount-${transaction.id}`}
|
|
className="sr-only"
|
|
>
|
|
Valor {index + 1}
|
|
</Label>
|
|
<CurrencyInput
|
|
id={`amount-${transaction.id}`}
|
|
placeholder="R$ 0,00"
|
|
value={transaction.amount}
|
|
onValueChange={(value) =>
|
|
updateTransaction(transaction.id, "amount", value)
|
|
}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="w-full">
|
|
<Label
|
|
htmlFor={`pagador-${transaction.id}`}
|
|
className="sr-only"
|
|
>
|
|
Pagador {index + 1}
|
|
</Label>
|
|
<Select
|
|
value={transaction.pagadorId}
|
|
onValueChange={(value) =>
|
|
updateTransaction(transaction.id, "pagadorId", value)
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
id={`pagador-${transaction.id}`}
|
|
className="w-32 truncate"
|
|
>
|
|
<SelectValue placeholder="Pagador">
|
|
{transaction.pagadorId &&
|
|
(() => {
|
|
const selectedOption = pagadorOptions.find(
|
|
(opt) => opt.value === transaction.pagadorId,
|
|
);
|
|
return selectedOption ? (
|
|
<PagadorSelectContent
|
|
label={selectedOption.label}
|
|
avatarUrl={selectedOption.avatarUrl}
|
|
/>
|
|
) : null;
|
|
})()}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{pagadorOptions.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
<PagadorSelectContent
|
|
label={option.label}
|
|
avatarUrl={option.avatarUrl}
|
|
/>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="w-full">
|
|
<Label
|
|
htmlFor={`categoria-${transaction.id}`}
|
|
className="sr-only"
|
|
>
|
|
Categoria {index + 1}
|
|
</Label>
|
|
<Select
|
|
value={transaction.categoriaId}
|
|
onValueChange={(value) =>
|
|
updateTransaction(
|
|
transaction.id,
|
|
"categoriaId",
|
|
value,
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
id={`categoria-${transaction.id}`}
|
|
className="w-32 truncate"
|
|
>
|
|
<SelectValue placeholder="Categoria" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{groupedCategorias.map((group) => (
|
|
<SelectGroup key={group.label}>
|
|
<SelectLabel>{group.label}</SelectLabel>
|
|
{group.options.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
>
|
|
<CategoriaSelectContent
|
|
label={option.label}
|
|
icon={option.icon}
|
|
/>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
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)}
|
|
disabled={transactions.length === 1}
|
|
>
|
|
<RiDeleteBinLine className="size-3.5" />
|
|
<span className="sr-only">Remover transação</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={loading}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={loading}>
|
|
{loading && <Spinner className="size-4" />}
|
|
Criar {transactions.length}{" "}
|
|
{transactions.length === 1 ? "lançamento" : "lançamentos"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|