From ffde55f58926dc78af79136f9b746e06a6cff2e1 Mon Sep 17 00:00:00 2001 From: Guilherme Bano Date: Wed, 18 Feb 2026 23:21:14 -0300 Subject: [PATCH] =?UTF-8?q?ajuste=20de=20layout=20mobile,=20melhorias=20e?= =?UTF-8?q?=20cria=C3=A7=C3=A3o=20de=20novas=20fun=C3=A7=C3=B5es.=20Detalh?= =?UTF-8?q?es=20adicionados=20no=20CHANGELOG.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 25 ++ app/(dashboard)/ajustes/actions.ts | 6 + app/(dashboard)/ajustes/data.ts | 4 + app/(dashboard)/ajustes/page.tsx | 40 ++- .../cartoes/[cartaoId]/fatura/page.tsx | 6 +- .../categorias/[categoryId]/page.tsx | 6 +- .../contas/[contaId]/extrato/page.tsx | 6 +- app/(dashboard)/lancamentos/page.tsx | 8 +- app/(dashboard)/layout.tsx | 2 +- .../pagadores/[pagadorId]/page.tsx | 5 + .../gastos-por-categoria/layout.tsx | 23 ++ .../gastos-por-categoria/loading.tsx | 30 ++ .../relatorios/gastos-por-categoria/page.tsx | 100 ++++++ components/ajustes/changelog-tab.tsx | 33 ++ components/ajustes/preferences-form.tsx | 133 +++++++- ...expenses-by-category-widget-with-chart.tsx | 316 +++++++++++------- components/header-dashboard.tsx | 2 +- .../lancamentos/page/lancamentos-page.tsx | 6 + .../lancamentos/table/lancamentos-table.tsx | 203 +++++++---- components/month-picker/month-navigation.tsx | 2 +- components/orcamentos/budgets-page.tsx | 58 ++-- components/sidebar/nav-link.tsx | 6 + db/schema.ts | 2 + docker-compose.yml | 2 + drizzle/0017_add_extrato_note_as_column.sql | 1 + drizzle/0018_add_lancamentos_column_order.sql | 1 + lib/changelog/parse-changelog.ts | 9 + lib/lancamentos/column-order.ts | 33 ++ package.json | 2 +- 29 files changed, 857 insertions(+), 213 deletions(-) create mode 100644 app/(dashboard)/relatorios/gastos-por-categoria/layout.tsx create mode 100644 app/(dashboard)/relatorios/gastos-por-categoria/loading.tsx create mode 100644 app/(dashboard)/relatorios/gastos-por-categoria/page.tsx create mode 100644 drizzle/0017_add_extrato_note_as_column.sql create mode 100644 drizzle/0018_add_lancamentos_column_order.sql create mode 100644 lib/lancamentos/column-order.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ffd5e4..2157d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Adicionado @@ -222,3 +246,4 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - Atualização de dependências - Aplicada formatação no código + diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts index 73760db..0776328 100644 --- a/app/(dashboard)/ajustes/actions.ts +++ b/app/(dashboard)/ajustes/actions.ts @@ -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, }); diff --git a/app/(dashboard)/ajustes/data.ts b/app/(dashboard)/ajustes/data.ts index 84ed107..3f19f56 100644 --- a/app/(dashboard)/ajustes/data.ts +++ b/app/(dashboard)/ajustes/data.ts @@ -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, }) diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index 9c46a6f..376cdf2 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -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 (
- - Preferências - Companion - Alterar nome - Alterar senha - Alterar e-mail - Changelog - - Deletar conta - - + {/* No mobile: rolagem horizontal + seta indicando mais opções à direita */} +
+
+ + Preferências + Companion + Alterar nome + Alterar senha + Alterar e-mail + Changelog + + Deletar conta + + +
+
+ +
+
@@ -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"} /> diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx index 7b5e6a3..0a7654d 100644 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx @@ -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 diff --git a/app/(dashboard)/categorias/[categoryId]/page.tsx b/app/(dashboard)/categorias/[categoryId]/page.tsx index e897c5d..ded00a2 100644 --- a/app/(dashboard)/categorias/[categoryId]/page.tsx +++ b/app/(dashboard)/categorias/[categoryId]/page.tsx @@ -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} /> ); diff --git a/app/(dashboard)/contas/[contaId]/extrato/page.tsx b/app/(dashboard)/contas/[contaId]/extrato/page.tsx index b0acf53..6537bdb 100644 --- a/app/(dashboard)/contas/[contaId]/extrato/page.tsx +++ b/app/(dashboard)/contas/[contaId]/extrato/page.tsx @@ -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} /> diff --git a/app/(dashboard)/lancamentos/page.tsx b/app/(dashboard)/lancamentos/page.tsx index 7ad5fc9..5cd3fad 100644 --- a/app/(dashboard)/lancamentos/page.tsx +++ b/app/(dashboard)/lancamentos/page.tsx @@ -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} /> ); diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 1773a8b..7df5fb6 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -70,7 +70,7 @@ export default async function DashboardLayout({ /> -
+
{children} diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx index ddc7866..3f40f53 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/page.tsx +++ b/app/(dashboard)/pagadores/[pagadorId]/page.tsx @@ -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 diff --git a/app/(dashboard)/relatorios/gastos-por-categoria/layout.tsx b/app/(dashboard)/relatorios/gastos-por-categoria/layout.tsx new file mode 100644 index 0000000..9942029 --- /dev/null +++ b/app/(dashboard)/relatorios/gastos-por-categoria/layout.tsx @@ -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 ( +
+ } + title="Gastos por categoria" + subtitle="Visualize suas despesas divididas por categoria no mês selecionado. Altere o mês para comparar períodos." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/relatorios/gastos-por-categoria/loading.tsx b/app/(dashboard)/relatorios/gastos-por-categoria/loading.tsx new file mode 100644 index 0000000..895a15e --- /dev/null +++ b/app/(dashboard)/relatorios/gastos-por-categoria/loading.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function GastosPorCategoriaLoading() { + return ( +
+
+ +
+
+ + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ +
+ + +
+
+ +
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/relatorios/gastos-por-categoria/page.tsx b/app/(dashboard)/relatorios/gastos-por-categoria/page.tsx new file mode 100644 index 0000000..2fa7a57 --- /dev/null +++ b/app/(dashboard)/relatorios/gastos-por-categoria/page.tsx @@ -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>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | 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 ( +
+ + + + + + + Resumo do mês + + + +
+
+

+ Total de despesas no mês +

+ +
+ {percentageChange !== null && ( + + {hasIncrease && } + {hasDecrease && } + {percentageChange > 0 ? "+" : ""} + {percentageChange.toFixed(1)}% em relação ao mês anterior + + )} +
+

+ Mês anterior: +

+
+
+ + + + +
+ ); +} diff --git a/components/ajustes/changelog-tab.tsx b/components/ajustes/changelog-tab.tsx index 8208c87..b81a18c 100644 --- a/components/ajustes/changelog-tab.tsx +++ b/components/ajustes/changelog-tab.tsx @@ -1,7 +1,17 @@ +import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; 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< string, "success" | "info" | "destructive" | "secondary" @@ -46,6 +56,29 @@ export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
))} + {version.contributor && ( +
+ + Contribuições:{" "} + {(() => { + const { label, url } = parseContributorLine(version.contributor); + if (url) { + return ( + + {label} + + ); + } + return {label}; + })()} + +
+ )}
))} diff --git a/components/ajustes/preferences-form.tsx b/components/ajustes/preferences-form.tsx index 454617c..ae209e2 100644 --- a/components/ajustes/preferences-form.tsx +++ b/components/ajustes/preferences-form.tsx @@ -1,5 +1,17 @@ "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 { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -15,16 +27,58 @@ import { SelectValue, } from "@/components/ui/select"; 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"; interface PreferencesFormProps { disableMagnetlines: boolean; + extratoNoteAsColumn: boolean; + lancamentosColumnOrder: string[] | null; systemFont: 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 ( +
+ + {label} +
+ ); +} + export function PreferencesForm({ disableMagnetlines, + extratoNoteAsColumn: initialExtratoNoteAsColumn, + lancamentosColumnOrder: initialColumnOrder, systemFont: initialSystemFont, moneyFont: initialMoneyFont, }: PreferencesFormProps) { @@ -32,10 +86,33 @@ export function PreferencesForm({ const [isPending, startTransition] = useTransition(); const [magnetlinesDisabled, setMagnetlinesDisabled] = useState(disableMagnetlines); + const [extratoNoteAsColumn, setExtratoNoteAsColumn] = + useState(initialExtratoNoteAsColumn); + const [columnOrder, setColumnOrder] = useState( + initialColumnOrder && initialColumnOrder.length > 0 + ? initialColumnOrder + : DEFAULT_LANCAMENTOS_COLUMN_ORDER, + ); const [selectedSystemFont, setSelectedSystemFont] = useState(initialSystemFont); 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(); // Live preview: update CSS vars when font selection changes @@ -53,6 +130,8 @@ export function PreferencesForm({ startTransition(async () => { const result = await updatePreferencesAction({ disableMagnetlines: magnetlinesDisabled, + extratoNoteAsColumn, + lancamentosColumnOrder: columnOrder, systemFont: selectedSystemFont, moneyFont: selectedMoneyFont, }); @@ -148,7 +227,59 @@ export function PreferencesForm({
- {/* Seção 3: Dashboard */} + {/* Seção: Extrato / Lançamentos */} +
+
+

Extrato e lançamentos

+

+ Como exibir anotações e a ordem das colunas na tabela de movimentações. +

+
+ +
+
+ +

+ Quando ativo, as anotações aparecem em uma coluna na tabela. Quando desativado, aparecem em um balão ao passar o mouse no ícone. +

+
+ +
+ +
+ +

+ Arraste os itens para definir a ordem em que as colunas aparecem na tabela do extrato e dos lançamentos. +

+ + +
+ {columnOrder.map((id) => ( + + ))} +
+
+
+
+
+ +
+ + {/* Seção: Dashboard */}

Dashboard

diff --git a/components/dashboard/expenses-by-category-widget-with-chart.tsx b/components/dashboard/expenses-by-category-widget-with-chart.tsx index 669a825..0dd2de3 100644 --- a/components/dashboard/expenses-by-category-widget-with-chart.tsx +++ b/components/dashboard/expenses-by-category-widget-with-chart.tsx @@ -4,20 +4,19 @@ import { RiArrowDownSFill, RiArrowUpSFill, RiExternalLinkLine, - RiListUnordered, - RiPieChart2Line, RiPieChartLine, RiWallet3Line, } from "@remixicon/react"; import Link from "next/link"; -import { useMemo, useState } from "react"; -import { Pie, PieChart, Tooltip } from "recharts"; +import { useRouter } from "next/navigation"; +import { useMemo } from "react"; +import { Cell, Pie, PieChart, Tooltip } from "recharts"; import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; +import { useIsMobile } from "@/hooks/use-mobile"; import MoneyValues from "@/components/money-values"; import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category"; import { formatPeriodForUrl } from "@/lib/utils/period"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { WidgetEmptyState } from "../widget-empty-state"; type ExpensesByCategoryWidgetWithChartProps = { @@ -35,11 +34,21 @@ const formatCurrency = (value: number) => currency: "BRL", }).format(value); +type ChartDataItem = { + category: string; + name: string; + value: number; + percentage: number; + fill: string | undefined; + href: string | undefined; +}; + export function ExpensesByCategoryWidgetWithChart({ data, period, }: ExpensesByCategoryWidgetWithChartProps) { - const [activeTab, setActiveTab] = useState<"list" | "chart">("list"); + const router = useRouter(); + const isMobile = useIsMobile(); const periodParam = formatPeriodForUrl(period); // Configuração do chart com cores do CSS @@ -80,50 +89,68 @@ export function ExpensesByCategoryWidgetWithChart({ return config; }, [data.categories]); - // Preparar dados para o gráfico de pizza - Top 7 + Outros - const chartData = useMemo(() => { + // Preparar dados para o gráfico de pizza - Top 7 + Outros (com href para navegação) + 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) { - return data.categories.map((category) => ({ - category: category.categoryId, - name: category.categoryName, - value: category.currentAmount, - percentage: category.percentageOfTotal, - fill: chartConfig[category.categoryId]?.color, - })); + return data.categories.map((category) => + buildItem( + category.categoryId, + category.categoryName, + category.currentAmount, + category.percentageOfTotal, + chartConfig[category.categoryId]?.color, + ), + ); } - // Pegar top 7 categorias const top7 = data.categories.slice(0, 7); const others = data.categories.slice(7); - - // Somar o restante const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0); const othersPercentage = others.reduce( (sum, cat) => sum + cat.percentageOfTotal, 0, ); - const top7Data = top7.map((category) => ({ - category: category.categoryId, - name: category.categoryName, - value: category.currentAmount, - percentage: category.percentageOfTotal, - fill: chartConfig[category.categoryId]?.color, - })); - - // Adicionar "Outros" se houver + const top7Data = top7.map((category) => + buildItem( + category.categoryId, + category.categoryName, + category.currentAmount, + category.percentageOfTotal, + chartConfig[category.categoryId]?.color, + ), + ); if (others.length > 0) { - top7Data.push({ - category: "outros", - name: "Outros", - value: othersTotal, - percentage: othersPercentage, - fill: chartConfig.outros?.color, - }); + top7Data.push( + buildItem( + "outros", + "Outros", + othersTotal, + othersPercentage, + chartConfig.outros?.color, + ), + ); } - return top7Data; - }, [data.categories, chartConfig]); + }, [data.categories, chartConfig, periodParam]); if (data.categories.length === 0) { return ( @@ -136,25 +163,146 @@ export function ExpensesByCategoryWidgetWithChart({ } return ( - setActiveTab(v as "list" | "chart")} - className="w-full" - > -
- - - - Lista - - - - Gráfico - - +
+ {/* Gráfico de pizza (donut) — fatias clicáveis */} +
+ + + { + 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 ( + + {formatPercentage(percentage)} + + ); + }} + labelLine={false} + > + {chartData.map((entry, index) => ( + + ))} + + {!isMobile && ( + { + if (active && payload?.length) { + const d = payload[0].payload as ChartDataItem; + return ( +
+
+ + {d.name} + + + {formatCurrency(d.value)} + + + {formatPercentage(d.percentage)} do total + + {d.href && ( + + Clique para ver detalhes + + )} +
+
+ ); + } + return null; + }} + cursor={false} + /> + )} +
+
+ + {/* Legenda clicável */} +
+ {chartData.map((entry, index) => { + const content = ( + <> + + + {entry.name} + + + {formatPercentage(entry.percentage)} + + + ); + return entry.href ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + })} +
- + {/* Lista de categorias */} +
{data.categories.map((category, index) => { const hasIncrease = @@ -264,65 +412,7 @@ export function ExpensesByCategoryWidgetWithChart({ ); })}
- - - -
- - - formatPercentage(entry.percentage)} - outerRadius={75} - dataKey="value" - nameKey="category" - /> - { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-
-
- - {data.name} - - - {formatCurrency(data.value)} - - - {formatPercentage(data.percentage)} do total - -
-
-
- ); - } - return null; - }} - /> -
-
- -
- {chartData.map((entry, index) => ( -
-
- - {entry.name} - -
- ))} -
-
- - +
+
); } diff --git a/components/header-dashboard.tsx b/components/header-dashboard.tsx index 9baa8af..7df29b9 100644 --- a/components/header-dashboard.tsx +++ b/components/header-dashboard.tsx @@ -16,7 +16,7 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) { const _user = await getUser(); return ( -
+
diff --git a/components/lancamentos/page/lancamentos-page.tsx b/components/lancamentos/page/lancamentos-page.tsx index 912f1af..7fe938f 100644 --- a/components/lancamentos/page/lancamentos-page.tsx +++ b/components/lancamentos/page/lancamentos-page.tsx @@ -48,6 +48,8 @@ interface LancamentosPageProps { selectedPeriod: string; estabelecimentos: string[]; allowCreate?: boolean; + noteAsColumn?: boolean; + columnOrder?: string[] | null; defaultCartaoId?: string | null; defaultPaymentMethod?: string | null; lockCartaoSelection?: boolean; @@ -76,6 +78,8 @@ export function LancamentosPage({ selectedPeriod, estabelecimentos, allowCreate = true, + noteAsColumn = false, + columnOrder = null, defaultCartaoId, defaultPaymentMethod, lockCartaoSelection, @@ -377,6 +381,8 @@ export function LancamentosPage({ { type BuildColumnsArgs = { currentUserId: string; + noteAsColumn: boolean; onEdit?: (item: LancamentoItem) => void; onCopy?: (item: LancamentoItem) => void; onImport?: (item: LancamentoItem) => void; @@ -106,6 +109,7 @@ type BuildColumnsArgs = { const buildColumns = ({ currentUserId, + noteAsColumn, onEdit, onCopy, onImport, @@ -269,7 +273,7 @@ const buildColumns = ({ )} - {hasNote ? ( + {!noteAsColumn && hasNote ? ( @@ -493,6 +497,24 @@ const buildColumns = ({ }, ]; + if (noteAsColumn) { + const contaCartaoIndex = columns.findIndex((c) => c.id === "contaCartao"); + const noteColumn: ColumnDef = { + accessorKey: "note", + header: "Anotação", + cell: ({ row }) => { + const note = row.original.note; + if (!note?.trim()) return ; + return ( + + {note} + + ); + }, + }; + columns.splice(contaCartaoIndex, 0, noteColumn); + } + if (showActions) { columns.push({ id: "actions", @@ -645,9 +667,51 @@ const buildColumns = ({ return columns; }; +const FIXED_START_IDS = ["select", "purchaseDate"]; +const FIXED_END_IDS = ["actions"]; + +function getColumnId(col: ColumnDef): string { + const c = col as { id?: string; accessorKey?: string }; + return c.id ?? c.accessorKey ?? ""; +} + +function reorderColumnsByPreference( + columns: ColumnDef[], + orderPreference: string[] | null | undefined, +): ColumnDef[] { + if (!orderPreference || orderPreference.length === 0) return columns; + + const order = orderPreference; + const fixedStart: ColumnDef[] = []; + const reorderable: ColumnDef[] = []; + const fixedEnd: ColumnDef[] = []; + + for (const col of columns) { + const id = getColumnId(col as ColumnDef); + 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); + const idB = getColumnId(b as ColumnDef); + 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 = { data: LancamentoItem[]; currentUserId: string; + noteAsColumn?: boolean; + columnOrder?: string[] | null; pagadorFilterOptions?: LancamentoFilterOption[]; categoriaFilterOptions?: LancamentoFilterOption[]; contaCartaoFilterOptions?: ContaCartaoFilterOption[]; @@ -672,6 +736,8 @@ type LancamentosTableProps = { export function LancamentosTable({ data, currentUserId, + noteAsColumn = false, + columnOrder: columnOrderPreference = null, pagadorFilterOptions = [], categoriaFilterOptions = [], contaCartaoFilterOptions = [], @@ -704,23 +770,10 @@ export function LancamentosTable({ }); const [rowSelection, setRowSelection] = useState({}); - const columns = useMemo( - () => - buildColumns({ - currentUserId, - onEdit, - onCopy, - onImport, - onConfirmDelete, - onViewDetails, - onToggleSettlement, - onAnticipate, - onViewAnticipationHistory, - isSettlementLoading: isSettlementLoading ?? (() => false), - showActions, - }), - [ + const columns = useMemo(() => { + const built = buildColumns({ currentUserId, + noteAsColumn, onEdit, onCopy, onImport, @@ -729,10 +782,28 @@ export function LancamentosTable({ onToggleSettlement, onAnticipate, onViewAnticipationHistory, - isSettlementLoading, + isSettlementLoading: isSettlementLoading ?? (() => false), 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({ data, @@ -789,47 +860,57 @@ export function LancamentosTable({ {showTopControls ? (
{onCreate || onMassAdd ? ( -
- {onCreate ? ( - <> - - - - ) : null} - {onMassAdd ? ( - - - - - -

Adicionar múltiplos lançamentos

-
-
- ) : null} +
+
+
+ {onCreate ? ( + <> + + + + ) : null} + {onMassAdd ? ( + + + + + +

Adicionar múltiplos lançamentos

+
+
+ ) : null} +
+
+
+ +
) : ( diff --git a/components/month-picker/month-navigation.tsx b/components/month-picker/month-navigation.tsx index 0fa3966..f60eda8 100644 --- a/components/month-picker/month-navigation.tsx +++ b/components/month-picker/month-navigation.tsx @@ -79,7 +79,7 @@ export default function MonthNavigation() { }; return ( - +
-
- - - Novo orçamento + {/* No mobile: rolagem horizontal + seta + botões menores */} +
+
+
+ + + Novo orçamento + + } + /> + - } - /> -
+
+
- - Copiar orçamentos do último mês - + +
{hasBudgets ? ( diff --git a/components/sidebar/nav-link.tsx b/components/sidebar/nav-link.tsx index 268eec0..d9bd771 100644 --- a/components/sidebar/nav-link.tsx +++ b/components/sidebar/nav-link.tsx @@ -7,6 +7,7 @@ import { RiDashboardLine, RiFileChartLine, RiFundsLine, + RiPieChartLine, RiGroupLine, RiInboxLine, RiPriceTag3Line, @@ -160,6 +161,11 @@ export function createSidebarNavData( url: "/relatorios/tendencias", icon: RiFileChartLine, }, + { + title: "Gastos por categoria", + url: "/relatorios/gastos-por-categoria", + icon: RiPieChartLine, + }, { title: "Uso de Cartões", url: "/relatorios/uso-cartoes", diff --git a/db/schema.ts b/db/schema.ts index 40d969b..fa9b6b9 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -107,8 +107,10 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", { .unique() .references(() => user.id, { onDelete: "cascade" }), disableMagnetlines: boolean("disable_magnetlines").notNull().default(false), + extratoNoteAsColumn: boolean("extrato_note_as_column").notNull().default(false), systemFont: text("system_font").notNull().default("ai-sans"), moneyFont: text("money_font").notNull().default("ai-sans"), + lancamentosColumnOrder: jsonb("lancamentos_column_order").$type(), dashboardWidgets: jsonb("dashboard_widgets").$type<{ order: string[]; hidden: string[]; diff --git a/docker-compose.yml b/docker-compose.yml index dd686a3..a8c432f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: POSTGRES_USER: ${POSTGRES_USER:-openmonetis} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password} 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 POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" diff --git a/drizzle/0017_add_extrato_note_as_column.sql b/drizzle/0017_add_extrato_note_as_column.sql new file mode 100644 index 0000000..92dd36b --- /dev/null +++ b/drizzle/0017_add_extrato_note_as_column.sql @@ -0,0 +1 @@ +ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "extrato_note_as_column" boolean DEFAULT false NOT NULL; diff --git a/drizzle/0018_add_lancamentos_column_order.sql b/drizzle/0018_add_lancamentos_column_order.sql new file mode 100644 index 0000000..59eb499 --- /dev/null +++ b/drizzle/0018_add_lancamentos_column_order.sql @@ -0,0 +1 @@ +ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "lancamentos_column_order" jsonb; diff --git a/lib/changelog/parse-changelog.ts b/lib/changelog/parse-changelog.ts index 9e9a317..57d60da 100644 --- a/lib/changelog/parse-changelog.ts +++ b/lib/changelog/parse-changelog.ts @@ -10,6 +10,8 @@ export type ChangelogVersion = { version: string; date: string; sections: ChangelogSection[]; + /** Linha de contribuições/autor (pode conter markdown, ex: [Nome](url)) */ + contributor?: string; }; export function parseChangelog(): ChangelogVersion[] { @@ -49,6 +51,13 @@ export function parseChangelog(): ChangelogVersion[] { const itemMatch = line.match(/^- (.+)$/); if (itemMatch && currentSection) { 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; } } diff --git a/lib/lancamentos/column-order.ts b/lib/lancamentos/column-order.ts new file mode 100644 index 0000000..9413235 --- /dev/null +++ b/lib/lancamentos/column-order.ts @@ -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 = { + 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, +]; diff --git a/package.json b/package.json index 6ae80ea..779802a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmonetis", - "version": "1.5.3", + "version": "1.6.0", "private": true, "scripts": { "dev": "next dev --turbopack",