Files
openmonetis/components/lancamentos/dialogs/mass-add-dialog.tsx
Felipe Coutinho fd817683ca feat: implementar sistema de preferências do usuário e refatorar changelog
Adiciona sistema completo de preferências de usuário:
  - Cria tabela userPreferences no schema com campos disableMagnetlines, periodMonthsBefore e periodMonthsAfter
  - Implementa página de Ajustes com abas (Preferências, Alterar nome, Senha, E-mail, Deletar conta)
  - Adiciona componente PreferencesForm para configuração de magnetlines e períodos de exibição
  - Propaga periodPreferences para todos os componentes de lançamentos e calendário

  Refatora sistema de changelog:
  - Remove implementação anterior baseada em JSON estático
  - Adiciona nova página de changelog dinâmica em app/(dashboard)/changelog
  - Adiciona componente changelog-list.tsx
  - Remove arquivos obsoletos (changelog-notification, actions, data, utils, scripts)

  Adiciona controle de saldo inicial em contas:
  - Novo campo excludeInitialBalanceFromIncome em contas
  - Permite excluir saldo inicial do cálculo de receitas
  - Atualiza queries de lançamentos para respeitar esta configuração

  Melhorias adicionais:
  - Adiciona componente ui/accordion.tsx do shadcn/ui
  - Refatora formatPeriodLabel para displayPeriod centralizado
  - Propaga estabelecimentos para componentes de lançamentos
  - Remove variável DB_PROVIDER obsoleta do .env.example e documentação
  - Adiciona 6 migrações de banco de dados (0003-0008)
2026-01-03 14:18:03 +00:00

631 lines
22 KiB
TypeScript

"use client";
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 { createMonthOptions } from "@/lib/utils/period";
import type { PeriodPreferences } from "@/lib/user-preferences/period";
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { SelectOption } from "../../types";
import {
CategoriaSelectContent,
ConditionSelectContent,
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;
periodPreferences: PeriodPreferences;
defaultPagadorId?: 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,
periodPreferences,
defaultPagadorId,
}: 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 [condition, setCondition] = useState<string>("À vista");
const [period, setPeriod] = useState<string>(selectedPeriod);
const [contaId, setContaId] = useState<string | undefined>();
const [cartaoId, setCartaoId] = useState<string | undefined>();
// Transaction rows
const [transactions, setTransactions] = useState<TransactionRow[]>([
{
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoriaId: undefined,
pagadorId: defaultPagadorId ?? undefined,
},
]);
// Period options
const periodOptions = useMemo(
() =>
createMonthOptions(
selectedPeriod,
periodPreferences.monthsBefore,
periodPreferences.monthsAfter
),
[selectedPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
);
// 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 (paymentMethod === "Cartão de crédito" && !cartaoId) {
toast.error("Selecione um cartão para continuar");
return;
}
if (paymentMethod !== "Cartão de crédito" && !contaId) {
toast.error("Selecione uma conta 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,
condition,
period,
contaId: paymentMethod !== "Cartão de crédito" ? contaId : undefined,
cartaoId: paymentMethod === "Cartão de crédito" ? cartaoId : undefined,
},
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]);
setCondition("À vista");
setPeriod(selectedPeriod);
setContaId(undefined);
setCartaoId(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.
</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-5">
{/* 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>
{/* 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 */}
<div className="space-y-2">
<Label htmlFor="period">Período</Label>
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger id="period" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{periodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Conta/Cartao */}
<div className="space-y-2">
<Label htmlFor="conta-cartao">
{paymentMethod === "Cartão de crédito" ? "Cartão" : "Conta"}
</Label>
{paymentMethod === "Cartão de crédito" ? (
<Select value={cartaoId} onValueChange={setCartaoId}>
<SelectTrigger id="conta-cartao" className="w-full">
<SelectValue placeholder="Selecione o cartão">
{cartaoId &&
(() => {
const selectedOption = cartaoOptions.find(
(opt) => opt.value === cartaoId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</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(
(opt) => opt.value === contaId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{contaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
<Separator />
{/* Transactions Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<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">
{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"
onClick={() => removeTransaction(transaction.id)}
disabled={transactions.length === 1}
>
<RiDeleteBinLine className="size-4" />
<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>
);
}