refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,11 @@
import { LoginForm } from "@/features/auth/components/login-form";
export default function LoginPage() {
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { SignupForm } from "@/features/auth/components/signup-form";
export default function Page() {
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<SignupForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import {
AccountStatementCardSkeleton,
FilterSkeleton,
TransactionsTableSkeleton,
} from "@/shared/components/skeletons";
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de extrato de conta
* 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" />
{/* Account Statement Card */}
<AccountStatementCardSkeleton />
{/* Seção de lançamentos */}
<section className="flex flex-col gap-4">
<div className="space-y-6 pt-4">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
</div>
{/* Filtros */}
<FilterSkeleton />
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,179 @@
import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation";
import { AccountDialog } from "@/features/accounts/components/account-dialog";
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
import type { Account } from "@/features/accounts/components/types";
import {
fetchAccountData,
fetchAccountLancamentos,
fetchAccountSummary,
} from "@/features/accounts/statement-queries";
import { fetchUserPreferences } from "@/features/settings/queries";
import { LancamentosPage as LancamentosSection } from "@/features/transactions/components/page/transactions-page";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
} from "@/features/transactions/page-helpers";
import {
fetchLancamentoFilterSources,
fetchRecentEstablishments,
} from "@/features/transactions/queries";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { Button } from "@/shared/components/ui/button";
import { getUserId } from "@/shared/lib/auth/server";
import { loadLogoOptions } from "@/shared/lib/logo/options";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
params: Promise<{ accountId: string }>;
searchParams?: PageSearchParams;
};
const capitalize = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
export default async function Page({ params, searchParams }: PageProps) {
const { accountId: 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 searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const account = await fetchAccountData(userId, contaId);
if (!account) {
notFound();
}
const [
filterSources,
logoOptions,
accountSummary,
estabelecimentos,
userPreferences,
] = await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchAccountSummary(userId, contaId, selectedPeriod),
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
accountId: account.id,
});
const lancamentoRows = await fetchAccountLancamentos(filters);
const lancamentosData = mapLancamentosData(lancamentoRows);
const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
accountSummary;
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 {
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 />
<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}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
/>
</section>
</main>
);
}

View File

@@ -0,0 +1,25 @@
import { RiBankLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Contas | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<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>
);
}

View File

@@ -0,0 +1,33 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function ContasLoading() {
return (
<main className="flex flex-col gap-6">
<div className="space-y-6 pt-4">
{/* 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>
);
}

View File

@@ -0,0 +1,19 @@
import { AccountsPage } from "@/features/accounts/components/accounts-page";
import { fetchAllAccountsForUser } from "@/features/accounts/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
const userId = await getUserId();
const { activeAccounts, archivedAccounts, logoOptions } =
await fetchAllAccountsForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<AccountsPage
accounts={activeAccounts}
archivedAccounts={archivedAccounts}
logoOptions={logoOptions}
/>
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiBarChart2Line } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Orçamentos | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiBarChart2Line />}
title="Orçamentos"
subtitle="Gerencie seus orçamentos mensais por categorias. Acompanhe o progresso do seu orçamento e faça ajustes conforme necessário."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,65 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de orçamentos
* Layout: MonthPicker + Header + Grid de cards de orçamento
*/
export default function OrcamentosLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
<div className="space-y-6 pt-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de cards de orçamentos */}
<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">
{/* Categoria com ícone */}
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Valor orçado */}
<div className="space-y-2 pt-4 border-t">
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
<Skeleton className="h-7 w-32 rounded-2xl bg-foreground/10" />
</div>
{/* Valor gasto */}
<div className="space-y-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-28 rounded-2xl bg-foreground/10" />
</div>
{/* Barra de progresso */}
<div className="space-y-2">
<Skeleton className="h-2 w-full rounded-full bg-foreground/10" />
<Skeleton className="h-3 w-16 rounded-2xl bg-foreground/10" />
</div>
{/* Botões de ação */}
<div className="flex gap-2 pt-2">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,54 @@
import { BudgetsPage } from "@/features/budgets/components/budgets-page";
import { fetchBudgetsForUser } from "@/features/budgets/queries";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
const capitalize = (value: string) =>
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName: rawMonthName,
year,
} = parsePeriodParam(periodoParam);
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
userId,
selectedPeriod,
);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<BudgetsPage
budgets={budgets}
categories={categoriesOptions}
selectedPeriod={selectedPeriod}
periodLabel={periodLabel}
/>
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiCalendarEventLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Calendário | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiCalendarEventLine />}
title="Calendário"
subtitle="Visualize lançamentos, vencimentos de cartões e boletos em um só lugar. Clique em uma data para detalhar ou criar um novo lançamento."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,59 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de calendário
* Layout: MonthPicker + Grade mensal 7x5/6 com dias e eventos
*/
export default function CalendarioLoading() {
return (
<main className="flex flex-col gap-3">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Calendar Container */}
<div className="rounded-2xl border p-4 space-y-4">
{/* Cabeçalho com dias da semana */}
<div className="grid grid-cols-7 gap-2 mb-4">
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
<div key={day} className="text-center">
<Skeleton className="h-4 w-12 mx-auto rounded-2xl bg-foreground/10" />
</div>
))}
</div>
{/* Grade de dias (6 semanas) */}
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 42 }).map((_, i) => (
<div
key={i}
className="min-h-[100px] rounded-2xl border p-2 space-y-2"
>
{/* Número do dia */}
<Skeleton className="h-5 w-6 rounded-2xl bg-foreground/10" />
{/* Indicadores de eventos (aleatório entre 0-3) */}
{i % 3 === 0 && (
<div className="space-y-1">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
{i % 5 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
</div>
)}
</div>
))}
</div>
{/* Legenda */}
<div className="flex flex-wrap items-center gap-4 pt-4 border-t">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="size-3 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,46 @@
import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar";
import { fetchCalendarData } from "@/features/calendar/queries";
import {
getSingleParam,
type ResolvedSearchParams,
} from "@/features/transactions/page-helpers";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUserId } from "@/shared/lib/auth/server";
import type { CalendarPeriod } from "@/shared/lib/types/calendar";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
const resolvedParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedParams, "periodo");
const { period, monthName, year } = parsePeriodParam(periodoParam);
const calendarData = await fetchCalendarData({
userId,
period,
});
const calendarPeriod: CalendarPeriod = {
period,
monthName,
year,
};
return (
<main className="flex flex-col gap-3">
<MonthNavigation />
<MonthlyCalendar
period={calendarPeriod}
events={calendarData.events}
formOptions={calendarData.formOptions}
/>
</main>
);
}

View File

@@ -0,0 +1,41 @@
import {
FilterSkeleton,
InvoiceSummaryCardSkeleton,
TransactionsTableSkeleton,
} from "@/shared/components/skeletons";
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de fatura de cartão
* Layout: MonthPicker + InvoiceSummaryCard + Filtros + Tabela de lançamentos
*/
export default function FaturaLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Invoice Summary Card */}
<section className="flex flex-col gap-4">
<InvoiceSummaryCardSkeleton />
</section>
{/* Seção de lançamentos */}
<section className="flex flex-col gap-4">
<div className="space-y-6 pt-4">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Filtros */}
<FilterSkeleton />
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,209 @@
import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation";
import type { Conta } from "@/db/schema";
import { CardDialog } from "@/features/cards/components/card-dialog";
import type { Card } from "@/features/cards/components/types";
import { InvoiceSummaryCard } from "@/features/invoices/components/invoice-summary-card";
import {
fetchCardData,
fetchCardLancamentos,
fetchInvoiceData,
} from "@/features/invoices/queries";
import { fetchUserPreferences } from "@/features/settings/queries";
import { LancamentosPage as LancamentosSection } from "@/features/transactions/components/page/transactions-page";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
} from "@/features/transactions/page-helpers";
import {
fetchLancamentoFilterSources,
fetchRecentEstablishments,
} from "@/features/transactions/queries";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { Button } from "@/shared/components/ui/button";
import { getUserId } from "@/shared/lib/auth/server";
import { loadLogoOptions } from "@/shared/lib/logo/options";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
params: Promise<{ cardId: string }>;
searchParams?: PageSearchParams;
};
export default async function Page({ params, searchParams }: PageProps) {
const { cardId: cartaoId } = 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 searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const card = await fetchCardData(userId, cartaoId);
if (!card) {
notFound();
}
const [
filterSources,
logoOptions,
invoiceData,
estabelecimentos,
userPreferences,
] = await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchInvoiceData(userId, cartaoId, selectedPeriod),
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
cardId: card.id,
});
const lancamentoRows = await fetchCardLancamentos(filters);
const lancamentosData = mapLancamentosData(lancamentoRows);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
limitCartaoId: card.id,
});
const accountOptions = filterSources.contaRows.map((conta: Conta) => ({
id: conta.id,
name: conta.name ?? "Conta",
logo: conta.logo ?? null,
}));
const contaName =
filterSources.contaRows.find((conta: Conta) => conta.id === card.contaId)
?.name ?? "Conta";
const cardDialogData: Card = {
id: card.id,
name: card.name,
brand: card.brand ?? "",
status: card.status ?? "",
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note ?? null,
logo: card.logo,
limit:
card.limit !== null && card.limit !== undefined
? Number(card.limit)
: null,
contaId: card.contaId,
contaName,
limitInUse: null,
limitAvailable: null,
};
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
const limitAmount =
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
1,
)} de ${year}`;
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<section className="flex flex-col gap-4">
<InvoiceSummaryCard
cartaoId={card.id}
period={selectedPeriod}
cardName={card.name}
cardBrand={card.brand ?? null}
cardStatus={card.status ?? null}
closingDay={card.closingDay}
dueDay={card.dueDay}
periodLabel={periodLabel}
totalAmount={totalAmount}
limitAmount={limitAmount}
invoiceStatus={invoiceStatus}
paymentDate={paymentDate}
logo={card.logo}
actions={
<CardDialog
mode="update"
card={cardDialogData}
logoOptions={logoOptions}
accounts={accountOptions}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Editar cartão"
>
<RiPencilLine className="size-4" />
</Button>
}
/>
}
/>
</section>
<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
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
defaultCartaoId={card.id}
defaultPaymentMethod="Cartão de crédito"
lockCartaoSelection
lockPaymentMethod
/>
</section>
</main>
);
}

View File

@@ -0,0 +1,25 @@
import { RiBankCard2Line } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Cartões | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiBankCard2Line />}
title="Cartões"
subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites
e transações previstas. Use o seletor abaixo para navegar pelos meses e
visualizar as movimentações correspondentes."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,30 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function CartoesLoading() {
return (
<main className="flex flex-col gap-6">
<div className="space-y-6 pt-4">
{/* 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 cartões */}
<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" />
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,20 @@
import { CardsPage } from "@/features/cards/components/cards-page";
import { fetchAllCardsForUser } from "@/features/cards/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
const userId = await getUserId();
const { activeCards, archivedCards, accounts, logoOptions } =
await fetchAllCardsForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<CardsPage
cards={activeCards}
archivedCards={archivedCards}
accounts={accounts}
logoOptions={logoOptions}
/>
</main>
);
}

View File

@@ -0,0 +1,105 @@
import { notFound } from "next/navigation";
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
import { fetchUserPreferences } from "@/features/settings/queries";
import { LancamentosPage } from "@/features/transactions/components/page/transactions-page";
import {
buildOptionSets,
buildSluggedFilters,
} from "@/features/transactions/page-helpers";
import {
fetchLancamentoFilterSources,
fetchRecentEstablishments,
} from "@/features/transactions/queries";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUserId } from "@/shared/lib/auth/server";
import { displayPeriod, parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
params: Promise<{ categoryId: string }>;
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ params, searchParams }: PageProps) {
const { categoryId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [detail, filterSources, estabelecimentos, userPreferences] =
await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchLancamentoFilterSources(userId),
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
if (!detail) {
notFound();
}
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const currentPeriodLabel = displayPeriod(detail.period);
const previousPeriodLabel = displayPeriod(detail.previousPeriod);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<CategoryDetailHeader
category={detail.category}
currentPeriodLabel={currentPeriodLabel}
previousPeriodLabel={previousPeriodLabel}
currentTotal={detail.currentTotal}
previousTotal={detail.previousTotal}
percentageChange={detail.percentageChange}
transactionCount={detail.transactions.length}
/>
<LancamentosPage
currentUserId={userId}
lancamentos={detail.transactions}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={detail.period}
estabelecimentos={estabelecimentos}
allowCreate={true}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
/>
</main>
);
}

View File

@@ -0,0 +1,33 @@
import { Card, CardContent } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-6 px-6">
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{/* Selected categories and counter */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-32 rounded-md" />
<Skeleton className="h-8 w-40 rounded-md" />
<Skeleton className="h-8 w-36 rounded-md" />
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-6 w-14" />
</div>
</div>
{/* Category selector button */}
<Skeleton className="h-9 w-full rounded-md" />
</div>
{/* Chart */}
<Skeleton className="h-[450px] w-full rounded-lg" />
</CardContent>
</Card>
</main>
);
}

View File

@@ -0,0 +1,17 @@
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
import { getUser } from "@/shared/lib/auth/server";
import { getCurrentPeriod } from "@/shared/utils/period";
export default async function HistoricoCategoriasPage() {
const user = await getUser();
const currentPeriod = getCurrentPeriod();
const data = await fetchCategoryHistory(user.id, currentPeriod);
return (
<main>
<CategoryHistoryWidget data={data} />
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiPriceTag3Line } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Categorias | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiPriceTag3Line />}
title="Categorias"
subtitle="Gerencie suas categorias de despesas e receitas, permitindo ajustes financeiros precisos conforme necessário."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,63 @@
import { Card, CardContent } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function CategoriasLoading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full 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>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2 border-b">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton
key={i}
className="h-10 w-32 rounded-t-2xl bg-foreground/10"
/>
))}
</div>
{/* Tabela de categorias */}
<Card className="py-2">
<CardContent className="px-2 py-4 sm:px-4">
<div className="space-y-0">
{/* Header da tabela */}
<div className="flex items-center gap-4 border-b px-2 pb-3">
<Skeleton className="size-5 rounded bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded bg-foreground/10" />
<div className="flex-1" />
<Skeleton className="h-4 w-14 rounded bg-foreground/10" />
</div>
{/* Linhas da tabela */}
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 border-b border-dashed px-2 py-3 last:border-b-0"
>
<Skeleton className="size-8 rounded-lg bg-foreground/10" />
<Skeleton
className="h-4 rounded bg-foreground/10"
style={{ width: `${100 + (i % 4) * 30}px` }}
/>
<div className="flex-1" />
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-14 rounded bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded bg-foreground/10" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,14 @@
import { CategoriesPage } from "@/features/categories/components/categories-page";
import { fetchCategoriesForUser } from "@/features/categories/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
const userId = await getUserId();
const categories = await fetchCategoriesForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<CategoriesPage categories={categories} />
</main>
);
}

View File

@@ -0,0 +1,19 @@
import { DashboardGridSkeleton } from "@/shared/components/skeletons";
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function DashboardLoading() {
return (
<main className="flex flex-col gap-4">
<div className="space-y-2 px-1 py-2">
<Skeleton className="h-8 w-72 rounded-xl bg-foreground/10" />
<Skeleton className="h-5 w-56 rounded-xl bg-foreground/10" />
</div>
{/* Month Picker skeleton */}
<Skeleton className="h-[56px] w-full rounded-xl bg-foreground/10" />
{/* Dashboard content skeleton (Section Cards + Widget Grid) */}
<DashboardGridSkeleton />
</main>
);
}

View File

@@ -0,0 +1,75 @@
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
import { triggerRecurringGeneration } from "@/features/recurring/trigger-recurring-generation";
import {
buildOptionSets,
buildSluggedFilters,
getSingleParam,
} from "@/features/transactions/page-helpers";
import {
fetchLancamentoFilterSources,
fetchRecentEstablishments,
} from "@/features/transactions/queries";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUser } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
const user = await getUser();
await triggerRecurringGeneration(user.id);
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [dashboardData, preferences, filterSources, estabelecimentos] =
await Promise.all([
fetchDashboardData(user.id, selectedPeriod),
fetchUserDashboardPreferences(user.id),
fetchLancamentoFilterSources(user.id),
fetchRecentEstablishments(user.id),
]);
const { dashboardWidgets } = preferences;
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
return (
<main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} />
<MonthNavigation />
<DashboardMetricsCards metrics={dashboardData.metrics} />
<DashboardGridEditable
data={dashboardData}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
quickActionOptions={{
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
}}
/>
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiAtLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Pré-Lançamentos | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiAtLine />}
title="Pré-Lançamentos"
subtitle="Notificações capturadas pelo Companion"
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,33 @@
import { Card } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="flex w-full flex-col gap-6">
<div className="flex justify-between">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-10 w-32" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="space-y-3">
<div className="flex justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
</Card>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,38 @@
import { InboxPage } from "@/features/inbox/components/inbox-page";
import {
fetchAppLogoMap,
fetchInboxDialogData,
fetchInboxItems,
} from "@/features/inbox/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
const userId = await getUserId();
const [pendingItems, processedItems, discardedItems, dialogData, appLogoMap] =
await Promise.all([
fetchInboxItems(userId, "pending"),
fetchInboxItems(userId, "processed"),
fetchInboxItems(userId, "discarded"),
fetchInboxDialogData(userId),
fetchAppLogoMap(userId),
]);
return (
<main className="flex flex-col items-start gap-6">
<InboxPage
pendingItems={pendingItems}
processedItems={processedItems}
discardedItems={discardedItems}
pagadorOptions={dialogData.pagadorOptions}
splitPagadorOptions={dialogData.splitPagadorOptions}
defaultPagadorId={dialogData.defaultPagadorId}
contaOptions={dialogData.contaOptions}
cartaoOptions={dialogData.cartaoOptions}
categoriaOptions={dialogData.categoriaOptions}
estabelecimentos={dialogData.estabelecimentos}
appLogoMap={appLogoMap}
/>
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiSparklingLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Insights | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiSparklingLine />}
title="Insights"
subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,39 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de insights com IA
*/
export default function InsightsLoading() {
return (
<main className="flex flex-col gap-6">
<div className="space-y-6 pt-4">
{/* Header */}
<div className="space-y-2">
<Skeleton className="h-10 w-64 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-96 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de insights */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-3/4 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="size-8 rounded-full bg-foreground/10" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-3 w-2/3 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,31 @@
import { InsightsPage } from "@/features/insights/components/insights-page";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<InsightsPage period={selectedPeriod} />
</main>
);
}

View File

@@ -0,0 +1,70 @@
import { fetchDashboardNotifications } from "@/features/dashboard/notifications-queries";
import { fetchPendingInboxCount } from "@/features/inbox/queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { FontProvider } from "@/shared/components/providers/font-provider";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { getUserSession } from "@/shared/lib/auth/server";
import { fetchPagadoresWithAccess } from "@/shared/lib/payers/access";
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { fetchUserFontPreferences } from "@/shared/lib/preferences/fonts";
import { parsePeriodParam } from "@/shared/utils/period";
export default async function DashboardLayout({
children,
searchParams,
}: Readonly<{
children: React.ReactNode;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}>) {
const session = await getUserSession();
const pagadoresList = await fetchPagadoresWithAccess(session.user.id);
// Encontrar o pagador admin do usuário
const adminPagador = pagadoresList.find(
(p) => p.role === PAGADOR_ROLE_ADMIN && p.userId === session.user.id,
);
// Buscar notificações para o período atual
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = resolvedSearchParams?.periodo;
const singlePeriodoParam =
typeof periodoParam === "string"
? periodoParam
: Array.isArray(periodoParam)
? periodoParam[0]
: null;
const { period: currentPeriod } = parsePeriodParam(
singlePeriodoParam ?? null,
);
// Buscar notificações, contagem de pré-lançamentos e preferências de fonte em paralelo
const [notificationsSnapshot, preLancamentosCount, fontPrefs] =
await Promise.all([
fetchDashboardNotifications(session.user.id, currentPeriod),
fetchPendingInboxCount(session.user.id),
fetchUserFontPreferences(session.user.id),
]);
return (
<FontProvider
systemFont={fontPrefs.systemFont}
moneyFont={fontPrefs.moneyFont}
>
<PrivacyProvider>
<AppNavbar
user={{ ...session.user, image: session.user.image ?? null }}
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
preLancamentosCount={preLancamentosCount}
notificationsSnapshot={notificationsSnapshot}
/>
<div className="relative flex flex-1 flex-col pt-16">
<div className="pointer-events-none absolute inset-0" />
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4 ">
{children}
</div>
</div>
</div>
</PrivacyProvider>
</FontProvider>
);
}

View File

@@ -0,0 +1,23 @@
import { RiTodoLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Anotações | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiTodoLine />}
title="Anotações"
subtitle="Gerencie suas anotações e mantenha o controle sobre suas ideias e tarefas."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,48 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de anotações
* Layout: Header com botão + Grid de cards de notas
*/
export default function AnotacoesLoading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full 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 cards de notas */}
<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-4 space-y-3">
{/* Título */}
<Skeleton className="h-6 w-3/4 rounded-2xl bg-foreground/10" />
{/* Conteúdo (3-4 linhas) */}
<div className="space-y-2">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-2/3 rounded-2xl bg-foreground/10" />
{i % 2 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
</div>
{/* Footer com data e ações */}
<div className="flex items-center justify-between pt-2 border-t">
<Skeleton className="h-3 w-24 rounded-2xl bg-foreground/10" />
<div className="flex gap-1">
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,14 @@
import { NotesPage } from "@/features/notes/components/notes-page";
import { fetchAllNotesForUser } from "@/features/notes/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
const userId = await getUserId();
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<NotesPage notes={activeNotes} archivedNotes={archivedNotes} />
</main>
);
}

View File

@@ -0,0 +1,74 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de detalhes do pagador.
* Layout: navegação mensal + tabs com card compartilhado do pagador.
*/
export default function PagadorDetailsLoading() {
return (
<main className="flex flex-col gap-6">
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
<div className="space-y-6 pt-4">
<div className="flex gap-2 border-b">
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-36 rounded-t-2xl bg-foreground/10" />
</div>
<div className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-20 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<div className="grid grid-cols-3 gap-4 pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-7 w-full rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
</div>
<div className="space-y-3 pt-4">
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-3/4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-1/2 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,406 @@
import {
RiBankCard2Line,
RiBarcodeLine,
RiWallet3Line,
} from "@remixicon/react";
import { notFound } from "next/navigation";
import { PagadorCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
import { PagadorHeaderCard } from "@/features/payers/components/details/payer-header-card";
import { PagadorHistoryCard } from "@/features/payers/components/details/payer-history-card";
import { PagadorInfoCard } from "@/features/payers/components/details/payer-info-card";
import { PagadorLeaveShareCard } from "@/features/payers/components/details/payer-leave-share-card";
import { PagadorMonthlySummaryCard } from "@/features/payers/components/details/payer-monthly-summary-card";
import {
PagadorBoletoCard,
PagadorPaymentStatusCard,
} from "@/features/payers/components/details/payer-payment-method-cards";
import { PagadorSharingCard } from "@/features/payers/components/details/payer-sharing-card";
import {
fetchCurrentUserShare,
fetchPagadorLancamentos,
fetchPagadorShares,
} from "@/features/payers/detail-queries";
import { buildReadOnlyOptionSets } from "@/features/payers/lib/build-readonly-option-sets";
import { fetchUserPreferences } from "@/features/settings/queries";
import { LancamentosPage as LancamentosSection } from "@/features/transactions/components/page/transactions-page";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
getSingleParam,
type LancamentoSearchFilters,
mapLancamentosData,
type ResolvedSearchParams,
type SluggedFilters,
type SlugMaps,
} from "@/features/transactions/page-helpers";
import {
fetchLancamentoFilterSources,
fetchRecentEstablishments,
} from "@/features/transactions/queries";
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { getUserId } from "@/shared/lib/auth/server";
import { getPagadorAccess } from "@/shared/lib/payers/access";
import {
fetchPagadorBoletoItems,
fetchPagadorBoletoStats,
fetchPagadorCardUsage,
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
fetchPagadorPaymentStatus,
type PagadorCardUsageItem,
} from "@/shared/lib/payers/details";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
params: Promise<{ payerId: string }>;
searchParams?: PageSearchParams;
};
const capitalize = (value: string) =>
value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value;
const EMPTY_FILTERS: LancamentoSearchFilters = {
transactionFilter: null,
conditionFilter: null,
paymentFilter: null,
pagadorFilter: null,
categoriaFilter: null,
contaCartaoFilter: null,
searchFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({
pagador: new Map(),
categoria: new Map(),
conta: new Map(),
cartao: new Map(),
});
type OptionSet = ReturnType<typeof buildOptionSets>;
export default async function Page({ params, searchParams }: PageProps) {
const { payerId: pagadorId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const access = await getPagadorAccess(userId, pagadorId);
if (!access) {
notFound();
}
const { pagador, canEdit } = access;
const dataOwnerId = pagador.userId;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const periodLabel = `${capitalize(monthName)} de ${year}`;
const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const searchFilters = canEdit
? allSearchFilters
: {
...EMPTY_FILTERS,
searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only
};
let filterSources: Awaited<
ReturnType<typeof fetchLancamentoFilterSources>
> | null = null;
let loggedUserFilterSources: Awaited<
ReturnType<typeof fetchLancamentoFilterSources>
> | null = null;
let sluggedFilters: SluggedFilters;
let slugMaps: SlugMaps;
if (canEdit) {
filterSources = await fetchLancamentoFilterSources(dataOwnerId);
sluggedFilters = buildSluggedFilters(filterSources);
slugMaps = buildSlugMaps(sluggedFilters);
} else {
// Buscar opções do usuário logado para usar ao importar
loggedUserFilterSources = await fetchLancamentoFilterSources(userId);
sluggedFilters = {
pagadorFiltersRaw: [],
categoriaFiltersRaw: [],
contaFiltersRaw: [],
cartaoFiltersRaw: [],
};
slugMaps = createEmptySlugMaps();
}
const filters = buildLancamentoWhere({
userId: dataOwnerId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
pagadorId: pagador.id,
});
const sharesPromise = canEdit
? fetchPagadorShares(pagador.id)
: Promise.resolve([]);
const currentUserSharePromise = !canEdit
? fetchCurrentUserShare(pagador.id, userId)
: Promise.resolve(null);
const [
lancamentoRows,
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
boletoItems,
paymentStatus,
shareRows,
currentUserShare,
estabelecimentos,
userPreferences,
] = await Promise.all([
fetchPagadorLancamentos(filters),
fetchPagadorMonthlyBreakdown({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorHistory({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorCardUsage({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorBoletoStats({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorBoletoItems({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorPaymentStatus({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
sharesPromise,
currentUserSharePromise,
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const mappedLancamentos = mapLancamentosData(lancamentoRows);
const lancamentosData = canEdit
? mappedLancamentos
: mappedLancamentos.map((item) => ({ ...item, readonly: true }));
const pagadorSharesData = shareRows;
let optionSets: OptionSet;
let loggedUserOptionSets: OptionSet | null = null;
let effectiveSluggedFilters = sluggedFilters;
if (canEdit && filterSources) {
optionSets = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
} else {
effectiveSluggedFilters = {
pagadorFiltersRaw: [
{
id: pagador.id,
label: pagador.name,
slug: pagador.id,
role: pagador.role,
avatarUrl: pagador.avatarUrl,
},
],
categoriaFiltersRaw: [],
contaFiltersRaw: [],
cartaoFiltersRaw: [],
};
optionSets = buildReadOnlyOptionSets(lancamentosData, pagador);
// Construir opções do usuário logado para usar ao importar
if (loggedUserFilterSources) {
const loggedUserSluggedFilters = buildSluggedFilters(
loggedUserFilterSources,
);
loggedUserOptionSets = buildOptionSets({
...loggedUserSluggedFilters,
pagadorRows: loggedUserFilterSources.pagadorRows,
});
}
}
const pagadorSlug =
effectiveSluggedFilters.pagadorFiltersRaw.find(
(item) => item.id === pagador.id,
)?.slug ?? null;
const pagadorFilterOptions = pagadorSlug
? optionSets.pagadorFilterOptions.filter(
(option) => option.slug === pagadorSlug,
)
: optionSets.pagadorFilterOptions;
const pagadorData = {
id: pagador.id,
name: pagador.name,
email: pagador.email ?? null,
avatarUrl: pagador.avatarUrl ?? null,
status: pagador.status,
note: pagador.note ?? null,
role: pagador.role ?? null,
isAutoSend: pagador.isAutoSend ?? false,
createdAt: pagador.createdAt
? pagador.createdAt.toISOString()
: new Date().toISOString(),
lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null,
shareCode: canEdit ? pagador.shareCode : null,
canEdit,
};
const summaryPreview = {
periodLabel,
totalExpenses: monthlyBreakdown.totalExpenses,
paymentSplits: monthlyBreakdown.paymentSplits,
cardUsage: cardUsage.slice(0, 3).map((item: PagadorCardUsageItem) => ({
name: item.name,
amount: item.amount,
})),
boletoStats: {
totalAmount: boletoStats.totalAmount,
paidAmount: boletoStats.paidAmount,
pendingAmount: boletoStats.pendingAmount,
paidCount: boletoStats.paidCount,
pendingCount: boletoStats.pendingCount,
},
lancamentoCount: lancamentosData.length,
};
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<Tabs defaultValue="profile" className="w-full">
<TabsList className="mb-2">
<TabsTrigger value="profile">Perfil</TabsTrigger>
<TabsTrigger value="painel">Painel</TabsTrigger>
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
</TabsList>
<PagadorHeaderCard
pagador={pagadorData}
selectedPeriod={selectedPeriod}
summary={summaryPreview}
/>
<TabsContent value="profile" className="space-y-4">
<PagadorInfoCard pagador={pagadorData} />
{canEdit && pagadorData.shareCode ? (
<PagadorSharingCard
pagadorId={pagador.id}
shareCode={pagadorData.shareCode}
shares={pagadorSharesData}
/>
) : null}
{!canEdit && currentUserShare ? (
<PagadorLeaveShareCard
shareId={currentUserShare.id}
pagadorName={pagadorData.name}
createdAt={currentUserShare.createdAt}
/>
) : null}
</TabsContent>
<TabsContent value="painel" className="space-y-4">
<section className="grid gap-3 lg:grid-cols-2">
<PagadorMonthlySummaryCard
periodLabel={periodLabel}
breakdown={monthlyBreakdown}
/>
<PagadorHistoryCard data={historyData} />
</section>
<section className="grid gap-3 lg:grid-cols-3">
<ExpandableWidgetCard
title="Minhas Faturas"
subtitle="Valores por cartão neste período"
icon={<RiBankCard2Line className="size-4" />}
>
<PagadorCardUsageCard items={cardUsage} />
</ExpandableWidgetCard>
<ExpandableWidgetCard
title="Boletos"
subtitle="Boletos registrados neste período"
icon={<RiBarcodeLine className="size-4" />}
>
<PagadorBoletoCard items={boletoItems} />
</ExpandableWidgetCard>
<ExpandableWidgetCard
title="Status de Pagamento"
subtitle="Situação das despesas no período"
icon={<RiWallet3Line className="size-4" />}
>
<PagadorPaymentStatusCard data={paymentStatus} />
</ExpandableWidgetCard>
</section>
</TabsContent>
<TabsContent value="lancamentos">
<section className="flex flex-col gap-4">
<LancamentosSection
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={optionSets.pagadorOptions}
splitPagadorOptions={optionSets.splitPagadorOptions}
defaultPagadorId={pagador.id}
contaOptions={optionSets.contaOptions}
cartaoOptions={optionSets.cartaoOptions}
categoriaOptions={optionSets.categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={optionSets.categoriaFilterOptions}
contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate={canEdit}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
importSplitPagadorOptions={
loggedUserOptionSets?.splitPagadorOptions
}
importDefaultPagadorId={loggedUserOptionSets?.defaultPagadorId}
importContaOptions={loggedUserOptionSets?.contaOptions}
importCartaoOptions={loggedUserOptionSets?.cartaoOptions}
importCategoriaOptions={loggedUserOptionSets?.categoriaOptions}
/>
</section>
</TabsContent>
</Tabs>
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiGroupLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Pagadores | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiGroupLine />}
title="Pagadores"
subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,57 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de pagadores
* Layout: Header + Input de compartilhamento + Grid de cards
*/
export default function PagadoresLoading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Input de código de compartilhamento */}
<div className="rounded-2xl border p-4 space-y-3">
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
<div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Grid de cards de pagadores */}
<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">
{/* Avatar + Nome + Badge */}
<div className="flex items-start gap-4">
<Skeleton className="size-16 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
</div>
{i === 0 && (
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
)}
</div>
{/* Email */}
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
{/* Status */}
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
{/* Botões de ação */}
<div className="flex gap-2 pt-2 border-t">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,14 @@
import { PagadoresPage } from "@/features/payers/components/payers-page";
import { fetchPagadoresForUser } from "@/features/payers/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
const userId = await getUserId();
const { pagadores, avatarOptions } = await fetchPagadoresForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<PagadoresPage pagadores={pagadores} avatarOptions={avatarOptions} />
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiBankCard2Line } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Uso de Cartões | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiBankCard2Line />}
title="Uso de Cartões"
subtitle="Análise detalhada do uso dos seus cartões de crédito."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,88 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-4">
{/* MonthNavigation skeleton */}
<Skeleton className="h-10 w-64" />
{/* Summary stats */}
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<Skeleton className="h-3 w-16 mb-1" />
<Skeleton className="h-6 w-24" />
</CardContent>
</Card>
))}
</div>
{/* Cards grid */}
<div className="grid gap-2 grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
{/* CardUsageChart */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-32" />
<div className="flex items-center gap-2">
<Skeleton className="size-6 rounded" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</CardHeader>
<CardContent>
<Skeleton className="h-[280px] w-full" />
</CardContent>
</Card>
{/* CategoryBreakdown + TopExpenses */}
<div className="grid gap-4 md:grid-cols-2">
<Card className="h-full">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-36" />
</CardHeader>
<CardContent className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</CardContent>
</Card>
<Card className="h-full">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-36" />
</CardHeader>
<CardContent className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</CardContent>
</Card>
</div>
{/* CardInvoiceStatus - timeline minimalista */}
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<Skeleton className="w-full h-3 rounded-sm" />
<Skeleton className="h-3 w-6" />
</div>
))}
</div>
</CardContent>
</Card>
</main>
);
}

View File

@@ -0,0 +1,80 @@
import { RiBankCard2Line } from "@remixicon/react";
import { fetchCartoesReportData } from "@/features/reports/cards-report-queries";
import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown";
import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status";
import { CardTopExpenses } from "@/features/reports/components/cards/card-top-expenses";
import { CardUsageChart } from "@/features/reports/components/cards/card-usage-chart";
import { CardsOverview } from "@/features/reports/components/cards/cards-overview";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { Card } from "@/shared/components/ui/card";
import { getUser } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function RelatorioCartoesPage({
searchParams,
}: PageProps) {
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const cartaoParam = getSingleParam(resolvedSearchParams, "cartao");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const data = await fetchCartoesReportData(
user.id,
selectedPeriod,
cartaoParam,
);
return (
<main className="flex flex-col gap-4">
<MonthNavigation />
<CardsOverview data={data} />
{data.selectedCard ? (
<>
<CardUsageChart
data={data.selectedCard.monthlyUsage}
limit={data.selectedCard.card.limit}
card={{
name: data.selectedCard.card.name,
logo: data.selectedCard.card.logo,
}}
/>
<CardInvoiceStatus data={data.selectedCard.invoiceStatus} />
<div className="grid gap-4 md:grid-cols-2">
<CardCategoryBreakdown data={data.selectedCard.categoryBreakdown} />
<CardTopExpenses data={data.selectedCard.topExpenses} />
</div>
</>
) : (
<Card className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-muted mb-4">
<RiBankCard2Line className="size-7 text-muted-foreground" />
</div>
<p className="text-base font-medium">Nenhum cartão selecionado</p>
<p className="text-sm text-muted-foreground mt-1">
Selecione um cartão para ver os detalhes de uso.
</p>
</Card>
)}
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiFileChartLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Tendências | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiFileChartLine />}
title="Tendências"
subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,9 @@
import { CategoryReportSkeleton } from "@/shared/components/skeletons/category-report-skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-6">
<CategoryReportSkeleton />
</main>
);
}

View File

@@ -0,0 +1,114 @@
import { redirect } from "next/navigation";
import type { Categoria } from "@/db/schema";
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
import { fetchCategoryReport } from "@/features/reports/category-report-queries";
import { fetchUserCategories } from "@/features/reports/category-trends-queries";
import { CategoryReportPage } from "@/features/reports/components/category-report-page";
import type {
CategoryOption,
FilterState,
} from "@/features/reports/components/types";
import { validateDateRange } from "@/features/reports/utils";
import { getUserId } from "@/shared/lib/auth/server";
import type { CategoryReportFilters } from "@/shared/lib/types/reports";
import { addMonthsToPeriod, getCurrentPeriod } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
): string | null => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
// Get authenticated user
const userId = await getUserId();
// Resolve search params
const resolvedSearchParams = searchParams ? await searchParams : undefined;
// Extract query params
const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
const fimParam = getSingleParam(resolvedSearchParams, "fim");
const categoriasParam = getSingleParam(resolvedSearchParams, "categorias");
// Calculate default period (last 6 months)
const currentPeriod = getCurrentPeriod();
const defaultStartPeriod = addMonthsToPeriod(currentPeriod, -5); // 6 months including current
// Use params or defaults
const startPeriod = inicioParam ?? defaultStartPeriod;
const endPeriod = fimParam ?? currentPeriod;
// Parse selected categories
const selectedCategoryIds = categoriasParam
? categoriasParam.split(",").filter(Boolean)
: [];
// Validate date range
const validation = validateDateRange(startPeriod, endPeriod);
if (!validation.isValid) {
// Redirect to default if validation fails
redirect(
`/reports/category-trends?inicio=${defaultStartPeriod}&fim=${currentPeriod}`,
);
}
// Fetch all categories for the user
const categoriaRows = await fetchUserCategories(userId);
// Map to CategoryOption format
const categoryOptions: CategoryOption[] = categoriaRows.map(
(cat: Categoria): CategoryOption => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
}),
);
// Build filters for data fetching
const filters: CategoryReportFilters = {
startPeriod,
endPeriod,
categoryIds:
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
};
// Fetch report data
const reportData = await fetchCategoryReport(userId, filters);
// Fetch chart data with same filters
const chartData = await fetchCategoryChartData(
userId,
startPeriod,
endPeriod,
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
);
// Build initial filter state for client component
const initialFilters: FilterState = {
selectedCategories: selectedCategoryIds,
startPeriod,
endPeriod,
};
return (
<main className="flex flex-col gap-6">
<CategoryReportPage
initialData={reportData}
categories={categoryOptions}
initialFilters={initialFilters}
chartData={chartData}
/>
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiStore2Line } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Top Estabelecimentos | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiStore2Line />}
title="Top Estabelecimentos"
subtitle="Análise dos locais onde você mais compra e gasta"
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,58 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-8 w-48" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</CardContent>
</Card>
</div>
<div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,76 @@
import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list";
import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
import { SummaryCards } from "@/features/reports/components/establishments/summary-cards";
import { TopCategories } from "@/features/reports/components/establishments/top-categories";
import {
fetchTopEstabelecimentosData,
type PeriodFilter,
} from "@/features/reports/establishments/queries";
import { Card } from "@/shared/components/ui/card";
import { getUser } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
const validatePeriodFilter = (value: string | null): PeriodFilter => {
if (value === "3" || value === "6" || value === "12") {
return value;
}
return "6";
};
export default async function TopEstabelecimentosPage({
searchParams,
}: PageProps) {
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const mesesParam = getSingleParam(resolvedSearchParams, "meses");
const { period: currentPeriod } = parsePeriodParam(periodoParam);
const periodFilter = validatePeriodFilter(mesesParam);
const data = await fetchTopEstabelecimentosData(
user.id,
currentPeriod,
periodFilter,
);
return (
<main className="flex flex-col gap-4">
<Card className="flex-row items-center justify-between p-3">
<span className="text-sm text-muted-foreground">
Selecione o intervalo de meses
</span>
<PeriodFilterButtons currentFilter={periodFilter} />
</Card>
<SummaryCards summary={data.summary} />
<HighlightsCards summary={data.summary} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<EstablishmentsList establishments={data.establishments} />
</div>
<div>
<TopCategories categories={data.topCategories} />
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiSecurePaymentLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Análise de Parcelas | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiSecurePaymentLine />}
title="Análise de Parcelas"
subtitle="Quanto você gastaria pagando suas despesas parceladas à vista?"
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,14 @@
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
import { getUser } from "@/shared/lib/auth/server";
export default async function Page() {
const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id);
return (
<main className="flex flex-col gap-4 pb-8">
<InstallmentAnalysisPage data={data} />
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { RiHistoryLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Cartões | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiHistoryLine />}
title="Changelog"
subtitle="Acompanhe todas as alterações feitas na plataforma."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,12 @@
import { ChangelogTab } from "@/features/settings/components/changelog-tab";
import { parseChangelog } from "@/features/settings/lib/parse-changelog";
export default function ChangelogPage() {
const versions = parseChangelog();
return (
<div className="w-full">
<ChangelogTab versions={versions} />
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { RiSettings2Line } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Ajustes | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiSettings2Line />}
title="Ajustes"
subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,175 @@
import { RiArrowRightSLine } from "@remixicon/react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { CompanionTab } from "@/features/settings/components/companion-tab";
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
import { PasskeysForm } from "@/features/settings/components/passkeys-form";
import { PreferencesForm } from "@/features/settings/components/preferences-form";
import { UpdateEmailForm } from "@/features/settings/components/update-email-form";
import { UpdateNameForm } from "@/features/settings/components/update-name-form";
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
import { fetchAjustesPageData } from "@/features/settings/queries";
import { DEFAULT_FONT_KEY } from "@/public/fonts/font_index";
import { Card } from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { auth } from "@/shared/lib/auth/config";
export default async function Page() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
redirect("/");
}
const userName = session.user.name || "";
const userEmail = session.user.email || "";
const { authProvider, userPreferences, userApiTokens } =
await fetchAjustesPageData(session.user.id);
return (
<div className="w-full">
<Tabs defaultValue="preferencias" className="w-full">
{/* No mobile: rolagem horizontal + seta indicando mais opções à direita */}
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList className="inline-flex w-max flex-nowrap md:w-full">
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
<TabsTrigger value="companion">Companion</TabsTrigger>
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
<TabsTrigger value="passkeys">Passkeys</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive">
Deletar conta
</TabsTrigger>
</TabsList>
</div>
<div
className="pointer-events-none absolute right-0 top-0 hidden h-9 w-10 items-center justify-end bg-linear-to-l from-background to-transparent md:hidden"
aria-hidden
>
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
</div>
</div>
<TabsContent value="preferencias" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground mb-4">
Personalize sua experiência no OpenMonetis ajustando as
configurações de acordo com suas necessidades.
</p>
</div>
<PreferencesForm
extratoNoteAsColumn={
userPreferences?.extratoNoteAsColumn ?? false
}
lancamentosColumnOrder={
userPreferences?.lancamentosColumnOrder ?? null
}
systemFont={userPreferences?.systemFont ?? DEFAULT_FONT_KEY}
moneyFont={userPreferences?.moneyFont ?? DEFAULT_FONT_KEY}
/>
</div>
</Card>
</TabsContent>
<TabsContent value="companion" className="mt-4">
<CompanionTab tokens={userApiTokens} />
</TabsContent>
<TabsContent value="nome" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground mb-4">
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
ser exibido em diferentes seções do app e em comunicações.
</p>
</div>
<UpdateNameForm currentName={userName} />
</div>
</Card>
</TabsContent>
<TabsContent value="senha" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground mb-4">
Defina uma nova senha para sua conta. Guarde-a em local
seguro.
</p>
</div>
<UpdatePasswordForm authProvider={authProvider} />
</div>
</Card>
</TabsContent>
<TabsContent value="passkeys" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground mb-4">
Passkeys permitem login sem senha, usando biometria (Face ID,
Touch ID, Windows Hello) ou chaves de segurança.
</p>
</div>
<PasskeysForm />
</div>
</Card>
</TabsContent>
<TabsContent value="email" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground mb-4">
Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail
atual (quando aplicável) para concluir a alteração.
</p>
</div>
<UpdateEmailForm
currentEmail={userEmail}
authProvider={authProvider}
/>
</div>
</Card>
</TabsContent>
<TabsContent value="deletar" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1 text-destructive">
Deletar conta
</h2>
<p className="text-sm text-muted-foreground mb-4">
Ao prosseguir, sua conta e todos os dados associados serão
excluídos de forma irreversível.
</p>
</div>
<DeleteAccountForm />
</div>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { RiArrowLeftRightLine } from "@remixicon/react";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Lançamentos | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<PageDescription
icon={<RiArrowLeftRightLine />}
title="Lançamentos"
subtitle="Acompanhe todos os lançamentos financeiros 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>
);
}

View File

@@ -0,0 +1,32 @@
import {
FilterSkeleton,
TransactionsTableSkeleton,
} from "@/shared/components/skeletons";
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de lançamentos
* Mantém o mesmo layout da página final
*/
export default function LancamentosLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
<div className="space-y-6 pt-4">
{/* Header com título e botão */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Filtros */}
<FilterSkeleton />
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</main>
);
}

View File

@@ -0,0 +1,97 @@
import { triggerRecurringGeneration } from "@/features/recurring/trigger-recurring-generation";
import { fetchUserPreferences } from "@/features/settings/queries";
import { LancamentosPage } from "@/features/transactions/components/page/transactions-page";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
} from "@/features/transactions/page-helpers";
import {
fetchLancamentoFilterSources,
fetchLancamentos,
fetchRecentEstablishments,
} from "@/features/transactions/queries";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
await triggerRecurringGeneration(userId);
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const [filterSources, userPreferences] = await Promise.all([
fetchLancamentoFilterSources(userId),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
});
const [lancamentoRows, estabelecimentos] = await Promise.all([
fetchLancamentos(filters),
fetchRecentEstablishments(userId),
]);
const lancamentosData = mapLancamentosData(lancamentoRows);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<LancamentosPage
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}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
/>
</main>
);
}

View File

@@ -0,0 +1,95 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
: "https://openmonetis.com";
const TITLE = "OpenMonetis | Finanças pessoais self-hosted e open source";
const DESCRIPTION =
"Aplicativo self-hosted de finanças pessoais. Controle lançamentos, cartões, orçamentos e categorias com total privacidade. Open source e gratuito.";
export const metadata: Metadata = {
metadataBase: new URL(BASE_URL),
title: TITLE,
description: DESCRIPTION,
keywords: [
"finanças pessoais",
"controle financeiro",
"self-hosted",
"open source",
"gestão financeira",
"orçamento pessoal",
"lançamentos financeiros",
"cartão de crédito",
"planejamento financeiro",
],
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
locale: "pt_BR",
url: "/",
siteName: "OpenMonetis",
title: TITLE,
description: DESCRIPTION,
images: [
{
url: "/images/dashboard-preview-light.webp",
width: 1920,
height: 1080,
alt: "OpenMonetis — Dashboard de finanças pessoais",
},
],
},
twitter: {
card: "summary_large_image",
title: TITLE,
description: DESCRIPTION,
images: ["/images/dashboard-preview-light.webp"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
export default function LandingLayout({ children }: { children: ReactNode }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "OpenMonetis",
applicationCategory: "FinanceApplication",
operatingSystem: "Web",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "BRL",
},
description: DESCRIPTION,
url: BASE_URL,
isAccessibleForFree: true,
author: {
"@type": "Organization",
name: "OpenMonetis",
url: BASE_URL,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{children}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/shared/lib/auth/config";
export const { GET, POST } = toNextJsHandler(auth.handler);

View File

@@ -0,0 +1,85 @@
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { tokensApi } from "@/db/schema";
import {
extractBearerToken,
hashToken,
refreshAccessToken,
verifyJwt,
} from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
export async function POST(request: Request) {
try {
// Extrair refresh token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Refresh token não fornecido" },
{ status: 401 },
);
}
// Validar refresh token
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_refresh") {
return NextResponse.json(
{ error: "Refresh token inválido ou expirado" },
{ status: 401 },
);
}
// Verificar se token não foi revogado
const tokenRecord = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.id, payload.tokenId),
eq(tokensApi.userId, payload.sub),
isNull(tokensApi.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 },
);
}
// Gerar novo access token
const result = refreshAccessToken(token);
if (!result) {
return NextResponse.json(
{ error: "Não foi possível renovar o token" },
{ status: 401 },
);
}
// Atualizar hash do token e último uso
await db
.update(tokensApi)
.set({
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(),
lastUsedIp:
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip"),
expiresAt: result.expiresAt,
})
.where(eq(tokensApi.id, payload.tokenId));
return NextResponse.json({
accessToken: result.accessToken,
expiresAt: result.expiresAt.toISOString(),
});
} catch (error) {
console.error("[API] Error refreshing device token:", error);
return NextResponse.json(
{ error: "Erro ao renovar token" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,71 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
import { tokensApi } from "@/db/schema";
import {
generateTokenPair,
getTokenPrefix,
hashToken,
} from "@/shared/lib/auth/api-token";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
const createTokenSchema = z.object({
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
deviceId: z.string().optional(),
});
export async function POST(request: Request) {
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Validar body
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);
// Gerar par de tokens
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
session.user.id,
deviceId,
);
// Salvar hash do token no banco
await db.insert(tokensApi).values({
id: tokenId,
userId: session.user.id,
name,
tokenHash: hashToken(accessToken),
tokenPrefix: getTokenPrefix(accessToken),
expiresAt,
});
// Retornar tokens (mostrados apenas uma vez)
return NextResponse.json(
{
accessToken,
refreshToken,
tokenId,
name,
expiresAt: expiresAt.toISOString(),
message:
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating device token:", error);
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
}
}

View File

@@ -0,0 +1,55 @@
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { tokensApi } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
interface RouteParams {
params: Promise<{ tokenId: string }>;
}
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const { tokenId } = await params;
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Verificar se token pertence ao usuário
const token = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.id, tokenId),
eq(tokensApi.userId, session.user.id),
),
});
if (!token) {
return NextResponse.json(
{ error: "Token não encontrado" },
{ status: 404 },
);
}
// Revogar token (soft delete)
await db
.update(tokensApi)
.set({ revokedAt: new Date() })
.where(eq(tokensApi.id, tokenId));
return NextResponse.json({
message: "Token revogado com sucesso",
tokenId,
});
} catch (error) {
console.error("[API] Error revoking device token:", error);
return NextResponse.json(
{ error: "Erro ao revogar token" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,42 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { tokensApi } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
export async function GET() {
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Buscar tokens ativos do usuário
const activeTokens = await db
.select({
id: tokensApi.id,
name: tokensApi.name,
tokenPrefix: tokensApi.tokenPrefix,
lastUsedAt: tokensApi.lastUsedAt,
lastUsedIp: tokensApi.lastUsedIp,
expiresAt: tokensApi.expiresAt,
createdAt: tokensApi.createdAt,
})
.from(tokensApi)
.where(
and(eq(tokensApi.userId, session.user.id), isNull(tokensApi.revokedAt)),
)
.orderBy(desc(tokensApi.createdAt));
return NextResponse.json({ tokens: activeTokens });
} catch (error) {
console.error("[API] Error listing device tokens:", error);
return NextResponse.json(
{ error: "Erro ao listar tokens" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,73 @@
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { tokensApi } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ valid: false, error: "Token não fornecido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash lookup
if (!token.startsWith("os_")) {
return NextResponse.json(
{ valid: false, error: "Formato de token inválido" },
{ status: 401 },
);
}
// Hash do token para buscar no DB
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.tokenHash, tokenHash),
isNull(tokensApi.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ valid: false, error: "Token inválido ou revogado" },
{ status: 401 },
);
}
// Atualizar último uso
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(tokensApi)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(tokensApi.id, tokenRecord.id));
return NextResponse.json({
valid: true,
userId: tokenRecord.userId,
tokenId: tokenRecord.id,
tokenName: tokenRecord.name,
});
} catch (error) {
console.error("[API] Error verifying device token:", error);
return NextResponse.json(
{ valid: false, error: "Erro ao validar token" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { db } from "@/shared/lib/db";
const APP_VERSION = "1.0.0";
/**
* Health check endpoint para Docker, monitoring e OpenMonetis Companion
* GET /api/health
*
* Retorna status 200 se a aplicação está saudável
* Verifica conexão com banco de dados
* Usado pelo app Android para validar URL do servidor
*/
export async function GET() {
try {
// Tenta fazer uma query simples no banco para verificar conexão
// Isso garante que o app está conectado ao banco antes de considerar "healthy"
await db.execute("SELECT 1");
return NextResponse.json(
{
status: "ok",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
},
{ status: 200 },
);
} catch (error) {
// Se houver erro na conexão com banco, retorna status 503 (Service Unavailable)
console.error("Health check failed:", error);
return NextResponse.json(
{
status: "error",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
message: "Database connection failed",
},
{ status: 503 },
);
}
}

View File

@@ -0,0 +1,163 @@
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { preLancamentos, tokensApi } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
import { inboxBatchSchema } from "@/shared/lib/schemas/inbox";
// Rate limiting simples em memória
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 20; // 20 batch requests
const RATE_WINDOW = 60 * 1000; // por minuto
function checkRateLimit(userId: string): boolean {
const now = Date.now();
const userLimit = rateLimitMap.get(userId);
if (!userLimit || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
userLimit.count++;
return true;
}
interface BatchResult {
clientId?: string;
serverId?: string;
success: boolean;
error?: string;
}
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
);
}
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.tokenHash, tokenHash),
isNull(tokensApi.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
// Rate limiting
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 },
);
}
// Validar body
const body = await request.json();
const { items } = inboxBatchSchema.parse(body);
// Processar cada item
const results: BatchResult[] = [];
for (const item of items) {
try {
const [inserted] = await db
.insert(preLancamentos)
.values({
userId: tokenRecord.userId,
sourceApp: item.sourceApp,
sourceAppName: item.sourceAppName,
originalTitle: item.originalTitle,
originalText: item.originalText,
notificationTimestamp: item.notificationTimestamp,
parsedName: item.parsedName,
parsedAmount: item.parsedAmount?.toString(),
status: "pending",
})
.returning({ id: preLancamentos.id });
results.push({
clientId: item.clientId,
serverId: inserted.id,
success: true,
});
} catch (error) {
results.push({
clientId: item.clientId,
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
});
}
}
// Atualizar último uso do token
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(tokensApi)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(tokensApi.id, tokenRecord.id));
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
return NextResponse.json(
{
message: `${successCount} notificações processadas${failCount > 0 ? `, ${failCount} falharam` : ""}`,
total: items.length,
success: successCount,
failed: failCount,
results,
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating batch inbox items:", error);
return NextResponse.json(
{ error: "Erro ao processar notificações" },
{ status: 500 },
);
}
}

133
src/app/api/inbox/route.ts Normal file
View File

@@ -0,0 +1,133 @@
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { preLancamentos, tokensApi } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
import { inboxItemSchema } from "@/shared/lib/schemas/inbox";
// Rate limiting simples em memória (em produção, use Redis)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 100; // 100 requests
const RATE_WINDOW = 60 * 1000; // por minuto
function checkRateLimit(userId: string): boolean {
const now = Date.now();
const userLimit = rateLimitMap.get(userId);
if (!userLimit || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
userLimit.count++;
return true;
}
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
);
}
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.tokensApi.findFirst({
where: and(
eq(tokensApi.tokenHash, tokenHash),
isNull(tokensApi.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
// Rate limiting
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 },
);
}
// Validar body
const body = await request.json();
const data = inboxItemSchema.parse(body);
// Inserir item na inbox
const [inserted] = await db
.insert(preLancamentos)
.values({
userId: tokenRecord.userId,
sourceApp: data.sourceApp,
sourceAppName: data.sourceAppName,
originalTitle: data.originalTitle,
originalText: data.originalText,
notificationTimestamp: data.notificationTimestamp,
parsedName: data.parsedName,
parsedAmount: data.parsedAmount?.toString(),
status: "pending",
})
.returning({ id: preLancamentos.id });
// Atualizar último uso do token
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(tokensApi)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(tokensApi.id, tokenRecord.id));
return NextResponse.json(
{
id: inserted.id,
clientId: data.clientId,
message: "Notificação recebida",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating inbox item:", error);
return NextResponse.json(
{ error: "Erro ao processar notificação" },
{ status: 500 },
);
}
}

BIN
src/app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

53
src/app/error.tsx Normal file
View File

@@ -0,0 +1,53 @@
"use client";
import { RiErrorWarningFill } from "@remixicon/react";
import Link from "next/link";
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/shared/components/ui/empty";
export default function ErrorComponent({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Empty className="max-w-md border-0">
<EmptyHeader>
<EmptyMedia variant="icon" className="bg-destructive/10 size-16">
<RiErrorWarningFill className="size-8 text-destructive" />
</EmptyMedia>
<EmptyTitle className="text-2xl">Algo deu errado</EmptyTitle>
<EmptyDescription>
Ocorreu um problema inesperado. Por favor, tente novamente ou volte
para o dashboard.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex flex-col gap-2 sm:flex-row">
<Button onClick={() => reset()}>Tentar Novamente</Button>
<Button variant="outline" asChild>
<Link href="/dashboard">Voltar para o Dashboard</Link>
</Button>
</div>
</EmptyContent>
</Empty>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

368
src/app/globals.css Normal file
View File

@@ -0,0 +1,368 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme {
--spacing-custom-height-card: 30rem;
--spacing-8xl: 88rem; /* 1408px */
--spacing-9xl: 96rem; /* 1536px */
}
:root {
/* Font customization */
--font-app: var(--font-ai-sans);
--font-money: var(--font-ai-sans);
/* Base surfaces - warm cream with subtle orange undertone */
--background: oklch(98.01% 0.00331 67.026);
--foreground: #201207;
--card: var(--background);
--card-foreground: #201207;
--popover: oklch(99.5% 0.004 80);
--popover-foreground: oklch(18% 0.02 45);
/* Primary - rich terracotta orange */
--primary: #f17a35;
--primary-foreground: oklch(98% 0.008 80);
/* Secondary - warm stone with subtle saturation */
--secondary: oklch(94% 0.018 70);
--secondary-foreground: oklch(25% 0.025 45);
/* Muted - softer background variant */
--muted: oklch(94.5% 0.014 75);
--muted-foreground: #44413c;
/* Accent - complementary warm tone */
--accent: oklch(94% 0.01 70);
--accent-foreground: #44413c;
/* Semantic states */
--success: oklch(55.87% 0.12943 157.517);
--success-foreground: oklch(98% 0.01 150);
--warning: oklch(69.913% 0.1798 49.649);
--warning-foreground: oklch(20% 0.04 85);
--info: oklch(55% 0.17 250);
--info-foreground: oklch(98% 0.01 250);
/* Destructive - accessible red */
--destructive: oklch(55% 0.22 27);
--destructive-foreground: oklch(98% 0.005 30);
/* Borders and inputs - defined but subtle */
--border: oklch(82% 0.012 75);
--input: oklch(82% 0.012 75);
--ring: oklch(69.18% 0.18855 38.353);
/* Charts - 10 harmonious, distinct, accessible colors */
--chart-1: var(--color-emerald-500);
--chart-2: var(--color-orange-500);
--chart-3: var(--color-indigo-500);
--chart-4: var(--color-amber-500);
--chart-5: var(--color-pink-500);
--chart-6: var(--color-stone-500);
--chart-7: var(--color-teal-500);
--chart-8: var(--color-violet-500);
--chart-9: var(--color-cyan-500);
--chart-10: var(--color-lime-500);
/* Sidebar - slight elevation from background */
--sidebar: oklch(100% 0 0);
--sidebar-foreground: oklch(20% 0.02 45);
--sidebar-primary: oklch(25% 0.025 45);
--sidebar-primary-foreground: oklch(98% 0.008 80);
--sidebar-accent: oklch(96.563% 0.00504 67.275);
--sidebar-accent-foreground: oklch(22% 0.025 45);
--sidebar-border: oklch(69.18% 0.18855 38.353);
--sidebar-ring: oklch(69.18% 0.18855 38.353);
/* Layout */
--radius: 0.5rem;
/* Shadows - warm tinted for cohesion */
--shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04);
--shadow-xs: 0 1px 3px 0px oklch(35% 0.02 45 / 0.06);
--shadow-sm: 0 1px 3px 0px oklch(35% 0.02 45 / 0.08),
0 1px 2px -1px oklch(35% 0.02 45 / 0.08);
--shadow: 0 2px 4px 0px oklch(35% 0.02 45 / 0.08),
0 1px 2px -1px oklch(35% 0.02 45 / 0.06);
--shadow-md: 0 4px 6px -1px oklch(35% 0.02 45 / 0.1),
0 2px 4px -2px oklch(35% 0.02 45 / 0.08);
--shadow-lg: 0 10px 15px -3px oklch(35% 0.02 45 / 0.1),
0 4px 6px -4px oklch(35% 0.02 45 / 0.08);
--shadow-xl: 0 20px 25px -5px oklch(35% 0.02 45 / 0.1),
0 8px 10px -6px oklch(35% 0.02 45 / 0.08);
--shadow-2xl: 0 25px 50px -12px oklch(35% 0.02 45 / 0.2);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
/* Base surfaces - warm dark with consistent hue family */
--background: oklch(18.5% 0.002 70);
--foreground: oklch(92% 0.015 80);
--card: var(--background);
--card-foreground: oklch(92% 0.015 80);
--popover: oklch(24% 0.003 70);
--popover-foreground: oklch(92% 0.015 80);
/* Primary - vibrant terracotta stands out on dark */
--primary: #fa6c26;
--primary-foreground: oklch(20% 0.002 70);
/* Secondary - elevated surface */
--secondary: oklch(22% 0.004 70);
--secondary-foreground: oklch(92% 0.015 80);
/* Muted - subtle surface variant */
--muted: oklch(33.5% 0.005 70);
--muted-foreground: oklch(72% 0.004 70);
/* Accent - subtle highlight */
--accent: oklch(27% 0.004 70);
--accent-foreground: oklch(92% 0.015 80);
/* Semantic states */
--success: oklch(65% 0.19 150);
--success-foreground: oklch(15% 0.02 150);
--warning: oklch(69.913% 0.1798 49.649);
--warning-foreground: oklch(15% 0.04 85);
--info: oklch(65% 0.17 250);
--info-foreground: oklch(15% 0.02 250);
/* Destructive - accessible red for dark */
--destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30);
/* Borders and inputs - visible but subtle */
--border: oklch(37% 0.01 70);
--input: oklch(32% 0.005 70);
--ring: oklch(69.18% 0.18855 38.353);
/* Charts - bright and distinct on dark */
--chart-1: var(--color-emerald-500);
--chart-2: var(--color-orange-500);
--chart-3: var(--color-indigo-500);
--chart-4: var(--color-amber-500);
--chart-5: var(--color-pink-500);
--chart-6: var(--color-stone-500);
--chart-7: var(--color-teal-500);
--chart-8: var(--color-violet-500);
--chart-9: var(--color-cyan-500);
--chart-10: var(--color-lime-500);
/* Sidebar - slight separation from main */
--sidebar: oklch(24% 0.003 70);
--sidebar-foreground: oklch(92% 0.015 80);
--sidebar-primary: oklch(69.18% 0.18855 38.353);
--sidebar-primary-foreground: oklch(13% 0.006 70);
--sidebar-accent: oklch(32% 0.004 70);
--sidebar-accent-foreground: oklch(92% 0.015 80);
--sidebar-border: oklch(26% 0.004 70);
--sidebar-ring: oklch(69.18% 0.18855 38.353);
/* Layout */
--radius: 0.5rem;
/* Shadows - deeper for dark mode */
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
--shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4);
--shadow-sm: 0 1px 3px 0px oklch(0% 0 0 / 0.45),
0 1px 2px -1px oklch(0% 0 0 / 0.45);
--shadow: 0 2px 4px 0px oklch(0% 0 0 / 0.5),
0 1px 2px -1px oklch(0% 0 0 / 0.4);
--shadow-md: 0 4px 6px -1px oklch(0% 0 0 / 0.55),
0 2px 4px -2px oklch(0% 0 0 / 0.45);
--shadow-lg: 0 10px 15px -3px oklch(0% 0 0 / 0.55),
0 4px 6px -4px oklch(0% 0 0 / 0.45);
--shadow-xl: 0 20px 25px -5px oklch(0% 0 0 / 0.6),
0 8px 10px -6px oklch(0% 0 0 / 0.5);
--shadow-2xl: 0 25px 50px -12px oklch(0% 0 0 / 0.75);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
@theme inline {
--default-font-family: var(--font-app);
--default-mono-font-family: var(--font-money);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-chart-6: var(--chart-6);
--color-chart-7: var(--chart-7);
--color-chart-8: var(--chart-8);
--color-chart-9: var(--chart-9);
--color-chart-10: var(--chart-10);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--tracking-normal: var(--tracking-normal);
--spacing: var(--spacing);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 80px;
}
body {
@apply bg-background text-foreground;
}
*::selection {
@apply bg-primary text-foreground;
}
.dark *::selection {
@apply bg-primary text-background;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
@layer components {
.container {
@apply mx-auto px-4 lg:px-0;
}
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* Dialog animations */
@keyframes dialog-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlay-out {
from { opacity: 1; }
to { opacity: 0; }
}
[data-slot="dialog-overlay"][data-state="open"] {
animation: overlay-in 0.2s ease-out;
}
[data-slot="dialog-overlay"][data-state="closed"] {
animation: overlay-out 0.15s ease-in;
}
[data-slot="dialog-content"][data-state="open"] {
animation: dialog-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
[data-slot="dialog-content"][data-state="closed"] {
animation: dialog-out 0.15s ease-in;
}
/* Overdue blink: alternates two stacked labels with a smooth crossfade */
@keyframes blink-in {
0%, 40% { opacity: 1; }
50%, 90% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes blink-out {
0%, 40% { opacity: 0; }
50%, 90% { opacity: 1; }
100% { opacity: 0; }
}
.overdue-blink {
position: relative;
display: inline-flex;
}
.overdue-blink-primary {
animation: blink-in 6s ease-in-out infinite;
}
.overdue-blink-secondary {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
animation: blink-out 6s ease-in-out infinite;
}

1
src/app/icon0.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
src/app/icon1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

38
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next";
import { allFontVariables } from "@/public/fonts/font_index";
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import "./globals.css";
export const metadata: Metadata = {
title: {
default: "OpenMonetis | Suas finanças, do seu jeito",
template: "%s | OpenMonetis",
},
description:
"Controle suas finanças pessoais de forma simples e transparente.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR" className={allFontVariables} suppressHydrationWarning>
<head>
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
</head>
<body className="subpixel-antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light">
{children}
<Toaster position="top-right" />
</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
</html>
);
}

21
src/app/manifest.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "OpenMonetis",
"short_name": "OpenMonetis",
"icons": [
{
"src": "/images/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/images/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#F2ECE7",
"background_color": "#F2ECE7",
"display": "standalone"
}

35
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { RiFileSearchLine } from "@remixicon/react";
import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/shared/components/ui/empty";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Empty className="max-w-md border-0">
<EmptyHeader>
<EmptyMedia variant="icon" className="size-16">
<RiFileSearchLine className="size-8" />
</EmptyMedia>
<EmptyTitle className="text-2xl">Página não encontrada</EmptyTitle>
<EmptyDescription>
A página que você está procurando não existe ou foi movida.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild>
<Link href="/dashboard">Voltar para o Dashboard</Link>
</Button>
</EmptyContent>
</Empty>
</div>
);
}

35
src/app/robots.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
: "https://openmonetis.com";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/dashboard",
"/transactions",
"/accounts",
"/cards",
"/categories",
"/budgets",
"/payers",
"/notes",
"/insights",
"/calendar",
"/consultor",
"/settings",
"/reports",
"/inbox",
"/login",
"/api/",
],
},
],
sitemap: `${BASE_URL}/sitemap.xml`,
};
}

16
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
: "https://openmonetis.com";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
];
}

941
src/db/schema.ts Normal file
View File

@@ -0,0 +1,941 @@
import { relations, sql } from "drizzle-orm";
import {
boolean,
date,
index,
integer,
jsonb,
numeric,
pgTable,
smallint,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("emailVerified").notNull(),
image: text("image"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: timestamp("accessTokenExpiresAt", {
mode: "date",
withTimezone: true,
}),
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", {
mode: "date",
withTimezone: true,
}),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt", {
mode: "date",
withTimezone: true,
}).notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expiresAt", {
mode: "date",
withTimezone: true,
}).notNull(),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}),
});
// ===================== PASSKEY (WebAuthn) =====================
export const passkey = pgTable("passkey", {
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("publicKey").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credentialID").notNull(),
counter: integer("counter").notNull(),
deviceType: text("deviceType").notNull(),
backedUp: boolean("backedUp").notNull(),
transports: text("transports"),
aaguid: text("aaguid"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}),
});
export const preferenciasUsuario = pgTable("preferencias_usuario", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id, { onDelete: "cascade" }),
extratoNoteAsColumn: boolean("extrato_note_as_column")
.notNull()
.default(false),
systemFont: text("system_font").notNull().default("ai-sans"),
moneyFont: text("money_font").notNull().default("ai-sans"),
lancamentosColumnOrder: jsonb("lancamentos_column_order").$type<
string[] | null
>(),
dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[];
hidden: string[];
}>(),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.default(sql`now()`),
updatedAt: timestamp("updated_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.default(sql`now()`),
});
// ===================== PUBLIC TABLES =====================
export const contas = pgTable(
"contas",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
accountType: text("tipo_conta").notNull(),
note: text("anotacao"),
status: text("status").notNull(),
logo: text("logo").notNull(),
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
.notNull()
.default("0"),
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
.notNull()
.default(false),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userIdStatusIdx: index("contas_user_id_status_idx").on(
table.userId,
table.status,
),
}),
);
export const categorias = pgTable(
"categorias",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
type: text("tipo").notNull(),
icon: text("icone"),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdTypeIdx: index("categorias_user_id_type_idx").on(
table.userId,
table.type,
),
}),
);
export const pagadores = pgTable(
"pagadores",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
email: text("email"),
avatarUrl: text("avatar_url"),
status: text("status").notNull(),
note: text("anotacao"),
role: text("role"),
isAutoSend: boolean("is_auto_send").notNull().default(false),
shareCode: text("share_code")
.notNull()
.default(sql`substr(encode(gen_random_bytes(24), 'base64'), 1, 24)`),
lastMailAt: timestamp("last_mail", {
mode: "date",
withTimezone: true,
}),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
table.shareCode,
),
userIdStatusIdx: index("pagadores_user_id_status_idx").on(
table.userId,
table.status,
),
userIdRoleIdx: index("pagadores_user_id_role_idx").on(
table.userId,
table.role,
),
}),
);
export const compartilhamentosPagador = pgTable(
"compartilhamentos_pagador",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
pagadorId: uuid("pagador_id")
.notNull()
.references(() => pagadores.id, { onDelete: "cascade" }),
sharedWithUserId: text("shared_with_user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
permission: text("permission").notNull().default("read"),
createdByUserId: text("created_by_user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
uniqueCompartilhamentoPagador: uniqueIndex(
"compartilhamentos_pagador_unique",
).on(table.pagadorId, table.sharedWithUserId),
}),
);
export const cartoes = pgTable(
"cartoes",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
closingDay: text("dt_fechamento").notNull(),
dueDay: text("dt_vencimento").notNull(),
note: text("anotacao"),
limit: numeric("limite", { precision: 10, scale: 2 }),
brand: text("bandeira"),
logo: text("logo"),
status: text("status").notNull(),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
contaId: uuid("conta_id")
.notNull()
.references(() => contas.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
},
(table) => ({
userIdStatusIdx: index("cartoes_user_id_status_idx").on(
table.userId,
table.status,
),
}),
);
export const faturas = pgTable(
"faturas",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
paymentStatus: text("status_pagamento"),
period: text("periodo"),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
cartaoId: uuid("cartao_id").references(() => cartoes.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
},
(table) => ({
userIdPeriodIdx: index("faturas_user_id_period_idx").on(
table.userId,
table.period,
),
cartaoIdPeriodIdx: index("faturas_cartao_id_period_idx").on(
table.cartaoId,
table.period,
),
}),
);
export const orcamentos = pgTable(
"orcamentos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
amount: numeric("valor", { precision: 10, scale: 2 }).notNull(),
period: text("periodo").notNull(),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
categoriaId: uuid("categoria_id").references(() => categorias.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
},
(table) => ({
userIdPeriodIdx: index("orcamentos_user_id_period_idx").on(
table.userId,
table.period,
),
}),
);
export const anotacoes = pgTable("anotacoes", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
title: text("titulo"),
description: text("descricao"),
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
tasks: text("tasks"), // JSON stringificado com array de tarefas
arquivada: boolean("arquivada").notNull().default(false),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const insightsSalvos = pgTable(
"insights_salvos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
period: text("period").notNull(),
modelId: text("model_id").notNull(),
data: text("data").notNull(), // JSON stringificado com as análises
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userPeriodIdx: uniqueIndex("insights_salvos_user_period_idx").on(
table.userId,
table.period,
),
}),
);
// ===================== OPENMONETIS COMPANION =====================
export const tokensApi = pgTable(
"tokens_api",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(), // Ex: "Meu Samsung Galaxy"
tokenHash: text("token_hash").notNull(), // SHA-256 do token
tokenPrefix: text("token_prefix").notNull(), // Primeiros 8 chars (display)
lastUsedAt: timestamp("last_used_at", { mode: "date", withTimezone: true }),
lastUsedIp: text("last_used_ip"),
expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true }),
revokedAt: timestamp("revoked_at", { mode: "date", withTimezone: true }),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
userIdIdx: index("tokens_api_user_id_idx").on(table.userId),
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
}),
);
export const preLancamentos = pgTable(
"pre_lancamentos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// Informações da fonte
sourceApp: text("source_app").notNull(), // Ex: "com.nu.production"
sourceAppName: text("source_app_name"), // Ex: "Nubank"
// Dados originais da notificação
originalTitle: text("original_title"),
originalText: text("original_text").notNull(),
notificationTimestamp: timestamp("notification_timestamp", {
mode: "date",
withTimezone: true,
}).notNull(),
// Dados parseados (editáveis pelo usuário antes de processar)
parsedName: text("parsed_name"), // Nome do estabelecimento
parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }),
// Status de processamento
status: text("status").notNull().default("pending"), // pending, processed, discarded
// Referência ao lançamento criado (se processado)
lancamentoId: uuid("lancamento_id").references(() => lancamentos.id, {
onDelete: "set null",
}),
// Metadados de processamento
processedAt: timestamp("processed_at", {
mode: "date",
withTimezone: true,
}),
discardedAt: timestamp("discarded_at", {
mode: "date",
withTimezone: true,
}),
// Timestamps
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
userIdStatusIdx: index("pre_lancamentos_user_id_status_idx").on(
table.userId,
table.status,
),
userIdCreatedAtIdx: index("pre_lancamentos_user_id_created_at_idx").on(
table.userId,
table.createdAt,
),
}),
);
export const antecipacoesParcelas = pgTable(
"antecipacoes_parcelas",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
seriesId: uuid("series_id").notNull(),
anticipationPeriod: text("periodo_antecipacao").notNull(),
anticipationDate: date("data_antecipacao", { mode: "date" }).notNull(),
anticipatedInstallmentIds: jsonb("parcelas_antecipadas")
.notNull()
.$type<string[]>(),
totalAmount: numeric("valor_total", { precision: 12, scale: 2 }).notNull(),
installmentCount: smallint("qtde_parcelas").notNull(),
discount: numeric("desconto", { precision: 12, scale: 2 })
.notNull()
.default("0"),
lancamentoId: uuid("lancamento_id")
.notNull()
.references(() => lancamentos.id, { onDelete: "cascade" }),
pagadorId: uuid("pagador_id").references(() => pagadores.id, {
onDelete: "cascade",
}),
categoriaId: uuid("categoria_id").references(() => categorias.id, {
onDelete: "cascade",
}),
note: text("anotacao"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
seriesIdIdx: index("antecipacoes_parcelas_series_id_idx").on(
table.seriesId,
),
userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId),
}),
);
// ===================== RECURRING SERIES =====================
export type RecurringSeriesTemplate = {
name: string;
amount: string;
transactionType: string;
paymentMethod: string;
categoriaId: string | null;
contaId: string | null;
cartaoId: string | null;
pagadorId: string | null;
note: string | null;
condition: string;
};
export const recurringSeries = pgTable(
"recurring_series",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
status: text("status").notNull().default("active"), // "active" | "paused" | "cancelled"
dayOfMonth: smallint("day_of_month").notNull(),
lastGeneratedPeriod: text("last_generated_period").notNull(), // YYYY-MM
templateData: jsonb("template_data")
.notNull()
.$type<RecurringSeriesTemplate>(),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userIdStatusIdx: index("recurring_series_user_id_status_idx").on(
table.userId,
table.status,
),
}),
);
// ===================== LANCAMENTOS =====================
export const lancamentos = pgTable(
"lancamentos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
condition: text("condicao").notNull(),
name: text("nome").notNull(),
paymentMethod: text("forma_pagamento").notNull(),
note: text("anotacao"),
amount: numeric("valor", { precision: 12, scale: 2 }).notNull(),
purchaseDate: date("data_compra", { mode: "date" }).notNull(),
transactionType: text("tipo_transacao").notNull(),
installmentCount: smallint("qtde_parcela"),
period: text("periodo").notNull(),
currentInstallment: smallint("parcela_atual"),
recurrenceCount: integer("qtde_recorrencia"),
dueDate: date("data_vencimento", { mode: "date" }),
boletoPaymentDate: date("dt_pagamento_boleto", { mode: "date" }),
isSettled: boolean("realizado").default(false),
isDivided: boolean("dividido").default(false),
isAnticipated: boolean("antecipado").default(false),
anticipationId: uuid("antecipacao_id").references(
() => antecipacoesParcelas.id,
{ onDelete: "set null" },
),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
cartaoId: uuid("cartao_id").references(() => cartoes.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
contaId: uuid("conta_id").references(() => contas.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
categoriaId: uuid("categoria_id").references(() => categorias.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
pagadorId: uuid("pagador_id").references(() => pagadores.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
seriesId: uuid("series_id"),
transferId: uuid("transfer_id"),
recurringSeriesId: uuid("recurring_series_id").references(
() => recurringSeries.id,
{ onDelete: "set null" },
),
},
(table) => ({
// Índice composto mais importante: userId + period (usado em quase todas as queries do dashboard)
userIdPeriodIdx: index("lancamentos_user_id_period_idx").on(
table.userId,
table.period,
),
// Índice composto userId + period + transactionType (cobre maioria das queries do dashboard)
userIdPeriodTypeIdx: index("lancamentos_user_id_period_type_idx").on(
table.userId,
table.period,
table.transactionType,
),
// Índice para queries por pagador + period (invoice/breakdown queries)
pagadorIdPeriodIdx: index("lancamentos_pagador_id_period_idx").on(
table.pagadorId,
table.period,
),
// Índice para queries ordenadas por data de compra
userIdPurchaseDateIdx: index("lancamentos_user_id_purchase_date_idx").on(
table.userId,
table.purchaseDate,
),
// Índice para buscar parcelas de uma série
seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId),
// Índice para buscar transferências relacionadas
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
// Índice para filtrar por condição (aberto, realizado, cancelado)
userIdConditionIdx: index("lancamentos_user_id_condition_idx").on(
table.userId,
table.condition,
),
// Índice para queries de cartão específico
cartaoIdPeriodIdx: index("lancamentos_cartao_id_period_idx").on(
table.cartaoId,
table.period,
),
}),
);
export const userRelations = relations(user, ({ many, one }) => ({
accounts: many(account),
sessions: many(session),
anotacoes: many(anotacoes),
cartoes: many(cartoes),
categorias: many(categorias),
contas: many(contas),
faturas: many(faturas),
lancamentos: many(lancamentos),
orcamentos: many(orcamentos),
pagadores: many(pagadores),
antecipacoesParcelas: many(antecipacoesParcelas),
tokensApi: many(tokensApi),
preLancamentos: many(preLancamentos),
recurringSeries: many(recurringSeries),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const contasRelations = relations(contas, ({ one, many }) => ({
user: one(user, {
fields: [contas.userId],
references: [user.id],
}),
cartoes: many(cartoes),
lancamentos: many(lancamentos),
}));
export const categoriasRelations = relations(categorias, ({ one, many }) => ({
user: one(user, {
fields: [categorias.userId],
references: [user.id],
}),
lancamentos: many(lancamentos),
orcamentos: many(orcamentos),
}));
export const pagadoresRelations = relations(pagadores, ({ one, many }) => ({
user: one(user, {
fields: [pagadores.userId],
references: [user.id],
}),
lancamentos: many(lancamentos),
compartilhamentos: many(compartilhamentosPagador),
}));
export const compartilhamentosPagadorRelations = relations(
compartilhamentosPagador,
({ one }) => ({
pagador: one(pagadores, {
fields: [compartilhamentosPagador.pagadorId],
references: [pagadores.id],
}),
sharedWithUser: one(user, {
fields: [compartilhamentosPagador.sharedWithUserId],
references: [user.id],
}),
createdByUser: one(user, {
fields: [compartilhamentosPagador.createdByUserId],
references: [user.id],
}),
}),
);
export const cartoesRelations = relations(cartoes, ({ one, many }) => ({
user: one(user, {
fields: [cartoes.userId],
references: [user.id],
}),
conta: one(contas, {
fields: [cartoes.contaId],
references: [contas.id],
}),
faturas: many(faturas),
lancamentos: many(lancamentos),
}));
export const faturasRelations = relations(faturas, ({ one }) => ({
user: one(user, {
fields: [faturas.userId],
references: [user.id],
}),
cartao: one(cartoes, {
fields: [faturas.cartaoId],
references: [cartoes.id],
}),
}));
export const orcamentosRelations = relations(orcamentos, ({ one }) => ({
user: one(user, {
fields: [orcamentos.userId],
references: [user.id],
}),
categoria: one(categorias, {
fields: [orcamentos.categoriaId],
references: [categorias.id],
}),
}));
export const anotacoesRelations = relations(anotacoes, ({ one }) => ({
user: one(user, {
fields: [anotacoes.userId],
references: [user.id],
}),
}));
export const insightsSalvosRelations = relations(insightsSalvos, ({ one }) => ({
user: one(user, {
fields: [insightsSalvos.userId],
references: [user.id],
}),
}));
export const tokensApiRelations = relations(tokensApi, ({ one }) => ({
user: one(user, {
fields: [tokensApi.userId],
references: [user.id],
}),
}));
export const preLancamentosRelations = relations(preLancamentos, ({ one }) => ({
user: one(user, {
fields: [preLancamentos.userId],
references: [user.id],
}),
lancamento: one(lancamentos, {
fields: [preLancamentos.lancamentoId],
references: [lancamentos.id],
}),
}));
export const lancamentosRelations = relations(lancamentos, ({ one }) => ({
user: one(user, {
fields: [lancamentos.userId],
references: [user.id],
}),
cartao: one(cartoes, {
fields: [lancamentos.cartaoId],
references: [cartoes.id],
}),
conta: one(contas, {
fields: [lancamentos.contaId],
references: [contas.id],
}),
categoria: one(categorias, {
fields: [lancamentos.categoriaId],
references: [categorias.id],
}),
pagador: one(pagadores, {
fields: [lancamentos.pagadorId],
references: [pagadores.id],
}),
anticipation: one(antecipacoesParcelas, {
fields: [lancamentos.anticipationId],
references: [antecipacoesParcelas.id],
}),
recurringSeries: one(recurringSeries, {
fields: [lancamentos.recurringSeriesId],
references: [recurringSeries.id],
}),
}));
export const recurringSeriesRelations = relations(
recurringSeries,
({ one, many }) => ({
user: one(user, {
fields: [recurringSeries.userId],
references: [user.id],
}),
lancamentos: many(lancamentos),
}),
);
export const antecipacoesParcRelations = relations(
antecipacoesParcelas,
({ one, many }) => ({
user: one(user, {
fields: [antecipacoesParcelas.userId],
references: [user.id],
}),
lancamento: one(lancamentos, {
fields: [antecipacoesParcelas.lancamentoId],
references: [lancamentos.id],
}),
pagador: one(pagadores, {
fields: [antecipacoesParcelas.pagadorId],
references: [pagadores.id],
}),
categoria: one(categorias, {
fields: [antecipacoesParcelas.categoriaId],
references: [categorias.id],
}),
}),
);
export type User = typeof user.$inferSelect;
export type NewUser = typeof user.$inferInsert;
export type Account = typeof account.$inferSelect;
export type Session = typeof session.$inferSelect;
export type Verification = typeof verification.$inferSelect;
export type PreferenciasUsuario = typeof preferenciasUsuario.$inferSelect;
export type NovasPreferenciasUsuario = typeof preferenciasUsuario.$inferInsert;
export type CompartilhamentoPagador =
typeof compartilhamentosPagador.$inferSelect;
export type Conta = typeof contas.$inferSelect;
export type Categoria = typeof categorias.$inferSelect;
export type Pagador = typeof pagadores.$inferSelect;
export type Cartao = typeof cartoes.$inferSelect;
export type Fatura = typeof faturas.$inferSelect;
export type Orcamento = typeof orcamentos.$inferSelect;
export type Anotacao = typeof anotacoes.$inferSelect;
export type InsightSalvo = typeof insightsSalvos.$inferSelect;
export type Lancamento = typeof lancamentos.$inferSelect;
export type AntecipacaoParcela = typeof antecipacoesParcelas.$inferSelect;
export type TokenApi = typeof tokensApi.$inferSelect;
export type NovoTokenApi = typeof tokensApi.$inferInsert;
export type PreLancamento = typeof preLancamentos.$inferSelect;
export type NovoPreLancamento = typeof preLancamentos.$inferInsert;
export type RecurringSeries = typeof recurringSeries.$inferSelect;
export type NewRecurringSeries = typeof recurringSeries.$inferInsert;

View File

@@ -0,0 +1,394 @@
"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,
} from "@/shared/lib/accounts/constants";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import {
TRANSFER_CATEGORY_NAME,
TRANSFER_CONDITION,
TRANSFER_ESTABLISHMENT_ENTRADA,
TRANSFER_ESTABLISHMENT_SAIDA,
TRANSFER_PAYMENT_METHOD,
} from "@/shared/lib/transfers/constants";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { getTodayInfo } from "@/shared/utils/date";
import { normalizeFilePath } from "@/shared/utils/string";
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"),
});
const createAccountSchema = accountBaseSchema;
const updateAccountSchema = accountBaseSchema.extend({
id: uuidSchema("Conta"),
});
const deleteAccountSchema = z.object({
id: uuidSchema("Conta"),
});
type AccountCreateInput = z.infer<typeof createAccountSchema>;
type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
export async function createAccountAction(
input: AccountCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createAccountSchema.parse(input);
const logoFile = normalizeFilePath(data.logo);
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 });
if (!createdAccount) {
throw new Error("Não foi possível criar a conta.");
}
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),
),
}),
]);
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.",
);
}
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,
});
});
revalidateForEntity("contas");
return {
success: true,
message: "Conta criada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}
export async function updateAccountAction(
input: AccountUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateAccountSchema.parse(input);
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();
if (!updated) {
return {
success: false,
error: "Conta não encontrada.",
};
}
revalidateForEntity("contas");
return {
success: true,
message: "Conta atualizada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}
export async function deleteAccountAction(
input: AccountDeleteInput,
): Promise<ActionResult> {
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 });
if (!deleted) {
return {
success: false,
error: "Conta não encontrada.",
};
}
revalidateForEntity("contas");
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."),
});
type TransferInput = z.infer<typeof transferSchema>;
export async function transferBetweenAccountsAction(
input: TransferInput,
): Promise<ActionResult> {
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.",
};
}
// 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),
),
}),
]);
if (!fromAccount) {
throw new Error("Conta de origem 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),
),
});
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),
),
});
if (!adminPagador) {
throw new Error(
"Pagador administrador não encontrado. Por favor, crie um pagador admin.",
);
}
const transferNote = `de ${fromAccount.name} -> ${toAccount.name}`;
// Create outgoing transaction (transfer from source account)
await tx.insert(lancamentos).values({
condition: TRANSFER_CONDITION,
name: TRANSFER_ESTABLISHMENT_SAIDA,
paymentMethod: TRANSFER_PAYMENT_METHOD,
note: transferNote,
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_ENTRADA,
paymentMethod: TRANSFER_PAYMENT_METHOD,
note: transferNote,
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");
return {
success: true,
message: "Transferência registrada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +1,152 @@
"use client";
import {
RiArrowLeftRightLine,
RiDeleteBin5Line,
RiFileList2Line,
RiInformationLine,
RiPencilLine,
} from "@remixicon/react";
import type React from "react";
import MoneyValues from "@/shared/components/money-values";
import { Card, CardContent, CardFooter } from "@/shared/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui";
interface AccountCardProps {
accountName: string;
accountType: string;
balance: number;
status?: string;
icon?: React.ReactNode;
excludeFromBalance?: boolean;
excludeInitialBalanceFromIncome?: boolean;
onViewStatement?: () => void;
onEdit?: () => void;
onRemove?: () => void;
onTransfer?: () => void;
className?: string;
}
export function AccountCard({
accountName,
accountType,
balance,
status,
icon,
excludeFromBalance,
excludeInitialBalanceFromIncome,
onViewStatement,
onEdit,
onRemove,
onTransfer,
className,
}: AccountCardProps) {
const isInactive = status?.toLowerCase() === "inativa";
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
variant: "default" as const,
},
{
label: "extrato",
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onViewStatement,
variant: "default" as const,
},
{
label: "transferir",
icon: <RiArrowLeftRightLine className="size-4" aria-hidden />,
onClick: onTransfer,
variant: "default" as const,
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
variant: "destructive" as const,
},
].filter((action) => typeof action.onClick === "function");
return (
<Card className={cn("h-full w-full gap-0", className)}>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="flex items-center gap-2">
{icon ? (
<div
className={cn(
"flex items-center justify-center",
isInactive && "[&_img]:grayscale [&_img]:opacity-40",
)}
>
{icon}
</div>
) : null}
<h2 className="text-lg font-semibold text-foreground">
{accountName}
</h2>
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="space-y-1">
{excludeFromBalance && (
<p className="text-xs">
<strong>Desconsiderado do saldo total:</strong> Esta conta
não é incluída no cálculo do saldo total geral.
</p>
)}
{excludeInitialBalanceFromIncome && (
<p className="text-xs">
<strong>
Saldo inicial desconsiderado das receitas:
</strong>{" "}
O saldo inicial desta conta não é contabilizado como
receita nas métricas.
</p>
)}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="space-y-2">
<MoneyValues amount={balance} className="text-3xl" />
<p className="text-sm text-muted-foreground">{accountType}</p>
</div>
</CardContent>
{actions.length > 0 ? (
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
{actions.map(({ label, icon, onClick, variant }) => (
<button
key={label}
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
variant === "destructive" ? "text-destructive" : "text-primary",
)}
aria-label={`${label} conta`}
>
{icon}
{label}
</button>
))}
</CardFooter>
) : null}
</Card>
);
}

View File

@@ -0,0 +1,313 @@
"use client";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createAccountAction,
updateAccountAction,
} from "@/features/accounts/actions";
import {
LogoPickerDialog,
LogoPickerTrigger,
} from "@/shared/components/logo-picker";
import { useLogoSelection } from "@/shared/components/logo-picker/use-logo-selection";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { useControlledState } from "@/shared/hooks/use-controlled-state";
import { useFormState } from "@/shared/hooks/use-form-state";
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
import {
formatInitialBalanceInput,
normalizeDecimalInput,
} from "@/shared/utils/currency";
import { AccountFormFields } from "./account-form-fields";
import type { Account, AccountFormValues } from "./types";
const DEFAULT_ACCOUNT_TYPES = [
"Conta Corrente",
"Conta Poupança",
"Carteira Digital",
"Conta Investimento",
"Pré-Pago | VR/VA",
] as const;
const DEFAULT_ACCOUNT_STATUS = ["Ativa", "Inativa"] as const;
interface AccountDialogProps {
mode: "create" | "update";
trigger?: React.ReactNode;
logoOptions: string[];
account?: Account;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const buildInitialValues = ({
account,
logoOptions,
accountTypes,
accountStatuses,
}: {
account?: Account;
logoOptions: string[];
accountTypes: string[];
accountStatuses: string[];
}): AccountFormValues => {
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
return {
name: account?.name ?? derivedName,
accountType: account?.accountType ?? accountTypes[0] ?? "",
status: account?.status ?? accountStatuses[0] ?? "",
note: account?.note ?? "",
logo: selectedLogo,
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
excludeFromBalance: account?.excludeFromBalance ?? false,
excludeInitialBalanceFromIncome:
account?.excludeInitialBalanceFromIncome ?? false,
};
};
export function AccountDialog({
mode,
trigger,
logoOptions,
account,
open,
onOpenChange,
}: AccountDialogProps) {
const [logoDialogOpen, setLogoDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const accountTypes = useMemo(() => {
const values = new Set<string>(DEFAULT_ACCOUNT_TYPES);
if (account?.accountType) {
values.add(account.accountType);
}
return Array.from(values);
}, [account?.accountType]);
const accountStatuses = useMemo(() => {
const values = new Set<string>(DEFAULT_ACCOUNT_STATUS);
if (account?.status) {
values.add(account.status);
}
return Array.from(values);
}, [account?.status]);
const initialState = useMemo(
() =>
buildInitialValues({
account,
logoOptions,
accountTypes,
accountStatuses,
}),
[account, logoOptions, accountTypes, accountStatuses],
);
// Use form state hook for form management
const { formState, resetForm, updateField, updateFields } =
useFormState<AccountFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, resetForm]);
// Close logo dialog when main dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
setLogoDialogOpen(false);
}
}, [dialogOpen]);
type AccountCreatePayload = Parameters<typeof createAccountAction>[0];
// Use logo selection hook
const handleLogoSelection = useLogoSelection({
mode,
currentLogo: formState.logo,
currentName: formState.name,
onUpdate: (updates) => {
updateFields(updates);
// Delay closing to avoid race condition on mobile
requestAnimationFrame(() => {
setLogoDialogOpen(false);
});
},
});
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const accountId = account?.id;
if (mode === "update" && !accountId) {
const message = "Conta inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload: AccountCreatePayload = {
name: formState.name.trim(),
accountType: formState.accountType,
status: formState.status,
note: formState.note.trim() || null,
logo: formState.logo,
initialBalance: Number(normalizeDecimalInput(formState.initialBalance)),
excludeFromBalance: formState.excludeFromBalance,
excludeInitialBalanceFromIncome:
formState.excludeInitialBalanceFromIncome,
};
if (!payload.logo) {
setErrorMessage("Selecione um logo.");
return;
}
startTransition(async () => {
if (mode === "create") {
const result = await createAccountAction(payload);
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
resetForm(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
return;
}
if (!accountId) {
return;
}
const result = await updateAccountAction({
id: accountId,
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
resetForm(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
};
const title = mode === "create" ? "Nova conta" : "Editar conta";
const description =
mode === "create"
? "Cadastre uma nova conta para organizar seus lançamentos."
: "Atualize as informações da conta selecionada.";
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
const handleMainDialogOpenChange = (open: boolean) => {
if (!open && logoDialogOpen) {
return;
}
setDialogOpen(open);
};
return (
<>
<Dialog open={dialogOpen} onOpenChange={handleMainDialogOpenChange}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent
className="sm:max-w-xl"
onPointerDownOutside={(e) => {
if (logoDialogOpen) e.preventDefault();
}}
onInteractOutside={(e) => {
if (logoDialogOpen) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<LogoPickerTrigger
selectedLogo={formState.logo}
disabled={logoOptions.length === 0}
onOpen={() => {
if (logoOptions.length > 0) {
setLogoDialogOpen(true);
}
}}
/>
</div>
<AccountFormFields
values={formState}
accountTypes={accountTypes}
accountStatuses={accountStatuses}
onChange={updateField}
showInitialBalance={mode === "create"}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<LogoPickerDialog
open={logoDialogOpen}
logos={logoOptions}
value={formState.logo}
onOpenChange={setLogoDialogOpen}
onSelect={handleLogoSelection}
/>
</>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Textarea } from "@/shared/components/ui/textarea";
import { StatusSelectContent } from "./account-select-items";
import type { AccountFormValues } from "./types";
interface AccountFormFieldsProps {
values: AccountFormValues;
accountTypes: string[];
accountStatuses: string[];
onChange: (field: keyof AccountFormValues, value: string) => void;
showInitialBalance?: boolean;
}
export function AccountFormFields({
values,
accountTypes,
accountStatuses,
onChange,
showInitialBalance = true,
}: AccountFormFieldsProps) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="account-name">Nome</Label>
<Input
id="account-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Nubank"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="account-type">Tipo de conta</Label>
<Select
value={values.accountType}
onValueChange={(value) => onChange("accountType", value)}
>
<SelectTrigger id="account-type" className="w-full">
<SelectValue placeholder="Selecione o tipo" />
</SelectTrigger>
<SelectContent>
{accountTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="account-status">Status</Label>
<Select
value={values.status}
onValueChange={(value) => onChange("status", value)}
>
<SelectTrigger id="account-status" className="w-full">
<SelectValue placeholder="Selecione o status">
{values.status && <StatusSelectContent label={values.status} />}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accountStatuses.map((status) => (
<SelectItem key={status} value={status}>
<StatusSelectContent label={status} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showInitialBalance ? (
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="account-initial-balance">Saldo inicial</Label>
<CurrencyInput
id="account-initial-balance"
value={values.initialBalance}
onValueChange={(value) => onChange("initialBalance", value)}
placeholder="R$ 0,00"
/>
</div>
) : null}
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="account-note">Anotação</Label>
<Textarea
id="account-note"
value={values.note}
onChange={(event) => onChange("note", event.target.value)}
placeholder="Informações adicionais sobre a conta"
/>
</div>
<div className="flex flex-col gap-3 sm:col-span-2">
<div className="flex items-center gap-2">
<Checkbox
id="exclude-from-balance"
checked={
values.excludeFromBalance === true ||
values.excludeFromBalance === "true"
}
onCheckedChange={(checked) =>
onChange("excludeFromBalance", checked ? "true" : "false")
}
/>
<Label
htmlFor="exclude-from-balance"
className="cursor-pointer text-sm font-normal leading-tight"
>
Desconsiderar do saldo total (útil para contas de investimento ou
reserva)
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="exclude-initial-balance-from-income"
checked={
values.excludeInitialBalanceFromIncome === true ||
values.excludeInitialBalanceFromIncome === "true"
}
onCheckedChange={(checked) =>
onChange(
"excludeInitialBalanceFromIncome",
checked ? "true" : "false",
)
}
/>
<Label
htmlFor="exclude-initial-balance-from-income"
className="cursor-pointer text-sm font-normal leading-tight"
>
Desconsiderar o saldo inicial ao calcular o total de receitas
</Label>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
import StatusDot from "@/shared/components/status-dot";
export function StatusSelectContent({ label }: { label: string }) {
const isActive = label === "Ativa";
return (
<span className="flex items-center gap-2">
<StatusDot
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
/>
<span>{label}</span>
</span>
);
}

View File

@@ -0,0 +1,205 @@
"use client";
import { RiInformationLine } from "@remixicon/react";
import Image from "next/image";
import type { ReactNode } from "react";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency";
import { cn } from "@/shared/utils/ui";
type DetailValue = string | number | ReactNode;
type AccountStatementCardProps = {
accountName: string;
accountType: string;
status: string;
periodLabel: string;
currentBalance: number;
openingBalance: number;
totalIncomes: number;
totalExpenses: number;
logo?: string | null;
actions?: React.ReactNode;
};
const getAccountStatusBadgeVariant = (
status: string,
): "success" | "outline" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativa") {
return "success";
}
return "outline";
};
export function AccountStatementCard({
accountName,
accountType,
status,
periodLabel,
currentBalance,
openingBalance,
totalIncomes,
totalExpenses,
logo,
actions,
}: AccountStatementCardProps) {
const logoPath = resolveLogoSrc(logo);
return (
<Card className="border">
<CardHeader className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-background">
<Image
src={logoPath}
alt={`Logo da conta ${accountName}`}
width={48}
height={48}
className="h-full w-full object-contain"
/>
</div>
) : null}
<div className="flex w-full items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
{accountName}
</CardTitle>
<p className="text-sm text-muted-foreground">
Extrato de {periodLabel}
</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
{/* Composição do Saldo */}
<div className="space-y-3">
<DetailItem
label="Saldo no início do período"
value={<MoneyValues amount={openingBalance} className="text-2xl" />}
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DetailItem
label="Entradas"
value={
<span className="font-medium text-success">
{formatCurrency(totalIncomes)}
</span>
}
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
/>
<DetailItem
label="Saídas"
value={
<span className="font-medium text-destructive">
{formatCurrency(totalExpenses)}
</span>
}
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
/>
<DetailItem
label="Resultado do período"
value={
<MoneyValues
amount={totalIncomes - totalExpenses}
className={cn(
"font-semibold text-xl",
totalIncomes - totalExpenses >= 0
? "text-success"
: "text-destructive",
)}
/>
}
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
/>
</div>
{/* Saldo Atual - Destaque Principal */}
<DetailItem
label="Saldo ao final do período"
value={<MoneyValues amount={currentBalance} className="text-2xl" />}
tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês."
/>
</div>
{/* Informações da Conta */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 pt-2 border-t border-border/60 border-dashed">
<DetailItem
label="Tipo da conta"
value={accountType}
tooltip="Classificação definida na criação da conta (corrente, poupança, etc.)."
/>
<DetailItem
label="Status da conta"
value={
<div className="flex items-center">
<Badge
variant={getAccountStatusBadgeVariant(status)}
className="text-xs"
>
{status}
</Badge>
</div>
}
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada."
/>
</div>
</CardContent>
</Card>
);
}
function DetailItem({
label,
value,
className,
tooltip,
}: {
label: string;
value: DetailValue;
className?: string;
tooltip?: string;
}) {
return (
<div className={cn("space-y-1", className)}>
<span className="flex items-center gap-1 text-xs font-medium uppercase text-muted-foreground/80">
{label}
{tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="size-3.5 cursor-help text-muted-foreground/60" />
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs text-xs"
>
{tooltip}
</TooltipContent>
</Tooltip>
) : null}
</span>
<div className="text-base text-foreground">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,240 @@
"use client";
import { RiAddCircleLine, RiBankLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/features/accounts/actions";
import { AccountCard } from "@/features/accounts/components/account-card";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { EmptyState } from "@/shared/components/empty-state";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { getCurrentPeriod } from "@/shared/utils/period";
import { AccountDialog } from "./account-dialog";
import { TransferDialog } from "./transfer-dialog";
import type { Account } from "./types";
interface AccountsPageProps {
accounts: Account[];
archivedAccounts: Account[];
logoOptions: string[];
}
export function AccountsPage({
accounts,
archivedAccounts,
logoOptions,
}: AccountsPageProps) {
const router = useRouter();
const [activeTab, setActiveTab] = useState("ativos");
const [editOpen, setEditOpen] = useState(false);
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
const [removeOpen, setRemoveOpen] = useState(false);
const [accountToRemove, setAccountToRemove] = useState<Account | null>(null);
const [transferOpen, setTransferOpen] = useState(false);
const [transferFromAccount, setTransferFromAccount] =
useState<Account | null>(null);
const sortAccounts = (list: Account[]) =>
[...list].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
);
const orderedAccounts = sortAccounts(accounts);
const orderedArchivedAccounts = sortAccounts(archivedAccounts);
const handleEdit = (account: Account) => {
setSelectedAccount(account);
setEditOpen(true);
};
const handleEditOpenChange = (open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedAccount(null);
}
};
const handleRemoveRequest = (account: Account) => {
setAccountToRemove(account);
setRemoveOpen(true);
};
const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open);
if (!open) {
setAccountToRemove(null);
}
};
const handleRemoveConfirm = async () => {
if (!accountToRemove) {
return;
}
const result = await deleteAccountAction({ id: accountToRemove.id });
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleTransferRequest = (account: Account) => {
setTransferFromAccount(account);
setTransferOpen(true);
};
const handleTransferOpenChange = (open: boolean) => {
setTransferOpen(open);
if (!open) {
setTransferFromAccount(null);
}
};
const removeTitle = accountToRemove
? `Remover conta "${accountToRemove.name}"?`
: "Remover conta?";
const renderAccountList = (list: Account[], isArchived: boolean) => {
if (list.length === 0) {
return (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiBankLine className="size-6 text-primary" />}
title={
isArchived
? "Nenhuma conta arquivada"
: "Nenhuma conta cadastrada"
}
description={
isArchived
? "As contas arquivadas aparecerão aqui."
: "Cadastre sua primeira conta para começar a organizar os lançamentos."
}
/>
</Card>
);
}
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{list.map((account) => {
const logoSrc = resolveLogoSrc(account.logo) ?? undefined;
return (
<AccountCard
key={account.id}
accountName={account.name}
accountType={`${account.accountType}`}
balance={account.balance ?? account.initialBalance ?? 0}
status={account.status}
excludeFromBalance={account.excludeFromBalance}
excludeInitialBalanceFromIncome={
account.excludeInitialBalanceFromIncome
}
icon={
logoSrc ? (
<Image
src={logoSrc}
alt={`Logo da conta ${account.name}`}
width={42}
height={42}
className="rounded-full"
/>
) : undefined
}
onEdit={() => handleEdit(account)}
onRemove={() => handleRemoveRequest(account)}
onTransfer={() => handleTransferRequest(account)}
onViewStatement={() =>
router.push(`/accounts/${account.id}/statement`)
}
/>
);
})}
</div>
);
};
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex">
<AccountDialog
mode="create"
logoOptions={logoOptions}
trigger={
<Button className="w-full sm:w-auto">
<RiAddCircleLine className="size-4" />
Nova conta
</Button>
}
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList>
<TabsTrigger value="ativos">Ativas</TabsTrigger>
<TabsTrigger value="arquivados">Arquivadas</TabsTrigger>
</TabsList>
<TabsContent value="ativos" className="mt-4">
{renderAccountList(orderedAccounts, false)}
</TabsContent>
<TabsContent value="arquivados" className="mt-4">
{renderAccountList(orderedArchivedAccounts, true)}
</TabsContent>
</Tabs>
</div>
<AccountDialog
mode="update"
logoOptions={logoOptions}
account={selectedAccount ?? undefined}
open={editOpen && !!selectedAccount}
onOpenChange={handleEditOpenChange}
/>
<ConfirmActionDialog
open={removeOpen && !!accountToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover esta conta, todos os dados relacionados a ela serão perdidos."
confirmLabel="Remover conta"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
{transferFromAccount && (
<TransferDialog
accounts={accounts.map((a) => ({
...a,
balance: a.balance ?? a.initialBalance ?? 0,
excludeFromBalance: a.excludeFromBalance ?? false,
excludeInitialBalanceFromIncome:
a.excludeInitialBalanceFromIncome ?? false,
}))}
fromAccountId={transferFromAccount.id}
currentPeriod={getCurrentPeriod()}
open={transferOpen}
onOpenChange={handleTransferOpenChange}
/>
)}
</>
);
}

View File

@@ -0,0 +1,253 @@
"use client";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { transferBetweenAccountsAction } from "@/features/accounts/actions";
import type { AccountData } from "@/features/accounts/queries";
import { ContaCartaoSelectContent } from "@/features/transactions/components/select-items";
import { PeriodPicker } from "@/shared/components/period-picker";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { useControlledState } from "@/shared/hooks/use-controlled-state";
import { getTodayDateString } from "@/shared/utils/date";
interface TransferDialogProps {
trigger?: React.ReactNode;
accounts: AccountData[];
fromAccountId: string;
currentPeriod: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function TransferDialog({
trigger,
accounts,
fromAccountId,
currentPeriod,
open,
onOpenChange,
}: TransferDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Form state
const [toAccountId, setToAccountId] = useState("");
const [amount, setAmount] = useState("");
const [date, setDate] = useState(getTodayDateString());
const [period, setPeriod] = useState(currentPeriod);
// Available destination accounts (exclude source account)
const availableAccounts = accounts.filter(
(account) => account.id !== fromAccountId,
);
// Source account info
const fromAccount = accounts.find((account) => account.id === fromAccountId);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (!toAccountId) {
setErrorMessage("Selecione a conta de destino.");
return;
}
if (toAccountId === fromAccountId) {
setErrorMessage("Selecione uma conta de destino diferente da origem.");
return;
}
if (!amount || parseFloat(amount.replace(",", ".")) <= 0) {
setErrorMessage("Informe um valor válido maior que zero.");
return;
}
startTransition(async () => {
const result = await transferBetweenAccountsAction({
fromAccountId,
toAccountId,
amount,
date: new Date(date),
period,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
// Reset form
setToAccountId("");
setAmount("");
setDate(getTodayDateString());
setPeriod(currentPeriod);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
};
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Transferir entre contas</DialogTitle>
<DialogDescription>
Registre uma transferência de valores entre suas contas.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="transfer-date">Data da transferência</Label>
<DatePicker
id="transfer-date"
value={date}
onChange={setDate}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="transfer-period">Período</Label>
<PeriodPicker
value={period}
onChange={setPeriod}
className="w-full"
/>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="transfer-amount">Valor</Label>
<CurrencyInput
id="transfer-amount"
value={amount}
onValueChange={setAmount}
placeholder="R$ 0,00"
required
/>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="from-account">Conta de origem</Label>
<Select value={fromAccountId} disabled>
<SelectTrigger id="from-account" className="w-full">
<SelectValue>
{fromAccount && (
<ContaCartaoSelectContent
label={fromAccount.name}
logo={fromAccount.logo}
isCartao={false}
/>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{fromAccount && (
<SelectItem value={fromAccount.id}>
<ContaCartaoSelectContent
label={fromAccount.name}
logo={fromAccount.logo}
isCartao={false}
/>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="to-account">Conta de destino</Label>
{availableAccounts.length === 0 ? (
<div className="rounded-md border border-border bg-muted p-3 text-sm text-muted-foreground">
É necessário ter mais de uma conta cadastrada para realizar
transferências.
</div>
) : (
<Select value={toAccountId} onValueChange={setToAccountId}>
<SelectTrigger id="to-account" className="w-full">
<SelectValue placeholder="Selecione a conta de destino">
{toAccountId &&
(() => {
const selectedAccount = availableAccounts.find(
(acc) => acc.id === toAccountId,
);
return selectedAccount ? (
<ContaCartaoSelectContent
label={selectedAccount.name}
logo={selectedAccount.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-full">
{availableAccounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<ContaCartaoSelectContent
label={account.name}
logo={account.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isPending || availableAccounts.length === 0}
>
{isPending ? "Processando..." : "Confirmar transferência"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,23 @@
export type Account = {
id: string;
name: string;
accountType: string;
status: string;
note: string | null;
logo: string | null;
initialBalance: number;
balance?: number | null;
excludeFromBalance?: boolean;
excludeInitialBalanceFromIncome?: boolean;
};
export type AccountFormValues = {
name: string;
accountType: string;
status: string;
note: string;
logo: string;
initialBalance: string;
excludeFromBalance: boolean;
excludeInitialBalanceFromIncome: boolean;
};

View File

@@ -0,0 +1,188 @@
import { and, eq, ilike, not, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { loadLogoOptions } from "@/shared/lib/logo/options";
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
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;
};
export async function fetchAccountsForUser(
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>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
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(),
]);
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 };
}
export async function fetchInativosForUser(
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>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
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(),
]);
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 };
}
export async function fetchAllAccountsForUser(userId: string): Promise<{
activeAccounts: AccountData[];
archivedAccounts: AccountData[];
logoOptions: LogoOption[];
}> {
const [activeData, archivedData] = await Promise.all([
fetchAccountsForUser(userId),
fetchInativosForUser(userId),
]);
return {
activeAccounts: activeData.accounts,
archivedAccounts: archivedData.accounts,
logoOptions: activeData.logoOptions,
};
}

View File

@@ -0,0 +1,151 @@
import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
export type AccountSummaryData = {
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)),
});
return account;
}
export async function fetchAccountSummary(
userId: string,
contaId: string,
selectedPeriod: string,
): Promise<AccountSummaryData> {
const [periodSummary] = await db
.select({
netAmount: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
0
)
`,
incomes: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
when ${lancamentos.transactionType} = 'Receita' then ${lancamentos.amount}
else 0
end
),
0
)
`,
expenses: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount}
else 0
end
),
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),
),
);
const [previousRow] = await db
.select({
previousMovements: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
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),
),
);
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;
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

@@ -0,0 +1,17 @@
import { RiTerminalLine } from "@remixicon/react";
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
interface AuthErrorAlertProps {
error: string;
}
export function AuthErrorAlert({ error }: AuthErrorAlertProps) {
if (!error) return null;
return (
<Alert className="mt-2 border border-destructive" variant="destructive">
<RiTerminalLine className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/shared/utils/ui";
interface AuthHeaderProps {
title: string;
}
export function AuthHeader({ title }: AuthHeaderProps) {
return (
<div className={cn("flex flex-col gap-1.5")}>
<h1 className="text-xl font-semibold tracking-tight text-card-foreground">
{title}
</h1>
</div>
);
}

View File

@@ -0,0 +1,19 @@
function AuthSidebar() {
return (
<div className="relative hidden flex-col overflow-hidden bg-primary md:flex">
<div className="relative flex flex-1 flex-col justify-between p-8">
<div className="space-y-4">
<h2 className="text-3xl font-semibold leading-tight">
Controle suas finanças com clareza e foco diário.
</h2>
<p className="text-sm opacity-90">
Centralize despesas, organize cartões e acompanhe metas mensais em
um painel inteligente feito para o seu dia a dia.
</p>
</div>
</div>
</div>
);
}
export default AuthSidebar;

View File

@@ -0,0 +1,54 @@
import { RiLoader4Line } from "@remixicon/react";
import { Button } from "@/shared/components/ui/button";
interface GoogleAuthButtonProps {
onClick: () => void;
loading?: boolean;
disabled?: boolean;
text?: string;
}
export function GoogleAuthButton({
onClick,
loading = false,
disabled = false,
text = "Continuar com Google",
}: GoogleAuthButtonProps) {
return (
<Button
variant="outline"
type="button"
onClick={onClick}
disabled={disabled || loading}
className="w-full gap-2"
>
{loading ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="h-5 w-5"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)}
<span>{text}</span>
</Button>
);
}

View File

@@ -0,0 +1,248 @@
"use client";
import { RiFingerprintLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/shared/components/logo";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/shared/components/ui/field";
import { Input } from "@/shared/components/ui/input";
import { authClient, googleSignInAvailable } from "@/shared/lib/auth/client";
import { cn } from "@/shared/utils/ui";
import { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header";
import AuthSidebar from "./auth-sidebar";
import { GoogleAuthButton } from "./google-auth-button";
type DivProps = React.ComponentProps<"div">;
export function LoginForm({ className, ...props }: DivProps) {
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
const [loadingPasskey, setLoadingPasskey] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (typeof PublicKeyCredential === "undefined") return;
setPasskeySupported(true);
}, []);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
await authClient.signIn.email(
{
email,
password,
callbackURL: "/dashboard",
rememberMe: false,
},
{
onRequest: () => {
setError("");
setLoadingEmail(true);
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("Login realizado com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {
if (
ctx.error.status === 500 &&
ctx.error.statusText === "Internal Server Error"
) {
toast.error(
"Ocorreu uma falha na requisição. Tente novamente mais tarde.",
);
}
setError(ctx.error.message);
setLoadingEmail(false);
},
},
);
}
async function handleGoogle() {
if (!isGoogleAvailable) {
setError("Login com Google não está disponível no momento.");
return;
}
// Ativa loading antes de iniciar o fluxo OAuth
setError("");
setLoadingGoogle(true);
// OAuth redirect - o loading permanece até a página ser redirecionada
await authClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
onError: (ctx) => {
// Só desativa loading se houver erro
setError(ctx.error.message);
setLoadingGoogle(false);
},
},
);
}
async function handlePasskey() {
setError("");
setLoadingPasskey(true);
const { error: passkeyError } = await authClient.signIn.passkey({
fetchOptions: {
onSuccess: () => {
setLoadingPasskey(false);
router.replace("/dashboard");
},
onError: (ctx) => {
setError(ctx.error.message);
setLoadingPasskey(false);
},
},
});
if (passkeyError) {
setError(passkeyError.message || "Erro ao entrar com passkey.");
setLoadingPasskey(false);
}
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" />
<Card className="overflow-hidden p-0">
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
<form
className="flex flex-col gap-6 p-6 md:p-8"
onSubmit={handleSubmit}
noValidate
>
<FieldGroup className="gap-4">
<AuthHeader title="Entrar no OpenMonetis" />
<AuthErrorAlert error={error} />
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="Digite seu e-mail"
autoComplete="username webauthn"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Senha</FieldLabel>
</div>
<Input
id="password"
type="password"
required
placeholder="Digite sua senha"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<Button
type="submit"
disabled={loadingEmail || loadingGoogle || loadingPasskey}
className="w-full"
>
{loadingEmail ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
"Entrar"
)}
</Button>
</Field>
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
Ou continue com
</FieldSeparator>
<Field>
<GoogleAuthButton
onClick={handleGoogle}
loading={loadingGoogle}
disabled={
loadingEmail ||
loadingGoogle ||
loadingPasskey ||
!isGoogleAvailable
}
text="Entrar com Google"
/>
</Field>
{passkeySupported && (
<Field>
<Button
variant="outline"
type="button"
onClick={handlePasskey}
disabled={loadingEmail || loadingGoogle || loadingPasskey}
className="w-full gap-2"
>
{loadingPasskey ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiFingerprintLine className="h-5 w-5" />
)}
<span>Entrar com passkey</span>
</Button>
</Field>
)}
<FieldDescription className="text-center">
Não tem uma conta?{" "}
<a href="/signup" className="underline underline-offset-4">
Inscreva-se
</a>
</FieldDescription>
</FieldGroup>
</form>
<AuthSidebar />
</CardContent>
</Card>
<FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4">
Voltar para o site
</a>
</FieldDescription>
</div>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/shared/components/logo";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/shared/components/ui/field";
import { Input } from "@/shared/components/ui/input";
import { authClient, googleSignInAvailable } from "@/shared/lib/auth/client";
import { cn } from "@/shared/utils/ui";
import { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header";
import AuthSidebar from "./auth-sidebar";
import { GoogleAuthButton } from "./google-auth-button";
interface PasswordValidation {
hasLowercase: boolean;
hasUppercase: boolean;
hasNumber: boolean;
hasSpecial: boolean;
hasMinLength: boolean;
hasMaxLength: boolean;
isValid: boolean;
}
function validatePassword(password: string): PasswordValidation {
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
return {
hasLowercase,
hasUppercase,
hasNumber,
hasSpecial,
hasMinLength,
hasMaxLength,
isValid:
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecial &&
hasMinLength &&
hasMaxLength,
};
}
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met ? "text-success" : "text-muted-foreground",
)}
>
{met ? (
<RiCheckLine className="h-3.5 w-3.5" />
) : (
<RiCloseLine className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</div>
);
}
type DivProps = React.ComponentProps<"div">;
export function SignupForm({ className, ...props }: DivProps) {
const router = useRouter();
const isGoogleAvailable = googleSignInAvailable;
const [fullname, setFullname] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
const passwordValidation = validatePassword(password);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!passwordValidation.isValid) {
setError("A senha não atende aos requisitos de segurança.");
return;
}
await authClient.signUp.email(
{
email,
password,
name: fullname,
},
{
onRequest: () => {
setError("");
setLoadingEmail(true);
},
onSuccess: () => {
setLoadingEmail(false);
toast.success("Conta criada com sucesso!");
router.replace("/dashboard");
},
onError: (ctx) => {
setError(ctx.error.message);
setLoadingEmail(false);
},
},
);
}
async function handleGoogle() {
if (!isGoogleAvailable) {
setError("Login com Google não está disponível no momento.");
return;
}
// Ativa loading antes de iniciar o fluxo OAuth
setError("");
setLoadingGoogle(true);
// OAuth redirect - o loading permanece até a página ser redirecionada
await authClient.signIn.social(
{
provider: "google",
callbackURL: "/dashboard",
},
{
onError: (ctx) => {
// Só desativa loading se houver erro
setError(ctx.error.message);
setLoadingGoogle(false);
},
},
);
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Logo className="mb-2" />
<Card className="overflow-hidden p-0">
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
<form
className="flex flex-col gap-6 p-6 md:p-8"
onSubmit={handleSubmit}
noValidate
>
<FieldGroup className="gap-4">
<AuthHeader title="Criar sua conta" />
<AuthErrorAlert error={error} />
<Field>
<FieldLabel htmlFor="name">Nome completo</FieldLabel>
<Input
id="name"
type="text"
placeholder="Digite seu nome"
autoComplete="name"
required
value={fullname}
onChange={(e) => setFullname(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="Digite seu e-mail"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
/>
</Field>
<Field>
<FieldLabel htmlFor="password">Senha</FieldLabel>
<Input
id="password"
type="password"
required
autoComplete="new-password"
placeholder="Crie uma senha forte"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={
!!error ||
(password.length > 0 && !passwordValidation.isValid)
}
maxLength={23}
/>
{password.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<PasswordRequirement
met={passwordValidation.hasMinLength}
label="Mínimo 7 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasMaxLength}
label="Máximo 23 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasLowercase}
label="Letra minúscula"
/>
<PasswordRequirement
met={passwordValidation.hasUppercase}
label="Letra maiúscula"
/>
<PasswordRequirement
met={passwordValidation.hasNumber}
label="Número"
/>
<PasswordRequirement
met={passwordValidation.hasSpecial}
label="Caractere especial"
/>
</div>
)}
</Field>
<Field>
<Button
type="submit"
disabled={
loadingEmail ||
loadingGoogle ||
(password.length > 0 && !passwordValidation.isValid)
}
className="w-full"
>
{loadingEmail ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
"Criar conta"
)}
</Button>
</Field>
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
Ou continue com
</FieldSeparator>
<Field>
<GoogleAuthButton
onClick={handleGoogle}
loading={loadingGoogle}
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
text="Continuar com Google"
/>
</Field>
<FieldDescription className="text-center">
tem uma conta?{" "}
<a href="/login" className="underline underline-offset-4">
Entrar
</a>
</FieldDescription>
</FieldGroup>
</form>
<AuthSidebar />
</CardContent>
</Card>
<FieldDescription className="text-center">
<a href="/" className="underline underline-offset-4">
Voltar para o site
</a>
</FieldDescription>
</div>
);
}

View File

@@ -0,0 +1,272 @@
"use server";
import { and, eq, ne } from "drizzle-orm";
import { z } from "zod";
import { categorias, orcamentos } from "@/db/schema";
import {
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { periodSchema, uuidSchema } from "@/shared/lib/schemas/common";
import type { ActionResult } from "@/shared/lib/types/actions";
import {
formatDecimalForDbRequired,
normalizeDecimalInput,
} from "@/shared/utils/currency";
import { getPreviousPeriod } from "@/shared/utils/period";
const budgetBaseSchema = z.object({
categoriaId: uuidSchema("Categoria"),
period: periodSchema,
amount: z
.string({ message: "Informe o valor limite." })
.trim()
.min(1, "Informe o valor limite.")
.transform((value) => normalizeDecimalInput(value))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor limite válido.",
)
.transform((value) => Number.parseFloat(value))
.refine(
(value) => value >= 0,
"O valor limite deve ser maior ou igual a zero.",
),
});
const createBudgetSchema = budgetBaseSchema;
const updateBudgetSchema = budgetBaseSchema.extend({
id: uuidSchema("Orçamento"),
});
const deleteBudgetSchema = z.object({
id: uuidSchema("Orçamento"),
});
type BudgetCreateInput = z.input<typeof createBudgetSchema>;
type BudgetUpdateInput = z.input<typeof updateBudgetSchema>;
type BudgetDeleteInput = z.input<typeof deleteBudgetSchema>;
type BudgetCopyRow = {
categoriaId: string | null;
amount: unknown;
};
const ensureCategory = async (userId: string, categoriaId: string) => {
const category = await db.query.categorias.findFirst({
columns: {
id: true,
type: true,
},
where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)),
});
if (!category) {
throw new Error("Categoria não encontrada.");
}
if (category.type !== "despesa") {
throw new Error("Selecione uma categoria de despesa.");
}
};
export async function createBudgetAction(
input: BudgetCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createBudgetSchema.parse(input);
await ensureCategory(user.id, data.categoriaId);
const duplicateConditions = [
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
eq(orcamentos.categoriaId, data.categoriaId),
] as const;
const duplicate = await db.query.orcamentos.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
if (duplicate) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
};
}
await db.insert(orcamentos).values({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
userId: user.id,
categoriaId: data.categoriaId,
});
revalidateForEntity("orcamentos");
return { success: true, message: "Orçamento criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateBudgetAction(
input: BudgetUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateBudgetSchema.parse(input);
await ensureCategory(user.id, data.categoriaId);
const duplicateConditions = [
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
eq(orcamentos.categoriaId, data.categoriaId),
ne(orcamentos.id, data.id),
] as const;
const duplicate = await db.query.orcamentos.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
if (duplicate) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
};
}
const [updated] = await db
.update(orcamentos)
.set({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
categoriaId: data.categoriaId,
})
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
.returning({ id: orcamentos.id });
if (!updated) {
return {
success: false,
error: "Orçamento não encontrado.",
};
}
revalidateForEntity("orcamentos");
return { success: true, message: "Orçamento atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteBudgetAction(
input: BudgetDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteBudgetSchema.parse(input);
const [deleted] = await db
.delete(orcamentos)
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
.returning({ id: orcamentos.id });
if (!deleted) {
return {
success: false,
error: "Orçamento não encontrado.",
};
}
revalidateForEntity("orcamentos");
return { success: true, message: "Orçamento removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
const duplicatePreviousMonthSchema = z.object({
period: periodSchema,
});
type DuplicatePreviousMonthInput = z.input<typeof duplicatePreviousMonthSchema>;
export async function duplicatePreviousMonthBudgetsAction(
input: DuplicatePreviousMonthInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = duplicatePreviousMonthSchema.parse(input);
// Calcular mês anterior
const previousPeriod = getPreviousPeriod(data.period);
// Buscar orçamentos do mês anterior
const previousBudgets = (await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, previousPeriod),
),
})) as BudgetCopyRow[];
if (previousBudgets.length === 0) {
return {
success: false,
error: "Não foram encontrados orçamentos no mês anterior.",
};
}
// Buscar orçamentos existentes do mês atual
const currentBudgets = (await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
),
})) as BudgetCopyRow[];
// Filtrar para evitar duplicatas
const existingCategoryIds = new Set(
currentBudgets.map((b) => b.categoriaId),
);
const budgetsToCopy = previousBudgets.filter(
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId),
);
if (budgetsToCopy.length === 0) {
return {
success: false,
error:
"Todas as categorias do mês anterior já possuem orçamento neste mês.",
};
}
// Inserir novos orçamentos
await db.insert(orcamentos).values(
budgetsToCopy.map((b) => ({
amount: b.amount,
period: data.period,
userId: user.id,
categoriaId: b.categoriaId as string,
})),
);
revalidateForEntity("orcamentos");
return {
success: true,
message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}

Some files were not shown because too many files have changed in this diff Show More