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:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -395,7 +395,10 @@ const buildShares = ({
|
||||
) {
|
||||
return [
|
||||
{ pagadorId, amountCents: primarySplitAmountCents },
|
||||
{ pagadorId: secondaryPagadorId, amountCents: secondarySplitAmountCents },
|
||||
{
|
||||
pagadorId: secondaryPagadorId,
|
||||
amountCents: secondarySplitAmountCents,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
11
db/schema.ts
11
db/schema.ts
@@ -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,
|
||||
|
||||
2
drizzle/0015_concerned_kat_farrell.sql
Normal file
2
drizzle/0015_concerned_kat_farrell.sql
Normal 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");
|
||||
2303
drizzle/meta/0015_snapshot.json
Normal file
2303
drizzle/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
||||
"when": 1769619226903,
|
||||
"tag": "0014_yielding_jack_flag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1770332054481,
|
||||
"tag": "0015_concerned_kat_farrell",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,55 +25,35 @@ 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
|
||||
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)`,
|
||||
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.pagadorId, adminPagadorId),
|
||||
inArray(lancamentos.period, [period, previousPeriod]),
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "despesa"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
@@ -84,78 +65,89 @@ export async function fetchExpensesByCategory(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
orcamentos.amount,
|
||||
);
|
||||
|
||||
// Busca despesas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
lancamentos.period,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
categoriaId: orcamentos.categoriaId,
|
||||
amount: orcamentos.amount,
|
||||
})
|
||||
.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);
|
||||
.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, 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));
|
||||
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);
|
||||
|
||||
@@ -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,33 +34,30 @@ export async function fetchIncomeByCategory(
|
||||
): Promise<IncomeByCategoryData> {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
|
||||
// Busca receitas do período atual agrupadas por categoria
|
||||
const currentPeriodRows = await db
|
||||
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)`,
|
||||
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.pagadorId, adminPagadorId),
|
||||
inArray(lancamentos.period, [period, previousPeriod]),
|
||||
eq(lancamentos.transactionType, "Receita"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
eq(categorias.type, "receita"),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
@@ -84,87 +75,89 @@ export async function fetchIncomeByCategory(
|
||||
categorias.id,
|
||||
categorias.name,
|
||||
categorias.icon,
|
||||
orcamentos.amount,
|
||||
);
|
||||
|
||||
// Busca receitas do período anterior agrupadas por categoria
|
||||
const previousPeriodRows = await db
|
||||
lancamentos.period,
|
||||
),
|
||||
db
|
||||
.select({
|
||||
categoryId: categorias.id,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
categoriaId: orcamentos.categoriaId,
|
||||
amount: 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))
|
||||
.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);
|
||||
.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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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,32 +67,29 @@ 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
|
||||
// Single query: GROUP BY period + transactionType instead of 12 separate queries
|
||||
const rows = await db
|
||||
.select({
|
||||
total: sql<number>`
|
||||
coalesce(
|
||||
sum(${lancamentos.amount}),
|
||||
0
|
||||
)
|
||||
`,
|
||||
period: lancamentos.period,
|
||||
transactionType: lancamentos.transactionType,
|
||||
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}%`})`,
|
||||
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),
|
||||
@@ -99,50 +97,37 @@ export async function fetchIncomeExpenseBalance(
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 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}%`})`,
|
||||
),
|
||||
);
|
||||
.groupBy(lancamentos.period, lancamentos.transactionType);
|
||||
|
||||
const income = Math.abs(toNumber(incomeRow?.total));
|
||||
const expense = Math.abs(toNumber(expenseRow?.total));
|
||||
const balance = income - expense;
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
return {
|
||||
month: period,
|
||||
monthLabel: monthLabel ?? "",
|
||||
income,
|
||||
expense,
|
||||
balance,
|
||||
income: entry.income,
|
||||
expense: entry.expense,
|
||||
balance: entry.income - entry.expense,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
months: results,
|
||||
};
|
||||
return { months };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
.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, "Receita"),
|
||||
eq(pagadores.role, "admin"),
|
||||
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
|
||||
)
|
||||
`,
|
||||
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 confirmedIncome = toNumber(incomeData.confirmed);
|
||||
const pendingIncome = toNumber(incomeData.pending);
|
||||
|
||||
const expensesData = expensesResult[0] ?? { confirmed: 0, pending: 0 };
|
||||
const confirmedExpenses = toNumber(expensesData.confirmed);
|
||||
const pendingExpenses = toNumber(expensesData.pending);
|
||||
|
||||
return {
|
||||
income: {
|
||||
total: confirmedIncome + pendingIncome,
|
||||
confirmed: confirmedIncome,
|
||||
pending: pendingIncome,
|
||||
},
|
||||
expenses: {
|
||||
total: confirmedExpenses + pendingExpenses,
|
||||
confirmed: confirmedExpenses,
|
||||
pending: pendingExpenses,
|
||||
},
|
||||
};
|
||||
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
|
||||
)
|
||||
`,
|
||||
pending: sql<number>`
|
||||
coalesce(
|
||||
sum(case when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount} else 0 end),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
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);
|
||||
|
||||
const result = { income: emptyCategory(), expenses: emptyCategory() };
|
||||
|
||||
for (const row of rows) {
|
||||
const confirmed = toNumber(row.confirmed);
|
||||
const pending = toNumber(row.pending);
|
||||
const category = {
|
||||
total: confirmed + pending,
|
||||
confirmed,
|
||||
pending,
|
||||
};
|
||||
|
||||
if (row.transactionType === "Receita") {
|
||||
result.income = category;
|
||||
} else if (row.transactionType === "Despesa") {
|
||||
result.expenses = category;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
25
lib/pagadores/get-admin-id.ts
Normal file
25
lib/pagadores/get-admin-id.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
18
package.json
18
package.json
@@ -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
1188
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/fonts/anthropicSans.woff2
Normal file
BIN
public/fonts/anthropicSans.woff2
Normal file
Binary file not shown.
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user