feat: topbar de navegação como experimento de UI (v1.7.0)

- Substitui header fixo por topbar com backdrop blur e navegação agrupada em 5 seções
- Adiciona FerramentasDropdown consolidando calculadora e modo privacidade
- NotificationBell expandida com orçamentos e pré-lançamentos
- Remove logout-button, header-dashboard e privacy-mode-toggle como componentes separados
- Logo refatorado com variante compact; topbar com links em lowercase
- Adiciona dependência radix-ui ^1.4.3
- Atualiza CHANGELOG para v1.7.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-24 15:43:14 +00:00
parent af7dd6f737
commit 1b90be6b54
54 changed files with 1492 additions and 787 deletions

View File

@@ -1,7 +1,13 @@
"use server";
import { and, eq, lt, sql } from "drizzle-orm";
import { cartoes, faturas, lancamentos } from "@/db/schema";
import { and, eq, lt, ne, sql } from "drizzle-orm";
import {
cartoes,
categorias,
faturas,
lancamentos,
orcamentos,
} from "@/db/schema";
import { db } from "@/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
@@ -16,15 +22,28 @@ export type DashboardNotification = {
status: NotificationType;
amount: number;
period?: string;
showAmount: boolean; // Controla se o valor deve ser exibido no card
showAmount: boolean;
};
export type BudgetStatus = "exceeded" | "critical";
export type BudgetNotification = {
id: string;
categoryName: string;
budgetAmount: number;
spentAmount: number;
usedPercentage: number;
status: BudgetStatus;
};
export type DashboardNotificationsSnapshot = {
notifications: DashboardNotification[];
totalCount: number;
budgetNotifications: BudgetNotification[];
};
const PAYMENT_METHOD_BOLETO = "Boleto";
const BUDGET_CRITICAL_THRESHOLD = 80;
/**
* Calcula a data de vencimento de uma fatura baseado no período e dia de vencimento
@@ -97,13 +116,11 @@ function parseUTCDate(dateString: string): Date {
function isOverdue(dueDate: string, today: Date): boolean {
const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due);
return dueNormalized < today;
}
/**
* Verifica se uma data vence nos próximos X dias (incluindo hoje)
* Exemplo: Se hoje é dia 4 e daysThreshold = 5, retorna true para datas de 4 a 8
*/
function isDueWithinDays(
dueDate: string,
@@ -112,25 +129,21 @@ function isDueWithinDays(
): boolean {
const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due);
// Data limite: hoje + daysThreshold dias (em UTC)
const limitDate = new Date(today);
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
// Vence se está entre hoje (inclusive) e a data limite (inclusive)
return dueNormalized >= today && dueNormalized <= limitDate;
}
function toNum(value: unknown): number {
if (typeof value === "number") return value;
return Number(value) || 0;
}
/**
* Busca todas as notificações do dashboard
*
* Regras:
* - Períodos anteriores: TODOS os não pagos (sempre status "atrasado")
* - Período atual: Itens atrasados + os que vencem nos próximos dias (sem mostrar valor)
*
* Status:
* - "overdue": vencimento antes do dia atual (ou qualquer período anterior)
* - "due_soon": vencimento no dia atual ou nos próximos dias
* Busca todas as notificações do dashboard:
* - Faturas de cartão atrasadas ou com vencimento próximo
* - Boletos não pagos atrasados ou com vencimento próximo
* - Orçamentos excedidos (≥ 100%) ou críticos (≥ 80%)
*/
export async function fetchDashboardNotifications(
userId: string,
@@ -141,8 +154,7 @@ export async function fetchDashboardNotifications(
const adminPagadorId = await getAdminPagadorId(userId);
// Buscar faturas pendentes de períodos anteriores
// Apenas faturas com registro na tabela (períodos antigos devem ter sido finalizados)
// --- Faturas atrasadas (períodos anteriores) ---
const overdueInvoices = await db
.select({
invoiceId: faturas.id,
@@ -171,8 +183,7 @@ export async function fetchDashboardNotifications(
),
);
// Buscar faturas do período atual
// Usa LEFT JOIN para incluir cartões com lançamentos mesmo sem registro em faturas
// --- Faturas do período atual ---
const currentInvoices = await db
.select({
invoiceId: faturas.id,
@@ -213,13 +224,12 @@ export async function fetchDashboardNotifications(
faturas.paymentStatus,
);
// Buscar boletos não pagos (usando pagadorId direto ao invés de JOIN)
// --- Boletos não pagos ---
const boletosConditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.isSettled, false),
];
if (adminPagadorId) {
boletosConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
}
@@ -235,18 +245,44 @@ export async function fetchDashboardNotifications(
.from(lancamentos)
.where(and(...boletosConditions));
// --- Orçamentos do período atual ---
const budgetJoinConditions = [
eq(lancamentos.categoriaId, orcamentos.categoriaId),
eq(lancamentos.userId, orcamentos.userId),
eq(lancamentos.period, orcamentos.period),
eq(lancamentos.transactionType, "Despesa"),
ne(lancamentos.condition, "cancelado"),
];
if (adminPagadorId) {
budgetJoinConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
}
const budgetRows = await db
.select({
orcamentoId: orcamentos.id,
budgetAmount: orcamentos.amount,
categoriaName: categorias.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
})
.from(orcamentos)
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
.leftJoin(lancamentos, and(...budgetJoinConditions))
.where(
and(eq(orcamentos.userId, userId), eq(orcamentos.period, currentPeriod)),
)
.groupBy(orcamentos.id, orcamentos.amount, categorias.name);
// =====================
// Processar notificações
// =====================
const notifications: DashboardNotification[] = [];
// Processar faturas atrasadas (períodos anteriores)
// Faturas atrasadas (períodos anteriores)
for (const invoice of overdueInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const amount =
typeof invoice.totalAmount === "number"
? invoice.totalAmount
: Number(invoice.totalAmount) || 0;
const amount = toNum(invoice.totalAmount);
const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`;
@@ -259,43 +295,28 @@ export async function fetchDashboardNotifications(
status: "overdue",
amount: Math.abs(amount),
period: invoice.period,
showAmount: true, // Mostrar valor para itens de períodos anteriores
showAmount: true,
});
}
// Processar faturas do período atual (atrasadas + vencimento iminente)
// Faturas do período atual
for (const invoice of currentInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
const amount =
typeof invoice.totalAmount === "number"
? invoice.totalAmount
: Number(invoice.totalAmount) || 0;
const transactionCount =
typeof invoice.transactionCount === "number"
? invoice.transactionCount
: Number(invoice.transactionCount) || 0;
const amount = toNum(invoice.totalAmount);
const transactionCount = toNum(invoice.transactionCount);
const paymentStatus =
invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
// Ignora se não tem lançamentos e não tem registro de fatura
const shouldInclude =
transactionCount > 0 ||
Math.abs(amount) > 0 ||
invoice.invoiceId !== null;
if (!shouldInclude) continue;
// Ignora se já foi paga
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const invoiceIsOverdue = isOverdue(dueDate, today);
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
const notificationId = invoice.invoiceId
@@ -314,11 +335,9 @@ export async function fetchDashboardNotifications(
});
}
// Processar boletos
// Boletos
for (const boleto of boletosRows) {
if (!boleto.dueDate) continue;
// Converter para string no formato YYYY-MM-DD (UTC)
const dueDate =
boleto.dueDate instanceof Date
? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}`
@@ -326,17 +345,11 @@ export async function fetchDashboardNotifications(
const boletoIsOverdue = isOverdue(dueDate, today);
const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod;
const amount = toNum(boleto.amount);
// Período anterior: incluir todos (sempre atrasado)
if (isOldPeriod) {
const amount =
typeof boleto.amount === "number"
? boleto.amount
: Number(boleto.amount) || 0;
notifications.push({
id: `boleto-${boleto.id}`,
type: "boleto",
@@ -345,24 +358,15 @@ export async function fetchDashboardNotifications(
status: "overdue",
amount: Math.abs(amount),
period: boleto.period,
showAmount: true, // Mostrar valor para períodos anteriores
showAmount: true,
});
}
// Período atual: incluir atrasados e os que vencem em breve (sem valor)
else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
const status: NotificationType = boletoIsOverdue ? "overdue" : "due_soon";
const amount =
typeof boleto.amount === "number"
? boleto.amount
: Number(boleto.amount) || 0;
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
notifications.push({
id: `boleto-${boleto.id}`,
type: "boleto",
name: boleto.name,
dueDate,
status,
status: boletoIsOverdue ? "overdue" : "due_soon",
amount: Math.abs(amount),
period: boleto.period,
showAmount: boletoIsOverdue,
@@ -377,8 +381,37 @@ export async function fetchDashboardNotifications(
return a.dueDate.localeCompare(b.dueDate);
});
// Orçamentos excedidos e críticos
const budgetNotifications: BudgetNotification[] = [];
for (const row of budgetRows) {
const budgetAmount = toNum(row.budgetAmount);
const spentAmount = toNum(row.spentAmount);
if (budgetAmount <= 0) continue;
const usedPercentage = (spentAmount / budgetAmount) * 100;
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
budgetNotifications.push({
id: `budget-${row.orcamentoId}`,
categoryName: row.categoriaName,
budgetAmount,
spentAmount,
usedPercentage,
status: usedPercentage >= 100 ? "exceeded" : "critical",
});
}
// Excedidos primeiro, depois por percentual decrescente
budgetNotifications.sort((a, b) => {
if (a.status === "exceeded" && b.status !== "exceeded") return -1;
if (a.status !== "exceeded" && b.status === "exceeded") return 1;
return b.usedPercentage - a.usedPercentage;
});
return {
notifications,
totalCount: notifications.length,
budgetNotifications,
};
}

View File

@@ -1,11 +1,11 @@
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { categorias, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { generatePeriodRange } from "./utils";
export type CategoryChartData = {
@@ -34,14 +34,17 @@ export async function fetchCategoryChartData(
endPeriod: string,
categoryIds?: string[],
): Promise<CategoryChartData> {
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Build WHERE conditions
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { months: [], categories: [], chartData: [], allCategories: [] };
}
const whereConditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or(
isNull(lancamentos.note),
@@ -49,46 +52,42 @@ export async function fetchCategoryChartData(
),
];
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
}
// Query to get aggregated data by category and period
const rows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period,
);
const [rows, allCategoriesRows] = await Promise.all([
db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period,
),
db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name),
]);
// Fetch all categories for the user (for category selection)
const allCategoriesRows = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
// Map all categories
const allCategories = allCategoriesRows.map(
(cat: { id: string; name: string; icon: string | null; type: string }) => ({
id: cat.id,
@@ -98,7 +97,6 @@ export async function fetchCategoryChartData(
}),
);
// Process results into chart format
const categoryMap = new Map<
string,
{
@@ -110,7 +108,6 @@ export async function fetchCategoryChartData(
}
>();
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } =
@@ -126,37 +123,31 @@ export async function fetchCategoryChartData(
});
}
const categoryItem = categoryMap.get(categoryId)!;
categoryItem.dataByPeriod.set(period, amount);
categoryMap.get(categoryId)!.dataByPeriod.set(period, amount);
}
// Build chart data
const chartData = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, 1);
const date = new Date(Number.parseInt(year, 10), Number.parseInt(month, 10) - 1, 1);
const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase();
const dataPoint: { month: string; [key: string]: number | string } = {
month: monthLabel,
};
// Add data for each category
for (const category of categoryMap.values()) {
const value = category.dataByPeriod.get(period) ?? 0;
dataPoint[category.name] = value;
dataPoint[category.name] = category.dataByPeriod.get(period) ?? 0;
}
return dataPoint;
});
// Generate month labels
const months = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, 1);
const date = new Date(Number.parseInt(year, 10), Number.parseInt(month, 10) - 1, 1);
return format(date, "MMM", { locale: ptBR }).toUpperCase();
});
// Build categories array
const categories = Array.from(categoryMap.values()).map((cat) => ({
id: cat.id,
name: cat.name,
@@ -164,10 +155,5 @@ export async function fetchCategoryChartData(
type: cat.type,
}));
return {
months,
categories,
chartData,
allCategories,
};
return { months, categories, chartData, allCategories };
}

View File

@@ -1,9 +1,9 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { categorias, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import type {
CategoryReportData,
CategoryReportFilters,
@@ -28,11 +28,16 @@ export async function fetchCategoryReport(
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { categories: [], periods, totals: new Map(), grandTotal: 0 };
}
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or(
isNull(lancamentos.note),
@@ -56,7 +61,6 @@ export async function fetchCategoryReport(
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(