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:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -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),
});
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
}
}

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}