diff --git a/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx b/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx index 096bb68..5a3383f 100644 --- a/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx +++ b/src/app/(dashboard)/accounts/[accountId]/statement/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { connection } from "next/server"; import { AccountDialog } from "@/features/accounts/components/account-dialog"; import { AccountStatementCard } from "@/features/accounts/components/account-statement-card"; +import { AddYieldDialog } from "@/features/accounts/components/add-yield-dialog"; import { AdjustBalanceDialog } from "@/features/accounts/components/adjust-balance-dialog"; import type { Account } from "@/features/accounts/components/types"; import { @@ -31,6 +32,7 @@ import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import { Button } from "@/shared/components/ui/button"; import { getUserId } from "@/shared/lib/auth/server"; import { loadLogoOptions } from "@/shared/lib/logo/options"; +import { getBusinessDateString } from "@/shared/utils/date"; import { parsePeriodParam } from "@/shared/utils/period"; type PageSearchParams = Promise; @@ -52,6 +54,17 @@ const resolveDefaultPaymentMethod = ( return "Pix"; }; +const resolveDefaultYieldDate = (period: string) => { + const today = getBusinessDateString(); + if (today.startsWith(period)) return today; + + const [year, month] = period.split("-").map((part) => Number(part)); + if (!year || !month) return today; + + const lastDay = new Date(year, month, 0).getDate(); + return `${period}-${String(lastDay).padStart(2, "0")}`; +}; + export default async function Page({ params, searchParams }: PageProps) { await connection(); const { accountId } = await params; @@ -109,6 +122,7 @@ export default async function Page({ params, searchParams }: PageProps) { accountSummary; const periodLabel = `${capitalize(monthName)} de ${year}`; + const defaultYieldDate = resolveDefaultYieldDate(selectedPeriod); const accountDialogData: Account = { id: account.id, @@ -152,11 +166,17 @@ export default async function Page({ params, searchParams }: PageProps) { totalExpenses={totalExpenses} logo={account.logo} balanceAdjustment={ - + <> + + + } actions={ ; +const addAccountYieldSchema = z.object({ + accountId: uuidSchema("FinancialAccount"), + amount: z + .number({ message: "Valor inválido." }) + .positive("Informe um valor maior que zero."), + date: z + .string({ message: "Data inválida." }) + .trim() + .regex(/^\d{4}-\d{2}-\d{2}$/u, "Data inválida."), +}); + +type AddAccountYieldInput = z.infer; + +export async function addAccountYieldAction( + input: AddAccountYieldInput, +): Promise { + try { + const user = await getUser(); + const data = addAccountYieldSchema.parse(input); + const adminPayerId = await getAdminPayerId(user.id); + + if (!adminPayerId) { + throw new Error( + "Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de adicionar rendimentos.", + ); + } + + const purchaseDate = parseLocalDateString(data.date); + if (Number.isNaN(purchaseDate.getTime())) { + throw new Error("Data inválida."); + } + + await db.transaction(async (tx: typeof db) => { + const account = await tx.query.financialAccounts.findFirst({ + columns: { id: true }, + where: and( + eq(financialAccounts.id, data.accountId), + eq(financialAccounts.userId, user.id), + ), + }); + + if (!account) { + throw new Error("Conta não encontrada."); + } + + const existingCategory = await tx.query.categories.findFirst({ + columns: { id: true }, + where: and( + eq(categories.userId, user.id), + eq(categories.type, "receita"), + eq(categories.name, ACCOUNT_YIELD_CATEGORY_NAME), + ), + }); + + const category = + existingCategory ?? + ( + await tx + .insert(categories) + .values({ + name: ACCOUNT_YIELD_CATEGORY_NAME, + type: "receita", + icon: ACCOUNT_YIELD_CATEGORY_ICON, + userId: user.id, + }) + .returning({ id: categories.id }) + )[0]; + + if (!category) { + throw new Error( + "Não foi possível preparar a categoria de rendimentos.", + ); + } + + await tx.insert(transactions).values({ + condition: ACCOUNT_YIELD_CONDITION, + name: ACCOUNT_YIELD_TRANSACTION_NAME, + paymentMethod: ACCOUNT_YIELD_PAYMENT_METHOD, + note: null, + amount: formatDecimalForDbRequired(data.amount), + purchaseDate, + transactionType: "Receita" as const, + period: derivePeriodFromDate(data.date), + isSettled: true, + userId: user.id, + accountId: data.accountId, + cardId: null, + categoryId: category.id, + payerId: adminPayerId, + }); + }); + + revalidateForEntity("accounts", user.id); + revalidateForEntity("transactions", user.id); + + return { success: true, message: "Rendimento adicionado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + export async function adjustAccountBalanceAction( input: AdjustAccountBalanceInput, ): Promise { diff --git a/src/features/accounts/components/add-yield-dialog.tsx b/src/features/accounts/components/add-yield-dialog.tsx new file mode 100644 index 0000000..d1bf38b --- /dev/null +++ b/src/features/accounts/components/add-yield-dialog.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { RiCalculatorLine, RiFundsLine } from "@remixicon/react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { addAccountYieldAction } from "@/features/accounts/actions"; +import { CalculatorDialogButton } from "@/shared/components/calculator/calculator-dialog"; +import { Button } from "@/shared/components/ui/button"; +import { CurrencyInput } from "@/shared/components/ui/currency-input"; +import { DatePicker } from "@/shared/components/ui/date-picker"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/components/ui/dialog"; +import { Label } from "@/shared/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; + +type AddYieldDialogProps = { + accountId: string; + defaultDate: string; +}; + +export function AddYieldDialog({ + accountId, + defaultDate, +}: AddYieldDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + const [amount, setAmount] = useState(""); + const [date, setDate] = useState(defaultDate); + + useEffect(() => { + if (open) { + setAmount(""); + setDate(defaultDate); + } + }, [open, defaultDate]); + + const handleSave = () => { + const numericAmount = Number(amount); + + if (!Number.isFinite(numericAmount) || numericAmount <= 0) { + toast.error("Informe um valor maior que zero."); + return; + } + + if (!date) { + toast.error("Informe a data do rendimento."); + return; + } + + startTransition(async () => { + const result = await addAccountYieldAction({ + accountId, + amount: numericAmount, + date, + }); + + if (result.success) { + toast.success(result.message); + setOpen(false); + router.refresh(); + return; + } + + toast.error(result.error); + }); + }; + + return ( + + + + + + + + Adicionar rendimento + + + + Adicionar rendimento + + Registre um rendimento como receita paga nesta conta. + + +
+
+ +
+ + + + +
+
+
+ + +
+
+ + + + +
+
+ ); +} diff --git a/src/features/accounts/components/adjust-balance-dialog.tsx b/src/features/accounts/components/adjust-balance-dialog.tsx index f7879b7..d6e9309 100644 --- a/src/features/accounts/components/adjust-balance-dialog.tsx +++ b/src/features/accounts/components/adjust-balance-dialog.tsx @@ -17,6 +17,11 @@ import { DialogTrigger, } from "@/shared/components/ui/dialog"; import { Label } from "@/shared/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; import { formatCurrency } from "@/shared/utils/currency"; type AdjustBalanceDialogProps = { @@ -79,17 +84,22 @@ export function AdjustBalanceDialog({ return ( - - - + + + + + + + Ajustar saldo + Ajustar saldo diff --git a/src/shared/lib/categories/defaults.ts b/src/shared/lib/categories/defaults.ts index d3ac225..a25514d 100644 --- a/src/shared/lib/categories/defaults.ts +++ b/src/shared/lib/categories/defaults.ts @@ -34,6 +34,7 @@ export const DEFAULT_CATEGORIES: DefaultCategory[] = [ // Receitas { name: "Salário", type: "receita", icon: "RiWallet3Line" }, { name: "Freelance", type: "receita", icon: "RiUserStarLine" }, + { name: "Rendimentos", type: "receita", icon: "RiFundsLine" }, { name: "Investimentos", type: "receita", icon: "RiStockLine" }, { name: "Vendas", type: "receita", icon: "RiShoppingCartLine" }, { name: "Prêmios", type: "receita", icon: "RiMedalLine" },