ajuste de layout mobile, melhorias e criação de novas funções. Detalhes adicionados no CHANGELOG.md

This commit is contained in:
Guilherme Bano
2026-02-18 23:21:14 -03:00
committed by Felipe Coutinho
parent 31fe752b7d
commit ffde55f589
29 changed files with 857 additions and 213 deletions

View File

@@ -70,6 +70,8 @@ const VALID_FONTS = [
const updatePreferencesSchema = z.object({
disableMagnetlines: z.boolean(),
extratoNoteAsColumn: z.boolean(),
lancamentosColumnOrder: z.array(z.string()).nullable(),
systemFont: z.enum(VALID_FONTS).default("ai-sans"),
moneyFont: z.enum(VALID_FONTS).default("ai-sans"),
});
@@ -417,6 +419,8 @@ export async function updatePreferencesAction(
.update(schema.preferenciasUsuario)
.set({
disableMagnetlines: validated.disableMagnetlines,
extratoNoteAsColumn: validated.extratoNoteAsColumn,
lancamentosColumnOrder: validated.lancamentosColumnOrder,
systemFont: validated.systemFont,
moneyFont: validated.moneyFont,
updatedAt: new Date(),
@@ -427,6 +431,8 @@ export async function updatePreferencesAction(
await db.insert(schema.preferenciasUsuario).values({
userId: session.user.id,
disableMagnetlines: validated.disableMagnetlines,
extratoNoteAsColumn: validated.extratoNoteAsColumn,
lancamentosColumnOrder: validated.lancamentosColumnOrder,
systemFont: validated.systemFont,
moneyFont: validated.moneyFont,
});

View File

@@ -4,6 +4,8 @@ import { db, schema } from "@/lib/db";
export interface UserPreferences {
disableMagnetlines: boolean;
extratoNoteAsColumn: boolean;
lancamentosColumnOrder: string[] | null;
systemFont: string;
moneyFont: string;
}
@@ -32,6 +34,8 @@ export async function fetchUserPreferences(
const result = await db
.select({
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn,
lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder,
systemFont: schema.preferenciasUsuario.systemFont,
moneyFont: schema.preferenciasUsuario.moneyFont,
})

View File

@@ -1,3 +1,4 @@
import { RiArrowRightSLine } from "@remixicon/react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
@@ -35,17 +36,28 @@ export default async function Page() {
return (
<div className="w-full">
<Tabs defaultValue="preferencias" className="w-full">
<TabsList>
<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="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="changelog">Changelog</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive">
Deletar conta
</TabsTrigger>
</TabsList>
{/* 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="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="changelog">Changelog</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-gradient-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">
@@ -61,6 +73,12 @@ export default async function Page() {
disableMagnetlines={
userPreferences?.disableMagnetlines ?? false
}
extratoNoteAsColumn={
userPreferences?.extratoNoteAsColumn ?? false
}
lancamentosColumnOrder={
userPreferences?.lancamentosColumnOrder ?? null
}
systemFont={userPreferences?.systemFont ?? "ai-sans"}
moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
/>

View File

@@ -1,5 +1,6 @@
import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation";
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { CardDialog } from "@/components/cartoes/card-dialog";
import type { Card } from "@/components/cartoes/types";
@@ -51,12 +52,13 @@ export default async function Page({ params, searchParams }: PageProps) {
notFound();
}
const [filterSources, logoOptions, invoiceData, estabelecimentos] =
const [filterSources, logoOptions, invoiceData, estabelecimentos, userPreferences] =
await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchInvoiceData(userId, cartaoId, selectedPeriod),
getRecentEstablishmentsAction(),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
@@ -182,6 +184,8 @@ export default async function Page({ params, searchParams }: PageProps) {
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
defaultCartaoId={card.id}
defaultPaymentMethod="Cartão de crédito"
lockCartaoSelection

View File

@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
@@ -36,10 +37,11 @@ export default async function Page({ params, searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [detail, filterSources, estabelecimentos] = await Promise.all([
const [detail, filterSources, estabelecimentos, userPreferences] = await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchLancamentoFilterSources(userId),
getRecentEstablishmentsAction(),
fetchUserPreferences(userId),
]);
if (!detail) {
@@ -92,6 +94,8 @@ export default async function Page({ params, searchParams }: PageProps) {
selectedPeriod={detail.period}
estabelecimentos={estabelecimentos}
allowCreate={true}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
/>
</main>
);

View File

@@ -1,5 +1,6 @@
import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation";
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { AccountDialog } from "@/components/contas/account-dialog";
import { AccountStatementCard } from "@/components/contas/account-statement-card";
@@ -57,12 +58,13 @@ export default async function Page({ params, searchParams }: PageProps) {
notFound();
}
const [filterSources, logoOptions, accountSummary, estabelecimentos] =
const [filterSources, logoOptions, accountSummary, estabelecimentos, userPreferences] =
await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchAccountSummary(userId, contaId, selectedPeriod),
getRecentEstablishmentsAction(),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
@@ -161,6 +163,8 @@ export default async function Page({ params, searchParams }: PageProps) {
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate={false}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
/>
</section>
</main>

View File

@@ -1,3 +1,4 @@
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { getUserId } from "@/lib/auth/server";
@@ -31,7 +32,10 @@ export default async function Page({ searchParams }: PageProps) {
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const filterSources = await fetchLancamentoFilterSources(userId);
const [filterSources, userPreferences] = await Promise.all([
fetchLancamentoFilterSources(userId),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
@@ -80,6 +84,8 @@ export default async function Page({ searchParams }: PageProps) {
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
/>
</main>
);

View File

@@ -70,7 +70,7 @@ export default async function DashboardLayout({
/>
<SidebarInset>
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col pt-12 md:pt-0">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6">
{children}

View File

@@ -4,6 +4,7 @@ import {
RiWallet3Line,
} from "@remixicon/react";
import { notFound } from "next/navigation";
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import type {
@@ -168,6 +169,7 @@ export default async function Page({ params, searchParams }: PageProps) {
shareRows,
currentUserShare,
estabelecimentos,
userPreferences,
] = await Promise.all([
fetchPagadorLancamentos(filters),
fetchPagadorMonthlyBreakdown({
@@ -203,6 +205,7 @@ export default async function Page({ params, searchParams }: PageProps) {
sharesPromise,
currentUserSharePromise,
getRecentEstablishmentsAction(),
fetchUserPreferences(userId),
]);
const mappedLancamentos = mapLancamentosData(lancamentoRows);
@@ -381,6 +384,8 @@ export default async function Page({ params, searchParams }: PageProps) {
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate={canEdit}
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
importSplitPagadorOptions={
loggedUserOptionSets?.splitPagadorOptions

View File

@@ -0,0 +1,23 @@
import { RiPieChartLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Gastos por categoria | OpenMonetis",
};
export default function GastosPorCategoriaLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiPieChartLine />}
title="Gastos por categoria"
subtitle="Visualize suas despesas divididas por categoria no mês selecionado. Altere o mês para comparar períodos."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,30 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function GastosPorCategoriaLoading() {
return (
<main className="flex flex-col gap-6">
<div className="h-14 animate-pulse rounded-xl bg-foreground/10" />
<div className="rounded-xl border p-4 md:p-6 space-y-4">
<div className="flex gap-2">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-20 rounded-lg" />
</div>
<div className="space-y-3 pt-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-2 border-b border-dashed">
<div className="flex items-center gap-2">
<Skeleton className="size-10 rounded-lg" />
<div className="space-y-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-5 w-20" />
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,100 @@
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiPieChartLine,
} from "@remixicon/react";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
import MoneyValues from "@/components/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getUserId } from "@/lib/auth/server";
import { fetchExpensesByCategory } from "@/lib/dashboard/categories/expenses-by-category";
import { calculatePercentageChange } from "@/lib/utils/math";
import { parsePeriodParam } from "@/lib/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 GastosPorCategoriaPage({
searchParams,
}: PageProps) {
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const data = await fetchExpensesByCategory(userId, selectedPeriod);
const percentageChange = calculatePercentageChange(
data.currentTotal,
data.previousTotal,
);
const hasIncrease = percentageChange !== null && percentageChange > 0;
const hasDecrease = percentageChange !== null && percentageChange < 0;
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<RiPieChartLine className="size-4 text-primary" />
Resumo do mês
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<p className="text-xs text-muted-foreground">
Total de despesas no mês
</p>
<MoneyValues
amount={data.currentTotal}
className="text-2xl font-semibold"
/>
</div>
{percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-sm ${
hasIncrease
? "text-destructive"
: hasDecrease
? "text-success"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpSFill className="size-4" />}
{hasDecrease && <RiArrowDownSFill className="size-4" />}
{percentageChange > 0 ? "+" : ""}
{percentageChange.toFixed(1)}% em relação ao mês anterior
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
Mês anterior: <MoneyValues amount={data.previousTotal} />
</p>
</CardContent>
</Card>
<Card className="p-4 md:p-6">
<ExpensesByCategoryWidgetWithChart
data={data}
period={selectedPeriod}
/>
</Card>
</main>
);
}