mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
feat(contas): adiciona rendimento pelo extrato
This commit is contained in:
@@ -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<ResolvedSearchParams>;
|
||||
@@ -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={
|
||||
<AdjustBalanceDialog
|
||||
accountId={account.id}
|
||||
period={selectedPeriod}
|
||||
currentBalance={currentBalance}
|
||||
/>
|
||||
<>
|
||||
<AddYieldDialog
|
||||
accountId={account.id}
|
||||
defaultDate={defaultYieldDate}
|
||||
/>
|
||||
<AdjustBalanceDialog
|
||||
accountId={account.id}
|
||||
period={selectedPeriod}
|
||||
currentBalance={currentBalance}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<AccountDialog
|
||||
|
||||
@@ -32,9 +32,20 @@ import {
|
||||
formatCurrency,
|
||||
formatDecimalForDbRequired,
|
||||
} from "@/shared/utils/currency";
|
||||
import { getBusinessTodayDate, getTodayInfo } from "@/shared/utils/date";
|
||||
import {
|
||||
getBusinessTodayDate,
|
||||
getTodayInfo,
|
||||
parseLocalDateString,
|
||||
} from "@/shared/utils/date";
|
||||
import { derivePeriodFromDate } from "@/shared/utils/period";
|
||||
import { normalizeFilePath } from "@/shared/utils/string";
|
||||
|
||||
const ACCOUNT_YIELD_CATEGORY_NAME = "Rendimentos";
|
||||
const ACCOUNT_YIELD_CATEGORY_ICON = "RiFundsLine";
|
||||
const ACCOUNT_YIELD_TRANSACTION_NAME = "Rendimento";
|
||||
const ACCOUNT_YIELD_CONDITION = INITIAL_BALANCE_CONDITION;
|
||||
const ACCOUNT_YIELD_PAYMENT_METHOD = "Transferência bancária" as const;
|
||||
|
||||
const accountBaseSchema = z.object({
|
||||
name: z
|
||||
.string({ message: "Informe o nome da conta." })
|
||||
@@ -408,6 +419,107 @@ const adjustAccountBalanceSchema = z.object({
|
||||
|
||||
type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>;
|
||||
|
||||
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<typeof addAccountYieldSchema>;
|
||||
|
||||
export async function addAccountYieldAction(
|
||||
input: AddAccountYieldInput,
|
||||
): Promise<ActionResult> {
|
||||
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<ActionResult> {
|
||||
|
||||
155
src/features/accounts/components/add-yield-dialog.tsx
Normal file
155
src/features/accounts/components/add-yield-dialog.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-primary hover:text-primary"
|
||||
aria-label="Adicionar rendimento"
|
||||
>
|
||||
<RiFundsLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Adicionar rendimento</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adicionar rendimento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Registre um rendimento como receita paga nesta conta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="yield-amount">Valor</Label>
|
||||
<div className="relative">
|
||||
<CurrencyInput
|
||||
id="yield-amount"
|
||||
value={amount}
|
||||
onValueChange={setAmount}
|
||||
autoFocus
|
||||
className="pr-10"
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
<CalculatorDialogButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
|
||||
onSelectValue={setAmount}
|
||||
>
|
||||
<RiCalculatorLine className="h-4 w-4 text-muted-foreground" />
|
||||
</CalculatorDialogButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="yield-date">Data</Label>
|
||||
<DatePicker
|
||||
id="yield-date"
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder="Data"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={isPending}>
|
||||
{isPending ? "Salvando..." : "Adicionar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Ajustar saldo"
|
||||
>
|
||||
<RiEqualizerLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-primary hover:text-primary"
|
||||
aria-label="Ajustar saldo"
|
||||
>
|
||||
<RiEqualizerLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Ajustar saldo</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajustar saldo</DialogTitle>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user