From 115cb8836c644288ba5a75c80e31b0d1e6f16fc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 15:49:05 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20adicionar=20p=C3=A1gina=20de=20an=C3=A1?= =?UTF-8?q?lise=20de=20parcelas=20e=20faturas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa uma nova funcionalidade que permite ao usuário visualizar todas as parcelas abertas e faturas não pagas em uma única página, respondendo à pergunta "quanto vou gastar se pagar tudo?". Funcionalidades: - Query para buscar lançamentos parcelados não antecipados - Query para buscar faturas pendentes - Página dedicada em /dashboard/analise-parcelas - Seleção individual de parcelas e faturas - Painel de resumo com breakdown de valores - Link "Ver Análise Completa" no widget de parcelas - UI responsiva com cards expansíveis - Cálculos em tempo real dos totais selecionados --- .../dashboard/analise-parcelas/page.tsx | 14 + .../analysis-summary-panel.tsx | 110 ++++++ .../installment-analysis-page.tsx | 314 ++++++++++++++++++ .../installment-group-card.tsx | 186 +++++++++++ .../pending-invoice-card.tsx | 173 ++++++++++ .../dashboard/installment-analysis/types.ts | 7 + .../dashboard/installment-expenses-widget.tsx | 11 +- .../expenses/installment-analysis.ts | 236 +++++++++++++ 8 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 app/(dashboard)/dashboard/analise-parcelas/page.tsx create mode 100644 components/dashboard/installment-analysis/analysis-summary-panel.tsx create mode 100644 components/dashboard/installment-analysis/installment-analysis-page.tsx create mode 100644 components/dashboard/installment-analysis/installment-group-card.tsx create mode 100644 components/dashboard/installment-analysis/pending-invoice-card.tsx create mode 100644 components/dashboard/installment-analysis/types.ts create mode 100644 lib/dashboard/expenses/installment-analysis.ts diff --git a/app/(dashboard)/dashboard/analise-parcelas/page.tsx b/app/(dashboard)/dashboard/analise-parcelas/page.tsx new file mode 100644 index 0000000..65c73d5 --- /dev/null +++ b/app/(dashboard)/dashboard/analise-parcelas/page.tsx @@ -0,0 +1,14 @@ +import { InstallmentAnalysisPage } from "@/components/dashboard/installment-analysis/installment-analysis-page"; +import { fetchInstallmentAnalysis } from "@/lib/dashboard/expenses/installment-analysis"; +import { getUser } from "@/lib/auth/server"; + +export default async function Page() { + const user = await getUser(); + const data = await fetchInstallmentAnalysis(user.id); + + return ( +
+ +
+ ); +} diff --git a/components/dashboard/installment-analysis/analysis-summary-panel.tsx b/components/dashboard/installment-analysis/analysis-summary-panel.tsx new file mode 100644 index 0000000..7a7de20 --- /dev/null +++ b/components/dashboard/installment-analysis/analysis-summary-panel.tsx @@ -0,0 +1,110 @@ +"use client"; + +import MoneyValues from "@/components/money-values"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { RiPieChartLine } from "@remixicon/react"; + +type AnalysisSummaryPanelProps = { + totalInstallments: number; + totalInvoices: number; + grandTotal: number; + selectedCount: number; +}; + +export function AnalysisSummaryPanel({ + totalInstallments, + totalInvoices, + grandTotal, + selectedCount, +}: AnalysisSummaryPanelProps) { + const hasInstallments = totalInstallments > 0; + const hasInvoices = totalInvoices > 0; + + return ( + + +
+ + Resumo +
+
+ + {/* Total geral */} +
+

+ Total Selecionado +

+ +

+ {selectedCount} {selectedCount === 1 ? "item" : "itens"} +

+
+ + + + {/* Breakdown */} +
+

Detalhamento

+ + {/* Parcelas */} +
+
+
+ Parcelas +
+ +
+ + {/* Faturas */} +
+
+
+ Faturas +
+ +
+
+ + + + {/* Percentuais */} + {grandTotal > 0 && ( +
+

Distribuição

+ + {hasInstallments && ( +
+ Parcelas + + {((totalInstallments / grandTotal) * 100).toFixed(1)}% + +
+ )} + + {hasInvoices && ( +
+ Faturas + + {((totalInvoices / grandTotal) * 100).toFixed(1)}% + +
+ )} +
+ )} + + {/* Mensagem quando nada está selecionado */} + {selectedCount === 0 && ( +
+

+ Selecione parcelas ou faturas para ver o resumo +

+
+ )} + + + ); +} diff --git a/components/dashboard/installment-analysis/installment-analysis-page.tsx b/components/dashboard/installment-analysis/installment-analysis-page.tsx new file mode 100644 index 0000000..5711e2f --- /dev/null +++ b/components/dashboard/installment-analysis/installment-analysis-page.tsx @@ -0,0 +1,314 @@ +"use client"; + +import MoneyValues from "@/components/money-values"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { useMemo, useState } from "react"; +import type { InstallmentAnalysisData } from "./types"; +import { InstallmentGroupCard } from "./installment-group-card"; +import { PendingInvoiceCard } from "./pending-invoice-card"; +import { AnalysisSummaryPanel } from "./analysis-summary-panel"; +import { + RiCalculatorLine, + RiCheckboxBlankLine, + RiCheckboxLine, +} from "@remixicon/react"; + +type InstallmentAnalysisPageProps = { + data: InstallmentAnalysisData; +}; + +export function InstallmentAnalysisPage({ + data, +}: InstallmentAnalysisPageProps) { + // Estado para parcelas selecionadas: Map> + const [selectedInstallments, setSelectedInstallments] = useState< + Map> + >(new Map()); + + // Estado para faturas selecionadas: Set + const [selectedInvoices, setSelectedInvoices] = useState>( + new Set() + ); + + // Calcular se está tudo selecionado + const isAllSelected = useMemo(() => { + const allInstallmentsSelected = data.installmentGroups.every((group) => { + const groupSelection = selectedInstallments.get(group.seriesId); + if (!groupSelection) return false; + return ( + groupSelection.size === group.pendingInstallments.length && + group.pendingInstallments.length > 0 + ); + }); + + const allInvoicesSelected = + data.pendingInvoices.length === selectedInvoices.size; + + return ( + allInstallmentsSelected && + allInvoicesSelected && + (data.installmentGroups.length > 0 || data.pendingInvoices.length > 0) + ); + }, [selectedInstallments, selectedInvoices, data]); + + // Função para selecionar/desselecionar tudo + const toggleSelectAll = () => { + if (isAllSelected) { + // Desmarcar tudo + setSelectedInstallments(new Map()); + setSelectedInvoices(new Set()); + } else { + // Marcar tudo + const newInstallments = new Map>(); + data.installmentGroups.forEach((group) => { + const ids = new Set(group.pendingInstallments.map((i) => i.id)); + newInstallments.set(group.seriesId, ids); + }); + + const newInvoices = new Set( + data.pendingInvoices.map((inv) => `${inv.cartaoId}:${inv.period}`) + ); + + setSelectedInstallments(newInstallments); + setSelectedInvoices(newInvoices); + } + }; + + // Função para selecionar/desselecionar um grupo de parcelas + const toggleGroupSelection = (seriesId: string, installmentIds: string[]) => { + const newMap = new Map(selectedInstallments); + const current = newMap.get(seriesId) || new Set(); + + if (current.size === installmentIds.length) { + // Já está tudo selecionado, desmarcar + newMap.delete(seriesId); + } else { + // Marcar tudo + newMap.set(seriesId, new Set(installmentIds)); + } + + setSelectedInstallments(newMap); + }; + + // Função para selecionar/desselecionar parcela individual + const toggleInstallmentSelection = (seriesId: string, installmentId: string) => { + const newMap = new Map(selectedInstallments); + const current = newMap.get(seriesId) || new Set(); + + if (current.has(installmentId)) { + current.delete(installmentId); + if (current.size === 0) { + newMap.delete(seriesId); + } else { + newMap.set(seriesId, current); + } + } else { + current.add(installmentId); + newMap.set(seriesId, current); + } + + setSelectedInstallments(newMap); + }; + + // Função para selecionar/desselecionar fatura + const toggleInvoiceSelection = (invoiceKey: string) => { + const newSet = new Set(selectedInvoices); + if (newSet.has(invoiceKey)) { + newSet.delete(invoiceKey); + } else { + newSet.add(invoiceKey); + } + setSelectedInvoices(newSet); + }; + + // Calcular totais + const { totalInstallments, totalInvoices, grandTotal, selectedCount } = + useMemo(() => { + let installmentsSum = 0; + let installmentsCount = 0; + + selectedInstallments.forEach((installmentIds, seriesId) => { + const group = data.installmentGroups.find( + (g) => g.seriesId === seriesId + ); + if (group) { + installmentIds.forEach((id) => { + const installment = group.pendingInstallments.find( + (i) => i.id === id + ); + if (installment) { + installmentsSum += installment.amount; + installmentsCount++; + } + }); + } + }); + + let invoicesSum = 0; + let invoicesCount = 0; + + selectedInvoices.forEach((key) => { + const [cartaoId, period] = key.split(":"); + const invoice = data.pendingInvoices.find( + (inv) => inv.cartaoId === cartaoId && inv.period === period + ); + if (invoice) { + invoicesSum += invoice.totalAmount; + invoicesCount++; + } + }); + + return { + totalInstallments: installmentsSum, + totalInvoices: invoicesSum, + grandTotal: installmentsSum + invoicesSum, + selectedCount: installmentsCount + invoicesCount, + }; + }, [selectedInstallments, selectedInvoices, data]); + + const hasNoData = + data.installmentGroups.length === 0 && data.pendingInvoices.length === 0; + + return ( +
+ {/* Header */} +
+
+ +
+
+

Análise de Parcelas e Faturas

+

+ Veja quanto você gastaria pagando tudo que está em aberto +

+
+
+ + {/* Card de resumo principal */} + + +

+ Se você pagar tudo que está selecionado: +

+ +

+ {selectedCount} {selectedCount === 1 ? "item" : "itens"} selecionados +

+
+
+ + {/* Botões de ação */} + {!hasNoData && ( +
+ +
+ )} + +
+ {/* Conteúdo principal */} +
+ {/* Seção de Lançamentos Parcelados */} + {data.installmentGroups.length > 0 && ( +
+
+ +

Lançamentos Parcelados

+ +
+ +
+ {data.installmentGroups.map((group) => ( + + toggleGroupSelection( + group.seriesId, + group.pendingInstallments.map((i) => i.id) + ) + } + onToggleInstallment={(installmentId) => + toggleInstallmentSelection(group.seriesId, installmentId) + } + /> + ))} +
+
+ )} + + {/* Seção de Faturas Pendentes */} + {data.pendingInvoices.length > 0 && ( +
+
+ +

Faturas Pendentes

+ +
+ +
+ {data.pendingInvoices.map((invoice) => { + const invoiceKey = `${invoice.cartaoId}:${invoice.period}`; + return ( + toggleInvoiceSelection(invoiceKey)} + /> + ); + })} +
+
+ )} + + {/* Estado vazio */} + {hasNoData && ( + + + +
+

Nenhuma parcela ou fatura pendente

+

+ Você está em dia com seus pagamentos! +

+
+
+
+ )} +
+ + {/* Painel lateral de resumo (sticky) */} + {!hasNoData && ( +
+ +
+ )} +
+
+ ); +} diff --git a/components/dashboard/installment-analysis/installment-group-card.tsx b/components/dashboard/installment-analysis/installment-group-card.tsx new file mode 100644 index 0000000..5ea6c17 --- /dev/null +++ b/components/dashboard/installment-analysis/installment-group-card.tsx @@ -0,0 +1,186 @@ +"use client"; + +import MoneyValues from "@/components/money-values"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils/ui"; +import { RiArrowDownSLine, RiArrowRightSLine } from "@remixicon/react"; +import { format } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { useState } from "react"; +import type { InstallmentGroup } from "./types"; + +type InstallmentGroupCardProps = { + group: InstallmentGroup; + selectedInstallments: Set; + onToggleGroup: () => void; + onToggleInstallment: (installmentId: string) => void; +}; + +export function InstallmentGroupCard({ + group, + selectedInstallments, + onToggleGroup, + onToggleInstallment, +}: InstallmentGroupCardProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const isFullySelected = + selectedInstallments.size === group.pendingInstallments.length && + group.pendingInstallments.length > 0; + + const isPartiallySelected = + selectedInstallments.size > 0 && + selectedInstallments.size < group.pendingInstallments.length; + + const progress = + group.totalInstallments > 0 + ? ((group.paidInstallments + selectedInstallments.size) / + group.totalInstallments) * + 100 + : 0; + + const selectedAmount = group.pendingInstallments + .filter((i) => selectedInstallments.has(i.id)) + .reduce((sum, i) => sum + i.amount, 0); + + return ( + + + {/* Header do card */} +
+ + +
+
+
+

{group.name}

+
+ {group.cartaoName && ( + <> + {group.cartaoName} + + + )} + {group.paymentMethod} +
+
+ +
+ + {selectedInstallments.size > 0 && ( + + )} +
+
+ + {/* Progress bar */} +
+
+ + {group.paidInstallments} de {group.totalInstallments} pagas + + + {group.pendingInstallments.length}{" "} + {group.pendingInstallments.length === 1 + ? "pendente" + : "pendentes"} + +
+ +
+ + {/* Badges de status */} +
+ {isPartiallySelected && ( + + {selectedInstallments.size} de{" "} + {group.pendingInstallments.length} selecionadas + + )} +
+ + {/* Botão de expandir */} + +
+
+ + {/* Lista de parcelas expandida */} + {isExpanded && ( +
+ {group.pendingInstallments.map((installment) => { + const isSelected = selectedInstallments.has(installment.id); + const dueDate = installment.dueDate + ? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR }) + : format(installment.purchaseDate, "dd/MM/yyyy", { + locale: ptBR, + }); + + return ( +
+ onToggleInstallment(installment.id)} + aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`} + /> + +
+
+

+ Parcela {installment.currentInstallment}/ + {group.totalInstallments} +

+

+ Vencimento: {dueDate} +

+
+ + +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/components/dashboard/installment-analysis/pending-invoice-card.tsx b/components/dashboard/installment-analysis/pending-invoice-card.tsx new file mode 100644 index 0000000..c29e5c6 --- /dev/null +++ b/components/dashboard/installment-analysis/pending-invoice-card.tsx @@ -0,0 +1,173 @@ +"use client"; + +import MoneyValues from "@/components/money-values"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils/ui"; +import { RiArrowDownSLine, RiArrowRightSLine, RiBillLine } from "@remixicon/react"; +import { format, parse } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { useState } from "react"; +import type { PendingInvoice } from "./types"; +import Image from "next/image"; + +type PendingInvoiceCardProps = { + invoice: PendingInvoice; + isSelected: boolean; + onToggle: () => void; +}; + +export function PendingInvoiceCard({ + invoice, + isSelected, + onToggle, +}: PendingInvoiceCardProps) { + const [isExpanded, setIsExpanded] = useState(false); + + // Formatar período (YYYY-MM) para texto legível + const periodDate = parse(invoice.period, "yyyy-MM", new Date()); + const periodText = format(periodDate, "MMMM 'de' yyyy", { locale: ptBR }); + + // Calcular data de vencimento aproximada + const dueDay = parseInt(invoice.dueDay, 10); + const dueDate = new Date(periodDate); + dueDate.setDate(dueDay); + const dueDateText = format(dueDate, "dd/MM/yyyy", { locale: ptBR }); + + return ( + + + {/* Header do card */} +
+ + +
+
+
+
+ {invoice.cartaoLogo ? ( + {invoice.cartaoName} + ) : ( +
+ +
+ )} +

{invoice.cartaoName}

+
+
+ {periodText} + + Vencimento: {dueDateText} +
+
+ + +
+ + {/* Badge de status */} +
+ + Pendente + + + {invoice.lancamentos.length}{" "} + {invoice.lancamentos.length === 1 + ? "lançamento" + : "lançamentos"} + +
+ + {/* Botão de expandir */} + +
+
+ + {/* Lista de lançamentos expandida */} + {isExpanded && ( +
+ {invoice.lancamentos.map((lancamento) => { + const purchaseDate = format( + lancamento.purchaseDate, + "dd/MM/yyyy", + { locale: ptBR } + ); + + const installmentLabel = + lancamento.condition === "Parcelado" && + lancamento.currentInstallment && + lancamento.installmentCount + ? `${lancamento.currentInstallment}/${lancamento.installmentCount}` + : null; + + return ( +
+
+
+

+ {lancamento.name} +

+
+ {purchaseDate} + {installmentLabel && ( + <> + + Parcela {installmentLabel} + + )} + {lancamento.condition !== "Parcelado" && ( + <> + + {lancamento.condition} + + )} +
+
+ + +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/components/dashboard/installment-analysis/types.ts b/components/dashboard/installment-analysis/types.ts new file mode 100644 index 0000000..f5b6509 --- /dev/null +++ b/components/dashboard/installment-analysis/types.ts @@ -0,0 +1,7 @@ +import type { + InstallmentAnalysisData, + InstallmentGroup, + PendingInvoice, +} from "@/lib/dashboard/expenses/installment-analysis"; + +export type { InstallmentAnalysisData, InstallmentGroup, PendingInvoice }; diff --git a/components/dashboard/installment-expenses-widget.tsx b/components/dashboard/installment-expenses-widget.tsx index 7c6b6ee..98903af 100644 --- a/components/dashboard/installment-expenses-widget.tsx +++ b/components/dashboard/installment-expenses-widget.tsx @@ -10,8 +10,9 @@ import { calculateLastInstallmentDate, formatLastInstallmentDate, } from "@/lib/installments/utils"; -import { RiNumbersLine } from "@remixicon/react"; +import { RiNumbersLine, RiArrowRightSLine } from "@remixicon/react"; import Image from "next/image"; +import Link from "next/link"; import { Progress } from "../ui/progress"; import { WidgetEmptyState } from "../widget-empty-state"; @@ -185,6 +186,14 @@ export function InstallmentExpensesWidget({ ); })} + + + Ver Análise Completa + + ); } diff --git a/lib/dashboard/expenses/installment-analysis.ts b/lib/dashboard/expenses/installment-analysis.ts new file mode 100644 index 0000000..55d9681 --- /dev/null +++ b/lib/dashboard/expenses/installment-analysis.ts @@ -0,0 +1,236 @@ +import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema"; +import { + ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, + INITIAL_BALANCE_NOTE, +} from "@/lib/accounts/constants"; +import { db } from "@/lib/db"; +import { toNumber } from "@/lib/dashboard/common"; +import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas"; +import { and, eq, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; + +export type InstallmentDetail = { + id: string; + currentInstallment: number; + amount: number; + dueDate: Date | null; + period: string; + isAnticipated: boolean; + purchaseDate: Date; +}; + +export type InstallmentGroup = { + seriesId: string; + name: string; + paymentMethod: string; + cartaoId: string | null; + cartaoName: string | null; + totalInstallments: number; + paidInstallments: number; + pendingInstallments: InstallmentDetail[]; + totalPendingAmount: number; + firstPurchaseDate: Date; +}; + +export type PendingInvoiceLancamento = { + id: string; + name: string; + amount: number; + purchaseDate: Date; + condition: string; + currentInstallment: number | null; + installmentCount: number | null; +}; + +export type PendingInvoice = { + invoiceId: string | null; + cartaoId: string; + cartaoName: string; + cartaoLogo: string | null; + period: string; + totalAmount: number; + dueDay: string; + lancamentos: PendingInvoiceLancamento[]; +}; + +export type InstallmentAnalysisData = { + installmentGroups: InstallmentGroup[]; + pendingInvoices: PendingInvoice[]; + totalPendingInstallments: number; + totalPendingInvoices: number; +}; + +export async function fetchInstallmentAnalysis( + userId: string +): Promise { + // 1. Buscar todos os lançamentos parcelados não antecipados e não pagos + const installmentRows = await db + .select({ + id: lancamentos.id, + seriesId: lancamentos.seriesId, + name: lancamentos.name, + amount: lancamentos.amount, + paymentMethod: lancamentos.paymentMethod, + currentInstallment: lancamentos.currentInstallment, + installmentCount: lancamentos.installmentCount, + dueDate: lancamentos.dueDate, + period: lancamentos.period, + isAnticipated: lancamentos.isAnticipated, + purchaseDate: lancamentos.purchaseDate, + cartaoId: lancamentos.cartaoId, + cartaoName: cartoes.name, + }) + .from(lancamentos) + .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.transactionType, "Despesa"), + eq(lancamentos.condition, "Parcelado"), + eq(lancamentos.isAnticipated, false), + isNotNull(lancamentos.seriesId), + or( + isNull(lancamentos.note), + and( + sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`, + sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + ) + .orderBy(lancamentos.purchaseDate, lancamentos.currentInstallment); + + // Agrupar por seriesId + const seriesMap = new Map(); + + for (const row of installmentRows) { + if (!row.seriesId) continue; + + const amount = Math.abs(toNumber(row.amount)); + const installmentDetail: InstallmentDetail = { + id: row.id, + currentInstallment: row.currentInstallment ?? 1, + amount, + dueDate: row.dueDate, + period: row.period, + isAnticipated: row.isAnticipated ?? false, + purchaseDate: row.purchaseDate, + }; + + if (seriesMap.has(row.seriesId)) { + const group = seriesMap.get(row.seriesId)!; + group.pendingInstallments.push(installmentDetail); + group.totalPendingAmount += amount; + } else { + seriesMap.set(row.seriesId, { + seriesId: row.seriesId, + name: row.name, + paymentMethod: row.paymentMethod, + cartaoId: row.cartaoId, + cartaoName: row.cartaoName, + totalInstallments: row.installmentCount ?? 0, + paidInstallments: 0, + pendingInstallments: [installmentDetail], + totalPendingAmount: amount, + firstPurchaseDate: row.purchaseDate, + }); + } + } + + // Calcular quantas parcelas já foram pagas para cada grupo + const installmentGroups = Array.from(seriesMap.values()).map((group) => { + const minPendingInstallment = Math.min( + ...group.pendingInstallments.map((i) => i.currentInstallment) + ); + group.paidInstallments = minPendingInstallment - 1; + return group; + }); + + // 2. Buscar faturas pendentes + const invoiceRows = await db + .select({ + invoiceId: faturas.id, + cardId: cartoes.id, + cardName: cartoes.name, + cardLogo: cartoes.logo, + dueDay: cartoes.dueDay, + period: faturas.period, + paymentStatus: faturas.paymentStatus, + }) + .from(faturas) + .innerJoin(cartoes, eq(faturas.cartaoId, cartoes.id)) + .where( + and( + eq(faturas.userId, userId), + eq(faturas.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING) + ) + ); + + // Buscar lançamentos de cada fatura pendente + const pendingInvoices: PendingInvoice[] = []; + + for (const invoice of invoiceRows) { + const invoiceLancamentos = await db + .select({ + id: lancamentos.id, + name: lancamentos.name, + amount: lancamentos.amount, + purchaseDate: lancamentos.purchaseDate, + condition: lancamentos.condition, + currentInstallment: lancamentos.currentInstallment, + installmentCount: lancamentos.installmentCount, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.cartaoId, invoice.cardId), + eq(lancamentos.period, invoice.period ?? "") + ) + ) + .orderBy(lancamentos.purchaseDate); + + const totalAmount = invoiceLancamentos.reduce( + (sum, l) => sum + Math.abs(toNumber(l.amount)), + 0 + ); + + if (totalAmount > 0) { + pendingInvoices.push({ + invoiceId: invoice.invoiceId, + cartaoId: invoice.cardId, + cartaoName: invoice.cardName, + cartaoLogo: invoice.cardLogo, + period: invoice.period ?? "", + totalAmount, + dueDay: invoice.dueDay, + lancamentos: invoiceLancamentos.map((l) => ({ + id: l.id, + name: l.name, + amount: Math.abs(toNumber(l.amount)), + purchaseDate: l.purchaseDate, + condition: l.condition, + currentInstallment: l.currentInstallment, + installmentCount: l.installmentCount, + })), + }); + } + } + + // Calcular totais + const totalPendingInstallments = installmentGroups.reduce( + (sum, group) => sum + group.totalPendingAmount, + 0 + ); + + const totalPendingInvoices = pendingInvoices.reduce( + (sum, invoice) => sum + invoice.totalAmount, + 0 + ); + + return { + installmentGroups, + pendingInvoices, + totalPendingInstallments, + totalPendingInvoices, + }; +}