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

@@ -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) });