forked from git.gladyson/openmonetis
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/),
|
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
|
||||||
|
|||||||
@@ -395,7 +395,10 @@ const buildShares = ({
|
|||||||
) {
|
) {
|
||||||
return [
|
return [
|
||||||
{ pagadorId, amountCents: primarySplitAmountCents },
|
{ pagadorId, amountCents: primarySplitAmountCents },
|
||||||
{ pagadorId: secondaryPagadorId, amountCents: secondarySplitAmountCents },
|
{
|
||||||
|
pagadorId: secondaryPagadorId,
|
||||||
|
amountCents: secondarySplitAmountCents,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
11
db/schema.ts
11
db/schema.ts
@@ -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,
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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",
|
"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
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",
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user