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/),
|
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-02-18
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Item "Gastos por categoria" no menu lateral (seção Análise), com link para `/relatorios/gastos-por-categoria`
|
||||||
|
- Gráfico de pizza moderno (estilo donut) na página Gastos por categoria: fatias com espaçamento, labels de percentual nas fatias maiores, legenda ao lado
|
||||||
|
- Fatias do gráfico e itens da legenda clicáveis — navegam para a página de detalhe da categoria no período selecionado
|
||||||
|
- Preferência "Anotações em coluna" em Ajustes > Extrato e lançamentos: quando ativa, a anotação dos lançamentos aparece em coluna na tabela; quando inativa, permanece no balão (tooltip) no ícone
|
||||||
|
- Preferência "Ordem das colunas" em Ajustes > Extrato e lançamentos: lista ordenável por arraste para definir a ordem das colunas na tabela do extrato e dos lançamentos (Estabelecimento, Transação, Valor, etc.); a linha inteira é arrastável
|
||||||
|
- Coluna `extrato_note_as_column` e `lancamentos_column_order` na tabela `preferencias_usuario` (migrations 0017 e 0018)
|
||||||
|
- Constantes e labels das colunas reordenáveis em `lib/lancamentos/column-order.ts`
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Tooltip do gráfico de pizza em Gastos por categoria oculto no mobile (evita informação flutuante em telas pequenas)
|
||||||
|
- Header do dashboard fixo apenas no mobile (`fixed top-0` com `md:static`); conteúdo com `pt-12 md:pt-0` para não ficar sob o header
|
||||||
|
- Abas da página Ajustes (Preferências, Companion, etc.): no mobile, rolagem horizontal com seta indicando mais opções à direita; scrollbar oculta
|
||||||
|
- Botões "Novo orçamento" e "Copiar orçamentos do último mês": no mobile, rolagem horizontal (`h-8`, `text-xs`)
|
||||||
|
- Botões "Nova Receita", "Nova Despesa" e ícone de múltiplos lançamentos: no mobile, mesma rolagem horizontal + botões menores
|
||||||
|
- Tabela de lançamentos aplica a ordem de colunas salva nas preferências (extrato, lançamentos, categoria, fatura, pagador)
|
||||||
|
- Adicionado variavel no docker compose para manter o caminho do volume no compose up/down
|
||||||
|
|
||||||
|
**Contribuições:** [Guilherme Bano](https://github.com/Gbano1)
|
||||||
|
|
||||||
## [1.5.3] - 2026-02-21
|
## [1.5.3] - 2026-02-21
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
@@ -222,3 +246,4 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
- Atualização de dependências
|
- Atualização de dependências
|
||||||
- Aplicada formatação no código
|
- Aplicada formatação no código
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ const VALID_FONTS = [
|
|||||||
|
|
||||||
const updatePreferencesSchema = z.object({
|
const updatePreferencesSchema = z.object({
|
||||||
disableMagnetlines: z.boolean(),
|
disableMagnetlines: z.boolean(),
|
||||||
|
extratoNoteAsColumn: z.boolean(),
|
||||||
|
lancamentosColumnOrder: z.array(z.string()).nullable(),
|
||||||
systemFont: z.enum(VALID_FONTS).default("ai-sans"),
|
systemFont: z.enum(VALID_FONTS).default("ai-sans"),
|
||||||
moneyFont: z.enum(VALID_FONTS).default("ai-sans"),
|
moneyFont: z.enum(VALID_FONTS).default("ai-sans"),
|
||||||
});
|
});
|
||||||
@@ -417,6 +419,8 @@ export async function updatePreferencesAction(
|
|||||||
.update(schema.preferenciasUsuario)
|
.update(schema.preferenciasUsuario)
|
||||||
.set({
|
.set({
|
||||||
disableMagnetlines: validated.disableMagnetlines,
|
disableMagnetlines: validated.disableMagnetlines,
|
||||||
|
extratoNoteAsColumn: validated.extratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: validated.lancamentosColumnOrder,
|
||||||
systemFont: validated.systemFont,
|
systemFont: validated.systemFont,
|
||||||
moneyFont: validated.moneyFont,
|
moneyFont: validated.moneyFont,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -427,6 +431,8 @@ export async function updatePreferencesAction(
|
|||||||
await db.insert(schema.preferenciasUsuario).values({
|
await db.insert(schema.preferenciasUsuario).values({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
disableMagnetlines: validated.disableMagnetlines,
|
disableMagnetlines: validated.disableMagnetlines,
|
||||||
|
extratoNoteAsColumn: validated.extratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: validated.lancamentosColumnOrder,
|
||||||
systemFont: validated.systemFont,
|
systemFont: validated.systemFont,
|
||||||
moneyFont: validated.moneyFont,
|
moneyFont: validated.moneyFont,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { db, schema } from "@/lib/db";
|
|||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
disableMagnetlines: boolean;
|
disableMagnetlines: boolean;
|
||||||
|
extratoNoteAsColumn: boolean;
|
||||||
|
lancamentosColumnOrder: string[] | null;
|
||||||
systemFont: string;
|
systemFont: string;
|
||||||
moneyFont: string;
|
moneyFont: string;
|
||||||
}
|
}
|
||||||
@@ -32,6 +34,8 @@ export async function fetchUserPreferences(
|
|||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
|
disableMagnetlines: schema.preferenciasUsuario.disableMagnetlines,
|
||||||
|
extratoNoteAsColumn: schema.preferenciasUsuario.extratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: schema.preferenciasUsuario.lancamentosColumnOrder,
|
||||||
systemFont: schema.preferenciasUsuario.systemFont,
|
systemFont: schema.preferenciasUsuario.systemFont,
|
||||||
moneyFont: schema.preferenciasUsuario.moneyFont,
|
moneyFont: schema.preferenciasUsuario.moneyFont,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { RiArrowRightSLine } from "@remixicon/react";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -35,17 +36,28 @@ export default async function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Tabs defaultValue="preferencias" className="w-full">
|
<Tabs defaultValue="preferencias" className="w-full">
|
||||||
<TabsList>
|
{/* No mobile: rolagem horizontal + seta indicando mais opções à direita */}
|
||||||
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
|
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||||
<TabsTrigger value="companion">Companion</TabsTrigger>
|
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
|
<TabsList className="inline-flex w-max flex-nowrap md:w-full">
|
||||||
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
|
||||||
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
<TabsTrigger value="companion">Companion</TabsTrigger>
|
||||||
<TabsTrigger value="changelog">Changelog</TabsTrigger>
|
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
|
||||||
<TabsTrigger value="deletar" className="text-destructive">
|
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
||||||
Deletar conta
|
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
||||||
</TabsTrigger>
|
<TabsTrigger value="changelog">Changelog</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="deletar" className="text-destructive">
|
||||||
|
Deletar conta
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute right-0 top-0 hidden h-9 w-10 items-center justify-end bg-gradient-to-l from-background to-transparent md:hidden"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent value="preferencias" className="mt-4">
|
<TabsContent value="preferencias" className="mt-4">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -61,6 +73,12 @@ export default async function Page() {
|
|||||||
disableMagnetlines={
|
disableMagnetlines={
|
||||||
userPreferences?.disableMagnetlines ?? false
|
userPreferences?.disableMagnetlines ?? false
|
||||||
}
|
}
|
||||||
|
extratoNoteAsColumn={
|
||||||
|
userPreferences?.extratoNoteAsColumn ?? false
|
||||||
|
}
|
||||||
|
lancamentosColumnOrder={
|
||||||
|
userPreferences?.lancamentosColumnOrder ?? null
|
||||||
|
}
|
||||||
systemFont={userPreferences?.systemFont ?? "ai-sans"}
|
systemFont={userPreferences?.systemFont ?? "ai-sans"}
|
||||||
moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
|
moneyFont={userPreferences?.moneyFont ?? "ai-sans"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { CardDialog } from "@/components/cartoes/card-dialog";
|
import { CardDialog } from "@/components/cartoes/card-dialog";
|
||||||
import type { Card } from "@/components/cartoes/types";
|
import type { Card } from "@/components/cartoes/types";
|
||||||
@@ -51,12 +52,13 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [filterSources, logoOptions, invoiceData, estabelecimentos] =
|
const [filterSources, logoOptions, invoiceData, estabelecimentos, userPreferences] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchLancamentoFilterSources(userId),
|
||||||
loadLogoOptions(),
|
loadLogoOptions(),
|
||||||
fetchInvoiceData(userId, cartaoId, selectedPeriod),
|
fetchInvoiceData(userId, cartaoId, selectedPeriod),
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
]);
|
]);
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
@@ -182,6 +184,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate
|
allowCreate
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
defaultCartaoId={card.id}
|
defaultCartaoId={card.id}
|
||||||
defaultPaymentMethod="Cartão de crédito"
|
defaultPaymentMethod="Cartão de crédito"
|
||||||
lockCartaoSelection
|
lockCartaoSelection
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
|
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
|
||||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
||||||
@@ -36,10 +37,11 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||||
|
|
||||||
const [detail, filterSources, estabelecimentos] = await Promise.all([
|
const [detail, filterSources, estabelecimentos, userPreferences] = await Promise.all([
|
||||||
fetchCategoryDetails(userId, categoryId, selectedPeriod),
|
fetchCategoryDetails(userId, categoryId, selectedPeriod),
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchLancamentoFilterSources(userId),
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
@@ -92,6 +94,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
selectedPeriod={detail.period}
|
selectedPeriod={detail.period}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate={true}
|
allowCreate={true}
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { AccountDialog } from "@/components/contas/account-dialog";
|
import { AccountDialog } from "@/components/contas/account-dialog";
|
||||||
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
||||||
@@ -57,12 +58,13 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const [filterSources, logoOptions, accountSummary, estabelecimentos] =
|
const [filterSources, logoOptions, accountSummary, estabelecimentos, userPreferences] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchLancamentoFilterSources(userId),
|
fetchLancamentoFilterSources(userId),
|
||||||
loadLogoOptions(),
|
loadLogoOptions(),
|
||||||
fetchAccountSummary(userId, contaId, selectedPeriod),
|
fetchAccountSummary(userId, contaId, selectedPeriod),
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
]);
|
]);
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
@@ -161,6 +163,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate={false}
|
allowCreate={false}
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
||||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||||
import { getUserId } from "@/lib/auth/server";
|
import { getUserId } from "@/lib/auth/server";
|
||||||
@@ -31,7 +32,10 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
||||||
|
|
||||||
const filterSources = await fetchLancamentoFilterSources(userId);
|
const [filterSources, userPreferences] = await Promise.all([
|
||||||
|
fetchLancamentoFilterSources(userId),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
@@ -80,6 +84,8 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default async function DashboardLayout({
|
|||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
|
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col pt-12 md:pt-0">
|
||||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||||
<div className="flex flex-col gap-4 py-4 md:gap-6">
|
<div className="flex flex-col gap-4 py-4 md:gap-6">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
RiWallet3Line,
|
RiWallet3Line,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||||
import type {
|
import type {
|
||||||
@@ -168,6 +169,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
shareRows,
|
shareRows,
|
||||||
currentUserShare,
|
currentUserShare,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
|
userPreferences,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchPagadorLancamentos(filters),
|
fetchPagadorLancamentos(filters),
|
||||||
fetchPagadorMonthlyBreakdown({
|
fetchPagadorMonthlyBreakdown({
|
||||||
@@ -203,6 +205,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
sharesPromise,
|
sharesPromise,
|
||||||
currentUserSharePromise,
|
currentUserSharePromise,
|
||||||
getRecentEstablishmentsAction(),
|
getRecentEstablishmentsAction(),
|
||||||
|
fetchUserPreferences(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mappedLancamentos = mapLancamentosData(lancamentoRows);
|
const mappedLancamentos = mapLancamentosData(lancamentoRows);
|
||||||
@@ -381,6 +384,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate={canEdit}
|
allowCreate={canEdit}
|
||||||
|
noteAsColumn={userPreferences?.extratoNoteAsColumn ?? false}
|
||||||
|
columnOrder={userPreferences?.lancamentosColumnOrder ?? null}
|
||||||
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
|
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
|
||||||
importSplitPagadorOptions={
|
importSplitPagadorOptions={
|
||||||
loggedUserOptionSets?.splitPagadorOptions
|
loggedUserOptionSets?.splitPagadorOptions
|
||||||
|
|||||||
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import type { ChangelogVersion } from "@/lib/changelog/parse-changelog";
|
import type { ChangelogVersion } from "@/lib/changelog/parse-changelog";
|
||||||
|
|
||||||
|
/** Converte "[texto](url)" em link; texto simples fica como está */
|
||||||
|
function parseContributorLine(content: string) {
|
||||||
|
const linkMatch = content.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/);
|
||||||
|
if (linkMatch) {
|
||||||
|
return { label: linkMatch[1], url: linkMatch[2] };
|
||||||
|
}
|
||||||
|
return { label: content, url: null };
|
||||||
|
}
|
||||||
|
|
||||||
const sectionBadgeVariant: Record<
|
const sectionBadgeVariant: Record<
|
||||||
string,
|
string,
|
||||||
"success" | "info" | "destructive" | "secondary"
|
"success" | "info" | "destructive" | "secondary"
|
||||||
@@ -46,6 +56,29 @@ export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{version.contributor && (
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Contribuições:{" "}
|
||||||
|
{(() => {
|
||||||
|
const { label, url } = parseContributorLine(version.contributor);
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-foreground underline underline-offset-2 hover:text-primary"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="font-medium text-foreground">{label}</span>;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { RiDragMove2Line } from "@remixicon/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -15,16 +27,58 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
DEFAULT_LANCAMENTOS_COLUMN_ORDER,
|
||||||
|
LANCAMENTOS_COLUMN_LABELS,
|
||||||
|
} from "@/lib/lancamentos/column-order";
|
||||||
import { FONT_OPTIONS, getFontVariable } from "@/public/fonts/font_index";
|
import { FONT_OPTIONS, getFontVariable } from "@/public/fonts/font_index";
|
||||||
|
|
||||||
interface PreferencesFormProps {
|
interface PreferencesFormProps {
|
||||||
disableMagnetlines: boolean;
|
disableMagnetlines: boolean;
|
||||||
|
extratoNoteAsColumn: boolean;
|
||||||
|
lancamentosColumnOrder: string[] | null;
|
||||||
systemFont: string;
|
systemFont: string;
|
||||||
moneyFont: string;
|
moneyFont: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortableColumnItem({ id }: { id: string }) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = LANCAMENTOS_COLUMN_LABELS[id] ?? id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`flex cursor-grab active:cursor-grabbing items-center gap-2 rounded-md border bg-card px-3 py-2 text-sm touch-none select-none ${
|
||||||
|
isDragging ? "z-10 opacity-90 shadow-md" : ""
|
||||||
|
}`}
|
||||||
|
aria-label={`Arrastar ${label}`}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<RiDragMove2Line className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PreferencesForm({
|
export function PreferencesForm({
|
||||||
disableMagnetlines,
|
disableMagnetlines,
|
||||||
|
extratoNoteAsColumn: initialExtratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: initialColumnOrder,
|
||||||
systemFont: initialSystemFont,
|
systemFont: initialSystemFont,
|
||||||
moneyFont: initialMoneyFont,
|
moneyFont: initialMoneyFont,
|
||||||
}: PreferencesFormProps) {
|
}: PreferencesFormProps) {
|
||||||
@@ -32,10 +86,33 @@ export function PreferencesForm({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
||||||
useState(disableMagnetlines);
|
useState(disableMagnetlines);
|
||||||
|
const [extratoNoteAsColumn, setExtratoNoteAsColumn] =
|
||||||
|
useState(initialExtratoNoteAsColumn);
|
||||||
|
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||||
|
initialColumnOrder && initialColumnOrder.length > 0
|
||||||
|
? initialColumnOrder
|
||||||
|
: DEFAULT_LANCAMENTOS_COLUMN_ORDER,
|
||||||
|
);
|
||||||
const [selectedSystemFont, setSelectedSystemFont] =
|
const [selectedSystemFont, setSelectedSystemFont] =
|
||||||
useState(initialSystemFont);
|
useState(initialSystemFont);
|
||||||
const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont);
|
const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(KeyboardSensor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleColumnDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setColumnOrder((items) => {
|
||||||
|
const oldIndex = items.indexOf(active.id as string);
|
||||||
|
const newIndex = items.indexOf(over.id as string);
|
||||||
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fontCtx = useFont();
|
const fontCtx = useFont();
|
||||||
|
|
||||||
// Live preview: update CSS vars when font selection changes
|
// Live preview: update CSS vars when font selection changes
|
||||||
@@ -53,6 +130,8 @@ export function PreferencesForm({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updatePreferencesAction({
|
const result = await updatePreferencesAction({
|
||||||
disableMagnetlines: magnetlinesDisabled,
|
disableMagnetlines: magnetlinesDisabled,
|
||||||
|
extratoNoteAsColumn,
|
||||||
|
lancamentosColumnOrder: columnOrder,
|
||||||
systemFont: selectedSystemFont,
|
systemFont: selectedSystemFont,
|
||||||
moneyFont: selectedMoneyFont,
|
moneyFont: selectedMoneyFont,
|
||||||
});
|
});
|
||||||
@@ -148,7 +227,59 @@ export function PreferencesForm({
|
|||||||
|
|
||||||
<div className="border-b" />
|
<div className="border-b" />
|
||||||
|
|
||||||
{/* Seção 3: Dashboard */}
|
{/* Seção: Extrato / Lançamentos */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">Extrato e lançamentos</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Como exibir anotações e a ordem das colunas na tabela de movimentações.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="extrato-note-column" className="text-base">
|
||||||
|
Anotações em coluna
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Quando ativo, as anotações aparecem em uma coluna na tabela. Quando desativado, aparecem em um balão ao passar o mouse no ícone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="extrato-note-column"
|
||||||
|
checked={extratoNoteAsColumn}
|
||||||
|
onCheckedChange={setExtratoNoteAsColumn}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-w-md">
|
||||||
|
<Label className="text-base">Ordem das colunas</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Arraste os itens para definir a ordem em que as colunas aparecem na tabela do extrato e dos lançamentos.
|
||||||
|
</p>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleColumnDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={columnOrder}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
|
{columnOrder.map((id) => (
|
||||||
|
<SortableColumnItem key={id} id={id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-b" />
|
||||||
|
|
||||||
|
{/* Seção: Dashboard */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold">Dashboard</h3>
|
<h3 className="text-base font-semibold">Dashboard</h3>
|
||||||
|
|||||||
@@ -4,20 +4,19 @@ import {
|
|||||||
RiArrowDownSFill,
|
RiArrowDownSFill,
|
||||||
RiArrowUpSFill,
|
RiArrowUpSFill,
|
||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
RiListUnordered,
|
|
||||||
RiPieChart2Line,
|
|
||||||
RiPieChartLine,
|
RiPieChartLine,
|
||||||
RiWallet3Line,
|
RiWallet3Line,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
import { Pie, PieChart, Tooltip } from "recharts";
|
import { useMemo } from "react";
|
||||||
|
import { Cell, Pie, PieChart, Tooltip } from "recharts";
|
||||||
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/money-values";
|
||||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||||
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
|
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
|
||||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { WidgetEmptyState } from "../widget-empty-state";
|
||||||
|
|
||||||
type ExpensesByCategoryWidgetWithChartProps = {
|
type ExpensesByCategoryWidgetWithChartProps = {
|
||||||
@@ -35,11 +34,21 @@ const formatCurrency = (value: number) =>
|
|||||||
currency: "BRL",
|
currency: "BRL",
|
||||||
}).format(value);
|
}).format(value);
|
||||||
|
|
||||||
|
type ChartDataItem = {
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
percentage: number;
|
||||||
|
fill: string | undefined;
|
||||||
|
href: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export function ExpensesByCategoryWidgetWithChart({
|
export function ExpensesByCategoryWidgetWithChart({
|
||||||
data,
|
data,
|
||||||
period,
|
period,
|
||||||
}: ExpensesByCategoryWidgetWithChartProps) {
|
}: ExpensesByCategoryWidgetWithChartProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
|
const router = useRouter();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const periodParam = formatPeriodForUrl(period);
|
const periodParam = formatPeriodForUrl(period);
|
||||||
|
|
||||||
// Configuração do chart com cores do CSS
|
// Configuração do chart com cores do CSS
|
||||||
@@ -80,50 +89,68 @@ export function ExpensesByCategoryWidgetWithChart({
|
|||||||
return config;
|
return config;
|
||||||
}, [data.categories]);
|
}, [data.categories]);
|
||||||
|
|
||||||
// Preparar dados para o gráfico de pizza - Top 7 + Outros
|
// Preparar dados para o gráfico de pizza - Top 7 + Outros (com href para navegação)
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo((): ChartDataItem[] => {
|
||||||
|
const buildItem = (
|
||||||
|
categoryId: string,
|
||||||
|
name: string,
|
||||||
|
value: number,
|
||||||
|
percentage: number,
|
||||||
|
fill: string | undefined,
|
||||||
|
): ChartDataItem => ({
|
||||||
|
category: categoryId,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
percentage,
|
||||||
|
fill,
|
||||||
|
href:
|
||||||
|
categoryId === "outros"
|
||||||
|
? undefined
|
||||||
|
: `/categorias/${categoryId}?periodo=${periodParam}`,
|
||||||
|
});
|
||||||
|
|
||||||
if (data.categories.length <= 7) {
|
if (data.categories.length <= 7) {
|
||||||
return data.categories.map((category) => ({
|
return data.categories.map((category) =>
|
||||||
category: category.categoryId,
|
buildItem(
|
||||||
name: category.categoryName,
|
category.categoryId,
|
||||||
value: category.currentAmount,
|
category.categoryName,
|
||||||
percentage: category.percentageOfTotal,
|
category.currentAmount,
|
||||||
fill: chartConfig[category.categoryId]?.color,
|
category.percentageOfTotal,
|
||||||
}));
|
chartConfig[category.categoryId]?.color,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pegar top 7 categorias
|
|
||||||
const top7 = data.categories.slice(0, 7);
|
const top7 = data.categories.slice(0, 7);
|
||||||
const others = data.categories.slice(7);
|
const others = data.categories.slice(7);
|
||||||
|
|
||||||
// Somar o restante
|
|
||||||
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
|
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
|
||||||
const othersPercentage = others.reduce(
|
const othersPercentage = others.reduce(
|
||||||
(sum, cat) => sum + cat.percentageOfTotal,
|
(sum, cat) => sum + cat.percentageOfTotal,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const top7Data = top7.map((category) => ({
|
const top7Data = top7.map((category) =>
|
||||||
category: category.categoryId,
|
buildItem(
|
||||||
name: category.categoryName,
|
category.categoryId,
|
||||||
value: category.currentAmount,
|
category.categoryName,
|
||||||
percentage: category.percentageOfTotal,
|
category.currentAmount,
|
||||||
fill: chartConfig[category.categoryId]?.color,
|
category.percentageOfTotal,
|
||||||
}));
|
chartConfig[category.categoryId]?.color,
|
||||||
|
),
|
||||||
// Adicionar "Outros" se houver
|
);
|
||||||
if (others.length > 0) {
|
if (others.length > 0) {
|
||||||
top7Data.push({
|
top7Data.push(
|
||||||
category: "outros",
|
buildItem(
|
||||||
name: "Outros",
|
"outros",
|
||||||
value: othersTotal,
|
"Outros",
|
||||||
percentage: othersPercentage,
|
othersTotal,
|
||||||
fill: chartConfig.outros?.color,
|
othersPercentage,
|
||||||
});
|
chartConfig.outros?.color,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return top7Data;
|
return top7Data;
|
||||||
}, [data.categories, chartConfig]);
|
}, [data.categories, chartConfig, periodParam]);
|
||||||
|
|
||||||
if (data.categories.length === 0) {
|
if (data.categories.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -136,25 +163,146 @@ export function ExpensesByCategoryWidgetWithChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<div className="flex flex-col gap-8">
|
||||||
value={activeTab}
|
{/* Gráfico de pizza (donut) — fatias clicáveis */}
|
||||||
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
|
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:gap-8">
|
||||||
className="w-full"
|
<ChartContainer
|
||||||
>
|
config={chartConfig}
|
||||||
<div className="flex items-center justify-between">
|
className="h-[280px] w-full min-w-0 sm:h-[320px] sm:max-w-[360px]"
|
||||||
<TabsList className="grid grid-cols-2">
|
>
|
||||||
<TabsTrigger value="list" className="text-xs">
|
<PieChart margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
<RiListUnordered className="size-3.5 mr-1" />
|
<Pie
|
||||||
Lista
|
data={chartData}
|
||||||
</TabsTrigger>
|
cx="50%"
|
||||||
<TabsTrigger value="chart" className="text-xs">
|
cy="50%"
|
||||||
<RiPieChart2Line className="size-3.5 mr-1" />
|
innerRadius="58%"
|
||||||
Gráfico
|
outerRadius="92%"
|
||||||
</TabsTrigger>
|
paddingAngle={2}
|
||||||
</TabsList>
|
dataKey="value"
|
||||||
|
nameKey="category"
|
||||||
|
stroke="transparent"
|
||||||
|
onClick={(payload: ChartDataItem) => {
|
||||||
|
if (payload?.href) router.push(payload.href);
|
||||||
|
}}
|
||||||
|
label={(props: {
|
||||||
|
cx?: number;
|
||||||
|
cy?: number;
|
||||||
|
midAngle?: number;
|
||||||
|
innerRadius?: number;
|
||||||
|
outerRadius?: number;
|
||||||
|
percent?: number;
|
||||||
|
}) => {
|
||||||
|
const { cx = 0, cy = 0, midAngle = 0, innerRadius = 0, outerRadius = 0, percent = 0 } = props;
|
||||||
|
const percentage = percent * 100;
|
||||||
|
if (percentage < 6) return null;
|
||||||
|
const radius = (Number(innerRadius) + Number(outerRadius)) / 2;
|
||||||
|
const x = cx + radius * Math.cos(-midAngle * (Math.PI / 180));
|
||||||
|
const y = cy + radius * Math.sin(-midAngle * (Math.PI / 180));
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="fill-foreground text-[10px] font-medium"
|
||||||
|
>
|
||||||
|
{formatPercentage(percentage)}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={entry.fill}
|
||||||
|
className={
|
||||||
|
entry.href
|
||||||
|
? "cursor-pointer transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
entry.href
|
||||||
|
? { filter: "drop-shadow(0 1px 2px rgb(0 0 0 / 0.08))" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
{!isMobile && (
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const d = payload[0].payload as ChartDataItem;
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border/80 bg-card px-3 py-2.5 shadow-lg">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium text-foreground">
|
||||||
|
{d.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold tabular-nums">
|
||||||
|
{formatCurrency(d.value)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{formatPercentage(d.percentage)} do total
|
||||||
|
</span>
|
||||||
|
{d.href && (
|
||||||
|
<span className="mt-1 text-[10px] text-primary">
|
||||||
|
Clique para ver detalhes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
cursor={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
{/* Legenda clicável */}
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-2 sm:flex-1 sm:flex-col sm:gap-1.5">
|
||||||
|
{chartData.map((entry, index) => {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="size-3 shrink-0 rounded-full ring-1 ring-border/50"
|
||||||
|
style={{ backgroundColor: entry.fill }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground/80">
|
||||||
|
{formatPercentage(entry.percentage)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return entry.href ? (
|
||||||
|
<Link
|
||||||
|
key={`legend-${index}`}
|
||||||
|
href={entry.href}
|
||||||
|
className="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={`legend-${index}`}
|
||||||
|
className="flex items-center gap-2 rounded-lg px-2 py-1.5"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="list" className="mt-0">
|
{/* Lista de categorias */}
|
||||||
|
<div className="border-t border-dashed pt-6">
|
||||||
<div className="flex flex-col px-0">
|
<div className="flex flex-col px-0">
|
||||||
{data.categories.map((category, index) => {
|
{data.categories.map((category, index) => {
|
||||||
const hasIncrease =
|
const hasIncrease =
|
||||||
@@ -264,65 +412,7 @@ export function ExpensesByCategoryWidgetWithChart({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
|
</div>
|
||||||
<TabsContent value="chart" className="mt-0">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={chartData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={(entry) => formatPercentage(entry.percentage)}
|
|
||||||
outerRadius={75}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="category"
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const data = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
|
||||||
{data.name}
|
|
||||||
</span>
|
|
||||||
<span className="font-bold text-foreground">
|
|
||||||
{formatCurrency(data.value)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatPercentage(data.percentage)} do total
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ChartContainer>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 min-w-[140px]">
|
|
||||||
{chartData.map((entry, index) => (
|
|
||||||
<div key={`legend-${index}`} className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="size-3 rounded-sm shrink-0"
|
|
||||||
style={{ backgroundColor: entry.fill }}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
{entry.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
|||||||
const _user = await getUser();
|
const _user = await getUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
<header className="fixed top-0 left-0 right-0 z-50 border-b bg-background md:static md:z-auto flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ interface LancamentosPageProps {
|
|||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
allowCreate?: boolean;
|
allowCreate?: boolean;
|
||||||
|
noteAsColumn?: boolean;
|
||||||
|
columnOrder?: string[] | null;
|
||||||
defaultCartaoId?: string | null;
|
defaultCartaoId?: string | null;
|
||||||
defaultPaymentMethod?: string | null;
|
defaultPaymentMethod?: string | null;
|
||||||
lockCartaoSelection?: boolean;
|
lockCartaoSelection?: boolean;
|
||||||
@@ -76,6 +78,8 @@ export function LancamentosPage({
|
|||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
allowCreate = true,
|
allowCreate = true,
|
||||||
|
noteAsColumn = false,
|
||||||
|
columnOrder = null,
|
||||||
defaultCartaoId,
|
defaultCartaoId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
lockCartaoSelection,
|
lockCartaoSelection,
|
||||||
@@ -377,6 +381,8 @@ export function LancamentosPage({
|
|||||||
<LancamentosTable
|
<LancamentosTable
|
||||||
data={lancamentos}
|
data={lancamentos}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
noteAsColumn={noteAsColumn}
|
||||||
|
columnOrder={columnOrder}
|
||||||
pagadorFilterOptions={pagadorFilterOptions}
|
pagadorFilterOptions={pagadorFilterOptions}
|
||||||
categoriaFilterOptions={categoriaFilterOptions}
|
categoriaFilterOptions={categoriaFilterOptions}
|
||||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
RiAddCircleFill,
|
RiAddCircleFill,
|
||||||
RiAddCircleLine,
|
RiAddCircleLine,
|
||||||
RiArrowLeftRightLine,
|
RiArrowLeftRightLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
RiChat1Line,
|
RiChat1Line,
|
||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiDeleteBin5Line,
|
RiDeleteBin5Line,
|
||||||
@@ -68,6 +69,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order";
|
||||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||||
import { formatDate } from "@/lib/utils/date";
|
import { formatDate } from "@/lib/utils/date";
|
||||||
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
|
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||||
@@ -92,6 +94,7 @@ const resolveLogoSrc = (logo: string | null) => {
|
|||||||
|
|
||||||
type BuildColumnsArgs = {
|
type BuildColumnsArgs = {
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
|
noteAsColumn: boolean;
|
||||||
onEdit?: (item: LancamentoItem) => void;
|
onEdit?: (item: LancamentoItem) => void;
|
||||||
onCopy?: (item: LancamentoItem) => void;
|
onCopy?: (item: LancamentoItem) => void;
|
||||||
onImport?: (item: LancamentoItem) => void;
|
onImport?: (item: LancamentoItem) => void;
|
||||||
@@ -106,6 +109,7 @@ type BuildColumnsArgs = {
|
|||||||
|
|
||||||
const buildColumns = ({
|
const buildColumns = ({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
noteAsColumn,
|
||||||
onEdit,
|
onEdit,
|
||||||
onCopy,
|
onCopy,
|
||||||
onImport,
|
onImport,
|
||||||
@@ -269,7 +273,7 @@ const buildColumns = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasNote ? (
|
{!noteAsColumn && hasNote ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="inline-flex rounded-full p-1 hover:bg-muted/60">
|
<span className="inline-flex rounded-full p-1 hover:bg-muted/60">
|
||||||
@@ -493,6 +497,24 @@ const buildColumns = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (noteAsColumn) {
|
||||||
|
const contaCartaoIndex = columns.findIndex((c) => c.id === "contaCartao");
|
||||||
|
const noteColumn: ColumnDef<LancamentoItem> = {
|
||||||
|
accessorKey: "note",
|
||||||
|
header: "Anotação",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const note = row.original.note;
|
||||||
|
if (!note?.trim()) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return (
|
||||||
|
<span className="max-w-[200px] truncate whitespace-pre-line text-sm" title={note}>
|
||||||
|
{note}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
columns.splice(contaCartaoIndex, 0, noteColumn);
|
||||||
|
}
|
||||||
|
|
||||||
if (showActions) {
|
if (showActions) {
|
||||||
columns.push({
|
columns.push({
|
||||||
id: "actions",
|
id: "actions",
|
||||||
@@ -645,9 +667,51 @@ const buildColumns = ({
|
|||||||
return columns;
|
return columns;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FIXED_START_IDS = ["select", "purchaseDate"];
|
||||||
|
const FIXED_END_IDS = ["actions"];
|
||||||
|
|
||||||
|
function getColumnId(col: ColumnDef<LancamentoItem>): string {
|
||||||
|
const c = col as { id?: string; accessorKey?: string };
|
||||||
|
return c.id ?? c.accessorKey ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderColumnsByPreference<T>(
|
||||||
|
columns: ColumnDef<T>[],
|
||||||
|
orderPreference: string[] | null | undefined,
|
||||||
|
): ColumnDef<T>[] {
|
||||||
|
if (!orderPreference || orderPreference.length === 0) return columns;
|
||||||
|
|
||||||
|
const order = orderPreference;
|
||||||
|
const fixedStart: ColumnDef<T>[] = [];
|
||||||
|
const reorderable: ColumnDef<T>[] = [];
|
||||||
|
const fixedEnd: ColumnDef<T>[] = [];
|
||||||
|
|
||||||
|
for (const col of columns) {
|
||||||
|
const id = getColumnId(col as ColumnDef<LancamentoItem>);
|
||||||
|
if (FIXED_START_IDS.includes(id)) fixedStart.push(col);
|
||||||
|
else if (FIXED_END_IDS.includes(id)) fixedEnd.push(col);
|
||||||
|
else reorderable.push(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...reorderable].sort((a, b) => {
|
||||||
|
const idA = getColumnId(a as ColumnDef<LancamentoItem>);
|
||||||
|
const idB = getColumnId(b as ColumnDef<LancamentoItem>);
|
||||||
|
const indexA = order.indexOf(idA);
|
||||||
|
const indexB = order.indexOf(idB);
|
||||||
|
if (indexA === -1 && indexB === -1) return 0;
|
||||||
|
if (indexA === -1) return 1;
|
||||||
|
if (indexB === -1) return -1;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...fixedStart, ...sorted, ...fixedEnd];
|
||||||
|
}
|
||||||
|
|
||||||
type LancamentosTableProps = {
|
type LancamentosTableProps = {
|
||||||
data: LancamentoItem[];
|
data: LancamentoItem[];
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
|
noteAsColumn?: boolean;
|
||||||
|
columnOrder?: string[] | null;
|
||||||
pagadorFilterOptions?: LancamentoFilterOption[];
|
pagadorFilterOptions?: LancamentoFilterOption[];
|
||||||
categoriaFilterOptions?: LancamentoFilterOption[];
|
categoriaFilterOptions?: LancamentoFilterOption[];
|
||||||
contaCartaoFilterOptions?: ContaCartaoFilterOption[];
|
contaCartaoFilterOptions?: ContaCartaoFilterOption[];
|
||||||
@@ -672,6 +736,8 @@ type LancamentosTableProps = {
|
|||||||
export function LancamentosTable({
|
export function LancamentosTable({
|
||||||
data,
|
data,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
noteAsColumn = false,
|
||||||
|
columnOrder: columnOrderPreference = null,
|
||||||
pagadorFilterOptions = [],
|
pagadorFilterOptions = [],
|
||||||
categoriaFilterOptions = [],
|
categoriaFilterOptions = [],
|
||||||
contaCartaoFilterOptions = [],
|
contaCartaoFilterOptions = [],
|
||||||
@@ -704,23 +770,10 @@ export function LancamentosTable({
|
|||||||
});
|
});
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(() => {
|
||||||
() =>
|
const built = buildColumns({
|
||||||
buildColumns({
|
|
||||||
currentUserId,
|
|
||||||
onEdit,
|
|
||||||
onCopy,
|
|
||||||
onImport,
|
|
||||||
onConfirmDelete,
|
|
||||||
onViewDetails,
|
|
||||||
onToggleSettlement,
|
|
||||||
onAnticipate,
|
|
||||||
onViewAnticipationHistory,
|
|
||||||
isSettlementLoading: isSettlementLoading ?? (() => false),
|
|
||||||
showActions,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
noteAsColumn,
|
||||||
onEdit,
|
onEdit,
|
||||||
onCopy,
|
onCopy,
|
||||||
onImport,
|
onImport,
|
||||||
@@ -729,10 +782,28 @@ export function LancamentosTable({
|
|||||||
onToggleSettlement,
|
onToggleSettlement,
|
||||||
onAnticipate,
|
onAnticipate,
|
||||||
onViewAnticipationHistory,
|
onViewAnticipationHistory,
|
||||||
isSettlementLoading,
|
isSettlementLoading: isSettlementLoading ?? (() => false),
|
||||||
showActions,
|
showActions,
|
||||||
],
|
});
|
||||||
);
|
const order = columnOrderPreference?.length
|
||||||
|
? columnOrderPreference
|
||||||
|
: DEFAULT_LANCAMENTOS_COLUMN_ORDER;
|
||||||
|
return reorderColumnsByPreference(built, order);
|
||||||
|
}, [
|
||||||
|
currentUserId,
|
||||||
|
noteAsColumn,
|
||||||
|
columnOrderPreference,
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onImport,
|
||||||
|
onConfirmDelete,
|
||||||
|
onViewDetails,
|
||||||
|
onToggleSettlement,
|
||||||
|
onAnticipate,
|
||||||
|
onViewAnticipationHistory,
|
||||||
|
isSettlementLoading,
|
||||||
|
showActions,
|
||||||
|
]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -789,47 +860,57 @@ export function LancamentosTable({
|
|||||||
{showTopControls ? (
|
{showTopControls ? (
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
{onCreate || onMassAdd ? (
|
{onCreate || onMassAdd ? (
|
||||||
<div className="flex gap-2">
|
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||||
{onCreate ? (
|
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
<>
|
<div className="flex w-max shrink-0 gap-2 py-1 md:w-full md:py-0">
|
||||||
<Button
|
{onCreate ? (
|
||||||
onClick={() => onCreate("Receita")}
|
<>
|
||||||
variant="outline"
|
<Button
|
||||||
className="w-full sm:w-auto"
|
onClick={() => onCreate("Receita")}
|
||||||
>
|
variant="outline"
|
||||||
<RiAddCircleLine className="size-4 text-success" />
|
className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
|
||||||
Nova Receita
|
>
|
||||||
</Button>
|
<RiAddCircleLine className="size-4 text-success" />
|
||||||
<Button
|
Nova Receita
|
||||||
onClick={() => onCreate("Despesa")}
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
className="w-full sm:w-auto"
|
onClick={() => onCreate("Despesa")}
|
||||||
>
|
variant="outline"
|
||||||
<RiAddCircleLine className="size-4 text-destructive" />
|
className="h-8 shrink-0 px-3 text-xs sm:w-auto md:h-9 md:px-4 md:text-sm"
|
||||||
Nova Despesa
|
>
|
||||||
</Button>
|
<RiAddCircleLine className="size-4 text-destructive" />
|
||||||
</>
|
Nova Despesa
|
||||||
) : null}
|
</Button>
|
||||||
{onMassAdd ? (
|
</>
|
||||||
<Tooltip>
|
) : null}
|
||||||
<TooltipTrigger asChild>
|
{onMassAdd ? (
|
||||||
<Button
|
<Tooltip>
|
||||||
onClick={onMassAdd}
|
<TooltipTrigger asChild>
|
||||||
variant="outline"
|
<Button
|
||||||
size="icon"
|
onClick={onMassAdd}
|
||||||
className="shrink-0"
|
variant="outline"
|
||||||
>
|
size="icon"
|
||||||
<RiAddCircleFill className="size-4" />
|
className="size-8 shrink-0 md:size-9"
|
||||||
<span className="sr-only">
|
>
|
||||||
Adicionar múltiplos lançamentos
|
<RiAddCircleFill className="size-4" />
|
||||||
</span>
|
<span className="sr-only">
|
||||||
</Button>
|
Adicionar múltiplos lançamentos
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
<TooltipContent>
|
</Button>
|
||||||
<p>Adicionar múltiplos lançamentos</p>
|
</TooltipTrigger>
|
||||||
</TooltipContent>
|
<TooltipContent>
|
||||||
</Tooltip>
|
<p>Adicionar múltiplos lançamentos</p>
|
||||||
) : null}
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute right-0 top-0 hidden h-9 w-10 items-center justify-end bg-gradient-to-l from-background to-transparent py-1 md:hidden"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className={showFilters ? "hidden sm:block" : ""} />
|
<span className={showFilters ? "hidden sm:block" : ""} />
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function MonthNavigation() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="sticky top-0 z-30 w-full flex-row bg-card text-card-foreground p-4">
|
<Card className="w-full flex-row bg-card text-card-foreground p-4">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<NavigationButton
|
<NavigationButton
|
||||||
direction="left"
|
direction="left"
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
|
import {
|
||||||
|
RiAddCircleLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiFileCopyLine,
|
||||||
|
RiFundsLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -105,26 +110,41 @@ export function BudgetsPage({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-6">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<div className="flex justify-start gap-4">
|
{/* No mobile: rolagem horizontal + seta + botões menores */}
|
||||||
<BudgetDialog
|
<div className="relative -mx-6 px-6 md:mx-0 md:px-0">
|
||||||
mode="create"
|
<div className="overflow-x-auto overflow-y-hidden scroll-smooth md:overflow-visible [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
categories={categories}
|
<div className="flex w-max shrink-0 justify-start gap-3 py-1 md:w-full md:gap-4 md:py-0">
|
||||||
defaultPeriod={selectedPeriod}
|
<BudgetDialog
|
||||||
trigger={
|
mode="create"
|
||||||
<Button disabled={categories.length === 0}>
|
categories={categories}
|
||||||
<RiAddCircleLine className="size-4" />
|
defaultPeriod={selectedPeriod}
|
||||||
Novo orçamento
|
trigger={
|
||||||
|
<Button
|
||||||
|
disabled={categories.length === 0}
|
||||||
|
className="h-8 shrink-0 px-3 text-xs md:h-9 md:px-4 md:text-sm"
|
||||||
|
>
|
||||||
|
<RiAddCircleLine className="size-4" />
|
||||||
|
Novo orçamento
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={categories.length === 0}
|
||||||
|
onClick={() => setDuplicateOpen(true)}
|
||||||
|
className="h-8 shrink-0 px-3 text-xs md:h-9 md:px-4 md:text-sm"
|
||||||
|
>
|
||||||
|
<RiFileCopyLine className="size-4" />
|
||||||
|
Copiar orçamentos do último mês
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</div>
|
||||||
/>
|
</div>
|
||||||
<Button
|
<div
|
||||||
variant="outline"
|
className="pointer-events-none absolute right-0 top-0 hidden h-9 w-10 items-center justify-end bg-gradient-to-l from-background to-transparent py-1 md:hidden"
|
||||||
disabled={categories.length === 0}
|
aria-hidden
|
||||||
onClick={() => setDuplicateOpen(true)}
|
|
||||||
>
|
>
|
||||||
<RiFileCopyLine className="size-4" />
|
<RiArrowRightSLine className="size-5 shrink-0 text-muted-foreground" />
|
||||||
Copiar orçamentos do último mês
|
</div>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasBudgets ? (
|
{hasBudgets ? (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RiDashboardLine,
|
RiDashboardLine,
|
||||||
RiFileChartLine,
|
RiFileChartLine,
|
||||||
RiFundsLine,
|
RiFundsLine,
|
||||||
|
RiPieChartLine,
|
||||||
RiGroupLine,
|
RiGroupLine,
|
||||||
RiInboxLine,
|
RiInboxLine,
|
||||||
RiPriceTag3Line,
|
RiPriceTag3Line,
|
||||||
@@ -160,6 +161,11 @@ export function createSidebarNavData(
|
|||||||
url: "/relatorios/tendencias",
|
url: "/relatorios/tendencias",
|
||||||
icon: RiFileChartLine,
|
icon: RiFileChartLine,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Gastos por categoria",
|
||||||
|
url: "/relatorios/gastos-por-categoria",
|
||||||
|
icon: RiPieChartLine,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Uso de Cartões",
|
title: "Uso de Cartões",
|
||||||
url: "/relatorios/uso-cartoes",
|
url: "/relatorios/uso-cartoes",
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ export const preferenciasUsuario = pgTable("preferencias_usuario", {
|
|||||||
.unique()
|
.unique()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
|
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
|
||||||
|
extratoNoteAsColumn: boolean("extrato_note_as_column").notNull().default(false),
|
||||||
systemFont: text("system_font").notNull().default("ai-sans"),
|
systemFont: text("system_font").notNull().default("ai-sans"),
|
||||||
moneyFont: text("money_font").notNull().default("ai-sans"),
|
moneyFont: text("money_font").notNull().default("ai-sans"),
|
||||||
|
lancamentosColumnOrder: jsonb("lancamentos_column_order").$type<string[] | null>(),
|
||||||
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
||||||
order: string[];
|
order: string[];
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
||||||
|
# Garante que os dados ficam no volume montado (evita perda após down/up)
|
||||||
|
PGDATA: /var/lib/postgresql/data
|
||||||
# Configurações de performance
|
# Configurações de performance
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||||
|
|
||||||
|
|||||||
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;
|
version: string;
|
||||||
date: string;
|
date: string;
|
||||||
sections: ChangelogSection[];
|
sections: ChangelogSection[];
|
||||||
|
/** Linha de contribuições/autor (pode conter markdown, ex: [Nome](url)) */
|
||||||
|
contributor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseChangelog(): ChangelogVersion[] {
|
export function parseChangelog(): ChangelogVersion[] {
|
||||||
@@ -49,6 +51,13 @@ export function parseChangelog(): ChangelogVersion[] {
|
|||||||
const itemMatch = line.match(/^- (.+)$/);
|
const itemMatch = line.match(/^- (.+)$/);
|
||||||
if (itemMatch && currentSection) {
|
if (itemMatch && currentSection) {
|
||||||
currentSection.items.push(itemMatch[1]);
|
currentSection.items.push(itemMatch[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Contribuições:** ou **Autor:** com texto/link opcional
|
||||||
|
const contributorMatch = line.match(/^\*\*(?:Contribuições|Autor):\*\*\s*(.+)$/);
|
||||||
|
if (contributorMatch && currentVersion) {
|
||||||
|
currentVersion.contributor = contributorMatch[1].trim() || undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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",
|
"name": "openmonetis",
|
||||||
"version": "1.5.3",
|
"version": "1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
Reference in New Issue
Block a user