feat(lancamentos): separar botões Nova Receita e Nova Despesa

- Substituir botão único "Novo lançamento" por dois botões separados
- Adicionar ícones coloridos (verde para Receita, vermelho para Despesa)
- Adicionar suporte a defaultTransactionType no dialog
- Atualizar título e descrição do dialog conforme tipo selecionado
- Ocultar campo de tipo de transação quando tipo é pré-definido

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-20 13:43:00 +00:00
parent 9b08a8e020
commit 478bd0c267
5 changed files with 126 additions and 65 deletions

View File

@@ -24,10 +24,13 @@ export function CategorySection({
categoriaOptions, categoriaOptions,
categoriaGroups, categoriaGroups,
isUpdateMode, isUpdateMode,
hideTransactionType = false,
}: CategorySectionProps) { }: CategorySectionProps) {
const showTransactionTypeField = !isUpdateMode && !hideTransactionType;
return ( return (
<div className="flex w-full flex-col gap-2 md:flex-row"> <div className="flex w-full flex-col gap-2 md:flex-row">
{!isUpdateMode ? ( {showTransactionTypeField ? (
<div className="w-full space-y-1 md:w-1/2"> <div className="w-full space-y-1 md:w-1/2">
<Label htmlFor="transactionType">Tipo de transação</Label> <Label htmlFor="transactionType">Tipo de transação</Label>
<Select <Select
@@ -45,7 +48,7 @@ export function CategorySection({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{LANCAMENTO_TRANSACTION_TYPES.filter( {LANCAMENTO_TRANSACTION_TYPES.filter(
(type) => type !== "Transferência" (type) => type !== "Transferência",
).map((type) => ( ).map((type) => (
<SelectItem key={type} value={type}> <SelectItem key={type} value={type}>
<TransactionTypeSelectContent label={type} /> <TransactionTypeSelectContent label={type} />
@@ -59,7 +62,7 @@ export function CategorySection({
<div <div
className={cn( className={cn(
"space-y-1 w-full", "space-y-1 w-full",
!isUpdateMode ? "md:w-1/2" : "md:w-full" showTransactionTypeField ? "md:w-1/2" : "md:w-full",
)} )}
> >
<Label htmlFor="categoria">Categoria</Label> <Label htmlFor="categoria">Categoria</Label>
@@ -72,7 +75,7 @@ export function CategorySection({
{formState.categoriaId && {formState.categoriaId &&
(() => { (() => {
const selectedOption = categoriaOptions.find( const selectedOption = categoriaOptions.find(
(opt) => opt.value === formState.categoriaId (opt) => opt.value === formState.categoriaId,
); );
return selectedOption ? ( return selectedOption ? (
<CategoriaSelectContent <CategoriaSelectContent

View File

@@ -23,6 +23,7 @@ export interface LancamentoDialogProps {
lockCartaoSelection?: boolean; lockCartaoSelection?: boolean;
lockPaymentMethod?: boolean; lockPaymentMethod?: boolean;
isImporting?: boolean; isImporting?: boolean;
defaultTransactionType?: "Despesa" | "Receita";
onBulkEditRequest?: (data: { onBulkEditRequest?: (data: {
id: string; id: string;
name: string; name: string;
@@ -41,7 +42,7 @@ export interface BaseFieldSectionProps {
formState: FormState; formState: FormState;
onFieldChange: <Key extends keyof FormState>( onFieldChange: <Key extends keyof FormState>(
key: Key, key: Key,
value: FormState[Key] value: FormState[Key],
) => void; ) => void;
} }
@@ -56,6 +57,7 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
options: SelectOption[]; options: SelectOption[];
}>; }>;
isUpdateMode: boolean; isUpdateMode: boolean;
hideTransactionType?: boolean;
} }
export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps { export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {

View File

@@ -63,12 +63,13 @@ export function LancamentoDialog({
lockCartaoSelection, lockCartaoSelection,
lockPaymentMethod, lockPaymentMethod,
isImporting = false, isImporting = false,
defaultTransactionType,
onBulkEditRequest, onBulkEditRequest,
}: LancamentoDialogProps) { }: LancamentoDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState( const [dialogOpen, setDialogOpen] = useControlledState(
open, open,
false, false,
onOpenChange onOpenChange,
); );
const [formState, setFormState] = useState<FormState>(() => const [formState, setFormState] = useState<FormState>(() =>
@@ -76,8 +77,9 @@ export function LancamentoDialog({
defaultCartaoId, defaultCartaoId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
defaultTransactionType,
isImporting, isImporting,
}) }),
); );
const [periodDirty, setPeriodDirty] = useState(false); const [periodDirty, setPeriodDirty] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -94,9 +96,10 @@ export function LancamentoDialog({
defaultCartaoId, defaultCartaoId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
defaultTransactionType,
isImporting, isImporting,
} },
) ),
); );
setErrorMessage(null); setErrorMessage(null);
setPeriodDirty(false); setPeriodDirty(false);
@@ -109,6 +112,7 @@ export function LancamentoDialog({
defaultCartaoId, defaultCartaoId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
defaultTransactionType,
isImporting, isImporting,
]); ]);
@@ -116,18 +120,17 @@ export function LancamentoDialog({
const secondaryPagadorOptions = useMemo( const secondaryPagadorOptions = useMemo(
() => filterSecondaryPagadorOptions(splitPagadorOptions, primaryPagador), () => filterSecondaryPagadorOptions(splitPagadorOptions, primaryPagador),
[splitPagadorOptions, primaryPagador] [splitPagadorOptions, primaryPagador],
); );
const categoriaGroups = useMemo(() => { const categoriaGroups = useMemo(() => {
const filtered = categoriaOptions.filter( const filtered = categoriaOptions.filter(
(option) => (option) =>
option.group?.toLowerCase() === formState.transactionType.toLowerCase() option.group?.toLowerCase() === formState.transactionType.toLowerCase(),
); );
return groupAndSortCategorias(filtered); return groupAndSortCategorias(filtered);
}, [categoriaOptions, formState.transactionType]); }, [categoriaOptions, formState.transactionType]);
const handleFieldChange = useCallback( const handleFieldChange = useCallback(
<Key extends keyof FormState>(key: Key, value: FormState[Key]) => { <Key extends keyof FormState>(key: Key, value: FormState[Key]) => {
if (key === "period") { if (key === "period") {
@@ -139,7 +142,7 @@ export function LancamentoDialog({
key, key,
value, value,
prev, prev,
periodDirty periodDirty,
); );
return { return {
@@ -149,7 +152,7 @@ export function LancamentoDialog({
}; };
}); });
}, },
[periodDirty] [periodDirty],
); );
const handleSubmit = useCallback( const handleSubmit = useCallback(
@@ -302,16 +305,24 @@ export function LancamentoDialog({
lancamento?.seriesId, lancamento?.seriesId,
setDialogOpen, setDialogOpen,
onBulkEditRequest, onBulkEditRequest,
] ],
); );
const isCopyMode = mode === "create" && Boolean(lancamento) && !isImporting; const isCopyMode = mode === "create" && Boolean(lancamento) && !isImporting;
const isImportMode = mode === "create" && Boolean(lancamento) && isImporting; const isImportMode = mode === "create" && Boolean(lancamento) && isImporting;
const title = mode === "create" const isNewWithType =
mode === "create" && !lancamento && defaultTransactionType;
const title =
mode === "create"
? isImportMode ? isImportMode
? "Importar para Minha Conta" ? "Importar para Minha Conta"
: isCopyMode : isCopyMode
? "Copiar lançamento" ? "Copiar lançamento"
: isNewWithType
? defaultTransactionType === "Despesa"
? "Nova Despesa"
: "Nova Receita"
: "Novo lançamento" : "Novo lançamento"
: "Editar lançamento"; : "Editar lançamento";
const description = const description =
@@ -320,6 +331,8 @@ export function LancamentoDialog({
? "Importando lançamento de outro usuário. Ajuste a categoria, pagador e cartão/conta antes de salvar." ? "Importando lançamento de outro usuário. Ajuste a categoria, pagador e cartão/conta antes de salvar."
: isCopyMode : isCopyMode
? "Os dados do lançamento foram copiados. Revise e ajuste conforme necessário antes de salvar." ? "Os dados do lançamento foram copiados. Revise e ajuste conforme necessário antes de salvar."
: isNewWithType
? `Informe os dados abaixo para registrar ${defaultTransactionType === "Despesa" ? "uma nova despesa" : "uma nova receita"}.`
: "Informe os dados abaixo para registrar um novo lançamento." : "Informe os dados abaixo para registrar um novo lançamento."
: "Atualize as informações do lançamento selecionado."; : "Atualize as informações do lançamento selecionado.";
const submitLabel = mode === "create" ? "Salvar lançamento" : "Atualizar"; const submitLabel = mode === "create" ? "Salvar lançamento" : "Atualizar";
@@ -358,6 +371,7 @@ export function LancamentoDialog({
categoriaOptions={categoriaOptions} categoriaOptions={categoriaOptions}
categoriaGroups={categoriaGroups} categoriaGroups={categoriaGroups}
isUpdateMode={isUpdateMode} isUpdateMode={isUpdateMode}
hideTransactionType={Boolean(isNewWithType)}
/> />
{!isUpdateMode ? ( {!isUpdateMode ? (

View File

@@ -73,13 +73,13 @@ import {
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { LancamentosExport } from "../lancamentos-export";
import { EstabelecimentoLogo } from "../shared/estabelecimento-logo"; import { EstabelecimentoLogo } from "../shared/estabelecimento-logo";
import type { import type {
ContaCartaoFilterOption, ContaCartaoFilterOption,
LancamentoFilterOption, LancamentoFilterOption,
LancamentoItem, LancamentoItem,
} from "../types"; } from "../types";
import { LancamentosExport } from "../lancamentos-export";
import { LancamentosFilters } from "./lancamentos-filters"; import { LancamentosFilters } from "./lancamentos-filters";
const resolveLogoSrc = (logo: string | null) => { const resolveLogoSrc = (logo: string | null) => {
@@ -325,7 +325,7 @@ const buildColumns = ({
isReceita isReceita
? "text-green-600 dark:text-green-400" ? "text-green-600 dark:text-green-400"
: "text-foreground", : "text-foreground",
isTransfer && "text-blue-700 dark:text-blue-500" isTransfer && "text-blue-700 dark:text-blue-500",
)} )}
/> />
); );
@@ -468,7 +468,7 @@ const buildColumns = ({
href={href ?? "#"} href={href ?? "#"}
className={cn( className={cn(
"flex items-center gap-2", "flex items-center gap-2",
href ? "underline " : "pointer-events-none" href ? "underline " : "pointer-events-none",
)} )}
aria-disabled={!href} aria-disabled={!href}
> >
@@ -553,13 +553,15 @@ const buildColumns = ({
Editar Editar
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{row.original.categoriaName !== "Pagamentos" && row.original.userId === currentUserId && ( {row.original.categoriaName !== "Pagamentos" &&
row.original.userId === currentUserId && (
<DropdownMenuItem onSelect={() => handleCopy(row.original)}> <DropdownMenuItem onSelect={() => handleCopy(row.original)}>
<RiFileCopyLine className="size-4" /> <RiFileCopyLine className="size-4" />
Copiar Copiar
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{row.original.categoriaName !== "Pagamentos" && row.original.userId !== currentUserId && ( {row.original.categoriaName !== "Pagamentos" &&
row.original.userId !== currentUserId && (
<DropdownMenuItem onSelect={() => handleImport(row.original)}> <DropdownMenuItem onSelect={() => handleImport(row.original)}>
<RiFileCopyLine className="size-4" /> <RiFileCopyLine className="size-4" />
Importar para Minha Conta Importar para Minha Conta
@@ -628,7 +630,7 @@ type LancamentosTableProps = {
categoriaFilterOptions?: LancamentoFilterOption[]; categoriaFilterOptions?: LancamentoFilterOption[];
contaCartaoFilterOptions?: ContaCartaoFilterOption[]; contaCartaoFilterOptions?: ContaCartaoFilterOption[];
selectedPeriod?: string; selectedPeriod?: string;
onCreate?: () => void; onCreate?: (type: "Despesa" | "Receita") => void;
onMassAdd?: () => void; onMassAdd?: () => void;
onEdit?: (item: LancamentoItem) => void; onEdit?: (item: LancamentoItem) => void;
onCopy?: (item: LancamentoItem) => void; onCopy?: (item: LancamentoItem) => void;
@@ -704,7 +706,7 @@ export function LancamentosTable({
onViewAnticipationHistory, onViewAnticipationHistory,
isSettlementLoading, isSettlementLoading,
showActions, showActions,
] ],
); );
const table = useReactTable({ const table = useReactTable({
@@ -731,7 +733,7 @@ export function LancamentosTable({
const selectedCount = selectedRows.length; const selectedCount = selectedRows.length;
const selectedTotal = selectedRows.reduce( const selectedTotal = selectedRows.reduce(
(total, row) => total + (row.original.amount ?? 0), (total, row) => total + (row.original.amount ?? 0),
0 0,
); );
// Check if there's any data from other users // Check if there's any data from other users
@@ -763,10 +765,24 @@ export function LancamentosTable({
{onCreate || onMassAdd ? ( {onCreate || onMassAdd ? (
<div className="flex gap-2"> <div className="flex gap-2">
{onCreate ? ( {onCreate ? (
<Button onClick={onCreate} className="w-full sm:w-auto"> <>
<RiAddCircleLine className="size-4" /> <Button
Novo lançamento onClick={() => onCreate("Receita")}
variant="outline"
className="w-full sm:w-auto"
>
<RiAddCircleLine className="size-4 text-green-500" />
Nova Receita
</Button> </Button>
<Button
onClick={() => onCreate("Despesa")}
variant="outline"
className="w-full sm:w-auto"
>
<RiAddCircleLine className="size-4 text-red-500" />
Nova Despesa
</Button>
</>
) : null} ) : null}
{onMassAdd ? ( {onMassAdd ? (
<Tooltip> <Tooltip>
@@ -813,7 +829,9 @@ export function LancamentosTable({
</div> </div>
) : null} ) : null}
{selectedCount > 0 && onBulkDelete && selectedRows.every(row => row.original.userId === currentUserId) ? ( {selectedCount > 0 &&
onBulkDelete &&
selectedRows.every((row) => row.original.userId === currentUserId) ? (
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2"> <div className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2">
<div className="flex flex-col text-sm text-muted-foreground sm:flex-row sm:items-center sm:gap-2"> <div className="flex flex-col text-sm text-muted-foreground sm:flex-row sm:items-center sm:gap-2">
<span> <span>
@@ -843,7 +861,9 @@ export function LancamentosTable({
</div> </div>
) : null} ) : null}
{selectedCount > 0 && onBulkImport && selectedRows.some(row => row.original.userId !== currentUserId) ? ( {selectedCount > 0 &&
onBulkImport &&
selectedRows.some((row) => row.original.userId !== currentUserId) ? (
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2"> <div className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2">
<div className="flex flex-col text-sm text-muted-foreground sm:flex-row sm:items-center sm:gap-2"> <div className="flex flex-col text-sm text-muted-foreground sm:flex-row sm:items-center sm:gap-2">
<span> <span>
@@ -891,7 +911,7 @@ export function LancamentosTable({
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext() header.getContext(),
)} )}
</TableHead> </TableHead>
))} ))}
@@ -905,7 +925,7 @@ export function LancamentosTable({
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext() cell.getContext(),
)} )}
</TableCell> </TableCell>
))} ))}

View File

@@ -3,7 +3,11 @@
*/ */
import type { LancamentoItem } from "@/components/lancamentos/types"; import type { LancamentoItem } from "@/components/lancamentos/types";
import { LANCAMENTO_CONDITIONS, LANCAMENTO_PAYMENT_METHODS, LANCAMENTO_TRANSACTION_TYPES } from "./constants"; import {
LANCAMENTO_CONDITIONS,
LANCAMENTO_PAYMENT_METHODS,
LANCAMENTO_TRANSACTION_TYPES,
} from "./constants";
import { derivePeriodFromDate } from "@/lib/utils/period"; import { derivePeriodFromDate } from "@/lib/utils/period";
import { getTodayDateString } from "@/lib/utils/date"; import { getTodayDateString } from "@/lib/utils/date";
@@ -39,6 +43,7 @@ export type LancamentoFormOverrides = {
defaultCartaoId?: string | null; defaultCartaoId?: string | null;
defaultPaymentMethod?: string | null; defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null; defaultPurchaseDate?: string | null;
defaultTransactionType?: "Despesa" | "Receita";
isImporting?: boolean; isImporting?: boolean;
}; };
@@ -49,11 +54,11 @@ export function buildLancamentoInitialState(
lancamento?: LancamentoItem, lancamento?: LancamentoItem,
defaultPagadorId?: string | null, defaultPagadorId?: string | null,
preferredPeriod?: string, preferredPeriod?: string,
overrides?: LancamentoFormOverrides overrides?: LancamentoFormOverrides,
): LancamentoFormState { ): LancamentoFormState {
const purchaseDate = lancamento?.purchaseDate const purchaseDate = lancamento?.purchaseDate
? lancamento.purchaseDate.slice(0, 10) ? lancamento.purchaseDate.slice(0, 10)
: overrides?.defaultPurchaseDate ?? ""; : (overrides?.defaultPurchaseDate ?? "");
const paymentMethod = const paymentMethod =
lancamento?.paymentMethod ?? lancamento?.paymentMethod ??
@@ -84,7 +89,11 @@ export function buildLancamentoInitialState(
let baseAmount = Math.abs(lancamento.amount); let baseAmount = Math.abs(lancamento.amount);
// Se está importando e é parcelado, usar o valor total (parcela * quantidade) // Se está importando e é parcelado, usar o valor total (parcela * quantidade)
if (isImporting && lancamento.condition === "Parcelado" && lancamento.installmentCount) { if (
isImporting &&
lancamento.condition === "Parcelado" &&
lancamento.installmentCount
) {
baseAmount = baseAmount * lancamento.installmentCount; baseAmount = baseAmount * lancamento.installmentCount;
} }
@@ -99,7 +108,9 @@ export function buildLancamentoInitialState(
: fallbackPeriod, : fallbackPeriod,
name: lancamento?.name ?? "", name: lancamento?.name ?? "",
transactionType: transactionType:
lancamento?.transactionType ?? LANCAMENTO_TRANSACTION_TYPES[0], lancamento?.transactionType ??
overrides?.defaultTransactionType ??
LANCAMENTO_TRANSACTION_TYPES[0],
amount: amountValue, amount: amountValue,
condition: lancamento?.condition ?? LANCAMENTO_CONDITIONS[0], condition: lancamento?.condition ?? LANCAMENTO_CONDITIONS[0],
paymentMethod, paymentMethod,
@@ -109,12 +120,18 @@ export function buildLancamentoInitialState(
contaId: contaId:
paymentMethod === "Cartão de crédito" paymentMethod === "Cartão de crédito"
? undefined ? undefined
: isImporting ? undefined : (lancamento?.contaId ?? undefined), : isImporting
? undefined
: (lancamento?.contaId ?? undefined),
cartaoId: cartaoId:
paymentMethod === "Cartão de crédito" paymentMethod === "Cartão de crédito"
? isImporting ? (overrides?.defaultCartaoId ?? undefined) : (lancamento?.cartaoId ?? overrides?.defaultCartaoId ?? undefined) ? isImporting
? (overrides?.defaultCartaoId ?? undefined)
: (lancamento?.cartaoId ?? overrides?.defaultCartaoId ?? undefined)
: undefined, : undefined,
categoriaId: isImporting ? undefined : (lancamento?.categoriaId ?? undefined), categoriaId: isImporting
? undefined
: (lancamento?.categoriaId ?? undefined),
installmentCount: lancamento?.installmentCount installmentCount: lancamento?.installmentCount
? String(lancamento.installmentCount) ? String(lancamento.installmentCount)
: "", : "",
@@ -127,7 +144,7 @@ export function buildLancamentoInitialState(
isSettled: isSettled:
paymentMethod === "Cartão de crédito" paymentMethod === "Cartão de crédito"
? null ? null
: lancamento?.isSettled ?? true, : (lancamento?.isSettled ?? true),
}; };
} }
@@ -139,7 +156,7 @@ export function applyFieldDependencies(
key: keyof LancamentoFormState, key: keyof LancamentoFormState,
value: LancamentoFormState[keyof LancamentoFormState], value: LancamentoFormState[keyof LancamentoFormState],
currentState: LancamentoFormState, currentState: LancamentoFormState,
_periodDirty: boolean _periodDirty: boolean,
): Partial<LancamentoFormState> { ): Partial<LancamentoFormState> {
const updates: Partial<LancamentoFormState> = {}; const updates: Partial<LancamentoFormState> = {};
@@ -174,11 +191,15 @@ export function applyFieldDependencies(
if (value !== "Boleto") { if (value !== "Boleto") {
updates.dueDate = ""; updates.dueDate = "";
updates.boletoPaymentDate = ""; updates.boletoPaymentDate = "";
} else if (currentState.isSettled || (updates.isSettled !== null && updates.isSettled !== undefined)) { } else if (
currentState.isSettled ||
(updates.isSettled !== null && updates.isSettled !== undefined)
) {
// Set today's date for boleto payment if settled // Set today's date for boleto payment if settled
const settled = updates.isSettled ?? currentState.isSettled; const settled = updates.isSettled ?? currentState.isSettled;
if (settled) { if (settled) {
updates.boletoPaymentDate = currentState.boletoPaymentDate || getTodayDateString(); updates.boletoPaymentDate =
currentState.boletoPaymentDate || getTodayDateString();
} }
} }
} }
@@ -199,7 +220,8 @@ export function applyFieldDependencies(
// When isSettled changes and payment method is Boleto // When isSettled changes and payment method is Boleto
if (key === "isSettled" && currentState.paymentMethod === "Boleto") { if (key === "isSettled" && currentState.paymentMethod === "Boleto") {
if (value === true) { if (value === true) {
updates.boletoPaymentDate = currentState.boletoPaymentDate || getTodayDateString(); updates.boletoPaymentDate =
currentState.boletoPaymentDate || getTodayDateString();
} else if (value === false) { } else if (value === false) {
updates.boletoPaymentDate = ""; updates.boletoPaymentDate = "";
} }