forked from git.gladyson/openmonetis
refactor: migrate from ESLint to Biome and extract SQL queries to data.ts
- Replace ESLint with Biome for linting and formatting - Configure Biome with tabs, double quotes, and organized imports - Move all SQL/Drizzle queries from page.tsx files to data.ts files - Create new data.ts files for: ajustes, dashboard, relatorios/categorias - Update existing data.ts files: extrato, fatura (add lancamentos queries) - Remove all drizzle-orm imports from page.tsx files - Update README.md with new tooling info Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,41 +1,41 @@
|
||||
import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { and, eq, lt, sql } from "drizzle-orm";
|
||||
|
||||
export type AccountSummaryData = {
|
||||
openingBalance: number;
|
||||
currentBalance: number;
|
||||
totalIncomes: number;
|
||||
totalExpenses: number;
|
||||
openingBalance: number;
|
||||
currentBalance: number;
|
||||
totalIncomes: number;
|
||||
totalExpenses: number;
|
||||
};
|
||||
|
||||
export async function fetchAccountData(userId: string, contaId: string) {
|
||||
const account = await db.query.contas.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
accountType: true,
|
||||
status: true,
|
||||
initialBalance: true,
|
||||
logo: true,
|
||||
note: true,
|
||||
},
|
||||
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
|
||||
});
|
||||
const account = await db.query.contas.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
accountType: true,
|
||||
status: true,
|
||||
initialBalance: true,
|
||||
logo: true,
|
||||
note: true,
|
||||
},
|
||||
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
|
||||
});
|
||||
|
||||
return account;
|
||||
return account;
|
||||
}
|
||||
|
||||
export async function fetchAccountSummary(
|
||||
userId: string,
|
||||
contaId: string,
|
||||
selectedPeriod: string
|
||||
userId: string,
|
||||
contaId: string,
|
||||
selectedPeriod: string,
|
||||
): Promise<AccountSummaryData> {
|
||||
const [periodSummary] = await db
|
||||
.select({
|
||||
netAmount: sql<number>`
|
||||
const [periodSummary] = await db
|
||||
.select({
|
||||
netAmount: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -46,7 +46,7 @@ export async function fetchAccountSummary(
|
||||
0
|
||||
)
|
||||
`,
|
||||
incomes: sql<number>`
|
||||
incomes: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -58,7 +58,7 @@ export async function fetchAccountSummary(
|
||||
0
|
||||
)
|
||||
`,
|
||||
expenses: sql<number>`
|
||||
expenses: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -70,22 +70,22 @@ export async function fetchAccountSummary(
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.contaId, contaId),
|
||||
eq(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.isSettled, true),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
)
|
||||
);
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.contaId, contaId),
|
||||
eq(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.isSettled, true),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
|
||||
const [previousRow] = await db
|
||||
.select({
|
||||
previousMovements: sql<number>`
|
||||
const [previousRow] = await db
|
||||
.select({
|
||||
previousMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -96,36 +96,56 @@ export async function fetchAccountSummary(
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.contaId, contaId),
|
||||
lt(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.isSettled, true),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
)
|
||||
);
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.contaId, contaId),
|
||||
lt(lancamentos.period, selectedPeriod),
|
||||
eq(lancamentos.isSettled, true),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
|
||||
const account = await fetchAccountData(userId, contaId);
|
||||
if (!account) {
|
||||
throw new Error("Account not found");
|
||||
}
|
||||
const account = await fetchAccountData(userId, contaId);
|
||||
if (!account) {
|
||||
throw new Error("Account not found");
|
||||
}
|
||||
|
||||
const initialBalance = Number(account.initialBalance ?? 0);
|
||||
const previousMovements = Number(previousRow?.previousMovements ?? 0);
|
||||
const openingBalance = initialBalance + previousMovements;
|
||||
const netAmount = Number(periodSummary?.netAmount ?? 0);
|
||||
const totalIncomes = Number(periodSummary?.incomes ?? 0);
|
||||
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0));
|
||||
const currentBalance = openingBalance + netAmount;
|
||||
const initialBalance = Number(account.initialBalance ?? 0);
|
||||
const previousMovements = Number(previousRow?.previousMovements ?? 0);
|
||||
const openingBalance = initialBalance + previousMovements;
|
||||
const netAmount = Number(periodSummary?.netAmount ?? 0);
|
||||
const totalIncomes = Number(periodSummary?.incomes ?? 0);
|
||||
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0));
|
||||
const currentBalance = openingBalance + netAmount;
|
||||
|
||||
return {
|
||||
openingBalance,
|
||||
currentBalance,
|
||||
totalIncomes,
|
||||
totalExpenses,
|
||||
};
|
||||
return {
|
||||
openingBalance,
|
||||
currentBalance,
|
||||
totalIncomes,
|
||||
totalExpenses,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAccountLancamentos(
|
||||
filters: SQL[],
|
||||
settledOnly = true,
|
||||
) {
|
||||
const allFilters = settledOnly
|
||||
? [...filters, eq(lancamentos.isSettled, true)]
|
||||
: filters;
|
||||
|
||||
return db.query.lancamentos.findMany({
|
||||
where: and(...allFilters),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: desc(lancamentos.purchaseDate),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
AccountStatementCardSkeleton,
|
||||
FilterSkeleton,
|
||||
TransactionsTableSkeleton,
|
||||
AccountStatementCardSkeleton,
|
||||
FilterSkeleton,
|
||||
TransactionsTableSkeleton,
|
||||
} from "@/components/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
@@ -10,29 +10,29 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
* Layout: MonthPicker + AccountStatementCard + Filtros + Tabela de lançamentos
|
||||
*/
|
||||
export default function ExtratoLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Account Statement Card */}
|
||||
<AccountStatementCardSkeleton />
|
||||
{/* Account Statement Card */}
|
||||
<AccountStatementCardSkeleton />
|
||||
|
||||
{/* Seção de lançamentos */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
{/* Seção de lançamentos */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<FilterSkeleton />
|
||||
{/* Filtros */}
|
||||
<FilterSkeleton />
|
||||
|
||||
{/* Tabela */}
|
||||
<TransactionsTableSkeleton />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
{/* Tabela */}
|
||||
<TransactionsTableSkeleton />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { AccountDialog } from "@/components/contas/account-dialog";
|
||||
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
||||
@@ -5,178 +7,162 @@ import type { Account } from "@/components/contas/types";
|
||||
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import {
|
||||
buildLancamentoWhere,
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
buildSlugMaps,
|
||||
extractLancamentoSearchFilters,
|
||||
fetchLancamentoFilterSources,
|
||||
getSingleParam,
|
||||
mapLancamentosData,
|
||||
type ResolvedSearchParams,
|
||||
buildLancamentoWhere,
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
buildSlugMaps,
|
||||
extractLancamentoSearchFilters,
|
||||
fetchLancamentoFilterSources,
|
||||
getSingleParam,
|
||||
mapLancamentosData,
|
||||
type ResolvedSearchParams,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { loadLogoOptions } from "@/lib/logo/options";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchAccountData, fetchAccountSummary } from "./data";
|
||||
import {
|
||||
fetchAccountData,
|
||||
fetchAccountLancamentos,
|
||||
fetchAccountSummary,
|
||||
} from "./data";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ contaId: string }>;
|
||||
searchParams?: PageSearchParams;
|
||||
params: Promise<{ contaId: string }>;
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const capitalize = (value: string) =>
|
||||
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
||||
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
const { contaId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const { contaId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const {
|
||||
period: selectedPeriod,
|
||||
monthName,
|
||||
year,
|
||||
} = parsePeriodParam(periodoParamRaw);
|
||||
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const {
|
||||
period: selectedPeriod,
|
||||
monthName,
|
||||
year,
|
||||
} = parsePeriodParam(periodoParamRaw);
|
||||
|
||||
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
||||
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
||||
|
||||
const account = await fetchAccountData(userId, contaId);
|
||||
const account = await fetchAccountData(userId, contaId);
|
||||
|
||||
if (!account) {
|
||||
notFound();
|
||||
}
|
||||
if (!account) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [
|
||||
filterSources,
|
||||
logoOptions,
|
||||
accountSummary,
|
||||
estabelecimentos,
|
||||
] = await Promise.all([
|
||||
fetchLancamentoFilterSources(userId),
|
||||
loadLogoOptions(),
|
||||
fetchAccountSummary(userId, contaId, selectedPeriod),
|
||||
getRecentEstablishmentsAction(),
|
||||
]);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||
const [filterSources, logoOptions, accountSummary, estabelecimentos] =
|
||||
await Promise.all([
|
||||
fetchLancamentoFilterSources(userId),
|
||||
loadLogoOptions(),
|
||||
fetchAccountSummary(userId, contaId, selectedPeriod),
|
||||
getRecentEstablishmentsAction(),
|
||||
]);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||
|
||||
const filters = buildLancamentoWhere({
|
||||
userId,
|
||||
period: selectedPeriod,
|
||||
filters: searchFilters,
|
||||
slugMaps,
|
||||
accountId: account.id,
|
||||
});
|
||||
const filters = buildLancamentoWhere({
|
||||
userId,
|
||||
period: selectedPeriod,
|
||||
filters: searchFilters,
|
||||
slugMaps,
|
||||
accountId: account.id,
|
||||
});
|
||||
|
||||
filters.push(eq(lancamentos.isSettled, true));
|
||||
const lancamentoRows = await fetchAccountLancamentos(filters);
|
||||
|
||||
const lancamentoRows = await db.query.lancamentos.findMany({
|
||||
where: and(...filters),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: desc(lancamentos.purchaseDate),
|
||||
});
|
||||
const lancamentosData = mapLancamentosData(lancamentoRows);
|
||||
|
||||
const lancamentosData = mapLancamentosData(lancamentoRows);
|
||||
const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
|
||||
accountSummary;
|
||||
|
||||
const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
|
||||
accountSummary;
|
||||
const periodLabel = `${capitalize(monthName)} de ${year}`;
|
||||
|
||||
const periodLabel = `${capitalize(monthName)} de ${year}`;
|
||||
const accountDialogData: Account = {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType: account.accountType,
|
||||
status: account.status,
|
||||
note: account.note,
|
||||
logo: account.logo,
|
||||
initialBalance: Number(account.initialBalance ?? 0),
|
||||
balance: currentBalance,
|
||||
};
|
||||
|
||||
const accountDialogData: Account = {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType: account.accountType,
|
||||
status: account.status,
|
||||
note: account.note,
|
||||
logo: account.logo,
|
||||
initialBalance: Number(account.initialBalance ?? 0),
|
||||
balance: currentBalance,
|
||||
};
|
||||
const {
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
pagadorFilterOptions,
|
||||
categoriaFilterOptions,
|
||||
contaCartaoFilterOptions,
|
||||
} = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
pagadorRows: filterSources.pagadorRows,
|
||||
limitContaId: account.id,
|
||||
});
|
||||
|
||||
const {
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
pagadorFilterOptions,
|
||||
categoriaFilterOptions,
|
||||
contaCartaoFilterOptions,
|
||||
} = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
pagadorRows: filterSources.pagadorRows,
|
||||
limitContaId: account.id,
|
||||
});
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthNavigation />
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthNavigation />
|
||||
<AccountStatementCard
|
||||
accountName={account.name}
|
||||
accountType={account.accountType}
|
||||
status={account.status}
|
||||
periodLabel={periodLabel}
|
||||
openingBalance={openingBalance}
|
||||
currentBalance={currentBalance}
|
||||
totalIncomes={totalIncomes}
|
||||
totalExpenses={totalExpenses}
|
||||
logo={account.logo}
|
||||
actions={
|
||||
<AccountDialog
|
||||
mode="update"
|
||||
account={accountDialogData}
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Editar conta"
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<AccountStatementCard
|
||||
accountName={account.name}
|
||||
accountType={account.accountType}
|
||||
status={account.status}
|
||||
periodLabel={periodLabel}
|
||||
openingBalance={openingBalance}
|
||||
currentBalance={currentBalance}
|
||||
totalIncomes={totalIncomes}
|
||||
totalExpenses={totalExpenses}
|
||||
logo={account.logo}
|
||||
actions={
|
||||
<AccountDialog
|
||||
mode="update"
|
||||
account={accountDialogData}
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Editar conta"
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
currentUserId={userId}
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={false}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
currentUserId={userId}
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={false}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,72 +1,75 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import {
|
||||
INITIAL_BALANCE_CATEGORY_NAME,
|
||||
INITIAL_BALANCE_CONDITION,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
INITIAL_BALANCE_CATEGORY_NAME,
|
||||
INITIAL_BALANCE_CONDITION,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
|
||||
import { revalidateForEntity } from "@/lib/actions/helpers";
|
||||
import {
|
||||
type ActionResult,
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/lib/actions/helpers";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||
import {
|
||||
TRANSFER_CATEGORY_NAME,
|
||||
TRANSFER_CONDITION,
|
||||
TRANSFER_ESTABLISHMENT,
|
||||
TRANSFER_PAYMENT_METHOD,
|
||||
} from "@/lib/transferencias/constants";
|
||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
||||
import { getTodayInfo } from "@/lib/utils/date";
|
||||
import { normalizeFilePath } from "@/lib/utils/string";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import {
|
||||
TRANSFER_CATEGORY_NAME,
|
||||
TRANSFER_CONDITION,
|
||||
TRANSFER_ESTABLISHMENT,
|
||||
TRANSFER_PAYMENT_METHOD,
|
||||
} from "@/lib/transferencias/constants";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
const accountBaseSchema = z.object({
|
||||
name: z
|
||||
.string({ message: "Informe o nome da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o nome da conta."),
|
||||
accountType: z
|
||||
.string({ message: "Informe o tipo da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o tipo da conta."),
|
||||
status: z
|
||||
.string({ message: "Informe o status da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o status da conta."),
|
||||
note: noteSchema,
|
||||
logo: z
|
||||
.string({ message: "Selecione um logo." })
|
||||
.trim()
|
||||
.min(1, "Selecione um logo."),
|
||||
initialBalance: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um saldo inicial válido."
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value)),
|
||||
excludeFromBalance: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((value) => value === true || value === "true"),
|
||||
excludeInitialBalanceFromIncome: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((value) => value === true || value === "true"),
|
||||
name: z
|
||||
.string({ message: "Informe o nome da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o nome da conta."),
|
||||
accountType: z
|
||||
.string({ message: "Informe o tipo da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o tipo da conta."),
|
||||
status: z
|
||||
.string({ message: "Informe o status da conta." })
|
||||
.trim()
|
||||
.min(1, "Informe o status da conta."),
|
||||
note: noteSchema,
|
||||
logo: z
|
||||
.string({ message: "Selecione um logo." })
|
||||
.trim()
|
||||
.min(1, "Selecione um logo."),
|
||||
initialBalance: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um saldo inicial válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value)),
|
||||
excludeFromBalance: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((value) => value === true || value === "true"),
|
||||
excludeInitialBalanceFromIncome: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((value) => value === true || value === "true"),
|
||||
});
|
||||
|
||||
const createAccountSchema = accountBaseSchema;
|
||||
const updateAccountSchema = accountBaseSchema.extend({
|
||||
id: uuidSchema("Conta"),
|
||||
id: uuidSchema("Conta"),
|
||||
});
|
||||
const deleteAccountSchema = z.object({
|
||||
id: uuidSchema("Conta"),
|
||||
id: uuidSchema("Conta"),
|
||||
});
|
||||
|
||||
type AccountCreateInput = z.infer<typeof createAccountSchema>;
|
||||
@@ -74,315 +77,315 @@ type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
|
||||
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
|
||||
|
||||
export async function createAccountAction(
|
||||
input: AccountCreateInput
|
||||
input: AccountCreateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createAccountSchema.parse(input);
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createAccountSchema.parse(input);
|
||||
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
|
||||
const normalizedInitialBalance = Math.abs(data.initialBalance);
|
||||
const hasInitialBalance = normalizedInitialBalance > 0;
|
||||
const normalizedInitialBalance = Math.abs(data.initialBalance);
|
||||
const hasInitialBalance = normalizedInitialBalance > 0;
|
||||
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
const [createdAccount] = await tx
|
||||
.insert(contas)
|
||||
.values({
|
||||
name: data.name,
|
||||
accountType: data.accountType,
|
||||
status: data.status,
|
||||
note: data.note ?? null,
|
||||
logo: logoFile,
|
||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||
excludeFromBalance: data.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||
userId: user.id,
|
||||
})
|
||||
.returning({ id: contas.id, name: contas.name });
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
const [createdAccount] = await tx
|
||||
.insert(contas)
|
||||
.values({
|
||||
name: data.name,
|
||||
accountType: data.accountType,
|
||||
status: data.status,
|
||||
note: data.note ?? null,
|
||||
logo: logoFile,
|
||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||
excludeFromBalance: data.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||
userId: user.id,
|
||||
})
|
||||
.returning({ id: contas.id, name: contas.name });
|
||||
|
||||
if (!createdAccount) {
|
||||
throw new Error("Não foi possível criar a conta.");
|
||||
}
|
||||
if (!createdAccount) {
|
||||
throw new Error("Não foi possível criar a conta.");
|
||||
}
|
||||
|
||||
if (!hasInitialBalance) {
|
||||
return;
|
||||
}
|
||||
if (!hasInitialBalance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [category, adminPagador] = await Promise.all([
|
||||
tx.query.categorias.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME)
|
||||
),
|
||||
}),
|
||||
tx.query.pagadores.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
),
|
||||
}),
|
||||
]);
|
||||
const [category, adminPagador] = await Promise.all([
|
||||
tx.query.categorias.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME),
|
||||
),
|
||||
}),
|
||||
tx.query.pagadores.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!category) {
|
||||
throw new Error(
|
||||
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.'
|
||||
);
|
||||
}
|
||||
if (!category) {
|
||||
throw new Error(
|
||||
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial."
|
||||
);
|
||||
}
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
|
||||
);
|
||||
}
|
||||
|
||||
const { date, period } = getTodayInfo();
|
||||
const { date, period } = getTodayInfo();
|
||||
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: INITIAL_BALANCE_CONDITION,
|
||||
name: `Saldo inicial - ${createdAccount.name}`,
|
||||
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
note: INITIAL_BALANCE_NOTE,
|
||||
amount: formatDecimalForDbRequired(normalizedInitialBalance),
|
||||
purchaseDate: date,
|
||||
transactionType: INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: createdAccount.id,
|
||||
categoriaId: category.id,
|
||||
pagadorId: adminPagador.id,
|
||||
});
|
||||
});
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: INITIAL_BALANCE_CONDITION,
|
||||
name: `Saldo inicial - ${createdAccount.name}`,
|
||||
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
|
||||
note: INITIAL_BALANCE_NOTE,
|
||||
amount: formatDecimalForDbRequired(normalizedInitialBalance),
|
||||
purchaseDate: date,
|
||||
transactionType: INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||
period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: createdAccount.id,
|
||||
categoriaId: category.id,
|
||||
pagadorId: adminPagador.id,
|
||||
});
|
||||
});
|
||||
|
||||
revalidateForEntity("contas");
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta criada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta criada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAccountAction(
|
||||
input: AccountUpdateInput
|
||||
input: AccountUpdateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateAccountSchema.parse(input);
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateAccountSchema.parse(input);
|
||||
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
|
||||
const [updated] = await db
|
||||
.update(contas)
|
||||
.set({
|
||||
name: data.name,
|
||||
accountType: data.accountType,
|
||||
status: data.status,
|
||||
note: data.note ?? null,
|
||||
logo: logoFile,
|
||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||
excludeFromBalance: data.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||
})
|
||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||
.returning();
|
||||
const [updated] = await db
|
||||
.update(contas)
|
||||
.set({
|
||||
name: data.name,
|
||||
accountType: data.accountType,
|
||||
status: data.status,
|
||||
note: data.note ?? null,
|
||||
logo: logoFile,
|
||||
initialBalance: formatDecimalForDbRequired(data.initialBalance),
|
||||
excludeFromBalance: data.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
|
||||
})
|
||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("contas");
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta atualizada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta atualizada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccountAction(
|
||||
input: AccountDeleteInput
|
||||
input: AccountDeleteInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = deleteAccountSchema.parse(input);
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = deleteAccountSchema.parse(input);
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(contas)
|
||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||
.returning({ id: contas.id });
|
||||
const [deleted] = await db
|
||||
.delete(contas)
|
||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||
.returning({ id: contas.id });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("contas");
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta removida com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta removida com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer between accounts
|
||||
const transferSchema = z.object({
|
||||
fromAccountId: uuidSchema("Conta de origem"),
|
||||
toAccountId: uuidSchema("Conta de destino"),
|
||||
amount: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um valor válido."
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value))
|
||||
.refine((value) => value > 0, "O valor deve ser maior que zero."),
|
||||
date: z.coerce.date({ message: "Informe uma data válida." }),
|
||||
period: z
|
||||
.string({ message: "Informe o período." })
|
||||
.trim()
|
||||
.min(1, "Informe o período."),
|
||||
fromAccountId: uuidSchema("Conta de origem"),
|
||||
toAccountId: uuidSchema("Conta de destino"),
|
||||
amount: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
|
||||
.refine(
|
||||
(value) => !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um valor válido.",
|
||||
)
|
||||
.transform((value) => Number.parseFloat(value))
|
||||
.refine((value) => value > 0, "O valor deve ser maior que zero."),
|
||||
date: z.coerce.date({ message: "Informe uma data válida." }),
|
||||
period: z
|
||||
.string({ message: "Informe o período." })
|
||||
.trim()
|
||||
.min(1, "Informe o período."),
|
||||
});
|
||||
|
||||
type TransferInput = z.infer<typeof transferSchema>;
|
||||
|
||||
export async function transferBetweenAccountsAction(
|
||||
input: TransferInput
|
||||
input: TransferInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = transferSchema.parse(input);
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = transferSchema.parse(input);
|
||||
|
||||
// Validate that accounts are different
|
||||
if (data.fromAccountId === data.toAccountId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "A conta de origem e destino devem ser diferentes.",
|
||||
};
|
||||
}
|
||||
// Validate that accounts are different
|
||||
if (data.fromAccountId === data.toAccountId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "A conta de origem e destino devem ser diferentes.",
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a unique transfer ID to link both transactions
|
||||
const transferId = crypto.randomUUID();
|
||||
// Generate a unique transfer ID to link both transactions
|
||||
const transferId = crypto.randomUUID();
|
||||
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
// Verify both accounts exist and belong to the user
|
||||
const [fromAccount, toAccount] = await Promise.all([
|
||||
tx.query.contas.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.fromAccountId),
|
||||
eq(contas.userId, user.id)
|
||||
),
|
||||
}),
|
||||
tx.query.contas.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.toAccountId),
|
||||
eq(contas.userId, user.id)
|
||||
),
|
||||
}),
|
||||
]);
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
// Verify both accounts exist and belong to the user
|
||||
const [fromAccount, toAccount] = await Promise.all([
|
||||
tx.query.contas.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.fromAccountId),
|
||||
eq(contas.userId, user.id),
|
||||
),
|
||||
}),
|
||||
tx.query.contas.findFirst({
|
||||
columns: { id: true, name: true },
|
||||
where: and(
|
||||
eq(contas.id, data.toAccountId),
|
||||
eq(contas.userId, user.id),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!fromAccount) {
|
||||
throw new Error("Conta de origem não encontrada.");
|
||||
}
|
||||
if (!fromAccount) {
|
||||
throw new Error("Conta de origem não encontrada.");
|
||||
}
|
||||
|
||||
if (!toAccount) {
|
||||
throw new Error("Conta de destino não encontrada.");
|
||||
}
|
||||
if (!toAccount) {
|
||||
throw new Error("Conta de destino não encontrada.");
|
||||
}
|
||||
|
||||
// Get the transfer category
|
||||
const transferCategory = await tx.query.categorias.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, TRANSFER_CATEGORY_NAME)
|
||||
),
|
||||
});
|
||||
// Get the transfer category
|
||||
const transferCategory = await tx.query.categorias.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(categorias.userId, user.id),
|
||||
eq(categorias.name, TRANSFER_CATEGORY_NAME),
|
||||
),
|
||||
});
|
||||
|
||||
if (!transferCategory) {
|
||||
throw new Error(
|
||||
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`
|
||||
);
|
||||
}
|
||||
if (!transferCategory) {
|
||||
throw new Error(
|
||||
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the admin payer
|
||||
const adminPagador = await tx.query.pagadores.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
|
||||
),
|
||||
});
|
||||
// Get the admin payer
|
||||
const adminPagador = await tx.query.pagadores.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(pagadores.userId, user.id),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
});
|
||||
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador administrador não encontrado. Por favor, crie um pagador admin."
|
||||
);
|
||||
}
|
||||
if (!adminPagador) {
|
||||
throw new Error(
|
||||
"Pagador administrador não encontrado. Por favor, crie um pagador admin.",
|
||||
);
|
||||
}
|
||||
|
||||
// Create outgoing transaction (transfer from source account)
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: TRANSFER_CONDITION,
|
||||
name: `${TRANSFER_ESTABLISHMENT} → ${toAccount.name}`,
|
||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||
note: `Transferência para ${toAccount.name}`,
|
||||
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
|
||||
purchaseDate: data.date,
|
||||
transactionType: "Transferência",
|
||||
period: data.period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: fromAccount.id,
|
||||
categoriaId: transferCategory.id,
|
||||
pagadorId: adminPagador.id,
|
||||
transferId,
|
||||
});
|
||||
// Create outgoing transaction (transfer from source account)
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: TRANSFER_CONDITION,
|
||||
name: `${TRANSFER_ESTABLISHMENT} → ${toAccount.name}`,
|
||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||
note: `Transferência para ${toAccount.name}`,
|
||||
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
|
||||
purchaseDate: data.date,
|
||||
transactionType: "Transferência",
|
||||
period: data.period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: fromAccount.id,
|
||||
categoriaId: transferCategory.id,
|
||||
pagadorId: adminPagador.id,
|
||||
transferId,
|
||||
});
|
||||
|
||||
// Create incoming transaction (transfer to destination account)
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: TRANSFER_CONDITION,
|
||||
name: `${TRANSFER_ESTABLISHMENT} ← ${fromAccount.name}`,
|
||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||
note: `Transferência de ${fromAccount.name}`,
|
||||
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
|
||||
purchaseDate: data.date,
|
||||
transactionType: "Transferência",
|
||||
period: data.period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: toAccount.id,
|
||||
categoriaId: transferCategory.id,
|
||||
pagadorId: adminPagador.id,
|
||||
transferId,
|
||||
});
|
||||
});
|
||||
// Create incoming transaction (transfer to destination account)
|
||||
await tx.insert(lancamentos).values({
|
||||
condition: TRANSFER_CONDITION,
|
||||
name: `${TRANSFER_ESTABLISHMENT} ← ${fromAccount.name}`,
|
||||
paymentMethod: TRANSFER_PAYMENT_METHOD,
|
||||
note: `Transferência de ${fromAccount.name}`,
|
||||
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
|
||||
purchaseDate: data.date,
|
||||
transactionType: "Transferência",
|
||||
period: data.period,
|
||||
isSettled: true,
|
||||
userId: user.id,
|
||||
contaId: toAccount.id,
|
||||
categoriaId: transferCategory.id,
|
||||
pagadorId: adminPagador.id,
|
||||
transferId,
|
||||
});
|
||||
});
|
||||
|
||||
revalidateForEntity("contas");
|
||||
revalidateForEntity("lancamentos");
|
||||
revalidateForEntity("contas");
|
||||
revalidateForEntity("lancamentos");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Transferência registrada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "Transferência registrada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { and, eq, ilike, not, sql } from "drizzle-orm";
|
||||
import { contas, lancamentos, pagadores } from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { loadLogoOptions } from "@/lib/logo/options";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { and, eq, ilike, not, sql } from "drizzle-orm";
|
||||
|
||||
export type AccountData = {
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
initialBalance: number;
|
||||
balance: number;
|
||||
excludeFromBalance: boolean;
|
||||
excludeInitialBalanceFromIncome: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
accountType: string;
|
||||
status: string;
|
||||
note: string | null;
|
||||
logo: string | null;
|
||||
initialBalance: number;
|
||||
balance: number;
|
||||
excludeFromBalance: boolean;
|
||||
excludeInitialBalanceFromIncome: boolean;
|
||||
};
|
||||
|
||||
export async function fetchAccountsForUser(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
|
||||
const [accountRows, logoOptions] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
note: contas.note,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
|
||||
balanceMovements: sql<number>`
|
||||
const [accountRows, logoOptions] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
note: contas.note,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
|
||||
balanceMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -44,72 +44,72 @@ export async function fetchAccountsForUser(
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true)
|
||||
)
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
not(ilike(contas.status, "inativa")),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.note,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
contas.excludeInitialBalanceFromIncome
|
||||
),
|
||||
loadLogoOptions(),
|
||||
]);
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true),
|
||||
),
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
not(ilike(contas.status, "inativa")),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.note,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
contas.excludeInitialBalanceFromIncome,
|
||||
),
|
||||
loadLogoOptions(),
|
||||
]);
|
||||
|
||||
const accounts = accountRows.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType: account.accountType,
|
||||
status: account.status,
|
||||
note: account.note,
|
||||
logo: account.logo,
|
||||
initialBalance: Number(account.initialBalance ?? 0),
|
||||
balance:
|
||||
Number(account.initialBalance ?? 0) +
|
||||
Number(account.balanceMovements ?? 0),
|
||||
excludeFromBalance: account.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
|
||||
}));
|
||||
const accounts = accountRows.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType: account.accountType,
|
||||
status: account.status,
|
||||
note: account.note,
|
||||
logo: account.logo,
|
||||
initialBalance: Number(account.initialBalance ?? 0),
|
||||
balance:
|
||||
Number(account.initialBalance ?? 0) +
|
||||
Number(account.balanceMovements ?? 0),
|
||||
excludeFromBalance: account.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
|
||||
}));
|
||||
|
||||
return { accounts, logoOptions };
|
||||
return { accounts, logoOptions };
|
||||
}
|
||||
|
||||
export async function fetchInativosForUser(
|
||||
userId: string
|
||||
userId: string,
|
||||
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
|
||||
const [accountRows, logoOptions] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
note: contas.note,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
|
||||
balanceMovements: sql<number>`
|
||||
const [accountRows, logoOptions] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: contas.id,
|
||||
name: contas.name,
|
||||
accountType: contas.accountType,
|
||||
status: contas.status,
|
||||
note: contas.note,
|
||||
logo: contas.logo,
|
||||
initialBalance: contas.initialBalance,
|
||||
excludeFromBalance: contas.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
|
||||
balanceMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
@@ -120,52 +120,52 @@ export async function fetchInativosForUser(
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true)
|
||||
)
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
ilike(contas.status, "inativa"),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.note,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
contas.excludeInitialBalanceFromIncome
|
||||
),
|
||||
loadLogoOptions(),
|
||||
]);
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.isSettled, true),
|
||||
),
|
||||
)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
ilike(contas.status, "inativa"),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.note,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance,
|
||||
contas.excludeInitialBalanceFromIncome,
|
||||
),
|
||||
loadLogoOptions(),
|
||||
]);
|
||||
|
||||
const accounts = accountRows.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType: account.accountType,
|
||||
status: account.status,
|
||||
note: account.note,
|
||||
logo: account.logo,
|
||||
initialBalance: Number(account.initialBalance ?? 0),
|
||||
balance:
|
||||
Number(account.initialBalance ?? 0) +
|
||||
Number(account.balanceMovements ?? 0),
|
||||
excludeFromBalance: account.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
|
||||
}));
|
||||
const accounts = accountRows.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType: account.accountType,
|
||||
status: account.status,
|
||||
note: account.note,
|
||||
logo: account.logo,
|
||||
initialBalance: Number(account.initialBalance ?? 0),
|
||||
balance:
|
||||
Number(account.initialBalance ?? 0) +
|
||||
Number(account.balanceMovements ?? 0),
|
||||
excludeFromBalance: account.excludeFromBalance,
|
||||
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
|
||||
}));
|
||||
|
||||
return { accounts, logoOptions };
|
||||
return { accounts, logoOptions };
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchInativosForUser } from "../data";
|
||||
|
||||
export default async function InativosPage() {
|
||||
const userId = await getUserId();
|
||||
const { accounts, logoOptions } = await fetchInativosForUser(userId);
|
||||
const userId = await getUserId();
|
||||
const { accounts, logoOptions } = await fetchInativosForUser(userId);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<AccountsPage accounts={accounts} logoOptions={logoOptions} isInativos={true} />
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<AccountsPage
|
||||
accounts={accounts}
|
||||
logoOptions={logoOptions}
|
||||
isInativos={true}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiBankLine } from "@remixicon/react";
|
||||
import PageDescription from "@/components/page-description";
|
||||
|
||||
export const metadata = {
|
||||
title: "Contas | Opensheets",
|
||||
title: "Contas | Opensheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiBankLine />}
|
||||
title="Contas"
|
||||
subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas,
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiBankLine />}
|
||||
title="Contas"
|
||||
subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas,
|
||||
despesas e transações previstas. Use o seletor abaixo para navegar pelos
|
||||
meses e visualizar as movimentações correspondentes."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,33 +4,33 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
* Loading state para a página de contas
|
||||
*/
|
||||
export default function ContasLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Grid de contas */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
{/* Grid de contas */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@ import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchAccountsForUser } from "./data";
|
||||
|
||||
export default async function Page() {
|
||||
const userId = await getUserId();
|
||||
const now = new Date();
|
||||
const userId = await getUserId();
|
||||
const { accounts, logoOptions } = await fetchAccountsForUser(userId);
|
||||
|
||||
const { accounts, logoOptions } = await fetchAccountsForUser(userId);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<AccountsPage accounts={accounts} logoOptions={logoOptions} />
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<AccountsPage accounts={accounts} logoOptions={logoOptions} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user