feat: adicionar página de análise de parcelas e faturas

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
This commit is contained in:
Claude
2025-11-16 15:49:05 +00:00
parent b124d5193f
commit 115cb8836c
8 changed files with 1050 additions and 1 deletions

View File

@@ -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 (
<main className="flex flex-col gap-4 px-4 pb-8">
<InstallmentAnalysisPage data={data} />
</main>
);
}

View File

@@ -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 (
<Card className="border-primary/20">
<CardHeader className="border-b">
<div className="flex items-center gap-2">
<RiPieChartLine className="size-4 text-primary" />
<CardTitle className="text-base">Resumo</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 pt-6">
{/* Total geral */}
<div className="flex flex-col items-center gap-2 rounded-lg bg-primary/10 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total Selecionado
</p>
<MoneyValues
amount={grandTotal}
className="text-2xl font-bold text-primary"
/>
<p className="text-xs text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "item" : "itens"}
</p>
</div>
<Separator />
{/* Breakdown */}
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Detalhamento</p>
{/* Parcelas */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-blue-500" />
<span className="text-sm text-muted-foreground">Parcelas</span>
</div>
<MoneyValues amount={totalInstallments} className="text-sm" />
</div>
{/* Faturas */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-purple-500" />
<span className="text-sm text-muted-foreground">Faturas</span>
</div>
<MoneyValues amount={totalInvoices} className="text-sm" />
</div>
</div>
<Separator />
{/* Percentuais */}
{grandTotal > 0 && (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Distribuição</p>
{hasInstallments && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Parcelas</span>
<span className="font-medium">
{((totalInstallments / grandTotal) * 100).toFixed(1)}%
</span>
</div>
)}
{hasInvoices && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Faturas</span>
<span className="font-medium">
{((totalInvoices / grandTotal) * 100).toFixed(1)}%
</span>
</div>
)}
</div>
)}
{/* Mensagem quando nada está selecionado */}
{selectedCount === 0 && (
<div className="rounded-lg bg-muted/50 p-3 text-center">
<p className="text-xs text-muted-foreground">
Selecione parcelas ou faturas para ver o resumo
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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<seriesId, Set<installmentId>>
const [selectedInstallments, setSelectedInstallments] = useState<
Map<string, Set<string>>
>(new Map());
// Estado para faturas selecionadas: Set<invoiceKey (cartaoId:period)>
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
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<string, Set<string>>();
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<string>();
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<string>();
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 (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
<RiCalculatorLine className="size-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold">Análise de Parcelas e Faturas</h1>
<p className="text-sm text-muted-foreground">
Veja quanto você gastaria pagando tudo que está em aberto
</p>
</div>
</div>
{/* Card de resumo principal */}
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10">
<CardContent className="flex flex-col items-center justify-center gap-3 py-8">
<p className="text-sm font-medium text-muted-foreground">
Se você pagar tudo que está selecionado:
</p>
<MoneyValues
amount={grandTotal}
className="text-4xl font-bold text-primary"
/>
<p className="text-sm text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "item" : "itens"} selecionados
</p>
</CardContent>
</Card>
{/* Botões de ação */}
{!hasNoData && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
className="gap-2"
>
{isAllSelected ? (
<RiCheckboxLine className="size-4" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
{isAllSelected ? "Desmarcar Tudo" : "Selecionar Tudo"}
</Button>
</div>
)}
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* Conteúdo principal */}
<div className="flex flex-col gap-6">
{/* Seção de Lançamentos Parcelados */}
{data.installmentGroups.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Separator className="flex-1" />
<h2 className="text-lg font-semibold">Lançamentos Parcelados</h2>
<Separator className="flex-1" />
</div>
<div className="flex flex-col gap-3">
{data.installmentGroups.map((group) => (
<InstallmentGroupCard
key={group.seriesId}
group={group}
selectedInstallments={
selectedInstallments.get(group.seriesId) || new Set()
}
onToggleGroup={() =>
toggleGroupSelection(
group.seriesId,
group.pendingInstallments.map((i) => i.id)
)
}
onToggleInstallment={(installmentId) =>
toggleInstallmentSelection(group.seriesId, installmentId)
}
/>
))}
</div>
</div>
)}
{/* Seção de Faturas Pendentes */}
{data.pendingInvoices.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Separator className="flex-1" />
<h2 className="text-lg font-semibold">Faturas Pendentes</h2>
<Separator className="flex-1" />
</div>
<div className="flex flex-col gap-3">
{data.pendingInvoices.map((invoice) => {
const invoiceKey = `${invoice.cartaoId}:${invoice.period}`;
return (
<PendingInvoiceCard
key={invoiceKey}
invoice={invoice}
isSelected={selectedInvoices.has(invoiceKey)}
onToggle={() => toggleInvoiceSelection(invoiceKey)}
/>
);
})}
</div>
</div>
)}
{/* Estado vazio */}
{hasNoData && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
<RiCalculatorLine className="size-12 text-muted-foreground/50" />
<div className="text-center">
<p className="font-medium">Nenhuma parcela ou fatura pendente</p>
<p className="text-sm text-muted-foreground">
Você está em dia com seus pagamentos!
</p>
</div>
</CardContent>
</Card>
)}
</div>
{/* Painel lateral de resumo (sticky) */}
{!hasNoData && (
<div className="lg:sticky lg:top-4 lg:self-start">
<AnalysisSummaryPanel
totalInstallments={totalInstallments}
totalInvoices={totalInvoices}
grandTotal={grandTotal}
selectedCount={selectedCount}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -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<string>;
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 (
<Card className={cn(isFullySelected && "border-primary/50")}>
<CardContent className="flex flex-col gap-3 py-4">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isFullySelected}
onCheckedChange={onToggleGroup}
className="mt-1"
aria-label={`Selecionar todas as parcelas de ${group.name}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium">{group.name}</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{group.cartaoName && (
<>
<span>{group.cartaoName}</span>
<span></span>
</>
)}
<span>{group.paymentMethod}</span>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<MoneyValues
amount={group.totalPendingAmount}
className="text-sm font-semibold"
/>
{selectedInstallments.size > 0 && (
<MoneyValues
amount={selectedAmount}
className="text-xs text-primary"
/>
)}
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span>
{group.paidInstallments} de {group.totalInstallments} pagas
</span>
<span>
{group.pendingInstallments.length}{" "}
{group.pendingInstallments.length === 1
? "pendente"
: "pendentes"}
</span>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* Badges de status */}
<div className="mt-2 flex flex-wrap gap-2">
{isPartiallySelected && (
<Badge variant="secondary" className="text-xs">
{selectedInstallments.size} de{" "}
{group.pendingInstallments.length} selecionadas
</Badge>
)}
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-3 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar parcelas ({group.pendingInstallments.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver parcelas ({group.pendingInstallments.length})
</>
)}
</button>
</div>
</div>
{/* Lista de parcelas expandida */}
{isExpanded && (
<div className="ml-9 mt-2 flex flex-col gap-2 border-l-2 border-muted pl-4">
{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 (
<div
key={installment.id}
className={cn(
"flex items-center gap-3 rounded-md border p-2 transition-colors",
isSelected && "border-primary/50 bg-primary/5"
)}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleInstallment(installment.id)}
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">
Parcela {installment.currentInstallment}/
{group.totalInstallments}
</p>
<p className="text-xs text-muted-foreground">
Vencimento: {dueDate}
</p>
</div>
<MoneyValues
amount={installment.amount}
className="shrink-0 text-sm"
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card className={cn(isSelected && "border-primary/50 bg-primary/5")}>
<CardContent className="flex flex-col gap-3 py-4">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
className="mt-1"
aria-label={`Selecionar fatura ${invoice.cartaoName} - ${periodText}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{invoice.cartaoLogo ? (
<Image
src={invoice.cartaoLogo}
alt={invoice.cartaoName}
width={24}
height={24}
className="size-6 rounded"
/>
) : (
<div className="flex size-6 items-center justify-center rounded bg-muted">
<RiBillLine className="size-4 text-muted-foreground" />
</div>
)}
<p className="font-medium">{invoice.cartaoName}</p>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="capitalize">{periodText}</span>
<span></span>
<span>Vencimento: {dueDateText}</span>
</div>
</div>
<MoneyValues
amount={invoice.totalAmount}
className="shrink-0 text-sm font-semibold"
/>
</div>
{/* Badge de status */}
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="destructive" className="text-xs">
Pendente
</Badge>
<Badge variant="secondary" className="text-xs">
{invoice.lancamentos.length}{" "}
{invoice.lancamentos.length === 1
? "lançamento"
: "lançamentos"}
</Badge>
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-3 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar lançamentos ({invoice.lancamentos.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver lançamentos ({invoice.lancamentos.length})
</>
)}
</button>
</div>
</div>
{/* Lista de lançamentos expandida */}
{isExpanded && (
<div className="ml-9 mt-2 flex flex-col gap-2 border-l-2 border-muted pl-4">
{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 (
<div
key={lancamento.id}
className="flex items-center gap-3 rounded-md border p-2"
>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{lancamento.name}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{purchaseDate}</span>
{installmentLabel && (
<>
<span></span>
<span>Parcela {installmentLabel}</span>
</>
)}
{lancamento.condition !== "Parcelado" && (
<>
<span></span>
<span>{lancamento.condition}</span>
</>
)}
</div>
</div>
<MoneyValues
amount={lancamento.amount}
className="shrink-0 text-sm"
/>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,7 @@
import type {
InstallmentAnalysisData,
InstallmentGroup,
PendingInvoice,
} from "@/lib/dashboard/expenses/installment-analysis";
export type { InstallmentAnalysisData, InstallmentGroup, PendingInvoice };

View File

@@ -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({
);
})}
</ul>
<Link
href="/dashboard/analise-parcelas"
className="flex items-center justify-center gap-1 px-6 py-2 text-sm font-medium text-primary hover:underline"
>
Ver Análise Completa
<RiArrowRightSLine className="size-4" />
</Link>
</CardContent>
);
}

View File

@@ -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<InstallmentAnalysisData> {
// 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<string, InstallmentGroup>();
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,
};
}