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 { connection } from "next/server";
|
||||||
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
||||||
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
|
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 { AdjustBalanceDialog } from "@/features/accounts/components/adjust-balance-dialog";
|
||||||
import type { Account } from "@/features/accounts/components/types";
|
import type { Account } from "@/features/accounts/components/types";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +32,7 @@ import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
||||||
|
import { getBusinessDateString } from "@/shared/utils/date";
|
||||||
import { parsePeriodParam } from "@/shared/utils/period";
|
import { parsePeriodParam } from "@/shared/utils/period";
|
||||||
|
|
||||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||||
@@ -52,6 +54,17 @@ const resolveDefaultPaymentMethod = (
|
|||||||
return "Pix";
|
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) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
await connection();
|
await connection();
|
||||||
const { accountId } = await params;
|
const { accountId } = await params;
|
||||||
@@ -109,6 +122,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
accountSummary;
|
accountSummary;
|
||||||
|
|
||||||
const periodLabel = `${capitalize(monthName)} de ${year}`;
|
const periodLabel = `${capitalize(monthName)} de ${year}`;
|
||||||
|
const defaultYieldDate = resolveDefaultYieldDate(selectedPeriod);
|
||||||
|
|
||||||
const accountDialogData: Account = {
|
const accountDialogData: Account = {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
@@ -152,11 +166,17 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
totalExpenses={totalExpenses}
|
totalExpenses={totalExpenses}
|
||||||
logo={account.logo}
|
logo={account.logo}
|
||||||
balanceAdjustment={
|
balanceAdjustment={
|
||||||
<AdjustBalanceDialog
|
<>
|
||||||
accountId={account.id}
|
<AddYieldDialog
|
||||||
period={selectedPeriod}
|
accountId={account.id}
|
||||||
currentBalance={currentBalance}
|
defaultDate={defaultYieldDate}
|
||||||
/>
|
/>
|
||||||
|
<AdjustBalanceDialog
|
||||||
|
accountId={account.id}
|
||||||
|
period={selectedPeriod}
|
||||||
|
currentBalance={currentBalance}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<AccountDialog
|
<AccountDialog
|
||||||
|
|||||||
@@ -32,9 +32,20 @@ import {
|
|||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatDecimalForDbRequired,
|
formatDecimalForDbRequired,
|
||||||
} from "@/shared/utils/currency";
|
} 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";
|
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({
|
const accountBaseSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string({ message: "Informe o nome da conta." })
|
.string({ message: "Informe o nome da conta." })
|
||||||
@@ -408,6 +419,107 @@ const adjustAccountBalanceSchema = z.object({
|
|||||||
|
|
||||||
type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>;
|
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(
|
export async function adjustAccountBalanceAction(
|
||||||
input: AdjustAccountBalanceInput,
|
input: AdjustAccountBalanceInput,
|
||||||
): Promise<ActionResult> {
|
): 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,
|
DialogTrigger,
|
||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { formatCurrency } from "@/shared/utils/currency";
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
|
|
||||||
type AdjustBalanceDialogProps = {
|
type AdjustBalanceDialogProps = {
|
||||||
@@ -79,17 +84,22 @@ export function AdjustBalanceDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<DialogTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon-sm"
|
type="button"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
variant="ghost"
|
||||||
aria-label="Ajustar saldo"
|
size="icon-sm"
|
||||||
>
|
className="text-primary hover:text-primary"
|
||||||
<RiEqualizerLine className="size-4" />
|
aria-label="Ajustar saldo"
|
||||||
</Button>
|
>
|
||||||
</DialogTrigger>
|
<RiEqualizerLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Ajustar saldo</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Ajustar saldo</DialogTitle>
|
<DialogTitle>Ajustar saldo</DialogTitle>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const DEFAULT_CATEGORIES: DefaultCategory[] = [
|
|||||||
// Receitas
|
// Receitas
|
||||||
{ name: "Salário", type: "receita", icon: "RiWallet3Line" },
|
{ name: "Salário", type: "receita", icon: "RiWallet3Line" },
|
||||||
{ name: "Freelance", type: "receita", icon: "RiUserStarLine" },
|
{ name: "Freelance", type: "receita", icon: "RiUserStarLine" },
|
||||||
|
{ name: "Rendimentos", type: "receita", icon: "RiFundsLine" },
|
||||||
{ name: "Investimentos", type: "receita", icon: "RiStockLine" },
|
{ name: "Investimentos", type: "receita", icon: "RiStockLine" },
|
||||||
{ name: "Vendas", type: "receita", icon: "RiShoppingCartLine" },
|
{ name: "Vendas", type: "receita", icon: "RiShoppingCartLine" },
|
||||||
{ name: "Prêmios", type: "receita", icon: "RiMedalLine" },
|
{ name: "Prêmios", type: "receita", icon: "RiMedalLine" },
|
||||||
|
|||||||
Reference in New Issue
Block a user