ajuste de layout mobile, melhorias e criação de novas funções. Detalhes adicionados no CHANGELOG.md
This commit is contained in:
committed by
Felipe Coutinho
parent
31fe752b7d
commit
ffde55f589
25
CHANGELOG.md
25
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RiArrowRightSLine } from "@remixicon/react";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
@@ -35,7 +36,10 @@ export default async function Page() {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Tabs defaultValue="preferencias" className="w-full">
|
||||
<TabsList>
|
||||
{/* No mobile: rolagem horizontal + seta indicando mais opções à direita */}
|
||||
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList className="inline-flex w-max flex-nowrap md:w-full">
|
||||
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
|
||||
<TabsTrigger value="companion">Companion</TabsTrigger>
|
||||
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
|
||||
@@ -46,6 +50,14 @@ export default async function Page() {
|
||||
Deletar conta
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute right-0 top-0 hidden h-9 w-10 items-center justify-end bg-gradient-to-l from-background to-transparent md:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="preferencias" className="mt-4">
|
||||
<Card className="p-6">
|
||||
@@ -61,6 +73,12 @@ export default async function Page() {
|
||||
disableMagnetlines={
|
||||
userPreferences?.disableMagnetlines ?? false
|
||||
}
|
||||
extratoNoteAsColumn={
|
||||
userPreferences?.extratoNoteAsColumn ?? false
|
||||
}
|
||||
lancamentosColumnOrder={
|
||||
userPreferences?.lancamentosColumnOrder ?? null
|
||||
}
|
||||
systemFont={userPreferences?.systemFont ?? "ai-sans"}
|
||||
moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
|
||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
||||
@@ -36,10 +37,11 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||
|
||||
const [detail, filterSources, estabelecimentos] = await Promise.all([
|
||||
const [detail, filterSources, estabelecimentos, userPreferences] = await Promise.all([
|
||||
fetchCategoryDetails(userId, categoryId, selectedPeriod),
|
||||
fetchLancamentoFilterSources(userId),
|
||||
getRecentEstablishmentsAction(),
|
||||
fetchUserPreferences(userId),
|
||||
]);
|
||||
|
||||
if (!detail) {
|
||||
@@ -92,6 +94,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
selectedPeriod={detail.period}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={true}
|
||||
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { AccountDialog } from "@/components/contas/account-dialog";
|
||||
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
||||
@@ -57,12 +58,13 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [filterSources, logoOptions, accountSummary, estabelecimentos] =
|
||||
const [filterSources, logoOptions, accountSummary, estabelecimentos, userPreferences] =
|
||||
await Promise.all([
|
||||
fetchLancamentoFilterSources(userId),
|
||||
loadLogoOptions(),
|
||||
fetchAccountSummary(userId, contaId, selectedPeriod),
|
||||
getRecentEstablishmentsAction(),
|
||||
fetchUserPreferences(userId),
|
||||
]);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||
@@ -161,6 +163,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={false}
|
||||
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
@@ -31,7 +32,10 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
|
||||
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
||||
|
||||
const filterSources = await fetchLancamentoFilterSources(userId);
|
||||
const [filterSources, userPreferences] = await Promise.all([
|
||||
fetchLancamentoFilterSources(userId),
|
||||
fetchUserPreferences(userId),
|
||||
]);
|
||||
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||
@@ -80,6 +84,8 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ export default async function DashboardLayout({
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col pt-12 md:pt-0">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6">
|
||||
{children}
|
||||
|
||||
@@ -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
|
||||
|
||||
23
app/(dashboard)/relatorios/gastos-por-categoria/layout.tsx
Normal file
23
app/(dashboard)/relatorios/gastos-por-categoria/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
app/(dashboard)/relatorios/gastos-por-categoria/loading.tsx
Normal file
30
app/(dashboard)/relatorios/gastos-por-categoria/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
app/(dashboard)/relatorios/gastos-por-categoria/page.tsx
Normal file
100
app/(dashboard)/relatorios/gastos-por-categoria/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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[] }) {
|
||||
</ul>
|
||||
</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>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
<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({
|
||||
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<string[]>(
|
||||
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({
|
||||
|
||||
<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">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Dashboard</h3>
|
||||
|
||||
@@ -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 (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
|
||||
className="w-full"
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Gráfico de pizza (donut) — fatias clicáveis */}
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:gap-8">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[280px] w-full min-w-0 sm:h-[320px] sm:max-w-[360px]"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="list" className="text-xs">
|
||||
<RiListUnordered className="size-3.5 mr-1" />
|
||||
Lista
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chart" className="text-xs">
|
||||
<RiPieChart2Line className="size-3.5 mr-1" />
|
||||
Gráfico
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<PieChart margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="58%"
|
||||
outerRadius="92%"
|
||||
paddingAngle={2}
|
||||
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>
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
{/* Lista de categorias */}
|
||||
<div className="border-t border-dashed pt-6">
|
||||
<div className="flex flex-col px-0">
|
||||
{data.categories.map((category, index) => {
|
||||
const hasIncrease =
|
||||
@@ -264,65 +412,7 @@ export function ExpensesByCategoryWidgetWithChart({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
||||
const _user = await getUser();
|
||||
|
||||
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">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
|
||||
@@ -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({
|
||||
<LancamentosTable
|
||||
data={lancamentos}
|
||||
currentUserId={currentUserId}
|
||||
noteAsColumn={noteAsColumn}
|
||||
columnOrder={columnOrder}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
RiAddCircleFill,
|
||||
RiAddCircleLine,
|
||||
RiArrowLeftRightLine,
|
||||
RiArrowRightSLine,
|
||||
RiChat1Line,
|
||||
RiCheckLine,
|
||||
RiDeleteBin5Line,
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order";
|
||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||
import { formatDate } from "@/lib/utils/date";
|
||||
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||
@@ -92,6 +94,7 @@ const resolveLogoSrc = (logo: string | null) => {
|
||||
|
||||
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 = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{hasNote ? (
|
||||
{!noteAsColumn && hasNote ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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) {
|
||||
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<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 = {
|
||||
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,10 +770,10 @@ export function LancamentosTable({
|
||||
});
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
buildColumns({
|
||||
const columns = useMemo(() => {
|
||||
const built = buildColumns({
|
||||
currentUserId,
|
||||
noteAsColumn,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onImport,
|
||||
@@ -718,9 +784,15 @@ export function LancamentosTable({
|
||||
onViewAnticipationHistory,
|
||||
isSettlementLoading: isSettlementLoading ?? (() => false),
|
||||
showActions,
|
||||
}),
|
||||
[
|
||||
});
|
||||
const order = columnOrderPreference?.length
|
||||
? columnOrderPreference
|
||||
: DEFAULT_LANCAMENTOS_COLUMN_ORDER;
|
||||
return reorderColumnsByPreference(built, order);
|
||||
}, [
|
||||
currentUserId,
|
||||
noteAsColumn,
|
||||
columnOrderPreference,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onImport,
|
||||
@@ -731,8 +803,7 @@ export function LancamentosTable({
|
||||
onViewAnticipationHistory,
|
||||
isSettlementLoading,
|
||||
showActions,
|
||||
],
|
||||
);
|
||||
]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -789,13 +860,15 @@ export function LancamentosTable({
|
||||
{showTopControls ? (
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
{onCreate || onMassAdd ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="flex w-max shrink-0 gap-2 py-1 md:w-full md:py-0">
|
||||
{onCreate ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => onCreate("Receita")}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
|
||||
>
|
||||
<RiAddCircleLine className="size-4 text-success" />
|
||||
Nova Receita
|
||||
@@ -803,7 +876,7 @@ export function LancamentosTable({
|
||||
<Button
|
||||
onClick={() => onCreate("Despesa")}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
|
||||
>
|
||||
<RiAddCircleLine className="size-4 text-destructive" />
|
||||
Nova Despesa
|
||||
@@ -817,7 +890,7 @@ export function LancamentosTable({
|
||||
onClick={onMassAdd}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
className="size-8 shrink-0 md:size-9"
|
||||
>
|
||||
<RiAddCircleFill className="size-4" />
|
||||
<span className="sr-only">
|
||||
@@ -831,6 +904,14 @@ export function LancamentosTable({
|
||||
</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>
|
||||
) : (
|
||||
<span className={showFilters ? "hidden sm:block" : ""} />
|
||||
)}
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function MonthNavigation() {
|
||||
};
|
||||
|
||||
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">
|
||||
<NavigationButton
|
||||
direction="left"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
|
||||
import {
|
||||
RiAddCircleLine,
|
||||
RiArrowRightSLine,
|
||||
RiFileCopyLine,
|
||||
RiFundsLine,
|
||||
} from "@remixicon/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -105,13 +110,19 @@ export function BudgetsPage({
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex justify-start gap-4">
|
||||
{/* No mobile: rolagem horizontal + seta + botões menores */}
|
||||
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="flex w-max shrink-0 justify-start gap-3 py-1 md:w-full md:gap-4 md:py-0">
|
||||
<BudgetDialog
|
||||
mode="create"
|
||||
categories={categories}
|
||||
defaultPeriod={selectedPeriod}
|
||||
trigger={
|
||||
<Button disabled={categories.length === 0}>
|
||||
<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>
|
||||
@@ -121,11 +132,20 @@ export function BudgetsPage({
|
||||
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>
|
||||
</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>
|
||||
|
||||
{hasBudgets ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string[] | null>(),
|
||||
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
1
drizzle/0017_add_extrato_note_as_column.sql
Normal file
1
drizzle/0017_add_extrato_note_as_column.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "extrato_note_as_column" boolean DEFAULT false NOT NULL;
|
||||
1
drizzle/0018_add_lancamentos_column_order.sql
Normal file
1
drizzle/0018_add_lancamentos_column_order.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "lancamentos_column_order" jsonb;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
lib/lancamentos/column-order.ts
Normal file
33
lib/lancamentos/column-order.ts
Normal 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,
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "1.5.3",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Reference in New Issue
Block a user