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/), 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/). 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 ## [1.2.6] - 2025-02-04
### Alterado ### Alterado

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import type { DashboardCardMetrics } from "@/lib/dashboard/metrics"; import type { DashboardCardMetrics } from "@/lib/dashboard/metrics";
import { title_font } from "@/public/fonts/font_index";
import MoneyValues from "../money-values"; import MoneyValues from "../money-values";
type SectionCardsProps = { type SectionCardsProps = {
@@ -61,9 +60,7 @@ const getPercentChange = (current: number, previous: number): string => {
export function SectionCards({ metrics }: SectionCardsProps) { export function SectionCards({ metrics }: SectionCardsProps) {
return ( return (
<div <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">
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`}
>
{CARDS.map(({ label, key, icon: Icon }) => { {CARDS.map(({ label, key, icon: Icon }) => {
const metric = metrics[key]; const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous); const trend = getTrend(metric.current, metric.previous);

View File

@@ -16,7 +16,8 @@ type TopExpensesWidgetProps = {
cardOnlyExpenses: TopExpensesData; 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", { const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short", weekday: "short",
day: "2-digit", day: "2-digit",
@@ -24,7 +25,7 @@ const formatTransactionDate = (date: Date) => {
timeZone: "UTC", timeZone: "UTC",
}); });
const formatted = formatter.format(date); const formatted = formatter.format(d);
// Capitaliza a primeira letra do dia da semana // Capitaliza a primeira letra do dia da semana
return formatted.charAt(0).toUpperCase() + formatted.slice(1); 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 { formatDate } from "@/lib/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons"; import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { title_font } from "@/public/fonts/font_index";
import { LancamentosExport } from "../lancamentos-export"; import { LancamentosExport } from "../lancamentos-export";
import { EstabelecimentoLogo } from "../shared/estabelecimento-logo"; import { EstabelecimentoLogo } from "../shared/estabelecimento-logo";
import type { import type {
@@ -928,7 +927,7 @@ export function LancamentosTable({
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader className={`${title_font.className}`}> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (

View File

@@ -89,7 +89,7 @@ export default function MonthNavigation() {
<div className="flex items-center"> <div className="flex items-center">
<div <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-current={!isDifferentFromCurrent ? "date" : undefined}
aria-label={`Período selecionado: ${currentMonthLabel} de ${currentYear}`} 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 { Progress } from "@/components/ui/progress";
import { WidgetEmptyState } from "@/components/widget-empty-state"; import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
import { title_font } from "@/public/fonts/font_index";
type CardCategoryBreakdownProps = { type CardCategoryBreakdownProps = {
data: CardDetailData["categoryBreakdown"]; data: CardDetailData["categoryBreakdown"];
@@ -18,9 +17,7 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
return ( return (
<Card className="h-full"> <Card className="h-full">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle <CardTitle className="flex items-center gap-1.5 text-base">
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<RiPieChartLine className="size-4 text-primary" /> <RiPieChartLine className="size-4 text-primary" />
Gastos por Categoria Gastos por Categoria
</CardTitle> </CardTitle>
@@ -41,9 +38,7 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
return ( return (
<Card className="h-full"> <Card className="h-full">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle <CardTitle className="flex items-center gap-1.5 text-base">
className={`${title_font.className} flex items-center gap-1.5 text-base`}
>
<RiPieChartLine className="size-4 text-primary" /> <RiPieChartLine className="size-4 text-primary" />
Gastos por Categoria Gastos por Categoria
</CardTitle> </CardTitle>

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
"use client"; "use client";
import { useMemo } from "react"; 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"; import { CategoryTable } from "./category-table";
interface CategoryReportTableProps { interface CategoryReportTableProps {

View File

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

View File

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

View File

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

View File

@@ -591,6 +591,17 @@ export const lancamentos = pgTable(
table.userId, table.userId,
table.period, 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 // Índice para queries ordenadas por data de compra
userIdPurchaseDateIdx: index("lancamentos_user_id_purchase_date_idx").on( userIdPurchaseDateIdx: index("lancamentos_user_id_purchase_date_idx").on(
table.userId, 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

@@ -106,6 +106,13 @@
"when": 1769619226903, "when": 1769619226903,
"tag": "0014_yielding_jack_flag", "tag": "0014_yielding_jack_flag",
"breakpoints": true "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 { z } from "zod";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import type { ActionResult } from "./types"; import type { ActionResult } from "./types";
@@ -35,14 +35,30 @@ export const revalidateConfig = {
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"], inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
} as const; } 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 * @param entity - The entity type
*/ */
export function revalidateForEntity( export function revalidateForEntity(
entity: keyof typeof revalidateConfig, entity: keyof typeof revalidateConfig,
): void { ): void {
revalidateConfig[entity].forEach((path) => revalidatePath(path)); 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 { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react";
import { auth } from "@/lib/auth/config"; 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 * Gets the current authenticated user
* @returns User object * @returns User object
* @throws Redirects to /login if user is not authenticated * @throws Redirects to /login if user is not authenticated
*/ */
export async function getUser() { export async function getUser() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await getSessionCached();
if (!session?.user) { if (!session?.user) {
redirect("/login"); redirect("/login");
@@ -23,7 +32,7 @@ export async function getUser() {
* @throws Redirects to /login if user is not authenticated * @throws Redirects to /login if user is not authenticated
*/ */
export async function getUserId() { export async function getUserId() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await getSessionCached();
if (!session?.user) { if (!session?.user) {
redirect("/login"); redirect("/login");
@@ -38,7 +47,7 @@ export async function getUserId() {
* @throws Redirects to /login if user is not authenticated * @throws Redirects to /login if user is not authenticated
*/ */
export async function getUserSession() { export async function getUserSession() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await getSessionCached();
if (!session?.user) { if (!session?.user) {
redirect("/login"); redirect("/login");
@@ -53,5 +62,5 @@ export async function getUserSession() {
* @note This function does not redirect if user is not authenticated * @note This function does not redirect if user is not authenticated
*/ */
export async function getOptionalUserSession() { export async function getOptionalUserSession() {
return auth.api.getSession({ headers: await headers() }); return getSessionCached();
} }

View File

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

View File

@@ -1,9 +1,10 @@
import { and, eq, isNull, or, sql } from "drizzle-orm"; import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos, pagadores } from "@/db/schema"; import { categorias, lancamentos, orcamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common"; import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db"; 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"; import { getPreviousPeriod } from "@/lib/utils/period";
export type CategoryExpenseItem = { export type CategoryExpenseItem = {
@@ -24,55 +25,35 @@ export type ExpensesByCategoryData = {
previousTotal: number; 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( export async function fetchExpensesByCategory(
userId: string, userId: string,
period: string, period: string,
): Promise<ExpensesByCategoryData> { ): Promise<ExpensesByCategoryData> {
const previousPeriod = getPreviousPeriod(period); const previousPeriod = getPreviousPeriod(period);
// Busca despesas do período atual agrupadas por categoria const adminPagadorId = await getAdminPagadorId(userId);
const currentPeriodRows = await db 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({ .select({
categoryId: categorias.id, categoryId: categorias.id,
categoryName: categorias.name, categoryName: categorias.name,
categoryIcon: categorias.icon, categoryIcon: categorias.icon,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`, total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
budgetAmount: orcamentos.amount,
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.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( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.period, period), eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Despesa"), eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "despesa"), eq(categorias.type, "despesa"),
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
@@ -84,78 +65,89 @@ export async function fetchExpensesByCategory(
categorias.id, categorias.id,
categorias.name, categorias.name,
categorias.icon, categorias.icon,
orcamentos.amount, lancamentos.period,
); ),
db
// Busca despesas do período anterior agrupadas por categoria
const previousPeriodRows = await db
.select({ .select({
categoryId: categorias.id, categoriaId: orcamentos.categoriaId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`, amount: orcamentos.amount,
}) })
.from(lancamentos) .from(orcamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
.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);
// Cria um mapa do período anterior para busca rápida // Build budget lookup
const previousMap = new Map<string, number>(); const budgetMap = new Map<string, number>();
let previousTotal = 0; for (const row of budgetRows) {
if (row.categoriaId) {
budgetMap.set(row.categoriaId, toNumber(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(toNumber(row.total)); const amount = Math.abs(toNumber(row.total));
previousMap.set(row.categoryId, amount); if (row.period === period) {
previousTotal += amount; entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
} }
// Calcula o total do período atual // Calculate totals
let currentTotal = 0; let currentTotal = 0;
for (const row of currentPeriodRows) { let previousTotal = 0;
currentTotal += Math.abs(toNumber(row.total)); for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
} }
// Monta os dados de cada categoria // Build result
const categories: CategoryExpenseItem[] = currentPeriodRows.map((row) => { const categories: CategoryExpenseItem[] = [];
const currentAmount = Math.abs(toNumber(row.total)); for (const [categoryId, entry] of categoryMap) {
const previousAmount = previousMap.get(row.categoryId) ?? 0;
const percentageChange = calculatePercentageChange( const percentageChange = calculatePercentageChange(
currentAmount, entry.current,
previousAmount, entry.previous,
); );
const percentageOfTotal = 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 = const budgetUsedPercentage =
budgetAmount && budgetAmount > 0 budgetAmount && budgetAmount > 0
? (currentAmount / budgetAmount) * 100 ? (entry.current / budgetAmount) * 100
: null; : null;
return { categories.push({
categoryId: row.categoryId, categoryId,
categoryName: row.categoryName, categoryName: entry.name,
categoryIcon: row.categoryIcon, categoryIcon: entry.icon,
currentAmount, currentAmount: entry.current,
previousAmount, previousAmount: entry.previous,
percentageChange, percentageChange,
percentageOfTotal, percentageOfTotal,
budgetAmount, budgetAmount,
budgetUsedPercentage, budgetUsedPercentage,
};
}); });
}
// Ordena por valor atual (maior para menor) // Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount); 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 { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { import { categorias, contas, lancamentos, orcamentos } from "@/db/schema";
categorias,
contas,
lancamentos,
orcamentos,
pagadores,
} from "@/db/schema";
import { import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants"; } from "@/lib/accounts/constants";
import { db } from "@/lib/db"; 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 { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number"; import { safeToNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period"; import { getPreviousPeriod } from "@/lib/utils/period";
@@ -40,33 +34,30 @@ export async function fetchIncomeByCategory(
): Promise<IncomeByCategoryData> { ): Promise<IncomeByCategoryData> {
const previousPeriod = getPreviousPeriod(period); const previousPeriod = getPreviousPeriod(period);
// Busca receitas do período atual agrupadas por categoria const adminPagadorId = await getAdminPagadorId(userId);
const currentPeriodRows = await db 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({ .select({
categoryId: categorias.id, categoryId: categorias.id,
categoryName: categorias.name, categoryName: categorias.name,
categoryIcon: categorias.icon, categoryIcon: categorias.icon,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`, total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
budgetAmount: orcamentos.amount,
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.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( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.period, period), eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, [period, previousPeriod]),
eq(lancamentos.transactionType, "Receita"), eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "receita"), eq(categorias.type, "receita"),
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
@@ -84,87 +75,89 @@ export async function fetchIncomeByCategory(
categorias.id, categorias.id,
categorias.name, categorias.name,
categorias.icon, categorias.icon,
orcamentos.amount, lancamentos.period,
); ),
db
// Busca receitas do período anterior agrupadas por categoria
const previousPeriodRows = await db
.select({ .select({
categoryId: categorias.id, categoriaId: orcamentos.categoriaId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`, amount: orcamentos.amount,
}) })
.from(lancamentos) .from(orcamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))),
.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),
),
),
)
.groupBy(categorias.id);
// Cria um mapa do período anterior para busca rápida // Build budget lookup
const previousMap = new Map<string, number>(); const budgetMap = new Map<string, number>();
let previousTotal = 0; 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)); const amount = Math.abs(safeToNumber(row.total));
previousMap.set(row.categoryId, amount); if (row.period === period) {
previousTotal += amount; entry.current = amount;
} else {
entry.previous = amount;
}
categoryMap.set(row.categoryId, entry);
} }
// Calcula o total do período atual // Calculate totals
let currentTotal = 0; let currentTotal = 0;
for (const row of currentPeriodRows) { let previousTotal = 0;
currentTotal += Math.abs(safeToNumber(row.total)); for (const entry of categoryMap.values()) {
currentTotal += entry.current;
previousTotal += entry.previous;
} }
// Monta os dados de cada categoria // Build result
const categories: CategoryIncomeItem[] = currentPeriodRows.map((row) => { const categories: CategoryIncomeItem[] = [];
const currentAmount = Math.abs(safeToNumber(row.total)); for (const [categoryId, entry] of categoryMap) {
const previousAmount = previousMap.get(row.categoryId) ?? 0;
const percentageChange = calculatePercentageChange( const percentageChange = calculatePercentageChange(
currentAmount, entry.current,
previousAmount, entry.previous,
); );
const percentageOfTotal = const percentageOfTotal =
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0; currentTotal > 0 ? (entry.current / currentTotal) * 100 : 0;
const budgetAmount = row.budgetAmount const budgetAmount = budgetMap.get(categoryId) ?? null;
? safeToNumber(row.budgetAmount)
: null;
const budgetUsedPercentage = const budgetUsedPercentage =
budgetAmount && budgetAmount > 0 budgetAmount && budgetAmount > 0
? (currentAmount / budgetAmount) * 100 ? (entry.current / budgetAmount) * 100
: null; : null;
return { categories.push({
categoryId: row.categoryId, categoryId,
categoryName: row.categoryName, categoryName: entry.name,
categoryIcon: row.categoryIcon, categoryIcon: entry.icon,
currentAmount, currentAmount: entry.current,
previousAmount, previousAmount: entry.previous,
percentageChange, percentageChange,
percentageOfTotal, percentageOfTotal,
budgetAmount, budgetAmount,
budgetUsedPercentage, budgetUsedPercentage,
};
}); });
}
// Ordena por valor atual (maior para menor) // Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount); 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 { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema"; import { lancamentos } from "@/db/schema";
import { import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants"; } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common"; import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type InstallmentExpense = { export type InstallmentExpense = {
id: string; id: string;
@@ -28,6 +28,11 @@ export async function fetchInstallmentExpenses(
userId: string, userId: string,
period: string, period: string,
): Promise<InstallmentExpensesData> { ): Promise<InstallmentExpensesData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { expenses: [] };
}
const rows = await db const rows = await db
.select({ .select({
id: lancamentos.id, id: lancamentos.id,
@@ -41,7 +46,6 @@ export async function fetchInstallmentExpenses(
period: lancamentos.period, period: lancamentos.period,
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
@@ -49,7 +53,7 @@ export async function fetchInstallmentExpenses(
eq(lancamentos.transactionType, "Despesa"), eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Parcelado"), eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false), eq(lancamentos.isAnticipated, false),
eq(pagadores.role, PAGADOR_ROLE_ADMIN), eq(lancamentos.pagadorId, adminPagadorId),
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
and( and(

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { unstable_cache } from "next/cache";
import { fetchDashboardAccounts } from "./accounts"; import { fetchDashboardAccounts } from "./accounts";
import { fetchDashboardBoletos } from "./boletos"; import { fetchDashboardBoletos } from "./boletos";
import { fetchExpensesByCategory } from "./categories/expenses-by-category"; import { fetchExpensesByCategory } from "./categories/expenses-by-category";
@@ -17,7 +18,7 @@ import { fetchPurchasesByCategory } from "./purchases-by-category";
import { fetchRecentTransactions } from "./recent-transactions"; import { fetchRecentTransactions } from "./recent-transactions";
import { fetchTopEstablishments } from "./top-establishments"; import { fetchTopEstablishments } from "./top-establishments";
export async function fetchDashboardData(userId: string, period: string) { async function fetchDashboardDataInternal(userId: string, period: string) {
const [ const [
metrics, metrics,
accountsSnapshot, 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>>; export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;

View File

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

View File

@@ -2,6 +2,7 @@ import {
and, and,
asc, asc,
eq, eq,
gte,
ilike, ilike,
isNull, isNull,
lte, lte,
@@ -10,15 +11,16 @@ import {
or, or,
sum, sum,
} from "drizzle-orm"; } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema"; import { contas, lancamentos } from "@/db/schema";
import { import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants"; } from "@/lib/accounts/constants";
import { db } from "@/lib/db"; 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 { safeToNumber } from "@/lib/utils/number";
import { import {
addMonthsToPeriod,
buildPeriodRange, buildPeriodRange,
comparePeriods, comparePeriods,
getPreviousPeriod, getPreviousPeriod,
@@ -80,6 +82,21 @@ export async function fetchDashboardCardMetrics(
): Promise<DashboardCardMetrics> { ): Promise<DashboardCardMetrics> {
const previousPeriod = getPreviousPeriod(period); 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 const rows = await db
.select({ .select({
period: lancamentos.period, period: lancamentos.period,
@@ -87,13 +104,13 @@ export async function fetchDashboardCardMetrics(
totalAmount: sum(lancamentos.amount).as("total"), totalAmount: sum(lancamentos.amount).as("total"),
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id)) .leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, period), lte(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA), ne(lancamentos.transactionType, TRANSFERENCIA),
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
@@ -129,12 +146,12 @@ export async function fetchDashboardCardMetrics(
const earliestPeriod = const earliestPeriod =
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period; periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
const startPeriod = const startRangePeriod =
comparePeriods(earliestPeriod, previousPeriod) <= 0 comparePeriods(earliestPeriod, previousPeriod) <= 0
? earliestPeriod ? earliestPeriod
: previousPeriod; : previousPeriod;
const periodRange = buildPeriodRange(startPeriod, period); const periodRange = buildPeriodRange(startRangePeriod, period);
const forecastByPeriod = new Map<string, number>(); const forecastByPeriod = new Map<string, number>();
let runningForecast = 0; let runningForecast = 0;

View File

@@ -1,9 +1,10 @@
"use server"; "use server";
import { and, eq, lt, sql } from "drizzle-orm"; 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 { db } from "@/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas"; import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type NotificationType = "overdue" | "due_soon"; export type NotificationType = "overdue" | "due_soon";
@@ -138,6 +139,8 @@ export async function fetchDashboardNotifications(
const today = normalizeDate(new Date()); const today = normalizeDate(new Date());
const DAYS_THRESHOLD = 5; const DAYS_THRESHOLD = 5;
const adminPagadorId = await getAdminPagadorId(userId);
// Buscar faturas pendentes de períodos anteriores // Buscar faturas pendentes de períodos anteriores
// Apenas faturas com registro na tabela (períodos antigos devem ter sido finalizados) // Apenas faturas com registro na tabela (períodos antigos devem ter sido finalizados)
const overdueInvoices = await db const overdueInvoices = await db
@@ -210,7 +213,17 @@ export async function fetchDashboardNotifications(
faturas.paymentStatus, 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 const boletosRows = await db
.select({ .select({
id: lancamentos.id, id: lancamentos.id,
@@ -220,15 +233,7 @@ export async function fetchDashboardNotifications(
period: lancamentos.period, period: lancamentos.period,
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .where(and(...boletosConditions));
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.isSettled, false),
eq(pagadores.role, "admin"),
),
);
const notifications: DashboardNotification[] = []; const notifications: DashboardNotification[] = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { and, eq, isNull, or, sql } from "drizzle-orm"; 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 { import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants"; } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common"; import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
export type TopEstablishment = { export type TopEstablishment = {
id: string; id: string;
@@ -38,6 +38,11 @@ export async function fetchTopEstablishments(
userId: string, userId: string,
period: string, period: string,
): Promise<TopEstablishmentsData> { ): Promise<TopEstablishmentsData> {
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { establishments: [] };
}
const rows = await db const rows = await db
.select({ .select({
name: lancamentos.name, name: lancamentos.name,
@@ -46,7 +51,6 @@ export async function fetchTopEstablishments(
logo: sql<string | null>`max(coalesce(${cartoes.logo}, ${contas.logo}))`, logo: sql<string | null>`max(coalesce(${cartoes.logo}, ${contas.logo}))`,
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id)) .leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where( .where(
@@ -54,7 +58,7 @@ export async function fetchTopEstablishments(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.period, period), eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"), eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN), eq(lancamentos.pagadorId, adminPagadorId),
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
and( 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", "name": "opensheets",
"version": "1.2.6", "version": "1.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -27,8 +27,8 @@
"docker:rebuild": "docker compose up --build --force-recreate" "docker:rebuild": "docker compose up --build --force-recreate"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.35", "@ai-sdk/anthropic": "^3.0.37",
"@ai-sdk/google": "^3.0.20", "@ai-sdk/google": "^3.0.21",
"@ai-sdk/openai": "^3.0.25", "@ai-sdk/openai": "^3.0.25",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -59,7 +59,7 @@
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
"ai": "^6.0.67", "ai": "^6.0.73",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"better-auth": "1.4.18", "better-auth": "1.4.18",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
@@ -67,7 +67,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "0.45.1", "drizzle-orm": "0.45.1",
"jspdf": "^4.0.0", "jspdf": "^4.1.0",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "0.4.6", "next-themes": "0.4.6",
@@ -84,13 +84,13 @@
"zod": "4.3.6" "zod": "4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.13", "@biomejs/biome": "2.3.14",
"@tailwindcss/postcss": "4.1.18", "@tailwindcss/postcss": "4.1.18",
"@types/node": "25.1.0", "@types/node": "25.2.1",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/react": "19.2.10", "@types/react": "19.2.13",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.4",
"drizzle-kit": "0.31.8", "drizzle-kit": "0.31.8",
"tailwindcss": "4.1.18", "tailwindcss": "4.1.18",
"tsx": "4.21.0", "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", display: "swap",
}); });
const anthropic_sans = localFont({
src: "./anthropicSans.woff2",
display: "swap",
});
const main_font = ai_sans; const main_font = ai_sans;
const money_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 };