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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user