feat(dashboard): add quick actions and new overview widgets

This commit is contained in:
Felipe Coutinho
2026-03-02 17:20:28 +00:00
parent 3d3a9e1414
commit 2a21bef2da
21 changed files with 1166 additions and 116 deletions

View File

@@ -27,7 +27,7 @@ export const revalidateConfig = {
estabelecimentos: ["/estabelecimentos", "/lancamentos"],
orcamentos: ["/orcamentos"],
pagadores: ["/pagadores"],
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
anotacoes: ["/anotacoes", "/anotacoes/arquivadas", "/dashboard"],
lancamentos: ["/lancamentos", "/contas"],
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
} as const;
@@ -39,6 +39,7 @@ const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
"cartoes",
"orcamentos",
"pagadores",
"anotacoes",
"inbox",
]);

View File

@@ -0,0 +1,147 @@
import { and, eq, ne, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
const BUDGET_CRITICAL_THRESHOLD = 80;
export type GoalProgressStatus = "on-track" | "critical" | "exceeded";
export type GoalProgressItem = {
id: string;
categoryId: string | null;
categoryName: string;
categoryIcon: string | null;
period: string;
createdAt: string;
budgetAmount: number;
spentAmount: number;
usedPercentage: number;
status: GoalProgressStatus;
};
export type GoalProgressCategory = {
id: string;
name: string;
icon: string | null;
};
export type GoalsProgressData = {
items: GoalProgressItem[];
categories: GoalProgressCategory[];
totalBudgets: number;
exceededCount: number;
criticalCount: number;
};
const resolveStatus = (usedPercentage: number): GoalProgressStatus => {
if (usedPercentage >= 100) {
return "exceeded";
}
if (usedPercentage >= BUDGET_CRITICAL_THRESHOLD) {
return "critical";
}
return "on-track";
};
export async function fetchGoalsProgressData(
userId: string,
period: string,
): Promise<GoalsProgressData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return {
items: [],
categories: [],
totalBudgets: 0,
exceededCount: 0,
criticalCount: 0,
};
}
const [rows, categoryRows] = await Promise.all([
db
.select({
orcamentoId: orcamentos.id,
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: orcamentos.period,
createdAt: orcamentos.createdAt,
budgetAmount: orcamentos.amount,
spentAmount: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
})
.from(orcamentos)
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
.leftJoin(
lancamentos,
and(
eq(lancamentos.categoriaId, orcamentos.categoriaId),
eq(lancamentos.userId, orcamentos.userId),
eq(lancamentos.period, orcamentos.period),
eq(lancamentos.pagadorId, adminPagadorId),
eq(lancamentos.transactionType, "Despesa"),
ne(lancamentos.condition, "cancelado"),
),
)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
.groupBy(
orcamentos.id,
categorias.id,
categorias.name,
categorias.icon,
orcamentos.period,
orcamentos.createdAt,
orcamentos.amount,
),
db.query.categorias.findMany({
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
orderBy: (category, { asc }) => [asc(category.name)],
}),
]);
const categories: GoalProgressCategory[] = categoryRows.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
}));
const items: GoalProgressItem[] = rows
.map((row) => {
const budgetAmount = toNumber(row.budgetAmount);
const spentAmount = toNumber(row.spentAmount);
const usedPercentage =
budgetAmount > 0 ? (spentAmount / budgetAmount) * 100 : 0;
return {
id: row.orcamentoId,
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
period: row.period,
createdAt: row.createdAt.toISOString(),
budgetAmount,
spentAmount,
usedPercentage,
status: resolveStatus(usedPercentage),
};
})
.sort((a, b) => b.usedPercentage - a.usedPercentage);
const exceededCount = items.filter(
(item) => item.status === "exceeded",
).length;
const criticalCount = items.filter(
(item) => item.status === "critical",
).length;
return {
items,
categories,
totalBudgets: items.length,
exceededCount,
criticalCount,
};
}

73
lib/dashboard/notes.ts Normal file
View File

@@ -0,0 +1,73 @@
import { and, eq } from "drizzle-orm";
import { anotacoes } from "@/db/schema";
import { db } from "@/lib/db";
export type DashboardTask = {
id: string;
text: string;
completed: boolean;
};
export type DashboardNote = {
id: string;
title: string;
description: string;
type: "nota" | "tarefa";
tasks?: DashboardTask[];
arquivada: boolean;
createdAt: string;
};
const parseTasks = (value: string | null): DashboardTask[] | undefined => {
if (!value) {
return undefined;
}
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) {
return undefined;
}
return parsed
.filter((item): item is DashboardTask => {
if (!item || typeof item !== "object") {
return false;
}
const candidate = item as Partial<DashboardTask>;
return (
typeof candidate.id === "string" &&
typeof candidate.text === "string" &&
typeof candidate.completed === "boolean"
);
})
.map((task) => ({
id: task.id,
text: task.text,
completed: task.completed,
}));
} catch (error) {
console.error("Failed to parse dashboard note tasks", error);
return undefined;
}
};
export async function fetchDashboardNotes(
userId: string,
): Promise<DashboardNote[]> {
const notes = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
orderBy: (note, { desc }) => [desc(note.createdAt)],
limit: 5,
});
return notes.map((note) => ({
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks: parseTasks(note.tasks),
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
}));
}

View File

@@ -1,9 +1,11 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } 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 { calculatePercentageChange } from "@/lib/utils/math";
import { getPreviousPeriod } from "@/lib/utils/period";
export type DashboardPagador = {
id: string;
@@ -11,6 +13,8 @@ export type DashboardPagador = {
email: string | null;
avatarUrl: string | null;
totalExpenses: number;
previousExpenses: number;
percentageChange: number | null;
isAdmin: boolean;
};
@@ -23,6 +27,8 @@ export async function fetchDashboardPagadores(
userId: string,
period: string,
): Promise<DashboardPagadoresSnapshot> {
const previousPeriod = getPreviousPeriod(period);
const rows = await db
.select({
id: pagadores.id,
@@ -30,6 +36,7 @@ export async function fetchDashboardPagadores(
email: pagadores.email,
avatarUrl: pagadores.avatarUrl,
role: pagadores.role,
period: lancamentos.period,
totalExpenses: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
})
.from(lancamentos)
@@ -37,7 +44,7 @@ export async function fetchDashboardPagadores(
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"),
or(
isNull(lancamentos.note),
@@ -51,19 +58,60 @@ export async function fetchDashboardPagadores(
pagadores.email,
pagadores.avatarUrl,
pagadores.role,
lancamentos.period,
)
.orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`));
const pagadoresList = rows
.map((row) => ({
const groupedPagadores = new Map<
string,
{
id: string;
name: string;
email: string | null;
avatarUrl: string | null;
isAdmin: boolean;
currentExpenses: number;
previousExpenses: number;
}
>();
for (const row of rows) {
const entry = groupedPagadores.get(row.id) ?? {
id: row.id,
name: row.name,
email: row.email,
avatarUrl: row.avatarUrl,
totalExpenses: toNumber(row.totalExpenses),
isAdmin: row.role === PAGADOR_ROLE_ADMIN,
currentExpenses: 0,
previousExpenses: 0,
};
const amount = toNumber(row.totalExpenses);
if (row.period === period) {
entry.currentExpenses = amount;
} else {
entry.previousExpenses = amount;
}
groupedPagadores.set(row.id, entry);
}
const pagadoresList = Array.from(groupedPagadores.values())
.filter((p) => p.currentExpenses > 0)
.map((pagador) => ({
id: pagador.id,
name: pagador.name,
email: pagador.email,
avatarUrl: pagador.avatarUrl,
totalExpenses: pagador.currentExpenses,
previousExpenses: pagador.previousExpenses,
percentageChange: calculatePercentageChange(
pagador.currentExpenses,
pagador.previousExpenses,
),
isAdmin: pagador.isAdmin,
}))
.filter((p) => p.totalExpenses > 0);
.sort((a, b) => b.totalExpenses - a.totalExpenses);
const totalExpenses = pagadoresList.reduce(
(sum, p) => sum + p.totalExpenses,

View File

@@ -69,7 +69,10 @@ export async function fetchTopEstablishments(
),
)
.groupBy(lancamentos.name)
.orderBy(sql`ABS(sum(${lancamentos.amount})) DESC`)
.orderBy(
sql`count(${lancamentos.id}) DESC`,
sql`ABS(sum(${lancamentos.amount})) DESC`,
)
.limit(10);
const establishments = rows

View File

@@ -7,33 +7,30 @@ import {
RiExchangeLine,
RiGroupLine,
RiLineChartLine,
RiMoneyDollarCircleLine,
RiNumbersLine,
RiPieChartLine,
RiRefreshLine,
RiSlideshowLine,
RiStore2Line,
RiStore3Line,
RiTodoLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import type { ReactNode } from "react";
import { BoletosWidget } from "@/components/dashboard/boletos-widget";
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
import { GoalsProgressWidget } from "@/components/dashboard/goals-progress-widget";
import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart";
import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget";
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget";
import { NotesWidget } from "@/components/dashboard/notes-widget";
import { PagadoresWidget } from "@/components/dashboard/pagadores-widget";
import { PaymentConditionsWidget } from "@/components/dashboard/payment-conditions-widget";
import { PaymentMethodsWidget } from "@/components/dashboard/payment-methods-widget";
import { PaymentOverviewWidget } from "@/components/dashboard/payment-overview-widget";
import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget";
import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget";
import { RecentTransactionsWidget } from "@/components/dashboard/recent-transactions-widget";
import { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget";
import { TopEstablishmentsWidget } from "@/components/dashboard/top-establishments-widget";
import { TopExpensesWidget } from "@/components/dashboard/top-expenses-widget";
import { SpendingOverviewWidget } from "@/components/dashboard/spending-overview-widget";
import type { DashboardData } from "./fetch-dashboard-data";
export type WidgetConfig = {
@@ -114,30 +111,49 @@ export const widgetsConfig: WidgetConfig[] = [
),
},
{
id: "recent-transactions",
title: "Lançamentos Recentes",
subtitle: "Últimas 5 despesas registradas",
id: "notes",
title: "Anotações",
subtitle: "Últimas anotações ativas",
icon: <RiTodoLine className="size-4" />,
component: ({ data }) => <NotesWidget notes={data.notesData} />,
action: (
<Link
href="/anotacoes"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver todas
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
id: "goals-progress",
title: "Progresso de Orçamentos",
subtitle: "Orçamentos por categoria no período",
icon: <RiExchangeLine className="size-4" />,
component: ({ data }) => (
<RecentTransactionsWidget data={data.recentTransactionsData} />
<GoalsProgressWidget data={data.goalsProgressData} />
),
action: (
<Link
href="/orcamentos"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver todos
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
id: "payment-conditions",
title: "Condições de Pagamentos",
subtitle: "Análise das condições",
icon: <RiSlideshowLine className="size-4" />,
id: "payment-overview",
title: "Comportamento de Pagamento",
subtitle: "Despesas por condição e forma de pagamento",
icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => (
<PaymentConditionsWidget data={data.paymentConditionsData} />
),
},
{
id: "payment-methods",
title: "Formas de Pagamento",
subtitle: "Distribuição das despesas",
icon: <RiMoneyDollarCircleLine className="size-4" />,
component: ({ data }) => (
<PaymentMethodsWidget data={data.paymentMethodsData} />
<PaymentOverviewWidget
paymentConditionsData={data.paymentConditionsData}
paymentMethodsData={data.paymentMethodsData}
/>
),
},
{
@@ -168,35 +184,18 @@ export const widgetsConfig: WidgetConfig[] = [
),
},
{
id: "top-expenses",
title: "Maiores Gastos do Mês",
subtitle: "Top 10 Despesas",
id: "spending-overview",
title: "Panorama de Gastos",
subtitle: "Principais despesas e frequência por local",
icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => (
<TopExpensesWidget
allExpenses={data.topExpensesAll}
cardOnlyExpenses={data.topExpensesCardOnly}
<SpendingOverviewWidget
topExpensesAll={data.topExpensesAll}
topExpensesCardOnly={data.topExpensesCardOnly}
topEstablishmentsData={data.topEstablishmentsData}
/>
),
},
{
id: "top-establishments",
title: "Top Estabelecimentos",
subtitle: "Frequência de gastos no período",
icon: <RiStore2Line className="size-4" />,
component: ({ data }) => (
<TopEstablishmentsWidget data={data.topEstablishmentsData} />
),
action: (
<Link
href="/top-estabelecimentos"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver mais
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
id: "purchases-by-category",
title: "Lançamentos por Categorias",