feat(dashboard): add quick actions and new overview widgets
This commit is contained in:
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
147
lib/dashboard/goals-progress.ts
Normal file
147
lib/dashboard/goals-progress.ts
Normal 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
73
lib/dashboard/notes.ts
Normal 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(),
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user