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