feat: implementar sistema de preferências do usuário e refatorar changelog

Adiciona sistema completo de preferências de usuário:
  - Cria tabela userPreferences no schema com campos disableMagnetlines, periodMonthsBefore e periodMonthsAfter
  - Implementa página de Ajustes com abas (Preferências, Alterar nome, Senha, E-mail, Deletar conta)
  - Adiciona componente PreferencesForm para configuração de magnetlines e períodos de exibição
  - Propaga periodPreferences para todos os componentes de lançamentos e calendário

  Refatora sistema de changelog:
  - Remove implementação anterior baseada em JSON estático
  - Adiciona nova página de changelog dinâmica em app/(dashboard)/changelog
  - Adiciona componente changelog-list.tsx
  - Remove arquivos obsoletos (changelog-notification, actions, data, utils, scripts)

  Adiciona controle de saldo inicial em contas:
  - Novo campo excludeInitialBalanceFromIncome em contas
  - Permite excluir saldo inicial do cálculo de receitas
  - Atualiza queries de lançamentos para respeitar esta configuração

  Melhorias adicionais:
  - Adiciona componente ui/accordion.tsx do shadcn/ui
  - Refatora formatPeriodLabel para displayPeriod centralizado
  - Propaga estabelecimentos para componentes de lançamentos
  - Remove variável DB_PROVIDER obsoleta do .env.example e documentação
  - Adiciona 6 migrações de banco de dados (0003-0008)
This commit is contained in:
Felipe Coutinho
2026-01-03 14:18:03 +00:00
parent 3eca48c71a
commit fd817683ca
87 changed files with 13582 additions and 1445 deletions

View File

@@ -13,9 +13,6 @@ POSTGRES_USER=opensheets
POSTGRES_PASSWORD=opensheets_dev_password
POSTGRES_DB=opensheets_db
# Provider: "local" para Docker, "remote" para Supabase/Neon/etc
DB_PROVIDER=local
# === Better Auth ===
# Gere com: openssl rand -base64 32
BETTER_AUTH_SECRET=your-secret-key-here-change-this

View File

@@ -241,7 +241,6 @@ Esta é a **melhor opção para desenvolvedores** que vão modificar o código.
```env
# Banco de dados (usando Docker)
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db
DB_PROVIDER=local
# Better Auth (gere com: openssl rand -base64 32)
BETTER_AUTH_SECRET=seu-secret-aqui
@@ -319,7 +318,6 @@ Ideal para quem quer apenas **usar a aplicação** sem mexer no código.
```env
# Use o host "db" (nome do serviço Docker)
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@db:5432/opensheets_db
DB_PROVIDER=local
# Better Auth
BETTER_AUTH_SECRET=seu-secret-aqui
@@ -365,7 +363,6 @@ Se você já tem PostgreSQL no **Supabase**, **Neon**, **Railway**, etc.
```env
DATABASE_URL=postgresql://user:password@host.region.provider.com:5432/database?sslmode=require
DB_PROVIDER=remote
BETTER_AUTH_SECRET=seu-secret-aqui
BETTER_AUTH_URL=http://localhost:3000
@@ -586,7 +583,6 @@ Copie o `.env.example` para `.env` e configure:
```env
# === Database ===
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db
DB_PROVIDER=local # ou "remote"
# === Better Auth ===
# Gere com: openssl rand -base64 32
@@ -654,9 +650,9 @@ pnpm env:setup
### Escolhendo entre Local e Remoto
| Modo | Quando usar | Como configurar |
| ---------- | ------------------------------------- | -------------------------------------- |
| **Local** | Desenvolvimento, testes, prototipagem | `DB_PROVIDER=local` + Docker |
| **Remoto** | Produção, deploy, banco gerenciado | `DB_PROVIDER=remote` + URL do provider |
| ---------- | ------------------------------------- | --------------------------------------------- |
| **Local** | Desenvolvimento, testes, prototipagem | `DATABASE_URL` com host "db" ou "localhost" |
| **Remoto** | Produção, deploy, banco gerenciado | `DATABASE_URL` com URL completa do provider |
### Drizzle ORM

View File

@@ -48,6 +48,20 @@ const deleteAccountSchema = z.object({
}),
});
const updatePreferencesSchema = z.object({
disableMagnetlines: z.boolean(),
periodMonthsBefore: z
.number()
.int("Deve ser um número inteiro")
.min(1, "Mínimo de 1 mês")
.max(24, "Máximo de 24 meses"),
periodMonthsAfter: z
.number()
.int("Deve ser um número inteiro")
.min(1, "Mínimo de 1 mês")
.max(24, "Máximo de 24 meses"),
});
// Actions
export async function updateNameAction(
@@ -327,3 +341,73 @@ export async function deleteAccountAction(
};
}
}
export async function updatePreferencesAction(
data: z.infer<typeof updatePreferencesSchema>
): Promise<ActionResponse> {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user?.id) {
return {
success: false,
error: "Não autenticado",
};
}
const validated = updatePreferencesSchema.parse(data);
// Check if preferences exist, if not create them
const existingResult = await db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, session.user.id))
.limit(1);
const existing = existingResult[0] || null;
if (existing) {
// Update existing preferences
await db
.update(schema.userPreferences)
.set({
disableMagnetlines: validated.disableMagnetlines,
periodMonthsBefore: validated.periodMonthsBefore,
periodMonthsAfter: validated.periodMonthsAfter,
updatedAt: new Date(),
})
.where(eq(schema.userPreferences.userId, session.user.id));
} else {
// Create new preferences
await db.insert(schema.userPreferences).values({
userId: session.user.id,
disableMagnetlines: validated.disableMagnetlines,
periodMonthsBefore: validated.periodMonthsBefore,
periodMonthsAfter: validated.periodMonthsAfter,
});
}
// Revalidar o layout do dashboard
revalidatePath("/", "layout");
return {
success: true,
message: "Preferências atualizadas com sucesso",
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message || "Dados inválidos",
};
}
console.error("Erro ao atualizar preferências:", error);
return {
success: false,
error: "Erro ao atualizar preferências. Tente novamente.",
};
}
}

View File

@@ -2,6 +2,7 @@ import { DeleteAccountForm } from "@/components/ajustes/delete-account-form";
import { UpdateEmailForm } from "@/components/ajustes/update-email-form";
import { UpdateNameForm } from "@/components/ajustes/update-name-form";
import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
import { PreferencesForm } from "@/components/ajustes/preferences-form";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { auth } from "@/lib/auth/config";
@@ -27,14 +28,28 @@ export default async function Page() {
where: eq(schema.account.userId, session.user.id),
});
// Buscar preferências do usuário
const userPreferencesResult = await db
.select({
disableMagnetlines: schema.userPreferences.disableMagnetlines,
periodMonthsBefore: schema.userPreferences.periodMonthsBefore,
periodMonthsAfter: schema.userPreferences.periodMonthsAfter,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, session.user.id))
.limit(1);
const userPreferences = userPreferencesResult[0] || null;
// Se o providerId for "google", o usuário usa Google OAuth
const authProvider = userAccount?.providerId || "credential";
return (
<div className="max-w-3xl">
<Tabs defaultValue="nome" className="w-full">
<TabsList className="w-full grid grid-cols-4 mb-2">
<TabsTrigger value="nome">Altere seu nome</TabsTrigger>
<div className="w-full">
<Tabs defaultValue="preferencias" className="w-full">
<TabsList>
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive">
@@ -42,31 +57,62 @@ export default async function Page() {
</TabsTrigger>
</TabsList>
<TabsContent value="preferencias" className="mt-4">
<Card className="p-6">
<TabsContent value="nome" className="space-y-4">
<div className="space-y-4">
<div>
<h2 className="text-lg font-medium mb-1">Alterar nome</h2>
<h2 className="text-lg font-bold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground mb-4">
Atualize como seu nome aparece no Opensheets. Esse nome pode ser
exibido em diferentes seções do app e em comunicações.
Personalize sua experiência no Opensheets ajustando as
configurações de acordo com suas necessidades.
</p>
</div>
<PreferencesForm
disableMagnetlines={
userPreferences?.disableMagnetlines ?? false
}
periodMonthsBefore={userPreferences?.periodMonthsBefore ?? 3}
periodMonthsAfter={userPreferences?.periodMonthsAfter ?? 3}
/>
</div>
</Card>
</TabsContent>
<TabsContent value="nome" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground mb-4">
Atualize como seu nome aparece no Opensheets. Esse nome pode
ser exibido em diferentes seções do app e em comunicações.
</p>
</div>
<UpdateNameForm currentName={userName} />
</div>
</Card>
</TabsContent>
<TabsContent value="senha" className="space-y-4">
<TabsContent value="senha" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-medium mb-1">Alterar senha</h2>
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground mb-4">
Defina uma nova senha para sua conta. Guarde-a em local seguro.
Defina uma nova senha para sua conta. Guarde-a em local
seguro.
</p>
</div>
<UpdatePasswordForm authProvider={authProvider} />
</div>
</Card>
</TabsContent>
<TabsContent value="email" className="space-y-4">
<TabsContent value="email" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-medium mb-1">Alterar e-mail</h2>
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground mb-4">
Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail
@@ -77,11 +123,15 @@ export default async function Page() {
currentEmail={userEmail}
authProvider={authProvider}
/>
</div>
</Card>
</TabsContent>
<TabsContent value="deletar" className="space-y-4">
<TabsContent value="deletar" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-medium mb-1 text-destructive">
<h2 className="text-lg font-bold mb-1 text-destructive">
Deletar conta
</h2>
<p className="text-sm text-muted-foreground mb-4">
@@ -90,8 +140,9 @@ export default async function Page() {
</p>
</div>
<DeleteAccountForm />
</TabsContent>
</div>
</Card>
</TabsContent>
</Tabs>
</div>
);

View File

@@ -7,6 +7,7 @@ import {
fetchLancamentoFilterSources,
mapLancamentosData,
} from "@/lib/lancamentos/page-helpers";
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
@@ -59,7 +60,8 @@ export const fetchCalendarData = async ({
const rangeStartKey = toDateKey(rangeStart);
const rangeEndKey = toDateKey(rangeEnd);
const [lancamentoRows, cardRows, filterSources] = await Promise.all([
const [lancamentoRows, cardRows, filterSources, periodPreferences] =
await Promise.all([
db.query.lancamentos.findMany({
where: and(
eq(lancamentos.userId, userId),
@@ -94,6 +96,7 @@ export const fetchCalendarData = async ({
where: eq(cartoes.userId, userId),
}),
fetchLancamentoFilterSources(userId),
fetchUserPeriodPreferences(userId),
]);
const lancamentosData = mapLancamentosData(lancamentoRows);
@@ -214,6 +217,7 @@ export const fetchCalendarData = async ({
cartaoOptions: optionSets.cartaoOptions,
categoriaOptions: optionSets.categoriaOptions,
estabelecimentos,
periodPreferences,
},
};
};

View File

@@ -1,3 +1,4 @@
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { CardDialog } from "@/components/cartoes/card-dialog";
import type { Card } from "@/components/cartoes/types";
import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card";
@@ -19,6 +20,7 @@ import {
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options";
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
import { parsePeriodParam } from "@/lib/utils/period";
import { RiPencilLine } from "@remixicon/react";
import { and, desc } from "drizzle-orm";
@@ -52,10 +54,18 @@ export default async function Page({ params, searchParams }: PageProps) {
notFound();
}
const [filterSources, logoOptions, invoiceData] = await Promise.all([
const [
filterSources,
logoOptions,
invoiceData,
estabelecimentos,
periodPreferences,
] = await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchInvoiceData(userId, cartaoId, selectedPeriod),
getRecentEstablishmentsAction(),
fetchUserPeriodPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
@@ -187,6 +197,8 @@ export default async function Page({ params, searchParams }: PageProps) {
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
periodPreferences={periodPreferences}
allowCreate
defaultCartaoId={card.id}
defaultPaymentMethod="Cartão de crédito"

View File

@@ -9,7 +9,8 @@ import {
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period";
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
import { displayPeriod, parsePeriodParam } from "@/lib/utils/period";
import { notFound } from "next/navigation";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -28,24 +29,6 @@ const getSingleParam = (
return Array.isArray(value) ? value[0] ?? null : value;
};
const formatPeriodLabel = (period: string) => {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1;
if (Number.isNaN(year) || Number.isNaN(monthIndex) || monthIndex < 0) {
return period;
}
const date = new Date(year, monthIndex, 1);
const label = date.toLocaleDateString("pt-BR", {
month: "long",
year: "numeric",
});
return label.charAt(0).toUpperCase() + label.slice(1);
};
export default async function Page({ params, searchParams }: PageProps) {
const { categoryId } = await params;
const userId = await getUserId();
@@ -54,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, periodPreferences] = await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchLancamentoFilterSources(userId),
getRecentEstablishmentsAction(),
fetchUserPeriodPreferences(userId),
]);
if (!detail) {
@@ -80,8 +64,8 @@ export default async function Page({ params, searchParams }: PageProps) {
pagadorRows: filterSources.pagadorRows,
});
const currentPeriodLabel = formatPeriodLabel(detail.period);
const previousPeriodLabel = formatPeriodLabel(detail.previousPeriod);
const currentPeriodLabel = displayPeriod(detail.period);
const previousPeriodLabel = displayPeriod(detail.previousPeriod);
return (
<main className="flex flex-col gap-6">
@@ -108,6 +92,7 @@ export default async function Page({ params, searchParams }: PageProps) {
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={detail.period}
estabelecimentos={estabelecimentos}
periodPreferences={periodPreferences}
allowCreate={true}
/>
</main>

View File

@@ -0,0 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiGitCommitLine } from "@remixicon/react";
export const metadata = {
title: "Changelog | Opensheets",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiGitCommitLine />}
title="Changelog"
subtitle="Histórico completo de alterações e atualizações do projeto."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,31 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-8">
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-96" />
</div>
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<Skeleton className="h-6 w-3/4 mb-2" />
<div className="flex gap-3">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
</CardHeader>
<CardContent className="pt-0">
<Skeleton className="h-4 w-32" />
</CardContent>
</Card>
))}
</div>
</main>
);
}

View File

@@ -0,0 +1,102 @@
import { ChangelogList } from "@/components/changelog/changelog-list";
import { execSync } from "child_process";
type GitCommit = {
hash: string;
shortHash: string;
author: string;
date: string;
message: string;
body: string;
filesChanged: string[];
};
function getGitRemoteUrl(): string | null {
try {
const remoteUrl = execSync("git config --get remote.origin.url", {
encoding: "utf-8",
cwd: process.cwd(),
}).trim();
// Converter SSH para HTTPS se necessário
if (remoteUrl.startsWith("git@")) {
return remoteUrl
.replace("git@github.com:", "https://github.com/")
.replace("git@gitlab.com:", "https://gitlab.com/")
.replace(".git", "");
}
return remoteUrl.replace(".git", "");
} catch (error) {
console.error("Error fetching git remote URL:", error);
return null;
}
}
function getGitCommits(): GitCommit[] {
try {
// Buscar os últimos 50 commits
const commits = execSync(
'git log -50 --pretty=format:"%H|%h|%an|%ad|%s|%b" --date=iso --name-only',
{
encoding: "utf-8",
cwd: process.cwd(),
}
)
.trim()
.split("\n\n");
return commits
.map((commitBlock) => {
const lines = commitBlock.split("\n");
const [hash, shortHash, author, date, message, ...rest] =
lines[0].split("|");
// Separar body e arquivos
const bodyLines: string[] = [];
const filesChanged: string[] = [];
let isBody = true;
rest.forEach((line) => {
if (line && !line.includes("/") && !line.includes(".")) {
bodyLines.push(line);
} else {
isBody = false;
}
});
lines.slice(1).forEach((line) => {
if (line.trim()) {
filesChanged.push(line.trim());
}
});
return {
hash,
shortHash,
author,
date,
message,
body: bodyLines.join("\n").trim(),
filesChanged: filesChanged.filter(
(f) => f && !f.startsWith("git log")
),
};
})
.filter((commit) => commit.hash && commit.message);
} catch (error) {
console.error("Error fetching git commits:", error);
return [];
}
}
export default async function ChangelogPage() {
const commits = getGitCommits();
const repoUrl = getGitRemoteUrl();
return (
<main>
<ChangelogList commits={commits} repoUrl={repoUrl} />
</main>
);
}

View File

@@ -1,3 +1,4 @@
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { AccountDialog } from "@/components/contas/account-dialog";
import { AccountStatementCard } from "@/components/contas/account-statement-card";
import type { Account } from "@/components/contas/types";
@@ -19,6 +20,7 @@ import {
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options";
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
import { parsePeriodParam } from "@/lib/utils/period";
import { RiPencilLine } from "@remixicon/react";
import { and, desc, eq } from "drizzle-orm";
@@ -55,10 +57,18 @@ export default async function Page({ params, searchParams }: PageProps) {
notFound();
}
const [filterSources, logoOptions, accountSummary] = await Promise.all([
const [
filterSources,
logoOptions,
accountSummary,
estabelecimentos,
periodPreferences,
] = await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchAccountSummary(userId, contaId, selectedPeriod),
getRecentEstablishmentsAction(),
fetchUserPeriodPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
@@ -165,6 +175,8 @@ export default async function Page({ params, searchParams }: PageProps) {
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
periodPreferences={periodPreferences}
allowCreate={false}
/>
</section>

View File

@@ -56,6 +56,9 @@ const accountBaseSchema = z.object({
excludeFromBalance: z
.union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"),
excludeInitialBalanceFromIncome: z
.union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"),
});
const createAccountSchema = accountBaseSchema;
@@ -93,6 +96,7 @@ export async function createAccountAction(
logo: logoFile,
initialBalance: formatDecimalForDbRequired(data.initialBalance),
excludeFromBalance: data.excludeFromBalance,
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
userId: user.id,
})
.returning({ id: contas.id, name: contas.name });
@@ -183,6 +187,7 @@ export async function updateAccountAction(
logo: logoFile,
initialBalance: formatDecimalForDbRequired(data.initialBalance),
excludeFromBalance: data.excludeFromBalance,
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
})
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
.returning();

View File

@@ -15,6 +15,7 @@ export type AccountData = {
initialBalance: number;
balance: number;
excludeFromBalance: boolean;
excludeInitialBalanceFromIncome: boolean;
};
export async function fetchAccountsForUser(
@@ -31,6 +32,7 @@ export async function fetchAccountsForUser(
logo: contas.logo,
initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>`
coalesce(
sum(
@@ -67,7 +69,8 @@ export async function fetchAccountsForUser(
contas.note,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome
),
loadLogoOptions(),
]);
@@ -84,6 +87,7 @@ export async function fetchAccountsForUser(
Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
}));
return { accounts, logoOptions };

View File

@@ -5,6 +5,8 @@ import MonthPicker from "@/components/month-picker/month-picker";
import { getUser } from "@/lib/auth/server";
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import { parsePeriodParam } from "@/lib/utils/period";
import { db, schema } from "@/lib/db";
import { eq } from "drizzle-orm";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -29,9 +31,20 @@ export default async function Page({ searchParams }: PageProps) {
const data = await fetchDashboardData(user.id, selectedPeriod);
// Buscar preferências do usuário
const preferencesResult = await db
.select({
disableMagnetlines: schema.userPreferences.disableMagnetlines,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1);
const disableMagnetlines = preferencesResult[0]?.disableMagnetlines ?? false;
return (
<main className="flex flex-col gap-4 px-6">
<DashboardWelcome name={user.name} />
<DashboardWelcome name={user.name} disableMagnetlines={disableMagnetlines} />
<MonthPicker />
<SectionCards metrics={data.metrics} />
<DashboardGrid data={data} period={selectedPeriod} />

View File

@@ -1,18 +1,41 @@
import { lancamentos } from "@/db/schema";
import { lancamentos, contas, pagadores, cartoes, categorias } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { and, desc, type SQL } from "drizzle-orm";
import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm";
export async function fetchLancamentos(filters: SQL[]) {
const lancamentoRows = await db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
});
const lancamentoRows = await db
.select({
lancamento: lancamentos,
pagador: pagadores,
conta: contas,
cartao: cartoes,
categoria: categorias,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
...filters,
// Excluir saldos iniciais de contas que têm excludeInitialBalanceFromIncome = true
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
return lancamentoRows;
// Transformar resultado para o formato esperado
return lancamentoRows.map((row) => ({
...row.lancamento,
pagador: row.pagador,
conta: row.conta,
cartao: row.cartao,
categoria: row.categoria,
}));
}

View File

@@ -12,6 +12,7 @@ import {
mapLancamentosData,
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
import { parsePeriodParam } from "@/lib/utils/period";
import { fetchLancamentos } from "./data";
import { getRecentEstablishmentsAction } from "./actions";
@@ -31,7 +32,11 @@ export default async function Page({ searchParams }: PageProps) {
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const filterSources = await fetchLancamentoFilterSources(userId);
const [filterSources, periodPreferences] = await Promise.all([
fetchLancamentoFilterSources(userId),
fetchUserPeriodPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
@@ -78,6 +83,7 @@ export default async function Page({ searchParams }: PageProps) {
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
periodPreferences={periodPreferences}
/>
</main>
);

View File

@@ -1,6 +1,7 @@
import MonthPicker from "@/components/month-picker/month-picker";
import { BudgetsPage } from "@/components/orcamentos/budgets-page";
import { getUserId } from "@/lib/auth/server";
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
import { parsePeriodParam } from "@/lib/utils/period";
import { fetchBudgetsForUser } from "./data";
@@ -35,10 +36,10 @@ export default async function Page({ searchParams }: PageProps) {
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
userId,
selectedPeriod
);
const [{ budgets, categoriesOptions }, periodPreferences] = await Promise.all([
fetchBudgetsForUser(userId, selectedPeriod),
fetchUserPeriodPreferences(userId),
]);
return (
<main className="flex flex-col gap-6">
@@ -48,6 +49,7 @@ export default async function Page({ searchParams }: PageProps) {
categories={categoriesOptions}
selectedPeriod={selectedPeriod}
periodLabel={periodLabel}
periodPreferences={periodPreferences}
/>
</main>
);

View File

@@ -9,6 +9,7 @@ import {
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details";
import { displayPeriod } from "@/lib/utils/period";
import { and, desc, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { Resend } from "resend";
@@ -32,17 +33,6 @@ const formatCurrency = (value: number) =>
maximumFractionDigits: 2,
});
const formatPeriodLabel = (period: string) => {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr, 10);
const month = Number.parseInt(monthStr, 10) - 1;
const date = new Date(year, month, 1);
return date.toLocaleDateString("pt-BR", {
month: "long",
year: "numeric",
});
};
const formatDate = (value: Date | null | undefined) => {
if (!value) return "—";
return value.toLocaleDateString("pt-BR", {
@@ -560,7 +550,7 @@ export async function sendPagadorSummaryAction(
const html = buildSummaryHtml({
pagadorName: pagadorRow.name,
periodLabel: formatPeriodLabel(period),
periodLabel: displayPeriod(period),
monthlyBreakdown,
historyData,
cardUsage,
@@ -573,7 +563,7 @@ export async function sendPagadorSummaryAction(
await resend.emails.send({
from: resendFrom,
to: pagadorRow.email,
subject: `Resumo Financeiro | ${formatPeriodLabel(period)}`,
subject: `Resumo Financeiro | ${displayPeriod(period)}`,
html,
});

View File

@@ -1,3 +1,4 @@
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
@@ -29,6 +30,7 @@ import {
type SlugMaps,
type SluggedFilters,
} from "@/lib/lancamentos/page-helpers";
import { fetchUserPeriodPreferences } from "@/lib/user-preferences/period";
import { parsePeriodParam } from "@/lib/utils/period";
import { getPagadorAccess } from "@/lib/pagadores/access";
import {
@@ -134,6 +136,8 @@ export default async function Page({ params, searchParams }: PageProps) {
cardUsage,
boletoStats,
shareRows,
estabelecimentos,
periodPreferences,
] = await Promise.all([
fetchPagadorLancamentos(filters),
fetchPagadorMonthlyBreakdown({
@@ -157,6 +161,8 @@ export default async function Page({ params, searchParams }: PageProps) {
period: selectedPeriod,
}),
sharesPromise,
getRecentEstablishmentsAction(),
fetchUserPeriodPreferences(dataOwnerId),
]);
const mappedLancamentos = mapLancamentosData(lancamentoRows);
@@ -289,6 +295,8 @@ export default async function Page({ params, searchParams }: PageProps) {
categoriaFilterOptions={optionSets.categoriaFilterOptions}
contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
periodPreferences={periodPreferences}
allowCreate={canEdit}
/>
</section>

View File

@@ -1,5 +1,4 @@
"use client";
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import {
@@ -13,7 +12,6 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth/client";
import { RiAlertLine } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
@@ -54,35 +52,30 @@ export function DeleteAccountForm() {
return (
<>
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 space-y-4">
<div className="flex items-start gap-3">
<RiAlertLine className="size-5 text-destructive mt-0.5" />
<div className="flex-1 space-y-1">
<h3 className="font-medium text-destructive">
Remoção definitiva de conta
</h3>
<p className="text-sm text-foreground">
Ao prosseguir, sua conta e todos os dados associados serão
excluídos de forma irreversível.
</p>
</div>
</div>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 pl-8">
<li>Lançamentos, anexos e notas</li>
<li>Contas, cartões, orçamentos e categorias</li>
<div className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Contas, cartões e categorias</li>
<li>Pagadores (incluindo o pagador padrão)</li>
<li>Preferências e configurações</li>
<li className="font-bold">
Resumindo tudo, sua conta será permanentemente removida
</li>
</ul>
</div>
<div className="flex justify-end">
<Button
variant="destructive"
onClick={handleOpenModal}
disabled={isPending}
className="w-fit"
>
Deletar conta
</Button>
</div>
</div>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent

View File

@@ -0,0 +1,138 @@
"use client";
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
interface PreferencesFormProps {
disableMagnetlines: boolean;
periodMonthsBefore: number;
periodMonthsAfter: number;
}
export function PreferencesForm({
disableMagnetlines,
periodMonthsBefore,
periodMonthsAfter,
}: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [magnetlinesDisabled, setMagnetlinesDisabled] =
useState(disableMagnetlines);
const [monthsBefore, setMonthsBefore] = useState(periodMonthsBefore);
const [monthsAfter, setMonthsAfter] = useState(periodMonthsAfter);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
startTransition(async () => {
const result = await updatePreferencesAction({
disableMagnetlines: magnetlinesDisabled,
periodMonthsBefore: monthsBefore,
periodMonthsAfter: monthsAfter,
});
if (result.success) {
toast.success(result.message);
// Recarregar a página para aplicar as mudanças nos componentes
router.refresh();
// Forçar reload completo para garantir que os hooks re-executem
setTimeout(() => {
window.location.reload();
}, 500);
} else {
toast.error(result.error);
}
});
};
return (
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-6"
>
<div className="space-y-4 max-w-md">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5">
<Label htmlFor="magnetlines" className="text-base">
Desabilitar Magnetlines
</Label>
<p className="text-sm text-muted-foreground">
Remove o recurso de linhas magnéticas do sistema. Essa mudança
afeta a interface e interações visuais.
</p>
</div>
<Switch
id="magnetlines"
checked={magnetlinesDisabled}
onCheckedChange={setMagnetlinesDisabled}
disabled={isPending}
/>
</div>
<div className="space-y-4 rounded-lg border border-dashed p-4">
<div>
<h3 className="text-base font-medium mb-2">
Seleção de Período
</h3>
<p className="text-sm text-muted-foreground mb-4">
Configure quantos meses antes e depois do mês atual serão exibidos
nos seletores de período.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="monthsBefore" className="text-sm">
Meses anteriores
</Label>
<Input
id="monthsBefore"
type="number"
min={1}
max={24}
value={monthsBefore}
onChange={(e) => setMonthsBefore(Number(e.target.value))}
disabled={isPending}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
1 a 24 meses
</p>
</div>
<div className="space-y-2">
<Label htmlFor="monthsAfter" className="text-sm">
Meses posteriores
</Label>
<Input
id="monthsAfter"
type="number"
min={1}
max={24}
value={monthsAfter}
onChange={(e) => setMonthsAfter(Number(e.target.value))}
disabled={isPending}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
1 a 24 meses
</p>
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Salvando..." : "Salvar preferências"}
</Button>
</div>
</form>
);
}

View File

@@ -68,7 +68,8 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* E-mail atual (apenas informativo) */}
<div className="space-y-2">
<Label htmlFor="currentEmail">E-mail atual</Label>
@@ -203,13 +204,17 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
</p>
)}
</div>
</div>
<div className="flex justify-end">
<Button
type="submit"
disabled={isPending || emailsMatch === false || !isEmailDifferent}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar e-mail"}
</Button>
</div>
</form>
);
}

View File

@@ -40,7 +40,8 @@ export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="firstName">Primeiro nome</Label>
<Input
@@ -62,10 +63,13 @@ export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
required
/>
</div>
</div>
<Button type="submit" disabled={isPending}>
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Atualizando..." : "Atualizar nome"}
</Button>
</div>
</form>
);
}

View File

@@ -5,7 +5,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils/ui";
import { RiEyeLine, RiEyeOffLine, RiCheckLine, RiCloseLine, RiAlertLine } from "@remixicon/react";
import {
RiEyeLine,
RiEyeOffLine,
RiCheckLine,
RiCloseLine,
RiAlertLine,
} from "@remixicon/react";
import { useState, useTransition, useMemo } from "react";
import { toast } from "sonner";
@@ -44,13 +50,7 @@ function validatePassword(password: string): PasswordValidation {
};
}
function PasswordRequirement({
met,
label,
}: {
met: boolean;
label: string;
}) {
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div
className={cn(
@@ -133,15 +133,16 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
<div className="flex gap-3">
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0 mt-0.5" />
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-amber-900 dark:text-amber-400">
Alteração de senha não disponível
</h3>
<p className="mt-1 text-sm text-amber-800 dark:text-amber-500">
Você fez login usando sua conta do Google. A senha é gerenciada diretamente pelo Google
e não pode ser alterada aqui. Para modificar sua senha, acesse as configurações de
segurança da sua conta Google.
Você fez login usando sua conta do Google. A senha é gerenciada
diretamente pelo Google e não pode ser alterada aqui. Para
modificar sua senha, acesse as configurações de segurança da sua
conta Google.
</p>
</div>
</div>
@@ -150,7 +151,8 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* Senha atual */}
<div className="space-y-2">
<Label htmlFor="currentPassword">
@@ -172,7 +174,11 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showCurrentPassword ? "Ocultar senha atual" : "Mostrar senha atual"}
aria-label={
showCurrentPassword
? "Ocultar senha atual"
: "Mostrar senha atual"
}
>
{showCurrentPassword ? (
<RiEyeOffLine size={20} />
@@ -181,7 +187,10 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
)}
</button>
</div>
<p id="current-password-help" className="text-xs text-muted-foreground">
<p
id="current-password-help"
className="text-xs text-muted-foreground"
>
Por segurança, confirme sua senha atual antes de alterá-la
</p>
</div>
@@ -204,13 +213,17 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
maxLength={23}
aria-required="true"
aria-describedby="new-password-help"
aria-invalid={newPassword.length > 0 && !passwordValidation.isValid}
aria-invalid={
newPassword.length > 0 && !passwordValidation.isValid
}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"}
aria-label={
showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"
}
>
{showNewPassword ? (
<RiEyeOffLine size={20} />
@@ -280,7 +293,11 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showConfirmPassword ? "Ocultar confirmação de senha" : "Mostrar confirmação de senha"}
aria-label={
showConfirmPassword
? "Ocultar confirmação de senha"
: "Mostrar confirmação de senha"
}
>
{showConfirmPassword ? (
<RiEyeOffLine size={20} />
@@ -292,31 +309,55 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
{passwordsMatch !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{passwordsMatch ? (
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="As senhas coincidem" />
<RiCheckLine
className="h-5 w-5 text-green-500"
aria-label="As senhas coincidem"
/>
) : (
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="As senhas não coincidem" />
<RiCloseLine
className="h-5 w-5 text-red-500"
aria-label="As senhas não coincidem"
/>
)}
</div>
)}
</div>
{/* Mensagem de erro em tempo real */}
{passwordsMatch === false && (
<p id="confirm-password-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
<p
id="confirm-password-help"
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
role="alert"
>
<RiCloseLine className="h-3.5 w-3.5" />
As senhas não coincidem
</p>
)}
{passwordsMatch === true && (
<p id="confirm-password-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<p
id="confirm-password-help"
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
>
<RiCheckLine className="h-3.5 w-3.5" />
As senhas coincidem
</p>
)}
</div>
</div>
<Button type="submit" disabled={isPending || passwordsMatch === false || (newPassword.length > 0 && !passwordValidation.isValid)}>
<div className="flex justify-end">
<Button
type="submit"
disabled={
isPending ||
passwordsMatch === false ||
(newPassword.length > 0 && !passwordValidation.isValid)
}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar senha"}
</Button>
</div>
</form>
);
}

View File

@@ -118,6 +118,7 @@ export function MonthlyCalendar({
cartaoOptions={formOptions.cartaoOptions}
categoriaOptions={formOptions.categoriaOptions}
estabelecimentos={formOptions.estabelecimentos}
periodPreferences={formOptions.periodPreferences}
defaultPeriod={period.period}
defaultPurchaseDate={createDate ?? undefined}
/>

View File

@@ -1,4 +1,5 @@
import type { LancamentoItem, SelectOption } from "@/components/lancamentos/types";
import type { PeriodPreferences } from "@/lib/user-preferences/period";
export type CalendarEventType = "lancamento" | "boleto" | "cartao";
@@ -53,6 +54,7 @@ export type CalendarFormOptions = {
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
periodPreferences: PeriodPreferences;
};
export type CalendarData = {

View File

@@ -0,0 +1,200 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
RiGitCommitLine,
RiUserLine,
RiCalendarLine,
RiFileList2Line,
} from "@remixicon/react";
type GitCommit = {
hash: string;
shortHash: string;
author: string;
date: string;
message: string;
body: string;
filesChanged: string[];
};
type ChangelogListProps = {
commits: GitCommit[];
repoUrl: string | null;
};
type CommitType = {
type: string;
scope?: string;
description: string;
};
function parseCommitMessage(message: string): CommitType {
const conventionalPattern = /^(\w+)(?:$$([^)]+)$$)?:\s*(.+)$/;
const match = message.match(conventionalPattern);
if (match) {
return {
type: match[1],
scope: match[2],
description: match[3],
};
}
return {
type: "chore",
description: message,
};
}
function getCommitTypeColor(type: string): string {
const colors: Record<string, string> = {
feat: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20",
fix: "bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20",
docs: "bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20",
style:
"bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20",
refactor:
"bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20",
perf: "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-yellow-500/20",
test: "bg-pink-500/10 text-pink-700 dark:text-pink-400 border-pink-500/20",
chore: "bg-gray-500/10 text-gray-700 dark:text-gray-400 border-gray-500/20",
};
return colors[type] || colors.chore;
}
export function ChangelogList({ commits, repoUrl }: ChangelogListProps) {
if (!commits || commits.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum commit encontrado no repositório
</p>
</div>
);
}
return (
<div className="space-y-2">
{commits.map((commit) => (
<CommitCard key={commit.hash} commit={commit} repoUrl={repoUrl} />
))}
</div>
);
}
function CommitCard({
commit,
repoUrl,
}: {
commit: GitCommit;
repoUrl: string | null;
}) {
const commitDate = new Date(commit.date);
const relativeTime = formatDistanceToNow(commitDate, {
addSuffix: true,
locale: ptBR,
});
const commitUrl = repoUrl ? `${repoUrl}/commit/${commit.hash}` : null;
const parsed = parseCommitMessage(commit.message);
return (
<Card className="hover:shadow-sm transition-shadow">
<CardHeader>
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant="outline"
className={`${getCommitTypeColor(parsed.type)} py-1`}
>
{parsed.type}
</Badge>
{parsed.scope && (
<Badge
variant="outline"
className="text-muted-foreground border-muted-foreground/30 text-xs py-0"
>
{parsed.scope}
</Badge>
)}
<span className="font-bold text-lg flex-1 min-w-0 first-letter:uppercase">
{parsed.description}
</span>
</div>
<div className="flex items-center gap-4 flex-wrap text-xs text-muted-foreground">
{commitUrl ? (
<a
href={commitUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground underline transition-colors font-mono flex items-center gap-1"
>
<RiGitCommitLine className="size-4" />
{commit.shortHash}
</a>
) : (
<span className="font-mono flex items-center gap-1">
<RiGitCommitLine className="size-3" />
{commit.shortHash}
</span>
)}
<span className="flex items-center gap-1">
<RiUserLine className="size-3" />
{commit.author}
</span>
<span className="flex items-center gap-1">
<RiCalendarLine className="size-3" />
{relativeTime}
</span>
</div>
</CardHeader>
{commit.body && (
<CardContent className="text-muted-foreground leading-relaxed">
{commit.body}
</CardContent>
)}
{commit.filesChanged.length > 0 && (
<CardContent>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="files-changed" className="border-0">
<AccordionTrigger className="py-0 text-xs text-muted-foreground hover:text-foreground hover:no-underline">
<div className="flex items-center gap-1.5">
<RiFileList2Line className="size-3.5" />
<span>
{commit.filesChanged.length} arquivo
{commit.filesChanged.length !== 1 ? "s" : ""}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-2 pb-0">
<ul className="space-y-1 max-h-48 overflow-y-auto">
{commit.filesChanged.map((file, index) => (
<li
key={index}
className="text-xs font-mono bg-muted rounded px-2 py-1 text-muted-foreground break-all"
>
{file}
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
)}
</Card>
);
}

View File

@@ -1,142 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { markAllUpdatesAsRead } from "@/lib/changelog/actions";
import type { ChangelogEntry } from "@/lib/changelog/data";
import {
getCategoryLabel,
groupEntriesByCategory,
parseSafariCompatibleDate,
} from "@/lib/changelog/utils";
import { cn } from "@/lib/utils";
import { RiMegaphoneLine } from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
interface ChangelogNotificationProps {
unreadCount: number;
entries: ChangelogEntry[];
}
export function ChangelogNotification({
unreadCount: initialUnreadCount,
entries,
}: ChangelogNotificationProps) {
const [unreadCount, setUnreadCount] = useState(initialUnreadCount);
const [isOpen, setIsOpen] = useState(false);
const handleMarkAllAsRead = async () => {
const updateIds = entries.map((e) => e.id);
await markAllUpdatesAsRead(updateIds);
setUnreadCount(0);
};
const grouped = groupEntriesByCategory(entries);
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border"
)}
>
<RiMegaphoneLine className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs"
variant="info"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Novidades</TooltipContent>
</Tooltip>
<PopoverContent className="w-96 p-0" align="end">
<div className="flex items-center justify-between p-4 pb-2">
<div className="flex items-center gap-2">
<RiMegaphoneLine className="h-5 w-5" />
<h3 className="font-semibold">Novidades</h3>
</div>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleMarkAllAsRead}
className="h-7 text-xs"
>
Marcar todas como lida
</Button>
)}
</div>
<Separator />
<ScrollArea className="h-[400px]">
<div className="p-4 space-y-4">
{Object.entries(grouped).map(([category, categoryEntries]) => (
<div key={category} className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">
{getCategoryLabel(category)}
</h4>
<div className="space-y-2">
{categoryEntries.map((entry) => (
<div key={entry.id} className="space-y-1">
<div className="flex items-start gap-2 border-b pb-2 border-dashed">
<span className="text-lg mt-0.5">{entry.icon}</span>
<div className="flex-1 space-y-1">
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
#{entry.id.substring(0, 7)}
</code>
<p className="text-sm leading-tight flex-1 first-letter:capitalize">
{entry.title}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(parseSafariCompatibleDate(entry.date), {
addSuffix: true,
locale: ptBR,
})}
</p>
</div>
</div>
</div>
))}
</div>
</div>
))}
{entries.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
Nenhuma atualização recente
</div>
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,16 +1,16 @@
"use client";
import { cn } from "@/lib/utils/ui";
import {
RiArrowLeftRightLine,
RiDeleteBin5Line,
RiEyeOffLine,
RiFileList2Line,
RiPencilLine,
RiInformationLine,
} from "@remixicon/react";
import type React from "react";
import MoneyValues from "../money-values";
import { Card, CardContent, CardFooter } from "../ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
interface AccountCardProps {
accountName: string;
@@ -19,6 +19,7 @@ interface AccountCardProps {
status?: string;
icon?: React.ReactNode;
excludeFromBalance?: boolean;
excludeInitialBalanceFromIncome?: boolean;
onViewStatement?: () => void;
onEdit?: () => void;
onRemove?: () => void;
@@ -33,6 +34,7 @@ export function AccountCard({
status,
icon,
excludeFromBalance,
excludeInitialBalanceFromIncome,
onViewStatement,
onEdit,
onRemove,
@@ -85,21 +87,39 @@ export function AccountCard({
<h2 className="text-lg font-semibold text-foreground">
{accountName}
</h2>
{excludeFromBalance ? (
<div
className="flex items-center gap-1 text-muted-foreground"
title="Excluída do saldo geral"
>
<RiEyeOffLine className="size-4" aria-hidden />
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
</div>
) : null}
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="space-y-1">
{excludeFromBalance && (
<p className="text-xs">
<strong>Desconsiderado do saldo total:</strong> Esta conta
não é incluída no cálculo do saldo total geral.
</p>
)}
{excludeInitialBalanceFromIncome && (
<p className="text-xs">
<strong>
Saldo inicial desconsiderado das receitas:
</strong>{" "}
O saldo inicial desta conta não é contabilizado como
receita nas métricas.
</p>
)}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Saldo</p>
<p className="text-3xl text-foreground">
<div className="space-y-2">
<MoneyValues amount={balance} className="text-3xl" />
</p>
<p className="text-sm text-muted-foreground">{accountType}</p>
</div>
</CardContent>

View File

@@ -38,7 +38,7 @@ const DEFAULT_ACCOUNT_TYPES = [
"Conta Poupança",
"Carteira Digital",
"Conta Investimento",
"Cartão Pré-pago",
"Pré-Pago | VR/VA",
] as const;
const DEFAULT_ACCOUNT_STATUS = ["Ativa", "Inativa"] as const;
@@ -75,6 +75,8 @@ const buildInitialValues = ({
logo: selectedLogo,
initialBalance: formatInitialBalanceInput(account?.initialBalance ?? 0),
excludeFromBalance: account?.excludeFromBalance ?? false,
excludeInitialBalanceFromIncome:
account?.excludeInitialBalanceFromIncome ?? false,
};
};

View File

@@ -106,18 +106,40 @@ export function AccountFormFields({
/>
</div>
<div className="flex items-center gap-2 sm:col-span-2">
<div className="flex flex-col gap-3 sm:col-span-2">
<div className="flex items-center gap-2">
<Checkbox
id="exclude-from-balance"
checked={values.excludeFromBalance}
checked={values.excludeFromBalance === true || values.excludeFromBalance === "true"}
onCheckedChange={(checked) =>
onChange("excludeFromBalance", checked ? "true" : "false")
onChange("excludeFromBalance", !!checked ? "true" : "false")
}
/>
<Label htmlFor="exclude-from-balance" className="cursor-pointer text-sm font-normal">
Excluir do saldo total (útil para contas de investimento ou reserva)
<Label
htmlFor="exclude-from-balance"
className="cursor-pointer text-sm font-normal leading-tight"
>
Desconsiderar do saldo total (útil para contas de investimento ou
reserva)
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="exclude-initial-balance-from-income"
checked={values.excludeInitialBalanceFromIncome === true || values.excludeInitialBalanceFromIncome === "true"}
onCheckedChange={(checked) =>
onChange("excludeInitialBalanceFromIncome", !!checked ? "true" : "false")
}
/>
<Label
htmlFor="exclude-initial-balance-from-income"
className="cursor-pointer text-sm font-normal leading-tight"
>
Desconsiderar o saldo inicial ao calcular o total de receitas
</Label>
</div>
</div>
</div>
);
}

View File

@@ -137,10 +137,13 @@ export function AccountsPage({ accounts, logoOptions }: AccountsPageProps) {
<AccountCard
key={account.id}
accountName={account.name}
accountType={`${account.accountType} - ${account.status}`}
accountType={`${account.accountType}`}
balance={account.balance ?? account.initialBalance ?? 0}
status={account.status}
excludeFromBalance={account.excludeFromBalance}
excludeInitialBalanceFromIncome={
account.excludeInitialBalanceFromIncome
}
icon={
logoSrc ? (
<Image

View File

@@ -8,6 +8,7 @@ export type Account = {
initialBalance: number;
balance?: number | null;
excludeFromBalance?: boolean;
excludeInitialBalanceFromIncome?: boolean;
};
export type AccountFormValues = {
@@ -18,4 +19,5 @@ export type AccountFormValues = {
logo: string;
initialBalance: string;
excludeFromBalance: boolean;
excludeInitialBalanceFromIncome: boolean;
};

View File

@@ -343,7 +343,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
}
title="Selecione categorias para visualizar"
description="Escolha até 5 categorias para acompanhar o histórico nos últimos 6 meses."
description="Escolha até 5 categorias para acompanhar o histórico dos últimos 8 meses, mês atual e próximo mês."
/>
</div>
) : (

View File

@@ -6,6 +6,7 @@ import { Card } from "../ui/card";
type DashboardWelcomeProps = {
name?: string | null;
disableMagnetlines?: boolean;
};
const capitalizeFirstLetter = (value: string) =>
@@ -44,7 +45,7 @@ const getGreeting = () => {
}
};
export function DashboardWelcome({ name }: DashboardWelcomeProps) {
export function DashboardWelcome({ name, disableMagnetlines = false }: DashboardWelcomeProps) {
const displayName = name && name.trim().length > 0 ? name : "Administrador";
const formattedDate = formatCurrentDate();
const greeting = getGreeting();
@@ -63,6 +64,7 @@ export function DashboardWelcome({ name }: DashboardWelcomeProps) {
lineHeight="5vmin"
baseAngle={0}
className="text-welcome-banner-foreground"
disabled={disableMagnetlines}
/>
</div>
<div className="relative tracking-tight text-welcome-banner-foreground">

View File

@@ -76,7 +76,7 @@ export function MyAccountsWidget({
return (
<li
key={account.id}
className="flex items-center justify-between gap-2 border-b border-dashed py-1"
className="flex items-center justify-between gap-2 border-b border-dashed py-2 last:border-0"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{logoSrc ? (

View File

@@ -1,9 +1,7 @@
import { ChangelogNotification } from "@/components/changelog/changelog-notification";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { NotificationBell } from "@/components/notificacoes/notification-bell";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { getUser } from "@/lib/auth/server";
import { getUnreadUpdates } from "@/lib/changelog/data";
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
import { AnimatedThemeToggler } from "./animated-theme-toggler";
import LogoutButton from "./auth/logout-button";
@@ -16,7 +14,6 @@ type SiteHeaderProps = {
export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
const user = await getUser();
const { unreadCount, allEntries } = await getUnreadUpdates(user.id);
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">
@@ -31,10 +28,6 @@ export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
<PrivacyModeToggle />
<AnimatedThemeToggler />
<span className="text-muted-foreground">|</span>
<ChangelogNotification
unreadCount={unreadCount}
entries={allEntries}
/>
<FeedbackDialog />
<LogoutButton />
</div>

View File

@@ -35,6 +35,8 @@ import { Textarea } from "@/components/ui/textarea";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
import type { PeriodPreferences } from "@/lib/user-preferences/period";
import { createMonthOptions } from "@/lib/utils/period";
import { RiLoader4Line } from "@remixicon/react";
import {
useCallback,
@@ -53,6 +55,7 @@ interface AnticipateInstallmentsDialogProps {
categorias: Array<{ id: string; name: string; icon: string | null }>;
pagadores: Array<{ id: string; name: string }>;
defaultPeriod: string;
periodPreferences: PeriodPreferences;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
@@ -65,57 +68,6 @@ type AnticipationFormValues = {
note: string;
};
type SelectOption = {
value: string;
label: string;
};
const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
month: "long",
year: "numeric",
});
const formatPeriodLabel = (period: string) => {
const [year, month] = period.split("-").map(Number);
if (!year || !month) {
return period;
}
const date = new Date(year, month - 1, 1);
if (Number.isNaN(date.getTime())) {
return period;
}
const label = monthFormatter.format(date);
return label.charAt(0).toUpperCase() + label.slice(1);
};
const buildPeriodOptions = (currentValue?: string): SelectOption[] => {
const now = new Date();
const options: SelectOption[] = [];
// Adiciona opções de 3 meses no passado até 6 meses no futuro
for (let offset = -3; offset <= 6; offset += 1) {
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
2,
"0"
)}`;
options.push({ value, label: formatPeriodLabel(value) });
}
// Adiciona o valor atual se não estiver na lista
if (
currentValue &&
!options.some((option) => option.value === currentValue)
) {
options.push({
value: currentValue,
label: formatPeriodLabel(currentValue),
});
}
return options.sort((a, b) => a.value.localeCompare(b.value));
};
export function AnticipateInstallmentsDialog({
trigger,
seriesId,
@@ -123,6 +75,7 @@ export function AnticipateInstallmentsDialog({
categorias,
pagadores,
defaultPeriod,
periodPreferences,
open,
onOpenChange,
}: AnticipateInstallmentsDialogProps) {
@@ -152,8 +105,13 @@ export function AnticipateInstallmentsDialog({
});
const periodOptions = useMemo(
() => buildPeriodOptions(formState.anticipationPeriod),
[formState.anticipationPeriod]
() =>
createMonthOptions(
formState.anticipationPeriod,
periodPreferences.monthsBefore,
periodPreferences.monthsAfter
),
[formState.anticipationPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
);
// Buscar parcelas elegíveis ao abrir o dialog

View File

@@ -110,10 +110,14 @@ export function LancamentoDetailsDialog({
<span className="capitalize">
<Badge
variant={getTransactionBadgeVariant(
lancamento.transactionType
lancamento.categoriaName === "Saldo inicial"
? "Saldo inicial"
: lancamento.transactionType
)}
>
{lancamento.transactionType}
{lancamento.categoriaName === "Saldo inicial"
? "Saldo Inicial"
: lancamento.transactionType}
</Badge>
</span>
</li>

View File

@@ -1,4 +1,5 @@
import type { LancamentoFormState } from "@/lib/lancamentos/form-helpers";
import type { PeriodPreferences } from "@/lib/user-preferences/period";
import type { LancamentoItem, SelectOption } from "../../types";
export type FormState = LancamentoFormState;
@@ -17,6 +18,7 @@ export interface LancamentoDialogProps {
estabelecimentos: string[];
lancamento?: LancamentoItem;
defaultPeriod?: string;
periodPreferences: PeriodPreferences;
defaultCartaoId?: string | null;
defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null;

View File

@@ -58,6 +58,7 @@ export function LancamentoDialog({
estabelecimentos,
lancamento,
defaultPeriod,
periodPreferences,
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
@@ -125,8 +126,13 @@ export function LancamentoDialog({
}, [categoriaOptions, formState.transactionType]);
const monthOptions = useMemo(
() => createMonthOptions(formState.period),
[formState.period]
() =>
createMonthOptions(
formState.period,
periodPreferences.monthsBefore,
periodPreferences.monthsAfter
),
[formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
);
const handleFieldChange = useCallback(

View File

@@ -31,8 +31,18 @@ export function PaymentMethodSection({
"Dinheiro",
"Boleto",
"Cartão de débito",
"Pré-Pago | VR/VA",
"Transferência bancária",
].includes(formState.paymentMethod);
// Filtrar contas apenas do tipo "Pré-Pago | VR/VA" quando forma de pagamento for "Pré-Pago | VR/VA"
const filteredContaOptions =
formState.paymentMethod === "Pré-Pago | VR/VA"
? contaOptions.filter(
(option) => option.accountType === "Pré-Pago | VR/VA"
)
: contaOptions;
return (
<>
{!isUpdateMode ? (
@@ -56,7 +66,9 @@ export function PaymentMethodSection({
>
<SelectValue placeholder="Selecione" className="w-full">
{formState.paymentMethod && (
<PaymentMethodSelectContent label={formState.paymentMethod} />
<PaymentMethodSelectContent
label={formState.paymentMethod}
/>
)}
</SelectValue>
</SelectTrigger>
@@ -138,7 +150,7 @@ export function PaymentMethodSection({
<SelectValue placeholder="Selecione">
{formState.contaId &&
(() => {
const selectedOption = contaOptions.find(
const selectedOption = filteredContaOptions.find(
(opt) => opt.value === formState.contaId
);
return selectedOption ? (
@@ -152,14 +164,14 @@ export function PaymentMethodSection({
</SelectValue>
</SelectTrigger>
<SelectContent>
{contaOptions.length === 0 ? (
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
contaOptions.map((option) => (
filteredContaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
@@ -246,7 +258,7 @@ export function PaymentMethodSection({
<SelectValue placeholder="Selecione">
{formState.contaId &&
(() => {
const selectedOption = contaOptions.find(
const selectedOption = filteredContaOptions.find(
(opt) => opt.value === formState.contaId
);
return selectedOption ? (
@@ -260,14 +272,14 @@ export function PaymentMethodSection({
</SelectValue>
</SelectTrigger>
<SelectContent>
{contaOptions.length === 0 ? (
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
contaOptions.map((option) => (
filteredContaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}

View File

@@ -27,6 +27,7 @@ import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import { getTodayDateString } from "@/lib/utils/date";
import { createMonthOptions } from "@/lib/utils/period";
import type { PeriodPreferences } from "@/lib/user-preferences/period";
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -51,6 +52,7 @@ interface MassAddDialogProps {
categoriaOptions: SelectOption[];
estabelecimentos: string[];
selectedPeriod: string;
periodPreferences: PeriodPreferences;
defaultPagadorId?: string | null;
}
@@ -91,6 +93,7 @@ export function MassAddDialog({
categoriaOptions,
estabelecimentos,
selectedPeriod,
periodPreferences,
defaultPagadorId,
}: MassAddDialogProps) {
const [loading, setLoading] = useState(false);
@@ -119,8 +122,13 @@ export function MassAddDialog({
// Period options
const periodOptions = useMemo(
() => createMonthOptions(selectedPeriod, 3),
[selectedPeriod]
() =>
createMonthOptions(
selectedPeriod,
periodPreferences.monthsBefore,
periodPreferences.monthsAfter
),
[selectedPeriod, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
);
// Categorias agrupadas e filtradas por tipo de transação

View File

@@ -25,6 +25,7 @@ import type {
LancamentoItem,
SelectOption,
} from "../types";
import type { PeriodPreferences } from "@/lib/user-preferences/period";
interface LancamentosPageProps {
lancamentos: LancamentoItem[];
@@ -39,6 +40,7 @@ interface LancamentosPageProps {
contaCartaoFilterOptions: ContaCartaoFilterOption[];
selectedPeriod: string;
estabelecimentos: string[];
periodPreferences: PeriodPreferences;
allowCreate?: boolean;
defaultCartaoId?: string | null;
defaultPaymentMethod?: string | null;
@@ -59,6 +61,7 @@ export function LancamentosPage({
contaCartaoFilterOptions,
selectedPeriod,
estabelecimentos,
periodPreferences,
allowCreate = true,
defaultCartaoId,
defaultPaymentMethod,
@@ -114,7 +117,7 @@ export function LancamentosPage({
return;
}
const supportedMethods = ["Pix", "Boleto", "Dinheiro", "Cartão de débito"];
const supportedMethods = ["Pix", "Boleto", "Dinheiro", "Cartão de débito", "Pré-Pago | VR/VA", "Transferência bancária"];
if (!supportedMethods.includes(item.paymentMethod)) {
return;
}
@@ -354,6 +357,7 @@ export function LancamentosPage({
categoriaOptions={categoriaOptions}
estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod}
periodPreferences={periodPreferences}
defaultCartaoId={defaultCartaoId}
defaultPaymentMethod={defaultPaymentMethod}
lockCartaoSelection={lockCartaoSelection}
@@ -379,6 +383,7 @@ export function LancamentosPage({
estabelecimentos={estabelecimentos}
lancamento={lancamentoToCopy ?? undefined}
defaultPeriod={selectedPeriod}
periodPreferences={periodPreferences}
/>
<LancamentoDialog
@@ -394,6 +399,7 @@ export function LancamentosPage({
estabelecimentos={estabelecimentos}
lancamento={selectedLancamento ?? undefined}
defaultPeriod={selectedPeriod}
periodPreferences={periodPreferences}
onBulkEditRequest={handleBulkEditRequest}
/>
@@ -473,6 +479,7 @@ export function LancamentosPage({
categoriaOptions={categoriaOptions}
estabelecimentos={estabelecimentos}
selectedPeriod={selectedPeriod}
periodPreferences={periodPreferences}
defaultPagadorId={defaultPagadorId}
/>
) : null}
@@ -508,6 +515,7 @@ export function LancamentosPage({
name: p.label,
}))}
defaultPeriod={selectedPeriod}
periodPreferences={periodPreferences}
/>
)}

View File

@@ -13,6 +13,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
import { displayPeriod } from "@/lib/utils/period";
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
@@ -26,24 +27,6 @@ interface AnticipationCardProps {
onCanceled?: () => void;
}
const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
month: "long",
year: "numeric",
});
const formatPeriodLabel = (period: string) => {
const [year, month] = period.split("-").map(Number);
if (!year || !month) {
return period;
}
const date = new Date(year, month - 1, 1);
if (Number.isNaN(date.getTime())) {
return period;
}
const label = monthFormatter.format(date);
return label.charAt(0).toUpperCase() + label.slice(1);
};
export function AnticipationCard({
anticipation,
onViewLancamento,
@@ -93,7 +76,7 @@ export function AnticipationCard({
</CardDescription>
</div>
<Badge variant="secondary">
{formatPeriodLabel(anticipation.anticipationPeriod)}
{displayPeriod(anticipation.anticipationPeriod)}
</Badge>
</CardHeader>

View File

@@ -288,16 +288,20 @@ const buildColumns = ({
{
accessorKey: "transactionType",
header: "Transação",
cell: ({ row }) => (
cell: ({ row }) => {
const type =
row.original.categoriaName === "Saldo inicial"
? "Saldo inicial"
: row.original.transactionType;
return (
<TypeBadge
type={
row.original.transactionType as
| "Despesa"
| "Receita"
| "Transferência"
type as "Despesa" | "Receita" | "Transferência" | "Saldo inicial"
}
/>
),
);
},
},
{
accessorKey: "amount",

View File

@@ -43,6 +43,7 @@ export type SelectOption = {
avatarUrl?: string | null;
logo?: string | null;
icon?: string | null;
accountType?: string | null;
};
export type LancamentoFilterOption = {

View File

@@ -1,3 +1,5 @@
"use client";
import React, { CSSProperties, useEffect, useRef } from "react";
interface MagnetLinesProps {
@@ -10,6 +12,7 @@ interface MagnetLinesProps {
baseAngle?: number;
className?: string;
style?: CSSProperties;
disabled?: boolean;
}
const MagnetLines: React.FC<MagnetLinesProps> = ({
@@ -22,9 +25,15 @@ const MagnetLines: React.FC<MagnetLinesProps> = ({
baseAngle = -10,
className = "",
style = {},
disabled = false,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
// Se magnetlines estiver desabilitado, não renderiza nada
if (disabled) {
return null;
}
useEffect(() => {
const container = containerRef.current;
if (!container) return;

View File

@@ -26,6 +26,8 @@ import {
import { Label } from "@/components/ui/label";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import type { PeriodPreferences } from "@/lib/user-preferences/period";
import { createMonthOptions } from "@/lib/utils/period";
import {
useCallback,
useEffect,
@@ -43,64 +45,11 @@ interface BudgetDialogProps {
budget?: Budget;
categories: BudgetCategory[];
defaultPeriod: string;
periodPreferences: PeriodPreferences;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
type SelectOption = {
value: string;
label: string;
};
const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
month: "long",
year: "numeric",
});
const formatPeriodLabel = (period: string) => {
const [year, month] = period.split("-").map(Number);
if (!year || !month) {
return period;
}
const date = new Date(year, month - 1, 1);
if (Number.isNaN(date.getTime())) {
return period;
}
const label = monthFormatter.format(date);
return label.charAt(0).toUpperCase() + label.slice(1);
};
const buildPeriodOptions = (currentValue?: string): SelectOption[] => {
const now = new Date();
const options: SelectOption[] = [];
for (let offset = -3; offset <= 3; offset += 1) {
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
2,
"0"
)}`;
options.push({ value, label: formatPeriodLabel(value) });
}
if (
currentValue &&
!options.some((option) => option.value === currentValue)
) {
options.push({
value: currentValue,
label: formatPeriodLabel(currentValue),
});
}
return options
.sort((a, b) => a.value.localeCompare(b.value))
.map((option) => ({
value: option.value,
label: option.label,
}));
};
const buildInitialValues = ({
budget,
defaultPeriod,
@@ -119,6 +68,7 @@ export function BudgetDialog({
budget,
categories,
defaultPeriod,
periodPreferences,
open,
onOpenChange,
}: BudgetDialogProps) {
@@ -161,8 +111,13 @@ export function BudgetDialog({
}, [dialogOpen]);
const periodOptions = useMemo(
() => buildPeriodOptions(formState.period),
[formState.period]
() =>
createMonthOptions(
formState.period,
periodPreferences.monthsBefore,
periodPreferences.monthsAfter
),
[formState.period, periodPreferences.monthsBefore, periodPreferences.monthsAfter]
);
const handleSubmit = useCallback(

View File

@@ -4,6 +4,7 @@ import { deleteBudgetAction } from "@/app/(dashboard)/orcamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import type { PeriodPreferences } from "@/lib/user-preferences/period";
import { RiAddCircleLine, RiFundsLine } from "@remixicon/react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
@@ -17,6 +18,7 @@ interface BudgetsPageProps {
categories: BudgetCategory[];
selectedPeriod: string;
periodLabel: string;
periodPreferences: PeriodPreferences;
}
export function BudgetsPage({
@@ -24,6 +26,7 @@ export function BudgetsPage({
categories,
selectedPeriod,
periodLabel,
periodPreferences,
}: BudgetsPageProps) {
const [editOpen, setEditOpen] = useState(false);
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
@@ -91,6 +94,7 @@ export function BudgetsPage({
mode="create"
categories={categories}
defaultPeriod={selectedPeriod}
periodPreferences={periodPreferences}
trigger={
<Button disabled={categories.length === 0}>
<RiAddCircleLine className="size-4" />
@@ -128,6 +132,7 @@ export function BudgetsPage({
budget={selectedBudget ?? undefined}
categories={categories}
defaultPeriod={selectedPeriod}
periodPreferences={periodPreferences}
open={editOpen && !!selectedBudget}
onOpenChange={handleEditOpenChange}
/>

View File

@@ -5,6 +5,7 @@ import {
RiBankLine,
RiCalendarEventLine,
RiDashboardLine,
RiGitCommitLine,
RiFundsLine,
RiGroupLine,
RiLineChartLine,
@@ -161,6 +162,11 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
},
],
navSecondary: [
{
title: "Changelog",
url: "/changelog",
icon: RiGitCommitLine,
},
{
title: "Ajustes",
url: "/ajustes",

View File

@@ -8,10 +8,12 @@ type TypeBadgeType =
| "Receita"
| "Despesa"
| "Transferência"
| "transferência";
| "transferência"
| "Saldo inicial"
| "Saldo Inicial";
interface TypeBadgeProps {
type: TypeBadgeType;
type: TypeBadgeType | string;
className?: string;
}
@@ -22,23 +24,26 @@ const TYPE_LABELS: Record<string, string> = {
Despesa: "Despesa",
Transferência: "Transferência",
transferência: "Transferência",
"Saldo inicial": "Saldo Inicial",
"Saldo Inicial": "Saldo Inicial",
};
export function TypeBadge({ type, className }: TypeBadgeProps) {
const normalizedType = type.toLowerCase();
const isReceita = normalizedType === "receita";
const isTransferencia = normalizedType === "transferência";
const isSaldoInicial = normalizedType === "saldo inicial";
const label = TYPE_LABELS[type] || type;
const colorClass = isTransferencia
? "text-blue-700 dark:text-blue-400"
: isReceita
: (isReceita || isSaldoInicial)
? "text-green-700 dark:text-green-400"
: "text-red-700 dark:text-red-400";
const dotColor = isTransferencia
? "bg-blue-700 dark:bg-blue-400"
: isReceita
: (isReceita || isSaldoInicial)
? "bg-green-600 dark:bg-green-400"
: "bg-red-600 dark:bg-red-400";

View File

@@ -0,0 +1,65 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { cn } from "@/lib/utils";
import { RiArrowDownBoxFill } from "@remixicon/react";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<RiArrowDownBoxFill className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -100,6 +100,31 @@ export const verification = pgTable("verification", {
}),
});
export const userPreferences = pgTable("user_preferences", {
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id, { onDelete: "cascade" }),
disableMagnetlines: boolean("disable_magnetlines").notNull().default(false),
periodMonthsBefore: integer("period_months_before").notNull().default(3),
periodMonthsAfter: integer("period_months_after").notNull().default(3),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.default(sql`now()`),
updatedAt: timestamp("updated_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.default(sql`now()`),
});
// ===================== PUBLIC TABLES =====================
export const contas = pgTable(
@@ -119,6 +144,9 @@ export const contas = pgTable(
excludeFromBalance: boolean("excluir_do_saldo")
.notNull()
.default(false),
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
.notNull()
.default(false),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
@@ -359,30 +387,6 @@ export const anotacoes = pgTable("anotacoes", {
.references(() => user.id, { onDelete: "cascade" }),
});
export const userUpdateLog = pgTable(
"user_update_log",
{
id: uuid("id")
.primaryKey()
.default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
updateId: text("update_id").notNull(), // commit hash
readAt: timestamp("read_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userIdUpdateIdIdx: uniqueIndex("user_update_log_user_update_idx").on(
table.userId,
table.updateId
),
})
);
export const savedInsights = pgTable(
"saved_insights",
@@ -556,7 +560,6 @@ export const userRelations = relations(user, ({ many, one }) => ({
orcamentos: many(orcamentos),
pagadores: many(pagadores),
installmentAnticipations: many(installmentAnticipations),
updateLogs: many(userUpdateLog),
}));
export const accountRelations = relations(account, ({ one }) => ({
@@ -664,12 +667,6 @@ export const savedInsightsRelations = relations(savedInsights, ({ one }) => ({
}),
}));
export const userUpdateLogRelations = relations(userUpdateLog, ({ one }) => ({
user: one(user, {
fields: [userUpdateLog.userId],
references: [user.id],
}),
}));
export const lancamentosRelations = relations(lancamentos, ({ one }) => ({
user: one(user, {
@@ -725,6 +722,8 @@ export type NewUser = typeof user.$inferInsert;
export type Account = typeof account.$inferSelect;
export type Session = typeof session.$inferSelect;
export type Verification = typeof verification.$inferSelect;
export type UserPreferences = typeof userPreferences.$inferSelect;
export type NewUserPreferences = typeof userPreferences.$inferInsert;
export type Conta = typeof contas.$inferSelect;
export type Categoria = typeof categorias.$inferSelect;
export type Pagador = typeof pagadores.$inferSelect;
@@ -736,4 +735,3 @@ export type SavedInsight = typeof savedInsights.$inferSelect;
export type Lancamento = typeof lancamentos.$inferSelect;
export type InstallmentAnticipation =
typeof installmentAnticipations.$inferSelect;
export type UserUpdateLog = typeof userUpdateLog.$inferSelect;

View File

@@ -3,12 +3,11 @@ name: opensheets
# MODOS DE USO:
# 1. Banco LOCAL (PostgreSQL em container):
# - Configure DB_PROVIDER=local no .env
# - Configure DATABASE_URL com host "db" no .env
# - Execute: docker compose up --build
#
# 2. Banco REMOTO (ex: Supabase):
# - Configure DB_PROVIDER=remote no .env
# - Configure DATABASE_URL com a URL do banco remoto
# - Configure DATABASE_URL com a URL do banco remoto no .env
# - Execute: docker compose up app --build (apenas o serviço app)
#
# 3. Para parar todos os serviços:
@@ -81,9 +80,9 @@ services:
# Variáveis de ambiente da aplicação
NODE_ENV: production
# DATABASE_URL será definida dinamicamente baseada em DB_PROVIDER
# Se DB_PROVIDER=local, usa o serviço 'db'
# Se DB_PROVIDER=remote, usa a DATABASE_URL do .env
# DATABASE_URL do .env
# Banco local: use host "db" (serviço Docker)
# Banco remoto: use a URL completa do provider
DATABASE_URL: ${DATABASE_URL}
# Outras variáveis de ambiente necessárias

View File

@@ -0,0 +1 @@
ALTER TABLE "anotacoes" ADD COLUMN "arquivada" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "contas" ADD COLUMN "excluir_saldo_inicial_receitas" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "disable_magnetlines" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1,17 @@
CREATE TABLE "user_preferences" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"disable_magnetlines" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_preferences_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-- Migrate existing data from user table to user_preferences
INSERT INTO "user_preferences" ("user_id", "disable_magnetlines", "created_at", "updated_at")
SELECT "id", COALESCE("disable_magnetlines", false), now(), now()
FROM "user"
WHERE "disable_magnetlines" IS NOT NULL OR "disable_magnetlines" = true;
--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "disable_magnetlines";

View File

@@ -0,0 +1 @@
DROP TABLE "user_update_log" CASCADE;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "user_preferences" ADD COLUMN "period_months_before" integer DEFAULT 3 NOT NULL;--> statement-breakpoint
ALTER TABLE "user_preferences" ADD COLUMN "period_months_after" integer DEFAULT 3 NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,48 @@
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767102605526,
"tag": "0003_green_korg",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767104066872,
"tag": "0004_acoustic_mach_iv",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767106121811,
"tag": "0005_adorable_bruce_banner",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767107487318,
"tag": "0006_youthful_mister_fear",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1767118780033,
"tag": "0007_sturdy_kate_bishop",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767125796314,
"tag": "0008_fat_stick",
"breakpoints": true
}
]
}

View File

@@ -1,76 +0,0 @@
"use server";
import { userUpdateLog } from "@/db/schema";
import { successResult, type ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { and, eq } from "drizzle-orm";
import { handleActionError } from "../actions/helpers";
export async function markUpdateAsRead(
updateId: string
): Promise<ActionResult> {
try {
const user = await getUser();
// Check if already marked as read
const existing = await db
.select()
.from(userUpdateLog)
.where(
and(
eq(userUpdateLog.userId, user.id),
eq(userUpdateLog.updateId, updateId)
)
)
.limit(1);
if (existing.length > 0) {
return successResult("Já marcado como lido");
}
await db.insert(userUpdateLog).values({
userId: user.id,
updateId,
});
return successResult("Marcado como lido");
} catch (error) {
return handleActionError(error);
}
}
export async function markAllUpdatesAsRead(
updateIds: string[]
): Promise<ActionResult> {
try {
const user = await getUser();
// Get existing read updates
const existing = await db
.select()
.from(userUpdateLog)
.where(eq(userUpdateLog.userId, user.id));
const existingIds = new Set(existing.map((log) => log.updateId));
// Filter out already read updates
const newUpdateIds = updateIds.filter((id) => !existingIds.has(id));
if (newUpdateIds.length === 0) {
return successResult("Todos já marcados como lidos");
}
// Insert new read logs
await db.insert(userUpdateLog).values(
newUpdateIds.map((updateId) => ({
userId: user.id,
updateId,
}))
);
return successResult("Todas as atualizações marcadas como lidas");
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,75 +0,0 @@
import { db } from "@/lib/db";
import { userUpdateLog } from "@/db/schema";
import { eq } from "drizzle-orm";
import fs from "fs";
import path from "path";
export interface ChangelogEntry {
id: string;
type: string;
title: string;
date: string;
icon: string;
category: string;
}
export interface Changelog {
version: string;
generatedAt: string;
entries: ChangelogEntry[];
}
export function getChangelog(): Changelog {
try {
const changelogPath = path.join(process.cwd(), "public", "changelog.json");
if (!fs.existsSync(changelogPath)) {
return {
version: "1.0.0",
generatedAt: new Date().toISOString(),
entries: [],
};
}
const content = fs.readFileSync(changelogPath, "utf-8");
return JSON.parse(content);
} catch (error) {
console.error("Error reading changelog:", error);
return {
version: "1.0.0",
generatedAt: new Date().toISOString(),
entries: [],
};
}
}
export async function getUnreadUpdates(userId: string) {
const changelog = getChangelog();
if (changelog.entries.length === 0) {
return {
unreadCount: 0,
unreadEntries: [],
allEntries: [],
};
}
// Get read updates from database
const readLogs = await db
.select()
.from(userUpdateLog)
.where(eq(userUpdateLog.userId, userId));
const readUpdateIds = new Set(readLogs.map((log) => log.updateId));
// Filter unread entries
const unreadEntries = changelog.entries.filter(
(entry) => !readUpdateIds.has(entry.id)
);
return {
unreadCount: unreadEntries.length,
unreadEntries,
allEntries: changelog.entries,
};
}

View File

@@ -1,43 +0,0 @@
import type { ChangelogEntry } from "./data";
/**
* Converte uma string de data para um formato compatível com Safari.
* Safari não aceita "YYYY-MM-DD HH:mm:ss ±HHMM", requer "YYYY-MM-DDTHH:mm:ss±HHMM"
*
* @param dateString - String de data no formato "YYYY-MM-DD HH:mm:ss ±HHMM"
* @returns Date object válido
*/
export function parseSafariCompatibleDate(dateString: string): Date {
// Substitui o espaço entre data e hora por "T" (formato ISO 8601)
// Exemplo: "2025-12-09 17:26:08 +0000" → "2025-12-09T17:26:08+0000"
const isoString = dateString.replace(/(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+/, "$1T$2");
return new Date(isoString);
}
export function getCategoryLabel(category: string): string {
const labels: Record<string, string> = {
feature: "Novidades",
bugfix: "Correções",
performance: "Performance",
documentation: "Documentação",
style: "Interface",
refactor: "Melhorias",
test: "Testes",
chore: "Manutenção",
other: "Outros",
};
return labels[category] || "Outros";
}
export function groupEntriesByCategory(entries: ChangelogEntry[]) {
return entries.reduce(
(acc, entry) => {
if (!acc[entry.category]) {
acc[entry.category] = [];
}
acc[entry.category].push(entry);
return acc;
},
{} as Record<string, ChangelogEntry[]>
);
}

View File

@@ -1,12 +1,12 @@
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { categorias, lancamentos, pagadores, contas } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import type { CategoryType } from "@/lib/categorias/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getPreviousPeriod } from "@/lib/utils/period";
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, isNull, or, sql, ne } from "drizzle-orm";
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
@@ -81,7 +81,17 @@ export async function fetchCategoryDetails(
});
const filteredRows = currentRows.filter(
(row) => row.pagador?.role === PAGADOR_ROLE_ADMIN
(row) => {
// Filtrar apenas pagadores admin
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
// Excluir saldos iniciais se a conta tiver o flag ativo
if (row.note === INITIAL_BALANCE_NOTE && row.conta?.excludeInitialBalanceFromIncome) {
return false;
}
return true;
}
);
const transactions = mapLancamentosData(filteredRows);
@@ -97,6 +107,7 @@ export async function fetchCategoryDetails(
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
@@ -104,7 +115,13 @@ export async function fetchCategoryDetails(
eq(lancamentos.transactionType, transactionType),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
sanitizedNote,
eq(lancamentos.period, previousPeriod)
eq(lancamentos.period, previousPeriod),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
);

View File

@@ -65,14 +65,15 @@ export async function fetchCategoryHistory(
userId: string,
currentPeriod: string
): Promise<CategoryHistoryData> {
// Generate last 6 months including current
// Generate last 8 months, current month, and next month (10 total)
const periods: string[] = [];
const monthLabels: string[] = [];
const [year, month] = currentPeriod.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
for (let i = 8; i >= 0; i--) {
// Generate months from -8 to +1 (relative to current)
for (let i = 8; i >= -1; i--) {
const date = addMonths(currentDate, -i);
const period = format(date, "yyyy-MM");
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();

View File

@@ -1,11 +1,11 @@
import { categorias, lancamentos, orcamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { categorias, lancamentos, orcamentos, pagadores, contas } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getPreviousPeriod } from "@/lib/utils/period";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { and, eq, isNull, or, sql, ne } from "drizzle-orm";
export type CategoryIncomeItem = {
categoryId: string;
@@ -43,6 +43,7 @@ export async function fetchIncomeByCategory(
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(
orcamentos,
and(
@@ -61,6 +62,12 @@ export async function fetchIncomeByCategory(
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
)
@@ -75,6 +82,7 @@ export async function fetchIncomeByCategory(
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
@@ -85,6 +93,12 @@ export async function fetchIncomeByCategory(
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
)

View File

@@ -1,8 +1,8 @@
import { lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { lancamentos, pagadores, contas } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, sql } from "drizzle-orm";
import { and, eq, sql, or, isNull, ne } from "drizzle-orm";
export type MonthData = {
month: string;
@@ -79,6 +79,7 @@ export async function fetchIncomeExpenseBalance(
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
@@ -87,7 +88,13 @@ export async function fetchIncomeExpenseBalance(
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
);

View File

@@ -1,5 +1,5 @@
import { lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { lancamentos, pagadores, contas } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import {
@@ -74,6 +74,7 @@ export async function fetchDashboardCardMetrics(
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
@@ -88,6 +89,12 @@ export async function fetchDashboardCardMetrics(
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`
)
)
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
)

View File

@@ -12,4 +12,6 @@ export const LANCAMENTO_PAYMENT_METHODS = [
"Pix",
"Dinheiro",
"Boleto",
"Pré-Pago | VR/VA",
"Transferência bancária",
] as const;

View File

@@ -110,7 +110,7 @@ export function buildLancamentoInitialState(
isSettled:
paymentMethod === "Cartão de crédito"
? null
: lancamento?.isSettled ?? false,
: lancamento?.isSettled ?? true,
};
}
@@ -150,7 +150,7 @@ export function applyFieldDependencies(
updates.isSettled = null;
} else {
updates.cartaoId = undefined;
updates.isSettled = currentState.isSettled ?? false;
updates.isSettled = currentState.isSettled ?? true;
}
// Clear boleto-specific fields if not boleto

View File

@@ -80,7 +80,10 @@ export function formatCondition(value?: string | null): string {
*/
export function getTransactionBadgeVariant(type?: string | null): "default" | "destructive" | "secondary" {
if (!type) return "secondary";
return type.toLowerCase() === "receita" ? "default" : "destructive";
const normalized = type.toLowerCase();
return normalized === "receita" || normalized === "saldo inicial"
? "default"
: "destructive";
}
/**

View File

@@ -55,6 +55,7 @@ type CategoriaSluggedOption = BaseSluggedOption & {
type ContaSluggedOption = BaseSluggedOption & {
kind: "conta";
logo: string | null;
accountType: string | null;
};
type CartaoSluggedOption = BaseSluggedOption & {
@@ -154,7 +155,8 @@ export const toOption = (
slug?: string | null,
avatarUrl?: string | null,
logo?: string | null,
icon?: string | null
icon?: string | null,
accountType?: string | null
): SelectOption => ({
value,
label: normalizeLabel(label),
@@ -164,6 +166,7 @@ export const toOption = (
avatarUrl: avatarUrl ?? null,
logo: logo ?? null,
icon: icon ?? null,
accountType: accountType ?? null,
});
export const fetchLancamentoFilterSources = async (userId: string) => {
@@ -234,6 +237,7 @@ export const buildSluggedFilters = ({
slug: contaCartaoSlugger(label),
kind: "conta" as const,
logo: conta.logo ?? null,
accountType: conta.accountType ?? null,
};
});
@@ -468,8 +472,8 @@ export const buildOptionSets = ({
: contaFiltersRaw;
const contaOptions = sortByLabel(
contaOptionsSource.map(({ id, label, slug, logo }) =>
toOption(id, label, undefined, undefined, slug, undefined, logo)
contaOptionsSource.map(({ id, label, slug, logo, accountType }) =>
toOption(id, label, undefined, undefined, slug, undefined, logo, undefined, accountType)
)
);

View File

@@ -0,0 +1,32 @@
import { db, schema } from "@/lib/db";
import { eq } from "drizzle-orm";
export type PeriodPreferences = {
monthsBefore: number;
monthsAfter: number;
};
/**
* Fetches period preferences for a user
* @param userId - User ID
* @returns Period preferences with defaults if not found
*/
export async function fetchUserPeriodPreferences(
userId: string
): Promise<PeriodPreferences> {
const result = await db
.select({
periodMonthsBefore: schema.userPreferences.periodMonthsBefore,
periodMonthsAfter: schema.userPreferences.periodMonthsAfter,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
const preferences = result[0];
return {
monthsBefore: preferences?.periodMonthsBefore ?? 3,
monthsAfter: preferences?.periodMonthsAfter ?? 3,
};
}

View File

@@ -55,6 +55,8 @@ export const getPaymentMethodIcon = (paymentMethod: string): ReactNode => {
<RemixIcons.RiBankCardLine className={ICON_CLASS} aria-hidden />
),
debito: <RemixIcons.RiBankCardLine className={ICON_CLASS} aria-hidden />,
prepagovrva: <RemixIcons.RiCouponLine className={ICON_CLASS} aria-hidden />,
transferenciabancaria: <RemixIcons.RiExchangeLine className={ICON_CLASS} aria-hidden />,
};
return registry[key] ?? null;

View File

@@ -367,17 +367,24 @@ export type SelectOption = {
/**
* Creates month options for a select dropdown, centered around current month
* @param currentValue - Current period value to ensure it's included in options
* @param offsetRange - Number of months before/after current month (default: 3)
* @param monthsBefore - Number of months before current month (default: 3)
* @param monthsAfter - Number of months after current month (default: same as monthsBefore)
* @returns Array of select options with formatted labels
* @example
* createMonthOptions() // -3 to +3
* createMonthOptions(undefined, 3) // -3 to +3
* createMonthOptions(undefined, 3, 6) // -3 to +6
*/
export function createMonthOptions(
currentValue?: string,
offsetRange: number = 3
monthsBefore: number = 3,
monthsAfter?: number
): SelectOption[] {
const now = new Date();
const options: SelectOption[] = [];
const after = monthsAfter ?? monthsBefore; // If not specified, use same as before
for (let offset = -offsetRange; offset <= offsetRange; offset += 1) {
for (let offset = -monthsBefore; offset <= after; offset += 1) {
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
const value = formatPeriod(date.getFullYear(), date.getMonth() + 1);
options.push({ value, label: displayPeriod(value) });

View File

@@ -5,7 +5,6 @@
"scripts": {
"dev": "next dev --turbopack",
"dev-env": "tsx scripts/dev.ts",
"prebuild": "tsx scripts/generate-changelog.ts",
"build": "next build",
"start": "next start",
"lint": "eslint .",
@@ -32,6 +31,7 @@
"@ai-sdk/google": "^3.0.1",
"@ai-sdk/openai": "^3.0.1",
"@openrouter/ai-sdk-provider": "^1.5.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
@@ -52,7 +52,7 @@
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.7.0",
"@remixicon/react": "4.8.0",
"@tanstack/react-table": "8.21.3",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",

43
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@openrouter/ai-sdk-provider':
specifier: ^1.5.4
version: 1.5.4(ai@6.0.3(zod@4.2.1))(zod@4.2.1)
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-alert-dialog':
specifier: 1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -81,8 +84,8 @@ importers:
specifier: 1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@remixicon/react':
specifier: 4.7.0
version: 4.7.0(react@19.2.3)
specifier: 4.8.0
version: 4.8.0(react@19.2.3)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1120,6 +1123,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-accordion@1.2.12':
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-alert-dialog@1.1.15':
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
peerDependencies:
@@ -1690,8 +1706,8 @@ packages:
react-redux:
optional: true
'@remixicon/react@4.7.0':
resolution: {integrity: sha512-ODBQjdbOjnFguCqctYkpDjERXOInNaBnRPDKfZOBvbzExBAwr2BaH/6AHFTg/UAFzBDkwtylfMT8iKPAkLwPLQ==}
'@remixicon/react@4.8.0':
resolution: {integrity: sha512-cbzR04GKWa3zWdgn0C2i+u/avb167iWeu9gqFO00UGu84meARPAm3oKowDZTU6dlk/WS3UHo6k//LMRM1l7CRw==}
peerDependencies:
react: '>=18.2.0'
@@ -4773,6 +4789,23 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -5357,7 +5390,7 @@ snapshots:
react: 19.2.3
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
'@remixicon/react@4.7.0(react@19.2.3)':
'@remixicon/react@4.8.0(react@19.2.3)':
dependencies:
react: 19.2.3

View File

@@ -1,166 +0,0 @@
{
"version": "1.0.0",
"generatedAt": "2025-12-13T13:52:07.921Z",
"entries": [
{
"id": "0767636eed5085a211e08de52577beed658f05cf",
"type": "feat",
"title": "ajustar layout e estilos",
"date": "2025-12-11 17:43:33 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "0744991edd717748fce24e148907eafd7222c64e",
"type": "chore",
"title": "remove unused code and clean up imports",
"date": "2025-12-10 16:53:19 +0000",
"icon": "🔧",
"category": "chore"
},
{
"id": "b767bd959955854e54b41270e9c220fe0b546947",
"type": "feat",
"title": "adicionar widgets de despesas e receitas com gráfico - Adiciona o widget de despesas por categoria com gráfico. - Adiciona o widget de receitas por categoria com gráfico. - Atualiza a configuração dos widgets para incluir novos componentes. - Ajusta estilos e tamanhos de elementos nos widgets existentes.",
"date": "2025-12-10 16:51:45 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "89765d4373b820a3e7c8e4fa40479dd2673558b0",
"type": "chore",
"title": "remover arquivo PLAN.md",
"date": "2025-12-09 17:26:08 +0000",
"icon": "🔧",
"category": "chore"
},
{
"id": "95d6a45a95c1a383dfa532aa72f764fcd4bff64e",
"type": "feat",
"title": "adicionar análise e sugestões para OpenSheets",
"date": "2025-12-09 17:24:07 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "0c445ee4a5a70dbeee834ba511ace1ea79471ada",
"type": "feat",
"title": "adicionar alerta de privacidade e ajustar estilos",
"date": "2025-12-09 17:23:45 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "ed2b7070ebd14c3274dcd515613d5eebbd990b24",
"type": "feat",
"title": "adicionar funcionalidades de leitura de atualizações",
"date": "2025-12-08 15:17:10 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "b7fcba77b7ed0f887ba26e2b0ceae19904e140cd",
"type": "feat",
"title": "implementar funcionalidades de leitura de atualizações - Adiciona funções para marcar atualizações como lidas - Implementa a lógica para marcar todas as atualizações como lidas - Adiciona suporte a logs de atualizações lidas no banco de dados - Cria funções utilitárias para manipulação de changelog - Gera changelog a partir de commits do Git - Salva changelog em formato JSON na pasta pública perf: adicionar índices de banco de dados para otimização de queries - Cria 14 índices compostos em tabelas principais (lancamentos, contas, etc) - Adiciona índice user_id + period em lancamentos, faturas e orçamentos - Adiciona índices para séries de parcelas e transferências",
"date": "2025-12-08 14:56:50 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "7a4a947e3fa4f78f174d1042906828045cbf6eaf",
"type": "fix",
"title": "atualizar dependências do projeto",
"date": "2025-12-07 18:50:00 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "244534921b9b10fbff79777a024da17a45722bce",
"type": "fix",
"title": "replace session cookie validation with actual session check in proxy middleware",
"date": "2025-12-07 09:50:55 -0300",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "de3d99a3b1a398ae01eec0f65f03309648cbe24d",
"type": "fix",
"title": "add error handling for internal server error in login form",
"date": "2025-12-06 07:35:25 -0300",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "9d03387079d9ff867d0309522d5cb8989075bc2f",
"type": "fix",
"title": "adjust padding and layout in various dashboard widgets for improved UI consistency",
"date": "2025-12-02 13:54:13 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "c834648d395e58a6fb62c620a0c5e2ee4d1b8a4f",
"type": "fix",
"title": "corrige condição de análise de gastos parcelados",
"date": "2025-12-01 00:16:50 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "47038ae687e5c6d611009171a5730f3c1477aa78",
"type": "fix",
"title": "corrige timezone e seleção de parcelas na análise de parcelas",
"date": "2025-11-29 18:26:28 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "cf5a0b7745bf2ade4970e7e15c29bdb643955878",
"type": "feat",
"title": "implement category history widget and loading state for category history page",
"date": "2025-11-28 13:42:21 +0000",
"icon": "✨",
"category": "feature"
},
{
"id": "bf1a310c286e39664908ca989ffda0d3cea4ef3c",
"type": "feat",
"title": "add AI coding assistant instructions and update Node.js version requirement in README",
"date": "2025-11-28 01:30:09 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "2d8d677bcc85d863b2aee58b0c9144a62588173a",
"type": "fix",
"title": "update dependencies to latest versions",
"date": "2025-11-25 14:17:58 +0000",
"icon": "🐛",
"category": "bugfix"
},
{
"id": "a34d92f3bd7ceb96285bc32f1f2ff2eb79052170",
"type": "feat",
"title": "aprimora a exibição do cartão de parcelas e ajusta a lógica de busca",
"date": "2025-11-23 14:52:22 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "e8a343a6dd1f2426d484afe2902b05cfc65ea32d",
"type": "feat",
"title": "adiciona integração com Speed Insights",
"date": "2025-11-23 12:32:38 -0300",
"icon": "✨",
"category": "feature"
},
{
"id": "9fbe722d00aa0105fc3a37e0d19555e1aaf27928",
"type": "feat",
"title": "adicionar estrutura para gerenciamento de mudanças de código",
"date": "2025-11-23 12:26:05 -0300",
"icon": "✨",
"category": "feature"
}
]
}

View File

@@ -1,178 +0,0 @@
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
interface ChangelogEntry {
id: string;
type: string;
title: string;
date: string;
icon: string;
category: string;
}
function getIcon(type: string): string {
const icons: Record<string, string> = {
feat: "✨",
fix: "🐛",
perf: "🚀",
docs: "📝",
style: "🎨",
refactor: "♻️",
test: "🧪",
chore: "🔧",
};
return icons[type] || "📦";
}
function getCategory(type: string): string {
const categories: Record<string, string> = {
feat: "feature",
fix: "bugfix",
perf: "performance",
docs: "documentation",
style: "style",
refactor: "refactor",
test: "test",
chore: "chore",
};
return categories[type] || "other";
}
function getCategoryLabel(category: string): string {
const labels: Record<string, string> = {
feature: "Novidades",
bugfix: "Correções",
performance: "Performance",
documentation: "Documentação",
style: "Interface",
refactor: "Melhorias",
test: "Testes",
chore: "Manutenção",
other: "Outros",
};
return labels[category] || "Outros";
}
function generateChangelog() {
try {
console.log("🔍 Gerando changelog dos últimos commits...\n");
// Pega commits dos últimos 30 dias
const gitCommand =
'git log --since="30 days ago" --pretty=format:"%H|%s|%ai" --no-merges';
let output: string;
try {
output = execSync(gitCommand, { encoding: "utf-8" });
} catch (error) {
console.warn("⚠️ Não foi possível acessar o Git. Gerando changelog vazio.");
output = "";
}
if (!output.trim()) {
console.log(" Nenhum commit encontrado nos últimos 30 dias.");
const emptyChangelog = {
version: "1.0.0",
generatedAt: new Date().toISOString(),
entries: [],
};
const publicDir = path.join(process.cwd(), "public");
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
fs.writeFileSync(
path.join(publicDir, "changelog.json"),
JSON.stringify(emptyChangelog, null, 2)
);
return;
}
const commits = output
.split("\n")
.filter((line) => line.trim())
.map((line) => {
const [hash, message, date] = line.split("|");
return { hash, message, date };
});
console.log(`📝 Processando ${commits.length} commits...\n`);
// Parseia conventional commits
const entries: ChangelogEntry[] = commits
.map((commit) => {
// Match conventional commit format: type: message or type(scope): message
const match = commit.message.match(
/^(feat|fix|perf|docs|style|refactor|test|chore)(\(.+\))?:\s*(.+)$/
);
if (!match) {
// Ignora commits que não seguem o padrão
return null;
}
const [, type, , title] = match;
return {
id: commit.hash,
type,
title: title.trim(),
date: commit.date,
icon: getIcon(type),
category: getCategory(type),
};
})
.filter((entry): entry is ChangelogEntry => entry !== null);
console.log(`${entries.length} commits válidos encontrados\n`);
// Agrupa por categoria
const grouped = entries.reduce(
(acc, entry) => {
if (!acc[entry.category]) {
acc[entry.category] = [];
}
acc[entry.category].push(entry);
return acc;
},
{} as Record<string, ChangelogEntry[]>
);
// Mostra resumo
Object.entries(grouped).forEach(([category, items]) => {
console.log(
`${getIcon(items[0].type)} ${getCategoryLabel(category)}: ${items.length}`
);
});
// Pega versão do package.json
const packageJson = JSON.parse(
fs.readFileSync(path.join(process.cwd(), "package.json"), "utf-8")
);
const changelog = {
version: packageJson.version || "1.0.0",
generatedAt: new Date().toISOString(),
entries: entries.slice(0, 20), // Limita a 20 mais recentes
};
// Salva em public/changelog.json
const publicDir = path.join(process.cwd(), "public");
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
const changelogPath = path.join(publicDir, "changelog.json");
fs.writeFileSync(changelogPath, JSON.stringify(changelog, null, 2));
console.log(`\n✅ Changelog gerado com sucesso em: ${changelogPath}`);
} catch (error) {
console.error("❌ Erro ao gerar changelog:", error);
// Não falha o build, apenas avisa
process.exit(0);
}
}
generateChangelog();