perf: otimizar dashboard com indexes, cache e consolidação de queries (v1.3.0)

- Adicionar indexes compostos em lancamentos para queries frequentes
- Eliminar ~20 JOINs com pagadores via helper cacheado getAdminPagadorId()
- Consolidar queries: income-expense-balance (12→1), payment-status (2→1), categories (4→2)
- Adicionar cache cross-request via unstable_cache com tag-based invalidation
- Limitar scan de métricas a 24 meses
- Deduplicar auth session por request via React.cache()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-06 12:24:15 +00:00
parent 21fac52e28
commit 6f5c41a4cf
45 changed files with 3589 additions and 1219 deletions

View File

@@ -5,6 +5,26 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [1.3.0] - 2026-02-06
### Adicionado
- Indexes compostos em `lancamentos`: `(userId, period, transactionType)` e `(pagadorId, period)`
- Cache cross-request no dashboard via `unstable_cache` com tag `"dashboard"` e TTL de 120s
- Invalidação automática do cache do dashboard via `revalidateTag("dashboard")` em mutations financeiras
- Helper `getAdminPagadorId()` com `React.cache()` para lookup cacheado do admin pagador
### Alterado
- Eliminados ~20 JOINs com tabela `pagadores` nos fetchers do dashboard (substituídos por filtro direto com `pagadorId`)
- Consolidadas queries de income-expense-balance: 12 queries → 1 (GROUP BY period + transactionType)
- Consolidadas queries de payment-status: 2 queries → 1 (GROUP BY transactionType)
- Consolidadas queries de expenses/income-by-category: 4 queries → 2 (GROUP BY categoriaId + period)
- Scan de métricas limitado a 24 meses ao invés de histórico completo
- Auth session deduplicada por request via `React.cache()`
- Widgets de dashboard ajustados para aceitar `Date | string` (compatibilidade com serialização do `unstable_cache`)
- `CLAUDE.md` otimizado de ~1339 linhas para ~140 linhas
## [1.2.6] - 2025-02-04
### Alterado

View File

@@ -395,7 +395,10 @@ const buildShares = ({
) {
return [
{ pagadorId, amountCents: primarySplitAmountCents },
{ pagadorId: secondaryPagadorId, amountCents: secondarySplitAmountCents },
{
pagadorId: secondaryPagadorId,
amountCents: secondarySplitAmountCents,
},
];
}

View File

@@ -67,9 +67,7 @@ export function TransferDialog({
);
// Source account info
const fromAccount = accounts.find(
(account) => account.id === fromAccountId,
);
const fromAccount = accounts.find((account) => account.id === fromAccountId);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

View File

@@ -19,7 +19,8 @@ type PurchasesByCategoryWidgetProps = {
data: PurchasesByCategoryData;
};
const formatTransactionDate = (date: Date) => {
const formatTransactionDate = (date: Date | string) => {
const d = date instanceof Date ? date : new Date(date);
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
@@ -27,7 +28,7 @@ const formatTransactionDate = (date: Date) => {
timeZone: "UTC",
});
const formatted = formatter.format(date);
const formatted = formatter.format(d);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};

View File

@@ -8,7 +8,8 @@ type RecentTransactionsWidgetProps = {
data: RecentTransactionsData;
};
const formatTransactionDate = (date: Date) => {
const formatTransactionDate = (date: Date | string) => {
const d = date instanceof Date ? date : new Date(date);
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
@@ -16,7 +17,7 @@ const formatTransactionDate = (date: Date) => {
timeZone: "UTC",
});
const formatted = formatter.format(date);
const formatted = formatter.format(d);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};

View File

@@ -14,7 +14,6 @@ import {
CardTitle,
} from "@/components/ui/card";
import type { DashboardCardMetrics } from "@/lib/dashboard/metrics";
import { title_font } from "@/public/fonts/font_index";
import MoneyValues from "../money-values";
type SectionCardsProps = {
@@ -61,9 +60,7 @@ const getPercentChange = (current: number, previous: number): string => {
export function SectionCards({ metrics }: SectionCardsProps) {
return (
<div
className={`${title_font.className} *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4`}
>
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map(({ label, key, icon: Icon }) => {
const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous);

View File

@@ -16,7 +16,8 @@ type TopExpensesWidgetProps = {
cardOnlyExpenses: TopExpensesData;
};
const formatTransactionDate = (date: Date) => {
const formatTransactionDate = (date: Date | string) => {
const d = date instanceof Date ? date : new Date(date);
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
@@ -24,7 +25,7 @@ const formatTransactionDate = (date: Date) => {
timeZone: "UTC",
});
const formatted = formatter.format(date);
const formatted = formatter.format(d);
// Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
};

View File

@@ -72,7 +72,6 @@ import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatDate } from "@/lib/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
import { cn } from "@/lib/utils/ui";
import { title_font } from "@/public/fonts/font_index";
import { LancamentosExport } from "../lancamentos-export";
import { EstabelecimentoLogo } from "../shared/estabelecimento-logo";
import type {
@@ -928,7 +927,7 @@ export function LancamentosTable({
<>
<div className="overflow-x-auto">
<Table>
<TableHeader className={`${title_font.className}`}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (

View File

@@ -89,7 +89,7 @@ export default function MonthNavigation() {
<div className="flex items-center">
<div
className="mx-1 space-x-1 capitalize font-bold"
className="mx-1 space-x-1 capitalize font-semibold"
aria-current={!isDifferentFromCurrent ? "date" : undefined}
aria-label={`Período selecionado: ${currentMonthLabel} de ${currentYear}`}
>

View File

@@ -7,7 +7,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
import { title_font } from "@/public/fonts/font_index";
type CardCategoryBreakdownProps = {
data: CardDetailData["categoryBreakdown"];
@@ -18,9 +17,7 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPieChartLine className="size-4 text-primary" />
Gastos por Categoria
</CardTitle>
@@ -41,9 +38,7 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPieChartLine className="size-4 text-primary" />
Gastos por Categoria
</CardTitle>

View File

@@ -10,7 +10,6 @@ import {
} from "@/components/ui/tooltip";
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
import { cn } from "@/lib/utils";
import { title_font } from "@/public/fonts/font_index";
type CardInvoiceStatusProps = {
data: CardDetailData["invoiceStatus"];
@@ -75,9 +74,7 @@ export function CardInvoiceStatus({ data }: CardInvoiceStatusProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiCalendarCheckLine className="size-4 text-primary" />
Faturas
</CardTitle>

View File

@@ -7,7 +7,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
import { title_font } from "@/public/fonts/font_index";
type CardTopExpensesProps = {
data: CardDetailData["topExpenses"];
@@ -18,9 +17,7 @@ export function CardTopExpenses({ data }: CardTopExpensesProps) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiShoppingBag3Line className="size-4 text-primary" />
Top 10 Gastos do Mês
</CardTitle>
@@ -43,9 +40,7 @@ export function CardTopExpenses({ data }: CardTopExpensesProps) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiShoppingBag3Line className="size-4 text-primary" />
Top 10 Gastos do Mês
</CardTitle>

View File

@@ -17,7 +17,6 @@ import {
ChartTooltip,
} from "@/components/ui/chart";
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
import { title_font } from "@/public/fonts/font_index";
type CardUsageChartProps = {
data: CardDetailData["monthlyUsage"];
@@ -82,9 +81,7 @@ export function CardUsageChart({ data, limit, card }: CardUsageChartProps) {
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiBarChartBoxLine className="size-4 text-primary" />
Histórico de Uso
</CardTitle>

View File

@@ -1,7 +1,10 @@
"use client";
import { useMemo } from "react";
import type { CategoryReportData, CategoryReportItem } from "@/lib/relatorios/types";
import type {
CategoryReportData,
CategoryReportItem,
} from "@/lib/relatorios/types";
import { CategoryTable } from "./category-table";
interface CategoryReportTableProps {

View File

@@ -78,9 +78,6 @@ function LogoContent() {
const isCollapsed = state === "collapsed";
return (
<Logo
variant={isCollapsed ? "small" : "full"}
showVersion={!isCollapsed}
/>
<Logo variant={isCollapsed ? "small" : "full"} showVersion={!isCollapsed} />
);
}

View File

@@ -6,7 +6,6 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { TopEstabelecimentosData } from "@/lib/top-estabelecimentos/fetch-data";
import { title_font } from "@/public/fonts/font_index";
import { Progress } from "../ui/progress";
type EstablishmentsListProps = {
@@ -32,9 +31,7 @@ export function EstablishmentsList({
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiStore2Line className="size-4 text-primary" />
Top Estabelecimentos
</CardTitle>
@@ -55,9 +52,7 @@ export function EstablishmentsList({
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiStore2Line className="size-4 text-primary" />
Top Estabelecimentos por Frequência
</CardTitle>

View File

@@ -6,7 +6,6 @@ import MoneyValues from "@/components/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { TopEstabelecimentosData } from "@/lib/top-estabelecimentos/fetch-data";
import { title_font } from "@/public/fonts/font_index";
import { Progress } from "../ui/progress";
type TopCategoriesProps = {
@@ -18,9 +17,7 @@ export function TopCategories({ categories }: TopCategoriesProps) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPriceTag3Line className="size-4 text-primary" />
Principais Categorias
</CardTitle>
@@ -41,9 +38,7 @@ export function TopCategories({ categories }: TopCategoriesProps) {
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<CardTitle className="flex items-center gap-1.5 text-base">
<RiPriceTag3Line className="size-4 text-primary" />
Principais Categorias
</CardTitle>

View File

@@ -14,7 +14,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { title_font } from "@/public/fonts/font_index";
import { Button } from "./ui/button";
const OVERFLOW_THRESHOLD_PX = 16;
@@ -79,9 +78,7 @@ export default function WidgetCard({
<CardHeader className="border-b [.border-b]:pb-2">
<div className="flex w-full items-start justify-between">
<div>
<CardTitle
className={`${title_font.className} flex items-center gap-1`}
>
<CardTitle className="flex items-center gap-1">
<span className="text-primary">{icon}</span>
{title}
</CardTitle>

View File

@@ -591,6 +591,17 @@ export const lancamentos = pgTable(
table.userId,
table.period,
),
// Índice composto userId + period + transactionType (cobre maioria das queries do dashboard)
userIdPeriodTypeIdx: index("lancamentos_user_id_period_type_idx").on(
table.userId,
table.period,
table.transactionType,
),
// Índice para queries por pagador + period (invoice/breakdown queries)
pagadorIdPeriodIdx: index("lancamentos_pagador_id_period_idx").on(
table.pagadorId,
table.period,
),
// Índice para queries ordenadas por data de compra
userIdPurchaseDateIdx: index("lancamentos_user_id_purchase_date_idx").on(
table.userId,

View File

@@ -0,0 +1,2 @@
CREATE INDEX "lancamentos_user_id_period_type_idx" ON "lancamentos" USING btree ("user_id","periodo","tipo_transacao");--> statement-breakpoint
CREATE INDEX "lancamentos_pagador_id_period_idx" ON "lancamentos" USING btree ("pagador_id","periodo");

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +1,118 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"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
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
}
]
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"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
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1770332054481,
"tag": "0015_concerned_kat_farrell",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { revalidatePath } from "next/cache";
import { revalidatePath, revalidateTag } from "next/cache";
import { z } from "zod";
import { getUser } from "@/lib/auth/server";
import type { ActionResult } from "./types";
@@ -35,14 +35,30 @@ export const revalidateConfig = {
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
} as const;
/** Entities whose mutations should invalidate the dashboard cache */
const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
"lancamentos",
"contas",
"cartoes",
"orcamentos",
"pagadores",
"inbox",
]);
/**
* Revalidates paths for a specific entity
* Revalidates paths for a specific entity.
* Also invalidates the dashboard "use cache" tag for financial entities.
* @param entity - The entity type
*/
export function revalidateForEntity(
entity: keyof typeof revalidateConfig,
): void {
revalidateConfig[entity].forEach((path) => revalidatePath(path));
// Invalidate dashboard cache for financial mutations
if (DASHBOARD_ENTITIES.has(entity)) {
revalidateTag("dashboard");
}
}
/**

View File

@@ -1,14 +1,23 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { cache } from "react";
import { auth } from "@/lib/auth/config";
/**
* Cached session fetch - deduplicates auth calls within a single request.
* Layout + page calling getUser() will only hit auth once.
*/
const getSessionCached = cache(async () => {
return auth.api.getSession({ headers: await headers() });
});
/**
* Gets the current authenticated user
* @returns User object
* @throws Redirects to /login if user is not authenticated
*/
export async function getUser() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
@@ -23,7 +32,7 @@ export async function getUser() {
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserId() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
@@ -38,7 +47,7 @@ export async function getUserId() {
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserSession() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await getSessionCached();
if (!session?.user) {
redirect("/login");
@@ -53,5 +62,5 @@ export async function getUserSession() {
* @note This function does not redirect if user is not authenticated
*/
export async function getOptionalUserSession() {
return auth.api.getSession({ headers: await headers() });
return getSessionCached();
}

View File

@@ -1,9 +1,10 @@
"use server";
import { and, asc, eq } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
const PAYMENT_METHOD_BOLETO = "Boleto";
@@ -51,6 +52,11 @@ export async function fetchDashboardBoletos(
userId: string,
period: string,
): Promise<DashboardBoletosSnapshot> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { boletos: [], totalPendingAmount: 0, pendingCount: 0 };
}
const rows = await db
.select({
id: lancamentos.id,
@@ -61,13 +67,12 @@ export async function fetchDashboardBoletos(
isSettled: lancamentos.isSettled,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(pagadores.role, "admin"),
eq(lancamentos.pagadorId, adminPagadorId),
),
)
.orderBy(

View File

@@ -1,9 +1,10 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos, pagadores } from "@/db/schema";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos } 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 { calculatePercentageChange } from "@/lib/utils/math";
import { getPreviousPeriod } from "@/lib/utils/period";
export type CategoryExpenseItem = {
@@ -24,138 +25,129 @@ export type ExpensesByCategoryData = {
previousTotal: number;
};
const calculatePercentageChange = (
current: number,
previous: number,
): number | null => {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return null;
return current > 0 ? 100 : -100;
}
const change = ((current - previous) / Math.abs(previous)) * 100;
// Protege contra valores absurdos (retorna null se > 1 milhão %)
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
};
export async function fetchExpensesByCategory(
userId: string,
period: string,
): Promise<ExpensesByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
// Busca despesas do período atual agrupadas por categoria
const currentPeriodRows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
budgetAmount: orcamentos.amount,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(
orcamentos,
and(
eq(orcamentos.categoriaId, categorias.id),
eq(orcamentos.period, period),
eq(orcamentos.userId, userId),
),
)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Single query: GROUP BY categoriaId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
orcamentos.amount,
);
db
.select({
categoriaId: orcamentos.categoriaId,
amount: orcamentos.amount,
})
.from(orcamentos)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
// Busca despesas do período anterior agrupadas por categoria
const previousPeriodRows = await db
.select({
categoryId: categorias.id,
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(
eq(lancamentos.userId, userId),
eq(lancamentos.period, previousPeriod),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(categorias.id);
// Build budget lookup
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, toNumber(row.amount));
}
}
// Cria um mapa do período anterior para busca rápida
const previousMap = new Map<string, number>();
let previousTotal = 0;
// Build category data from grouped results
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
for (const row of previousPeriodRows) {
const amount = Math.abs(toNumber(row.total));
previousMap.set(row.categoryId, amount);
previousTotal += amount;
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
// Calcula o total do período atual
// Calculate totals
let currentTotal = 0;
for (const row of currentPeriodRows) {
currentTotal += Math.abs(toNumber(row.total));
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
// Monta os dados de cada categoria
const categories: CategoryExpenseItem[] = currentPeriodRows.map((row) => {
const currentAmount = Math.abs(toNumber(row.total));
const previousAmount = previousMap.get(row.categoryId) ?? 0;
// Build result
const categories: CategoryExpenseItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
currentAmount,
previousAmount,
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = row.budgetAmount ? toNumber(row.budgetAmount) : null;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (currentAmount / budgetAmount) * 100
? (entry.current / budgetAmount) * 100
: null;
return {
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
currentAmount,
previousAmount,
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
};
});
});
}
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);

View File

@@ -1,17 +1,11 @@
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import {
categorias,
contas,
lancamentos,
orcamentos,
pagadores,
} from "@/db/schema";
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { categorias, contas, lancamentos, orcamentos } 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 { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
@@ -40,131 +34,130 @@ export async function fetchIncomeByCategory(
): Promise<IncomeByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
// Busca receitas do período atual agrupadas por categoria
const currentPeriodRows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
budgetAmount: orcamentos.amount,
})
.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(
eq(orcamentos.categoriaId, categorias.id),
eq(orcamentos.period, period),
eq(orcamentos.userId, userId),
),
)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "receita"),
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),
),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
orcamentos.amount,
);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { categories: [], currentTotal: 0, previousTotal: 0 };
}
// Busca receitas do período anterior agrupadas por categoria
const previousPeriodRows = await db
.select({
categoryId: categorias.id,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.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),
eq(lancamentos.period, previousPeriod),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "receita"),
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),
// Single query: GROUP BY categoriaId + period for both current and previous periods
const [rows, budgetRows] = await Promise.all([
db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Receita"),
eq(categorias.type, "receita"),
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),
),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
),
)
.groupBy(categorias.id);
db
.select({
categoriaId: orcamentos.categoriaId,
amount: orcamentos.amount,
})
.from(orcamentos)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
]);
// Cria um mapa do período anterior para busca rápida
const previousMap = new Map<string, number>();
let previousTotal = 0;
// Build budget lookup
const budgetMap = new Map<string, number>();
for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, safeToNumber(row.amount));
}
}
// Build category data from grouped results
const categoryMap = new Map<
string,
{
name: string;
icon: string | null;
current: number;
previous: number;
}
>();
for (const row of rows) {
const entry = categoryMap.get(row.categoryId) ?? {
name: row.categoryName,
icon: row.categoryIcon,
current: 0,
previous: 0,
};
for (const row of previousPeriodRows) {
const amount = Math.abs(safeToNumber(row.total));
previousMap.set(row.categoryId, amount);
previousTotal += amount;
if (row.period === period) {
entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
}
// Calcula o total do período atual
// Calculate totals
let currentTotal = 0;
for (const row of currentPeriodRows) {
currentTotal += Math.abs(safeToNumber(row.total));
let previousTotal = 0;
for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
}
// Monta os dados de cada categoria
const categories: CategoryIncomeItem[] = currentPeriodRows.map((row) => {
const currentAmount = Math.abs(safeToNumber(row.total));
const previousAmount = previousMap.get(row.categoryId) ?? 0;
// Build result
const categories: CategoryIncomeItem[] = [];
for (const [categoryId, entry] of categoryMap) {
const percentageChange = calculatePercentageChange(
currentAmount,
previousAmount,
entry.current,
entry.previous,
);
const percentageOfTotal =
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = row.budgetAmount
? safeToNumber(row.budgetAmount)
: null;
const budgetAmount = budgetMap.get(categoryId) ?? null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (currentAmount / budgetAmount) * 100
? (entry.current / budgetAmount) * 100
: null;
return {
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
currentAmount,
previousAmount,
categories.push({
categoryId,
categoryName: entry.name,
categoryIcon: entry.icon,
currentAmount: entry.current,
previousAmount: entry.previous,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
};
});
});
}
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);

View File

@@ -1,12 +1,12 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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";
export type InstallmentExpense = {
id: string;
@@ -28,6 +28,11 @@ export async function fetchInstallmentExpenses(
userId: string,
period: string,
): Promise<InstallmentExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { expenses: [] };
}
const rows = await db
.select({
id: lancamentos.id,
@@ -41,7 +46,6 @@ export async function fetchInstallmentExpenses(
period: lancamentos.period,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
@@ -49,7 +53,7 @@ export async function fetchInstallmentExpenses(
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,12 +1,12 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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";
export type RecurringExpense = {
id: string;
@@ -24,6 +24,11 @@ export async function fetchRecurringExpenses(
userId: string,
period: string,
): Promise<RecurringExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { expenses: [] };
}
const results = await db
.select({
id: lancamentos.id,
@@ -33,14 +38,13 @@ export async function fetchRecurringExpenses(
recurrenceCount: lancamentos.recurrenceCount,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Recorrente"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,12 +1,12 @@
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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";
export type TopExpense = {
id: string;
@@ -26,11 +26,16 @@ export async function fetchTopExpenses(
period: string,
cardOnly: boolean = false,
): Promise<TopExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { expenses: [] };
}
const conditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(
@@ -60,7 +65,6 @@ export async function fetchTopExpenses(
accountLogo: contas.logo,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(and(...conditions))

View File

@@ -1,3 +1,4 @@
import { unstable_cache } from "next/cache";
import { fetchDashboardAccounts } from "./accounts";
import { fetchDashboardBoletos } from "./boletos";
import { fetchExpensesByCategory } from "./categories/expenses-by-category";
@@ -17,7 +18,7 @@ import { fetchPurchasesByCategory } from "./purchases-by-category";
import { fetchRecentTransactions } from "./recent-transactions";
import { fetchTopEstablishments } from "./top-establishments";
export async function fetchDashboardData(userId: string, period: string) {
async function fetchDashboardDataInternal(userId: string, period: string) {
const [
metrics,
accountsSnapshot,
@@ -83,4 +84,20 @@ export async function fetchDashboardData(userId: string, period: string) {
};
}
/**
* Cached dashboard data fetcher.
* Uses unstable_cache with tags for revalidation on mutations.
* Cache is keyed by userId + period, and invalidated via "dashboard" tag.
*/
export function fetchDashboardData(userId: string, period: string) {
return unstable_cache(
() => fetchDashboardDataInternal(userId, period),
[`dashboard-${userId}-${period}`],
{
tags: ["dashboard", `dashboard-${userId}`],
revalidate: 120,
},
)();
}
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;

View File

@@ -1,11 +1,12 @@
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type MonthData = {
month: string;
@@ -66,83 +67,67 @@ export async function fetchIncomeExpenseBalance(
userId: string,
currentPeriod: string,
): Promise<IncomeExpenseBalanceData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { months: [] };
}
const periods = generateLast6Months(currentPeriod);
const results = await Promise.all(
periods.map(async (period) => {
// Busca receitas do período
const [incomeRow] = await db
.select({
total: sql<number>`
coalesce(
sum(${lancamentos.amount}),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${
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),
),
),
);
// Single query: GROUP BY period + transactionType instead of 12 separate queries
const rows = await db
.select({
period: lancamentos.period,
transactionType: lancamentos.transactionType,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, periods),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
sql`(${lancamentos.note} IS NULL OR ${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),
),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType);
// Busca despesas do período
const [expenseRow] = await db
.select({
total: sql<number>`
coalesce(
sum(${lancamentos.amount}),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
),
);
// Build lookup from query results
const dataMap = new Map<string, { income: number; expense: number }>();
for (const row of rows) {
if (!row.period) continue;
const entry = dataMap.get(row.period) ?? { income: 0, expense: 0 };
const total = Math.abs(toNumber(row.total));
if (row.transactionType === "Receita") {
entry.income = total;
} else if (row.transactionType === "Despesa") {
entry.expense = total;
}
dataMap.set(row.period, entry);
}
const income = Math.abs(toNumber(incomeRow?.total));
const expense = Math.abs(toNumber(expenseRow?.total));
const balance = income - expense;
// Build result array preserving period order
const months = periods.map((period) => {
const entry = dataMap.get(period) ?? { income: 0, expense: 0 };
const [, monthPart] = period.split("-");
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
const [, monthPart] = period.split("-");
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
return {
month: period,
monthLabel: monthLabel ?? "",
income: entry.income,
expense: entry.expense,
balance: entry.income - entry.expense,
};
});
return {
month: period,
monthLabel: monthLabel ?? "",
income,
expense,
balance,
};
}),
);
return {
months: results,
};
return { months };
}

View File

@@ -2,6 +2,7 @@ import {
and,
asc,
eq,
gte,
ilike,
isNull,
lte,
@@ -10,15 +11,16 @@ import {
or,
sum,
} from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { contas, lancamentos } 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 { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber } from "@/lib/utils/number";
import {
addMonthsToPeriod,
buildPeriodRange,
comparePeriods,
getPreviousPeriod,
@@ -80,6 +82,21 @@ export async function fetchDashboardCardMetrics(
): Promise<DashboardCardMetrics> {
const previousPeriod = getPreviousPeriod(period);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return {
period,
previousPeriod,
receitas: { current: 0, previous: 0 },
despesas: { current: 0, previous: 0 },
balanco: { current: 0, previous: 0 },
previsto: { current: 0, previous: 0 },
};
}
// Limitar scan histórico a 24 meses para evitar scans progressivamente mais lentos
const startPeriod = addMonthsToPeriod(period, -24);
const rows = await db
.select({
period: lancamentos.period,
@@ -87,13 +104,13 @@ export async function fetchDashboardCardMetrics(
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
@@ -129,12 +146,12 @@ export async function fetchDashboardCardMetrics(
const earliestPeriod =
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
const startPeriod =
const startRangePeriod =
comparePeriods(earliestPeriod, previousPeriod) <= 0
? earliestPeriod
: previousPeriod;
const periodRange = buildPeriodRange(startPeriod, period);
const periodRange = buildPeriodRange(startRangePeriod, period);
const forecastByPeriod = new Map<string, number>();
let runningForecast = 0;

View File

@@ -1,9 +1,10 @@
"use server";
import { and, eq, lt, sql } from "drizzle-orm";
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
import { cartoes, faturas, lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type NotificationType = "overdue" | "due_soon";
@@ -138,6 +139,8 @@ export async function fetchDashboardNotifications(
const today = normalizeDate(new Date());
const DAYS_THRESHOLD = 5;
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)
const overdueInvoices = await db
@@ -210,7 +213,17 @@ export async function fetchDashboardNotifications(
faturas.paymentStatus,
);
// Buscar boletos não pagos
// Buscar boletos não pagos (usando pagadorId direto ao invés de JOIN)
const boletosConditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.isSettled, false),
];
if (adminPagadorId) {
boletosConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
}
const boletosRows = await db
.select({
id: lancamentos.id,
@@ -220,15 +233,7 @@ export async function fetchDashboardNotifications(
period: lancamentos.period,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.isSettled, false),
eq(pagadores.role, "admin"),
),
);
.where(and(...boletosConditions));
const notifications: DashboardNotification[] = [];

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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";
export type PaymentConditionSummary = {
condition: string;
@@ -23,6 +23,11 @@ export async function fetchPaymentConditions(
userId: string,
period: string,
): Promise<PaymentConditionsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { conditions: [] };
}
const rows = await db
.select({
condition: lancamentos.condition,
@@ -30,13 +35,12 @@ export async function fetchPaymentConditions(
transactions: sql<number>`count(${lancamentos.id})`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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";
export type PaymentMethodSummary = {
paymentMethod: string;
@@ -23,6 +23,11 @@ export async function fetchPaymentMethods(
userId: string,
period: string,
): Promise<PaymentMethodsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { methods: [] };
}
const rows = await db
.select({
paymentMethod: lancamentos.paymentMethod,
@@ -30,13 +35,12 @@ export async function fetchPaymentMethods(
transactions: sql<number>`count(${lancamentos.id})`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,8 +1,9 @@
import { and, eq, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import { and, eq, inArray, sql } from "drizzle-orm";
import { 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 { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type PaymentStatusCategory = {
total: number;
@@ -15,106 +16,67 @@ export type PaymentStatusData = {
expenses: PaymentStatusCategory;
};
const emptyCategory = (): PaymentStatusCategory => ({
total: 0,
confirmed: 0,
pending: 0,
});
export async function fetchPaymentStatus(
userId: string,
period: string,
): Promise<PaymentStatusData> {
// Busca receitas confirmadas e pendentes para o período do pagador admin
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
const incomeResult = await db
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { income: emptyCategory(), expenses: emptyCategory() };
}
// Single query: GROUP BY transactionType instead of 2 separate queries
const rows = await db
.select({
transactionType: lancamentos.transactionType,
confirmed: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = true then ${lancamentos.amount}
else 0
end
),
0
)
`,
coalesce(
sum(case when ${lancamentos.isSettled} = true then ${lancamentos.amount} else 0 end),
0
)
`,
pending: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount}
else 0
end
),
0
)
`,
coalesce(
sum(case when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount} else 0 end),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, "admin"),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.transactionType, ["Receita", "Despesa"]),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
),
);
)
.groupBy(lancamentos.transactionType);
// Busca despesas confirmadas e pendentes para o período do pagador admin
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
const expensesResult = await db
.select({
confirmed: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = true then ${lancamentos.amount}
else 0
end
),
0
)
`,
pending: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount}
else 0
end
),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`,
),
);
const result = { income: emptyCategory(), expenses: emptyCategory() };
const incomeData = incomeResult[0] ?? { confirmed: 0, pending: 0 };
const confirmedIncome = toNumber(incomeData.confirmed);
const pendingIncome = toNumber(incomeData.pending);
for (const row of rows) {
const confirmed = toNumber(row.confirmed);
const pending = toNumber(row.pending);
const category = {
total: confirmed + pending,
confirmed,
pending,
};
const expensesData = expensesResult[0] ?? { confirmed: 0, pending: 0 };
const confirmedExpenses = toNumber(expensesData.confirmed);
const pendingExpenses = toNumber(expensesData.pending);
if (row.transactionType === "Receita") {
result.income = category;
} else if (row.transactionType === "Despesa") {
result.expenses = category;
}
}
return {
income: {
total: confirmedIncome + pendingIncome,
confirmed: confirmedIncome,
pending: pendingIncome,
},
expenses: {
total: confirmedExpenses + pendingExpenses,
confirmed: confirmedExpenses,
pending: pendingExpenses,
},
};
return result;
}

View File

@@ -1,18 +1,12 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
} from "@/db/schema";
import { cartoes, categorias, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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";
export type CategoryOption = {
id: string;
@@ -51,6 +45,11 @@ export async function fetchPurchasesByCategory(
userId: string,
period: string,
): Promise<PurchasesByCategoryData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { categories: [], transactionsByCategory: {} };
}
const transactionsRows = await db
.select({
id: lancamentos.id,
@@ -64,7 +63,6 @@ export async function fetchPurchasesByCategory(
accountLogo: contas.logo,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
@@ -72,7 +70,7 @@ export async function fetchPurchasesByCategory(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(categorias.type, ["despesa", "receita"]),
or(
isNull(lancamentos.note),

View File

@@ -1,12 +1,12 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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";
export type RecentTransaction = {
id: string;
@@ -25,6 +25,11 @@ export async function fetchRecentTransactions(
userId: string,
period: string,
): Promise<RecentTransactionsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { transactions: [] };
}
const results = await db
.select({
id: lancamentos.id,
@@ -36,7 +41,6 @@ export async function fetchRecentTransactions(
note: lancamentos.note,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
@@ -44,7 +48,7 @@ export async function fetchRecentTransactions(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import { cartoes, contas, lancamentos } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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";
export type TopEstablishment = {
id: string;
@@ -38,6 +38,11 @@ export async function fetchTopEstablishments(
userId: string,
period: string,
): Promise<TopEstablishmentsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { establishments: [] };
}
const rows = await db
.select({
name: lancamentos.name,
@@ -46,7 +51,6 @@ export async function fetchTopEstablishments(
logo: sql<string | null>`max(coalesce(${cartoes.logo}, ${contas.logo}))`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
@@ -54,7 +58,7 @@ export async function fetchTopEstablishments(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.pagadorId, adminPagadorId),
or(
isNull(lancamentos.note),
and(

View File

@@ -0,0 +1,25 @@
import { and, eq } from "drizzle-orm";
import { cache } from "react";
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
/**
* Returns the admin pagador ID for a user (cached per request via React.cache).
* Eliminates the need for JOIN with pagadores in ~20 dashboard queries.
*/
export const getAdminPagadorId = cache(
async (userId: string): Promise<string | null> => {
const [row] = await db
.select({ id: pagadores.id })
.from(pagadores)
.where(
and(
eq(pagadores.userId, userId),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
)
.limit(1);
return row?.id ?? null;
},
);

View File

@@ -1,6 +1,6 @@
{
"name": "opensheets",
"version": "1.2.6",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -27,8 +27,8 @@
"docker:rebuild": "docker compose up --build --force-recreate"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.35",
"@ai-sdk/google": "^3.0.20",
"@ai-sdk/anthropic": "^3.0.37",
"@ai-sdk/google": "^3.0.21",
"@ai-sdk/openai": "^3.0.25",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -59,7 +59,7 @@
"@tanstack/react-table": "8.21.3",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"ai": "^6.0.67",
"ai": "^6.0.73",
"babel-plugin-react-compiler": "^1.0.0",
"better-auth": "1.4.18",
"class-variance-authority": "0.7.1",
@@ -67,7 +67,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "0.45.1",
"jspdf": "^4.0.0",
"jspdf": "^4.1.0",
"jspdf-autotable": "^5.0.7",
"next": "16.1.6",
"next-themes": "0.4.6",
@@ -84,13 +84,13 @@
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.3.13",
"@biomejs/biome": "2.3.14",
"@tailwindcss/postcss": "4.1.18",
"@types/node": "25.1.0",
"@types/node": "25.2.1",
"@types/pg": "^8.16.0",
"@types/react": "19.2.10",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"dotenv": "^17.2.3",
"dotenv": "^17.2.4",
"drizzle-kit": "0.31.8",
"tailwindcss": "4.1.18",
"tsx": "4.21.0",

1188
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -16,8 +16,12 @@ const ai_sans = localFont({
display: "swap",
});
const anthropic_sans = localFont({
src: "./anthropicSans.woff2",
display: "swap",
});
const main_font = ai_sans;
const money_font = ai_sans;
const title_font = ai_sans;
export { main_font, money_font, title_font };
export { main_font, money_font };