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

@@ -5,6 +5,30 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [1.6.0] - 2026-02-18
### Adicionado
- Item "Gastos por categoria" no menu lateral (seção Análise), com link para `/relatorios/gastos-por-categoria`
- Gráfico de pizza moderno (estilo donut) na página Gastos por categoria: fatias com espaçamento, labels de percentual nas fatias maiores, legenda ao lado
- Fatias do gráfico e itens da legenda clicáveis — navegam para a página de detalhe da categoria no período selecionado
- Preferência "Anotações em coluna" em Ajustes > Extrato e lançamentos: quando ativa, a anotação dos lançamentos aparece em coluna na tabela; quando inativa, permanece no balão (tooltip) no ícone
- Preferência "Ordem das colunas" em Ajustes > Extrato e lançamentos: lista ordenável por arraste para definir a ordem das colunas na tabela do extrato e dos lançamentos (Estabelecimento, Transação, Valor, etc.); a linha inteira é arrastável
- Coluna `extrato_note_as_column` e `lancamentos_column_order` na tabela `preferencias_usuario` (migrations 0017 e 0018)
- Constantes e labels das colunas reordenáveis em `lib/lancamentos/column-order.ts`
### Alterado
- Tooltip do gráfico de pizza em Gastos por categoria oculto no mobile (evita informação flutuante em telas pequenas)
- Header do dashboard fixo apenas no mobile (`fixed top-0` com `md:static`); conteúdo com `pt-12 md:pt-0` para não ficar sob o header
- Abas da página Ajustes (Preferências, Companion, etc.): no mobile, rolagem horizontal com seta indicando mais opções à direita; scrollbar oculta
- Botões "Novo orçamento" e "Copiar orçamentos do último mês": no mobile, rolagem horizontal (`h-8`, `text-xs`)
- Botões "Nova Receita", "Nova Despesa" e ícone de múltiplos lançamentos: no mobile, mesma rolagem horizontal + botões menores
- Tabela de lançamentos aplica a ordem de colunas salva nas preferências (extrato, lançamentos, categoria, fatura, pagador)
- Adicionado variavel no docker compose para manter o caminho do volume no compose up/down
**Contribuições:** [Guilherme Bano](https://github.com/Gbano1)
## [1.5.3] - 2026-02-21 ## [1.5.3] - 2026-02-21
### Adicionado ### Adicionado
@@ -222,3 +246,4 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
- Atualização de dependências - Atualização de dependências
- Aplicada formatação no código - Aplicada formatação no código

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { RiArrowRightSLine } from "@remixicon/react";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -35,17 +36,28 @@ export default async function Page() {
return ( return (
<div className="w-full"> <div className="w-full">
<Tabs defaultValue="preferencias" className="w-full"> <Tabs defaultValue="preferencias" className="w-full">
<TabsList> {/* No mobile: rolagem horizontal + seta indicando mais opções à direita */}
<TabsTrigger value="preferencias">Preferências</TabsTrigger> <div className="relative -mx-6 px-6 md:mx-0 md:px-0">
<TabsTrigger value="companion">Companion</TabsTrigger> <div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsTrigger value="nome">Alterar nome</TabsTrigger> <TabsList className="inline-flex w-max flex-nowrap md:w-full">
<TabsTrigger value="senha">Alterar senha</TabsTrigger> <TabsTrigger value="preferencias">Preferências</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger> <TabsTrigger value="companion">Companion</TabsTrigger>
<TabsTrigger value="changelog">Changelog</TabsTrigger> <TabsTrigger value="nome">Alterar nome</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive"> <TabsTrigger value="senha">Alterar senha</TabsTrigger>
Deletar conta <TabsTrigger value="email">Alterar e-mail</TabsTrigger>
</TabsTrigger> <TabsTrigger value="changelog">Changelog</TabsTrigger>
</TabsList> <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"> <TabsContent value="preferencias" className="mt-4">
<Card className="p-6"> <Card className="p-6">
@@ -61,6 +73,12 @@ export default async function Page() {
disableMagnetlines={ disableMagnetlines={
userPreferences?.disableMagnetlines ?? false userPreferences?.disableMagnetlines ?? false
} }
extratoNoteAsColumn={
userPreferences?.extratoNoteAsColumn ?? false
}
lancamentosColumnOrder={
userPreferences?.lancamentosColumnOrder ?? null
}
systemFont={userPreferences?.systemFont ?? "ai-sans"} systemFont={userPreferences?.systemFont ?? "ai-sans"}
moneyFont={userPreferences?.moneyFont ?? "ai-sans"} moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,17 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import type { ChangelogVersion } from "@/lib/changelog/parse-changelog"; import type { ChangelogVersion } from "@/lib/changelog/parse-changelog";
/** Converte "[texto](url)" em link; texto simples fica como está */
function parseContributorLine(content: string) {
const linkMatch = content.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/);
if (linkMatch) {
return { label: linkMatch[1], url: linkMatch[2] };
}
return { label: content, url: null };
}
const sectionBadgeVariant: Record< const sectionBadgeVariant: Record<
string, string,
"success" | "info" | "destructive" | "secondary" "success" | "info" | "destructive" | "secondary"
@@ -46,6 +56,29 @@ export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
</ul> </ul>
</div> </div>
))} ))}
{version.contributor && (
<div className="border-t pt-4 mt-4">
<span className="text-sm text-muted-foreground">
Contribuições:{" "}
{(() => {
const { label, url } = parseContributorLine(version.contributor);
if (url) {
return (
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary"
>
{label}
</Link>
);
}
return <span className="font-medium text-foreground">{label}</span>;
})()}
</span>
</div>
)}
</div> </div>
</Card> </Card>
))} ))}

View File

@@ -1,5 +1,17 @@
"use client"; "use client";
import {
DndContext,
closestCenter,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { RiDragMove2Line } from "@remixicon/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -15,16 +27,58 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
DEFAULT_LANCAMENTOS_COLUMN_ORDER,
LANCAMENTOS_COLUMN_LABELS,
} from "@/lib/lancamentos/column-order";
import { FONT_OPTIONS, getFontVariable } from "@/public/fonts/font_index"; import { FONT_OPTIONS, getFontVariable } from "@/public/fonts/font_index";
interface PreferencesFormProps { interface PreferencesFormProps {
disableMagnetlines: boolean; disableMagnetlines: boolean;
extratoNoteAsColumn: boolean;
lancamentosColumnOrder: string[] | null;
systemFont: string; systemFont: string;
moneyFont: string; moneyFont: string;
} }
function SortableColumnItem({ id }: { id: string }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const label = LANCAMENTOS_COLUMN_LABELS[id] ?? id;
return (
<div
ref={setNodeRef}
style={style}
className={`flex cursor-grab active:cursor-grabbing items-center gap-2 rounded-md border bg-card px-3 py-2 text-sm touch-none select-none ${
isDragging ? "z-10 opacity-90 shadow-md" : ""
}`}
aria-label={`Arrastar ${label}`}
{...attributes}
{...listeners}
>
<RiDragMove2Line className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span>{label}</span>
</div>
);
}
export function PreferencesForm({ export function PreferencesForm({
disableMagnetlines, disableMagnetlines,
extratoNoteAsColumn: initialExtratoNoteAsColumn,
lancamentosColumnOrder: initialColumnOrder,
systemFont: initialSystemFont, systemFont: initialSystemFont,
moneyFont: initialMoneyFont, moneyFont: initialMoneyFont,
}: PreferencesFormProps) { }: PreferencesFormProps) {
@@ -32,10 +86,33 @@ export function PreferencesForm({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [magnetlinesDisabled, setMagnetlinesDisabled] = const [magnetlinesDisabled, setMagnetlinesDisabled] =
useState(disableMagnetlines); useState(disableMagnetlines);
const [extratoNoteAsColumn, setExtratoNoteAsColumn] =
useState(initialExtratoNoteAsColumn);
const [columnOrder, setColumnOrder] = useState<string[]>(
initialColumnOrder && initialColumnOrder.length > 0
? initialColumnOrder
: DEFAULT_LANCAMENTOS_COLUMN_ORDER,
);
const [selectedSystemFont, setSelectedSystemFont] = const [selectedSystemFont, setSelectedSystemFont] =
useState(initialSystemFont); useState(initialSystemFont);
const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont); const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor),
);
const handleColumnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setColumnOrder((items) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const fontCtx = useFont(); const fontCtx = useFont();
// Live preview: update CSS vars when font selection changes // Live preview: update CSS vars when font selection changes
@@ -53,6 +130,8 @@ export function PreferencesForm({
startTransition(async () => { startTransition(async () => {
const result = await updatePreferencesAction({ const result = await updatePreferencesAction({
disableMagnetlines: magnetlinesDisabled, disableMagnetlines: magnetlinesDisabled,
extratoNoteAsColumn,
lancamentosColumnOrder: columnOrder,
systemFont: selectedSystemFont, systemFont: selectedSystemFont,
moneyFont: selectedMoneyFont, moneyFont: selectedMoneyFont,
}); });
@@ -148,7 +227,59 @@ export function PreferencesForm({
<div className="border-b" /> <div className="border-b" />
{/* Seção 3: Dashboard */} {/* Seção: Extrato / Lançamentos */}
<section className="space-y-4">
<div>
<h3 className="text-base font-semibold">Extrato e lançamentos</h3>
<p className="text-sm text-muted-foreground">
Como exibir anotações e a ordem das colunas na tabela de movimentações.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
<div className="space-y-0.5">
<Label htmlFor="extrato-note-column" className="text-base">
Anotações em coluna
</Label>
<p className="text-sm text-muted-foreground">
Quando ativo, as anotações aparecem em uma coluna na tabela. Quando desativado, aparecem em um balão ao passar o mouse no ícone.
</p>
</div>
<Switch
id="extrato-note-column"
checked={extratoNoteAsColumn}
onCheckedChange={setExtratoNoteAsColumn}
disabled={isPending}
/>
</div>
<div className="space-y-2 max-w-md">
<Label className="text-base">Ordem das colunas</Label>
<p className="text-sm text-muted-foreground">
Arraste os itens para definir a ordem em que as colunas aparecem na tabela do extrato e dos lançamentos.
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleColumnDragEnd}
>
<SortableContext
items={columnOrder}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-2 pt-2">
{columnOrder.map((id) => (
<SortableColumnItem key={id} id={id} />
))}
</div>
</SortableContext>
</DndContext>
</div>
</section>
<div className="border-b" />
{/* Seção: Dashboard */}
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<h3 className="text-base font-semibold">Dashboard</h3> <h3 className="text-base font-semibold">Dashboard</h3>

View File

@@ -4,20 +4,19 @@ import {
RiArrowDownSFill, RiArrowDownSFill,
RiArrowUpSFill, RiArrowUpSFill,
RiExternalLinkLine, RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine, RiPieChartLine,
RiWallet3Line, RiWallet3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; import { useRouter } from "next/navigation";
import { Pie, PieChart, Tooltip } from "recharts"; import { useMemo } from "react";
import { Cell, Pie, PieChart, Tooltip } from "recharts";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import { useIsMobile } from "@/hooks/use-mobile";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category"; import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import { formatPeriodForUrl } from "@/lib/utils/period"; import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state"; import { WidgetEmptyState } from "../widget-empty-state";
type ExpensesByCategoryWidgetWithChartProps = { type ExpensesByCategoryWidgetWithChartProps = {
@@ -35,11 +34,21 @@ const formatCurrency = (value: number) =>
currency: "BRL", currency: "BRL",
}).format(value); }).format(value);
type ChartDataItem = {
category: string;
name: string;
value: number;
percentage: number;
fill: string | undefined;
href: string | undefined;
};
export function ExpensesByCategoryWidgetWithChart({ export function ExpensesByCategoryWidgetWithChart({
data, data,
period, period,
}: ExpensesByCategoryWidgetWithChartProps) { }: ExpensesByCategoryWidgetWithChartProps) {
const [activeTab, setActiveTab] = useState<"list" | "chart">("list"); const router = useRouter();
const isMobile = useIsMobile();
const periodParam = formatPeriodForUrl(period); const periodParam = formatPeriodForUrl(period);
// Configuração do chart com cores do CSS // Configuração do chart com cores do CSS
@@ -80,50 +89,68 @@ export function ExpensesByCategoryWidgetWithChart({
return config; return config;
}, [data.categories]); }, [data.categories]);
// Preparar dados para o gráfico de pizza - Top 7 + Outros // Preparar dados para o gráfico de pizza - Top 7 + Outros (com href para navegação)
const chartData = useMemo(() => { const chartData = useMemo((): ChartDataItem[] => {
const buildItem = (
categoryId: string,
name: string,
value: number,
percentage: number,
fill: string | undefined,
): ChartDataItem => ({
category: categoryId,
name,
value,
percentage,
fill,
href:
categoryId === "outros"
? undefined
: `/categorias/${categoryId}?periodo=${periodParam}`,
});
if (data.categories.length <= 7) { if (data.categories.length <= 7) {
return data.categories.map((category) => ({ return data.categories.map((category) =>
category: category.categoryId, buildItem(
name: category.categoryName, category.categoryId,
value: category.currentAmount, category.categoryName,
percentage: category.percentageOfTotal, category.currentAmount,
fill: chartConfig[category.categoryId]?.color, category.percentageOfTotal,
})); chartConfig[category.categoryId]?.color,
),
);
} }
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7); const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7); const others = data.categories.slice(7);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0); const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce( const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal, (sum, cat) => sum + cat.percentageOfTotal,
0, 0,
); );
const top7Data = top7.map((category) => ({ const top7Data = top7.map((category) =>
category: category.categoryId, buildItem(
name: category.categoryName, category.categoryId,
value: category.currentAmount, category.categoryName,
percentage: category.percentageOfTotal, category.currentAmount,
fill: chartConfig[category.categoryId]?.color, category.percentageOfTotal,
})); chartConfig[category.categoryId]?.color,
),
// Adicionar "Outros" se houver );
if (others.length > 0) { if (others.length > 0) {
top7Data.push({ top7Data.push(
category: "outros", buildItem(
name: "Outros", "outros",
value: othersTotal, "Outros",
percentage: othersPercentage, othersTotal,
fill: chartConfig.outros?.color, othersPercentage,
}); chartConfig.outros?.color,
),
);
} }
return top7Data; return top7Data;
}, [data.categories, chartConfig]); }, [data.categories, chartConfig, periodParam]);
if (data.categories.length === 0) { if (data.categories.length === 0) {
return ( return (
@@ -136,25 +163,146 @@ export function ExpensesByCategoryWidgetWithChart({
} }
return ( return (
<Tabs <div className="flex flex-col gap-8">
value={activeTab} {/* Gráfico de pizza (donut) — fatias clicáveis */}
onValueChange={(v) => setActiveTab(v as "list" | "chart")} <div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:gap-8">
className="w-full" <ChartContainer
> config={chartConfig}
<div className="flex items-center justify-between"> className="h-[280px] w-full min-w-0 sm:h-[320px] sm:max-w-[360px]"
<TabsList className="grid grid-cols-2"> >
<TabsTrigger value="list" className="text-xs"> <PieChart margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<RiListUnordered className="size-3.5 mr-1" /> <Pie
Lista data={chartData}
</TabsTrigger> cx="50%"
<TabsTrigger value="chart" className="text-xs"> cy="50%"
<RiPieChart2Line className="size-3.5 mr-1" /> innerRadius="58%"
Gráfico outerRadius="92%"
</TabsTrigger> paddingAngle={2}
</TabsList> dataKey="value"
nameKey="category"
stroke="transparent"
onClick={(payload: ChartDataItem) => {
if (payload?.href) router.push(payload.href);
}}
label={(props: {
cx?: number;
cy?: number;
midAngle?: number;
innerRadius?: number;
outerRadius?: number;
percent?: number;
}) => {
const { cx = 0, cy = 0, midAngle = 0, innerRadius = 0, outerRadius = 0, percent = 0 } = props;
const percentage = percent * 100;
if (percentage < 6) return null;
const radius = (Number(innerRadius) + Number(outerRadius)) / 2;
const x = cx + radius * Math.cos(-midAngle * (Math.PI / 180));
const y = cy + radius * Math.sin(-midAngle * (Math.PI / 180));
return (
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
className="fill-foreground text-[10px] font-medium"
>
{formatPercentage(percentage)}
</text>
);
}}
labelLine={false}
>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.fill}
className={
entry.href
? "cursor-pointer transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
: ""
}
style={
entry.href
? { filter: "drop-shadow(0 1px 2px rgb(0 0 0 / 0.08))" }
: undefined
}
/>
))}
</Pie>
{!isMobile && (
<Tooltip
content={({ active, payload }) => {
if (active && payload?.length) {
const d = payload[0].payload as ChartDataItem;
return (
<div className="rounded-xl border border-border/80 bg-card px-3 py-2.5 shadow-lg">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-foreground">
{d.name}
</span>
<span className="text-sm font-semibold tabular-nums">
{formatCurrency(d.value)}
</span>
<span className="text-[10px] text-muted-foreground">
{formatPercentage(d.percentage)} do total
</span>
{d.href && (
<span className="mt-1 text-[10px] text-primary">
Clique para ver detalhes
</span>
)}
</div>
</div>
);
}
return null;
}}
cursor={false}
/>
)}
</PieChart>
</ChartContainer>
{/* Legenda clicável */}
<div className="flex flex-wrap gap-x-4 gap-y-2 sm:flex-1 sm:flex-col sm:gap-1.5">
{chartData.map((entry, index) => {
const content = (
<>
<span
className="size-3 shrink-0 rounded-full ring-1 ring-border/50"
style={{ backgroundColor: entry.fill }}
aria-hidden
/>
<span className="truncate text-sm text-muted-foreground">
{entry.name}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground/80">
{formatPercentage(entry.percentage)}
</span>
</>
);
return entry.href ? (
<Link
key={`legend-${index}`}
href={entry.href}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{content}
</Link>
) : (
<div
key={`legend-${index}`}
className="flex items-center gap-2 rounded-lg px-2 py-1.5"
>
{content}
</div>
);
})}
</div>
</div> </div>
<TabsContent value="list" className="mt-0"> {/* Lista de categorias */}
<div className="border-t border-dashed pt-6">
<div className="flex flex-col px-0"> <div className="flex flex-col px-0">
{data.categories.map((category, index) => { {data.categories.map((category, index) => {
const hasIncrease = const hasIncrease =
@@ -264,65 +412,7 @@ export function ExpensesByCategoryWidgetWithChart({
); );
})} })}
</div> </div>
</TabsContent> </div>
</div>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
); );
} }

View File

@@ -16,7 +16,7 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
const _user = await getUser(); const _user = await getUser();
return ( return (
<header className="border-b flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> <header className="fixed top-0 left-0 right-0 z-50 border-b bg-background md:static md:z-auto flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6"> <div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">

View File

@@ -48,6 +48,8 @@ interface LancamentosPageProps {
selectedPeriod: string; selectedPeriod: string;
estabelecimentos: string[]; estabelecimentos: string[];
allowCreate?: boolean; allowCreate?: boolean;
noteAsColumn?: boolean;
columnOrder?: string[] | null;
defaultCartaoId?: string | null; defaultCartaoId?: string | null;
defaultPaymentMethod?: string | null; defaultPaymentMethod?: string | null;
lockCartaoSelection?: boolean; lockCartaoSelection?: boolean;
@@ -76,6 +78,8 @@ export function LancamentosPage({
selectedPeriod, selectedPeriod,
estabelecimentos, estabelecimentos,
allowCreate = true, allowCreate = true,
noteAsColumn = false,
columnOrder = null,
defaultCartaoId, defaultCartaoId,
defaultPaymentMethod, defaultPaymentMethod,
lockCartaoSelection, lockCartaoSelection,
@@ -377,6 +381,8 @@ export function LancamentosPage({
<LancamentosTable <LancamentosTable
data={lancamentos} data={lancamentos}
currentUserId={currentUserId} currentUserId={currentUserId}
noteAsColumn={noteAsColumn}
columnOrder={columnOrder}
pagadorFilterOptions={pagadorFilterOptions} pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions} categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions} contaCartaoFilterOptions={contaCartaoFilterOptions}

View File

@@ -3,6 +3,7 @@ import {
RiAddCircleFill, RiAddCircleFill,
RiAddCircleLine, RiAddCircleLine,
RiArrowLeftRightLine, RiArrowLeftRightLine,
RiArrowRightSLine,
RiChat1Line, RiChat1Line,
RiCheckLine, RiCheckLine,
RiDeleteBin5Line, RiDeleteBin5Line,
@@ -68,6 +69,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order";
import { getAvatarSrc } from "@/lib/pagadores/utils"; import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatDate } from "@/lib/utils/date"; import { formatDate } from "@/lib/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons"; import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
@@ -92,6 +94,7 @@ const resolveLogoSrc = (logo: string | null) => {
type BuildColumnsArgs = { type BuildColumnsArgs = {
currentUserId: string; currentUserId: string;
noteAsColumn: boolean;
onEdit?: (item: LancamentoItem) => void; onEdit?: (item: LancamentoItem) => void;
onCopy?: (item: LancamentoItem) => void; onCopy?: (item: LancamentoItem) => void;
onImport?: (item: LancamentoItem) => void; onImport?: (item: LancamentoItem) => void;
@@ -106,6 +109,7 @@ type BuildColumnsArgs = {
const buildColumns = ({ const buildColumns = ({
currentUserId, currentUserId,
noteAsColumn,
onEdit, onEdit,
onCopy, onCopy,
onImport, onImport,
@@ -269,7 +273,7 @@ const buildColumns = ({
</Tooltip> </Tooltip>
)} )}
{hasNote ? ( {!noteAsColumn && hasNote ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-muted/60"> <span className="inline-flex rounded-full p-1 hover:bg-muted/60">
@@ -493,6 +497,24 @@ const buildColumns = ({
}, },
]; ];
if (noteAsColumn) {
const contaCartaoIndex = columns.findIndex((c) => c.id === "contaCartao");
const noteColumn: ColumnDef<LancamentoItem> = {
accessorKey: "note",
header: "Anotação",
cell: ({ row }) => {
const note = row.original.note;
if (!note?.trim()) return <span className="text-muted-foreground"></span>;
return (
<span className="max-w-[200px] truncate whitespace-pre-line text-sm" title={note}>
{note}
</span>
);
},
};
columns.splice(contaCartaoIndex, 0, noteColumn);
}
if (showActions) { if (showActions) {
columns.push({ columns.push({
id: "actions", id: "actions",
@@ -645,9 +667,51 @@ const buildColumns = ({
return columns; return columns;
}; };
const FIXED_START_IDS = ["select", "purchaseDate"];
const FIXED_END_IDS = ["actions"];
function getColumnId(col: ColumnDef<LancamentoItem>): string {
const c = col as { id?: string; accessorKey?: string };
return c.id ?? c.accessorKey ?? "";
}
function reorderColumnsByPreference<T>(
columns: ColumnDef<T>[],
orderPreference: string[] | null | undefined,
): ColumnDef<T>[] {
if (!orderPreference || orderPreference.length === 0) return columns;
const order = orderPreference;
const fixedStart: ColumnDef<T>[] = [];
const reorderable: ColumnDef<T>[] = [];
const fixedEnd: ColumnDef<T>[] = [];
for (const col of columns) {
const id = getColumnId(col as ColumnDef<LancamentoItem>);
if (FIXED_START_IDS.includes(id)) fixedStart.push(col);
else if (FIXED_END_IDS.includes(id)) fixedEnd.push(col);
else reorderable.push(col);
}
const sorted = [...reorderable].sort((a, b) => {
const idA = getColumnId(a as ColumnDef<LancamentoItem>);
const idB = getColumnId(b as ColumnDef<LancamentoItem>);
const indexA = order.indexOf(idA);
const indexB = order.indexOf(idB);
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
return [...fixedStart, ...sorted, ...fixedEnd];
}
type LancamentosTableProps = { type LancamentosTableProps = {
data: LancamentoItem[]; data: LancamentoItem[];
currentUserId: string; currentUserId: string;
noteAsColumn?: boolean;
columnOrder?: string[] | null;
pagadorFilterOptions?: LancamentoFilterOption[]; pagadorFilterOptions?: LancamentoFilterOption[];
categoriaFilterOptions?: LancamentoFilterOption[]; categoriaFilterOptions?: LancamentoFilterOption[];
contaCartaoFilterOptions?: ContaCartaoFilterOption[]; contaCartaoFilterOptions?: ContaCartaoFilterOption[];
@@ -672,6 +736,8 @@ type LancamentosTableProps = {
export function LancamentosTable({ export function LancamentosTable({
data, data,
currentUserId, currentUserId,
noteAsColumn = false,
columnOrder: columnOrderPreference = null,
pagadorFilterOptions = [], pagadorFilterOptions = [],
categoriaFilterOptions = [], categoriaFilterOptions = [],
contaCartaoFilterOptions = [], contaCartaoFilterOptions = [],
@@ -704,23 +770,10 @@ export function LancamentosTable({
}); });
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const columns = useMemo( const columns = useMemo(() => {
() => const built = buildColumns({
buildColumns({
currentUserId,
onEdit,
onCopy,
onImport,
onConfirmDelete,
onViewDetails,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
isSettlementLoading: isSettlementLoading ?? (() => false),
showActions,
}),
[
currentUserId, currentUserId,
noteAsColumn,
onEdit, onEdit,
onCopy, onCopy,
onImport, onImport,
@@ -729,10 +782,28 @@ export function LancamentosTable({
onToggleSettlement, onToggleSettlement,
onAnticipate, onAnticipate,
onViewAnticipationHistory, onViewAnticipationHistory,
isSettlementLoading, isSettlementLoading: isSettlementLoading ?? (() => false),
showActions, showActions,
], });
); const order = columnOrderPreference?.length
? columnOrderPreference
: DEFAULT_LANCAMENTOS_COLUMN_ORDER;
return reorderColumnsByPreference(built, order);
}, [
currentUserId,
noteAsColumn,
columnOrderPreference,
onEdit,
onCopy,
onImport,
onConfirmDelete,
onViewDetails,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
isSettlementLoading,
showActions,
]);
const table = useReactTable({ const table = useReactTable({
data, data,
@@ -789,47 +860,57 @@ export function LancamentosTable({
{showTopControls ? ( {showTopControls ? (
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
{onCreate || onMassAdd ? ( {onCreate || onMassAdd ? (
<div className="flex gap-2"> <div className="relative -mx-6 px-6 md:mx-0 md:px-0">
{onCreate ? ( <div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<> <div className="flex w-max shrink-0 gap-2 py-1 md:w-full md:py-0">
<Button {onCreate ? (
onClick={() => onCreate("Receita")} <>
variant="outline" <Button
className="w-full sm:w-auto" onClick={() => onCreate("Receita")}
> variant="outline"
<RiAddCircleLine className="size-4 text-success" /> className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
Nova Receita >
</Button> <RiAddCircleLine className="size-4 text-success" />
<Button Nova Receita
onClick={() => onCreate("Despesa")} </Button>
variant="outline" <Button
className="w-full sm:w-auto" onClick={() => onCreate("Despesa")}
> variant="outline"
<RiAddCircleLine className="size-4 text-destructive" /> className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
Nova Despesa >
</Button> <RiAddCircleLine className="size-4 text-destructive" />
</> Nova Despesa
) : null} </Button>
{onMassAdd ? ( </>
<Tooltip> ) : null}
<TooltipTrigger asChild> {onMassAdd ? (
<Button <Tooltip>
onClick={onMassAdd} <TooltipTrigger asChild>
variant="outline" <Button
size="icon" onClick={onMassAdd}
className="shrink-0" variant="outline"
> size="icon"
<RiAddCircleFill className="size-4" /> className="size-8 shrink-0 md:size-9"
<span className="sr-only"> >
Adicionar múltiplos lançamentos <RiAddCircleFill className="size-4" />
</span> <span className="sr-only">
</Button> Adicionar múltiplos lançamentos
</TooltipTrigger> </span>
<TooltipContent> </Button>
<p>Adicionar múltiplos lançamentos</p> </TooltipTrigger>
</TooltipContent> <TooltipContent>
</Tooltip> <p>Adicionar múltiplos lançamentos</p>
) : null} </TooltipContent>
</Tooltip>
) : null}
</div>
</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 py-1 md:hidden"
aria-hidden
>
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
</div>
</div> </div>
) : ( ) : (
<span className={showFilters ? "hidden sm:block" : ""} /> <span className={showFilters ? "hidden sm:block" : ""} />

View File

@@ -79,7 +79,7 @@ export default function MonthNavigation() {
}; };
return ( return (
<Card className="sticky top-0 z-30 w-full flex-row bg-card text-card-foreground p-4"> <Card className="w-full flex-row bg-card text-card-foreground p-4">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<NavigationButton <NavigationButton
direction="left" direction="left"

View File

@@ -1,6 +1,11 @@
"use client"; "use client";
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react"; import {
RiAddCircleLine,
RiArrowRightSLine,
RiFileCopyLine,
RiFundsLine,
} from "@remixicon/react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -105,26 +110,41 @@ export function BudgetsPage({
return ( return (
<> <>
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex justify-start gap-4"> {/* No mobile: rolagem horizontal + seta + botões menores */}
<BudgetDialog <div className="relative -mx-6 px-6 md:mx-0 md:px-0">
mode="create" <div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
categories={categories} <div className="flex w-max shrink-0 justify-start gap-3 py-1 md:w-full md:gap-4 md:py-0">
defaultPeriod={selectedPeriod} <BudgetDialog
trigger={ mode="create"
<Button disabled={categories.length === 0}> categories={categories}
<RiAddCircleLine className="size-4" /> defaultPeriod={selectedPeriod}
Novo orçamento trigger={
<Button
disabled={categories.length === 0}
className="h-8 shrink-0 px-3 text-xs md:h-9 md:px-4 md:text-sm"
>
<RiAddCircleLine className="size-4" />
Novo orçamento
</Button>
}
/>
<Button
variant="outline"
disabled={categories.length === 0}
onClick={() => setDuplicateOpen(true)}
className="h-8 shrink-0 px-3 text-xs md:h-9 md:px-4 md:text-sm"
>
<RiFileCopyLine className="size-4" />
Copiar orçamentos do último mês
</Button> </Button>
} </div>
/> </div>
<Button <div
variant="outline" 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 py-1 md:hidden"
disabled={categories.length === 0} aria-hidden
onClick={() => setDuplicateOpen(true)}
> >
<RiFileCopyLine className="size-4" /> <RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
Copiar orçamentos do último mês </div>
</Button>
</div> </div>
{hasBudgets ? ( {hasBudgets ? (

View File

@@ -7,6 +7,7 @@ import {
RiDashboardLine, RiDashboardLine,
RiFileChartLine, RiFileChartLine,
RiFundsLine, RiFundsLine,
RiPieChartLine,
RiGroupLine, RiGroupLine,
RiInboxLine, RiInboxLine,
RiPriceTag3Line, RiPriceTag3Line,
@@ -160,6 +161,11 @@ export function createSidebarNavData(
url: "/relatorios/tendencias", url: "/relatorios/tendencias",
icon: RiFileChartLine, icon: RiFileChartLine,
}, },
{
title: "Gastos por categoria",
url: "/relatorios/gastos-por-categoria",
icon: RiPieChartLine,
},
{ {
title: "Uso de Cartões", title: "Uso de Cartões",
url: "/relatorios/uso-cartoes", url: "/relatorios/uso-cartoes",

View File

@@ -107,8 +107,10 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", {
.unique() .unique()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false), disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
extratoNoteAsColumn: boolean("extrato_note_as_column").notNull().default(false),
systemFont: text("system_font").notNull().default("ai-sans"), systemFont: text("system_font").notNull().default("ai-sans"),
moneyFont: text("money_font").notNull().default("ai-sans"), moneyFont: text("money_font").notNull().default("ai-sans"),
lancamentosColumnOrder: jsonb("lancamentos_column_order").$type<string[] | null>(),
dashboardWidgets: jsonb("dashboard_widgets").$type<{ dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[]; order: string[];
hidden: string[]; hidden: string[];

View File

@@ -29,6 +29,8 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-openmonetis} POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db} POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
# Garante que os dados ficam no volume montado (evita perda após down/up)
PGDATA: /var/lib/postgresql/data
# Configurações de performance # Configurações de performance
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "extrato_note_as_column" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "lancamentos_column_order" jsonb;

View File

@@ -10,6 +10,8 @@ export type ChangelogVersion = {
version: string; version: string;
date: string; date: string;
sections: ChangelogSection[]; sections: ChangelogSection[];
/** Linha de contribuições/autor (pode conter markdown, ex: [Nome](url)) */
contributor?: string;
}; };
export function parseChangelog(): ChangelogVersion[] { export function parseChangelog(): ChangelogVersion[] {
@@ -49,6 +51,13 @@ export function parseChangelog(): ChangelogVersion[] {
const itemMatch = line.match(/^- (.+)$/); const itemMatch = line.match(/^- (.+)$/);
if (itemMatch && currentSection) { if (itemMatch && currentSection) {
currentSection.items.push(itemMatch[1]); currentSection.items.push(itemMatch[1]);
continue;
}
// **Contribuições:** ou **Autor:** com texto/link opcional
const contributorMatch = line.match(/^\*\*(?:Contribuições|Autor):\*\*\s*(.+)$/);
if (contributorMatch && currentVersion) {
currentVersion.contributor = contributorMatch[1].trim() || undefined;
} }
} }

View File

@@ -0,0 +1,33 @@
/**
* Ids das colunas reordenáveis da tabela de lançamentos (extrato).
* select, purchaseDate e actions são fixos (início, oculto, fim).
*/
export const LANCAMENTOS_REORDERABLE_COLUMN_IDS = [
"name",
"transactionType",
"amount",
"condition",
"paymentMethod",
"categoriaName",
"pagadorName",
"note",
"contaCartao",
] as const;
export type LancamentosColumnId = (typeof LANCAMENTOS_REORDERABLE_COLUMN_IDS)[number];
export const LANCAMENTOS_COLUMN_LABELS: Record<string, string> = {
name: "Estabelecimento",
transactionType: "Transação",
amount: "Valor",
condition: "Condição",
paymentMethod: "Forma de Pagamento",
categoriaName: "Categoria",
pagadorName: "Pagador",
note: "Anotação",
contaCartao: "Conta/Cartão",
};
export const DEFAULT_LANCAMENTOS_COLUMN_ORDER: string[] = [
...LANCAMENTOS_REORDERABLE_COLUMN_IDS,
];

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "1.5.3", "version": "1.6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",