mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
11
src/app/(auth)/login/page.tsx
Normal file
11
src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/(auth)/signup/page.tsx
Normal file
11
src/app/(auth)/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
179
src/app/(dashboard)/accounts/[accountId]/statement/page.tsx
Normal file
179
src/app/(dashboard)/accounts/[accountId]/statement/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/(dashboard)/accounts/layout.tsx
Normal file
25
src/app/(dashboard)/accounts/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/(dashboard)/accounts/loading.tsx
Normal file
33
src/app/(dashboard)/accounts/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/app/(dashboard)/accounts/page.tsx
Normal file
19
src/app/(dashboard)/accounts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/budgets/layout.tsx
Normal file
23
src/app/(dashboard)/budgets/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/app/(dashboard)/budgets/loading.tsx
Normal file
65
src/app/(dashboard)/budgets/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/(dashboard)/budgets/page.tsx
Normal file
54
src/app/(dashboard)/budgets/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/calendar/layout.tsx
Normal file
23
src/app/(dashboard)/calendar/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/app/(dashboard)/calendar/loading.tsx
Normal file
59
src/app/(dashboard)/calendar/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/(dashboard)/calendar/page.tsx
Normal file
46
src/app/(dashboard)/calendar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/app/(dashboard)/cards/[cardId]/invoice/loading.tsx
Normal file
41
src/app/(dashboard)/cards/[cardId]/invoice/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/app/(dashboard)/cards/[cardId]/invoice/page.tsx
Normal file
209
src/app/(dashboard)/cards/[cardId]/invoice/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/(dashboard)/cards/layout.tsx
Normal file
25
src/app/(dashboard)/cards/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/(dashboard)/cards/loading.tsx
Normal file
30
src/app/(dashboard)/cards/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/app/(dashboard)/cards/page.tsx
Normal file
20
src/app/(dashboard)/cards/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/app/(dashboard)/categories/[categoryId]/page.tsx
Normal file
105
src/app/(dashboard)/categories/[categoryId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/(dashboard)/categories/history/loading.tsx
Normal file
33
src/app/(dashboard)/categories/history/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/(dashboard)/categories/history/page.tsx
Normal file
17
src/app/(dashboard)/categories/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/categories/layout.tsx
Normal file
23
src/app/(dashboard)/categories/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/app/(dashboard)/categories/loading.tsx
Normal file
63
src/app/(dashboard)/categories/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/(dashboard)/categories/page.tsx
Normal file
14
src/app/(dashboard)/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/app/(dashboard)/dashboard/loading.tsx
Normal file
19
src/app/(dashboard)/dashboard/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/app/(dashboard)/dashboard/page.tsx
Normal file
75
src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/inbox/layout.tsx
Normal file
23
src/app/(dashboard)/inbox/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/(dashboard)/inbox/loading.tsx
Normal file
33
src/app/(dashboard)/inbox/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/app/(dashboard)/inbox/page.tsx
Normal file
38
src/app/(dashboard)/inbox/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/insights/layout.tsx
Normal file
23
src/app/(dashboard)/insights/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/app/(dashboard)/insights/loading.tsx
Normal file
39
src/app/(dashboard)/insights/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/app/(dashboard)/insights/page.tsx
Normal file
31
src/app/(dashboard)/insights/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/app/(dashboard)/layout.tsx
Normal file
70
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/notes/layout.tsx
Normal file
23
src/app/(dashboard)/notes/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/app/(dashboard)/notes/loading.tsx
Normal file
48
src/app/(dashboard)/notes/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/(dashboard)/notes/page.tsx
Normal file
14
src/app/(dashboard)/notes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/app/(dashboard)/payers/[payerId]/loading.tsx
Normal file
74
src/app/(dashboard)/payers/[payerId]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
406
src/app/(dashboard)/payers/[payerId]/page.tsx
Normal file
406
src/app/(dashboard)/payers/[payerId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/payers/layout.tsx
Normal file
23
src/app/(dashboard)/payers/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/app/(dashboard)/payers/loading.tsx
Normal file
57
src/app/(dashboard)/payers/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/(dashboard)/payers/page.tsx
Normal file
14
src/app/(dashboard)/payers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/reports/card-usage/layout.tsx
Normal file
23
src/app/(dashboard)/reports/card-usage/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/app/(dashboard)/reports/card-usage/loading.tsx
Normal file
88
src/app/(dashboard)/reports/card-usage/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/app/(dashboard)/reports/card-usage/page.tsx
Normal file
80
src/app/(dashboard)/reports/card-usage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/reports/category-trends/layout.tsx
Normal file
23
src/app/(dashboard)/reports/category-trends/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/(dashboard)/reports/category-trends/loading.tsx
Normal file
9
src/app/(dashboard)/reports/category-trends/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/app/(dashboard)/reports/category-trends/page.tsx
Normal file
114
src/app/(dashboard)/reports/category-trends/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/reports/establishments/layout.tsx
Normal file
23
src/app/(dashboard)/reports/establishments/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/app/(dashboard)/reports/establishments/loading.tsx
Normal file
58
src/app/(dashboard)/reports/establishments/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/app/(dashboard)/reports/establishments/page.tsx
Normal file
76
src/app/(dashboard)/reports/establishments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/reports/installment-analysis/layout.tsx
Normal file
23
src/app/(dashboard)/reports/installment-analysis/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/(dashboard)/reports/installment-analysis/page.tsx
Normal file
14
src/app/(dashboard)/reports/installment-analysis/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/settings/changelog/layout.tsx
Normal file
23
src/app/(dashboard)/settings/changelog/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/(dashboard)/settings/changelog/page.tsx
Normal file
12
src/app/(dashboard)/settings/changelog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/settings/layout.tsx
Normal file
23
src/app/(dashboard)/settings/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
src/app/(dashboard)/settings/page.tsx
Normal file
175
src/app/(dashboard)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/(dashboard)/transactions/layout.tsx
Normal file
25
src/app/(dashboard)/transactions/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/(dashboard)/transactions/loading.tsx
Normal file
32
src/app/(dashboard)/transactions/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/app/(dashboard)/transactions/page.tsx
Normal file
97
src/app/(dashboard)/transactions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/app/(landing-page)/layout.tsx
Normal file
95
src/app/(landing-page)/layout.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1008
src/app/(landing-page)/page.tsx
Normal file
1008
src/app/(landing-page)/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
4
src/app/api/auth/[...all]/route.ts
Normal file
4
src/app/api/auth/[...all]/route.ts
Normal 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);
|
||||
85
src/app/api/auth/device/refresh/route.ts
Normal file
85
src/app/api/auth/device/refresh/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/app/api/auth/device/token/route.ts
Normal file
71
src/app/api/auth/device/token/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
src/app/api/auth/device/tokens/[tokenId]/route.ts
Normal file
55
src/app/api/auth/device/tokens/[tokenId]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/auth/device/tokens/route.ts
Normal file
42
src/app/api/auth/device/tokens/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/app/api/auth/device/verify/route.ts
Normal file
73
src/app/api/auth/device/verify/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/health/route.ts
Normal file
44
src/app/api/health/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
163
src/app/api/inbox/batch/route.ts
Normal file
163
src/app/api/inbox/batch/route.ts
Normal 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
133
src/app/api/inbox/route.ts
Normal 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
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
53
src/app/error.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
368
src/app/globals.css
Normal file
368
src/app/globals.css
Normal 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
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
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
38
src/app/layout.tsx
Normal 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
21
src/app/manifest.json
Normal 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
35
src/app/not-found.tsx
Normal 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
35
src/app/robots.ts
Normal 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
16
src/app/sitemap.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user