diff --git a/components/dashboard/dashboard-grid.tsx b/components/dashboard/dashboard-grid.tsx index 271b7f4..b48b581 100644 --- a/components/dashboard/dashboard-grid.tsx +++ b/components/dashboard/dashboard-grid.tsx @@ -16,6 +16,7 @@ export function DashboardGrid({ data, period }: DashboardGridProps) { title={widget.title} subtitle={widget.subtitle} icon={widget.icon} + action={widget.action} > {widget.component({ data, period })} diff --git a/components/dashboard/installment-analysis/analysis-summary-panel.tsx b/components/dashboard/installment-analysis/analysis-summary-panel.tsx index 7a7de20..cae9a8e 100644 --- a/components/dashboard/installment-analysis/analysis-summary-panel.tsx +++ b/components/dashboard/installment-analysis/analysis-summary-panel.tsx @@ -2,25 +2,19 @@ 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 ( @@ -29,9 +23,9 @@ export function AnalysisSummaryPanel({ Resumo - + {/* Total geral */} -
+

Total Selecionado

@@ -40,67 +34,15 @@ export function AnalysisSummaryPanel({ className="text-2xl font-bold text-primary" />

- {selectedCount} {selectedCount === 1 ? "item" : "itens"} + {selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}

- - - {/* 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 + Selecione parcelas para ver o resumo

)} diff --git a/components/dashboard/installment-analysis/installment-analysis-page.tsx b/components/dashboard/installment-analysis/installment-analysis-page.tsx index 5711e2f..0217406 100644 --- a/components/dashboard/installment-analysis/installment-analysis-page.tsx +++ b/components/dashboard/installment-analysis/installment-analysis-page.tsx @@ -7,7 +7,6 @@ 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, @@ -27,52 +26,38 @@ export function InstallmentAnalysisPage({ Map> >(new Map()); - // Estado para faturas selecionadas: Set - const [selectedInvoices, setSelectedInvoices] = useState>( - new Set() - ); - - // Calcular se está tudo selecionado + // Calcular se está tudo selecionado (apenas parcelas não pagas) 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 unpaidInstallments = group.pendingInstallments.filter( + (i) => !i.isSettled ); + if (!groupSelection || unpaidInstallments.length === 0) return false; + return groupSelection.size === unpaidInstallments.length; }); - const allInvoicesSelected = - data.pendingInvoices.length === selectedInvoices.size; - - return ( - allInstallmentsSelected && - allInvoicesSelected && - (data.installmentGroups.length > 0 || data.pendingInvoices.length > 0) - ); - }, [selectedInstallments, selectedInvoices, data]); + return allInstallmentsSelected && data.installmentGroups.length > 0; + }, [selectedInstallments, data]); // Função para selecionar/desselecionar tudo const toggleSelectAll = () => { if (isAllSelected) { // Desmarcar tudo setSelectedInstallments(new Map()); - setSelectedInvoices(new Set()); } else { - // Marcar tudo + // Marcar tudo (exceto parcelas já pagas) const newInstallments = new Map>(); data.installmentGroups.forEach((group) => { - const ids = new Set(group.pendingInstallments.map((i) => i.id)); - newInstallments.set(group.seriesId, ids); + const unpaidIds = group.pendingInstallments + .filter((i) => !i.isSettled) + .map((i) => i.id); + if (unpaidIds.length > 0) { + newInstallments.set(group.seriesId, new Set(unpaidIds)); + } }); - const newInvoices = new Set( - data.pendingInvoices.map((inv) => `${inv.cartaoId}:${inv.period}`) - ); - setSelectedInstallments(newInstallments); - setSelectedInvoices(newInvoices); } }; @@ -112,92 +97,64 @@ export function InstallmentAnalysisPage({ 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; + const { totalInstallments, 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++; - } - }); - } - }); + 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 && !installment.isSettled) { + installmentsSum += installment.amount; + installmentsCount++; + } + }); + } + }); - let invoicesSum = 0; - let invoicesCount = 0; + return { + totalInstallments: installmentsSum, + grandTotal: installmentsSum, + selectedCount: installmentsCount, + }; + }, [selectedInstallments, data]); - 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; + const hasNoData = data.installmentGroups.length === 0; return ( -
+
{/* Header */}
-
- +
+
-

Análise de Parcelas e Faturas

-

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

Análise de Parcelas

+

+ 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 +

+ {selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"} selecionadas

@@ -221,7 +178,7 @@ export function InstallmentAnalysisPage({
)} -
+
{/* Conteúdo principal */}
{/* Seção de Lançamentos Parcelados */} @@ -244,7 +201,9 @@ export function InstallmentAnalysisPage({ onToggleGroup={() => toggleGroupSelection( group.seriesId, - group.pendingInstallments.map((i) => i.id) + group.pendingInstallments + .filter((i) => !i.isSettled) + .map((i) => i.id) ) } onToggleInstallment={(installmentId) => @@ -256,38 +215,13 @@ export function InstallmentAnalysisPage({
)} - {/* 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

+

Nenhuma parcela pendente

Você está em dia com seus pagamentos!

@@ -302,7 +236,6 @@ export function InstallmentAnalysisPage({
diff --git a/components/dashboard/installment-analysis/installment-group-card.tsx b/components/dashboard/installment-analysis/installment-group-card.tsx index 5ea6c17..26e277f 100644 --- a/components/dashboard/installment-analysis/installment-group-card.tsx +++ b/components/dashboard/installment-analysis/installment-group-card.tsx @@ -27,13 +27,17 @@ export function InstallmentGroupCard({ }: InstallmentGroupCardProps) { const [isExpanded, setIsExpanded] = useState(false); + const unpaidInstallments = group.pendingInstallments.filter( + (i) => !i.isSettled + ); + const isFullySelected = - selectedInstallments.size === group.pendingInstallments.length && - group.pendingInstallments.length > 0; + selectedInstallments.size === unpaidInstallments.length && + unpaidInstallments.length > 0; const isPartiallySelected = selectedInstallments.size > 0 && - selectedInstallments.size < group.pendingInstallments.length; + selectedInstallments.size < unpaidInstallments.length; const progress = group.totalInstallments > 0 @@ -48,7 +52,7 @@ export function InstallmentGroupCard({ return ( - + {/* Header do card */}
-

{group.name}

-
+

{group.name}

+
{group.cartaoName && ( <> {group.cartaoName} @@ -73,7 +77,7 @@ export function InstallmentGroupCard({
-
+
{/* Progress bar */} -
+
{group.paidInstallments} de {group.totalInstallments} pagas @@ -100,7 +104,7 @@ export function InstallmentGroupCard({ : "pendentes"}
- +
{/* Badges de status */} @@ -139,6 +143,7 @@ export function InstallmentGroupCard({
{group.pendingInstallments.map((installment) => { const isSelected = selectedInstallments.has(installment.id); + const isPaid = installment.isSettled; const dueDate = installment.dueDate ? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR }) : format(installment.purchaseDate, "dd/MM/yyyy", { @@ -150,20 +155,27 @@ export function InstallmentGroupCard({ key={installment.id} className={cn( "flex items-center gap-3 rounded-md border p-2 transition-colors", - isSelected && "border-primary/50 bg-primary/5" + isSelected && !isPaid && "border-primary/50 bg-primary/5", + isPaid && "bg-muted/50 opacity-60" )} > onToggleInstallment(installment.id)} + checked={isPaid ? false : isSelected} + disabled={isPaid} + onCheckedChange={() => !isPaid && onToggleInstallment(installment.id)} aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`} />
-

+

Parcela {installment.currentInstallment}/ {group.totalInstallments} + {isPaid && ( + + Paga + + )}

Vencimento: {dueDate} @@ -172,7 +184,7 @@ export function InstallmentGroupCard({

diff --git a/components/dashboard/installment-expenses-widget.tsx b/components/dashboard/installment-expenses-widget.tsx index 98903af..7c6b6ee 100644 --- a/components/dashboard/installment-expenses-widget.tsx +++ b/components/dashboard/installment-expenses-widget.tsx @@ -10,9 +10,8 @@ import { calculateLastInstallmentDate, formatLastInstallmentDate, } from "@/lib/installments/utils"; -import { RiNumbersLine, RiArrowRightSLine } from "@remixicon/react"; +import { RiNumbersLine } from "@remixicon/react"; import Image from "next/image"; -import Link from "next/link"; import { Progress } from "../ui/progress"; import { WidgetEmptyState } from "../widget-empty-state"; @@ -186,14 +185,6 @@ export function InstallmentExpensesWidget({ ); })} - - - Ver Análise Completa - - ); } diff --git a/components/widget-card.tsx b/components/widget-card.tsx index f8adfe1..51c7012 100644 --- a/components/widget-card.tsx +++ b/components/widget-card.tsx @@ -24,6 +24,7 @@ type WidgetProps = { subtitle: string; children: React.ReactNode; icon: React.ReactElement; + action?: React.ReactNode; }; export default function WidgetCard({ @@ -31,6 +32,7 @@ export default function WidgetCard({ subtitle, icon, children, + action, }: WidgetProps) { const contentRef = useRef(null); const [hasOverflow, setHasOverflow] = useState(false); @@ -84,6 +86,7 @@ export default function WidgetCard({ {subtitle}
+ {action &&
{action}
}
diff --git a/lib/dashboard/expenses/installment-analysis.ts b/lib/dashboard/expenses/installment-analysis.ts index 55d9681..9d59310 100644 --- a/lib/dashboard/expenses/installment-analysis.ts +++ b/lib/dashboard/expenses/installment-analysis.ts @@ -1,12 +1,28 @@ -import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema"; +import { cartoes, lancamentos } 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"; +import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm"; + +// Calcula a data de vencimento baseada no período e dia de vencimento do cartão +function calculateDueDate(period: string, dueDay: string | null): Date | null { + if (!dueDay) return null; + + try { + const [year, month] = period.split("-"); + if (!year || !month) return null; + + const day = parseInt(dueDay, 10); + if (isNaN(day)) return null; + + return new Date(parseInt(year), parseInt(month) - 1, day); + } catch { + return null; + } +} export type InstallmentDetail = { id: string; @@ -16,6 +32,7 @@ export type InstallmentDetail = { period: string; isAnticipated: boolean; purchaseDate: Date; + isSettled: boolean; }; export type InstallmentGroup = { @@ -24,6 +41,7 @@ export type InstallmentGroup = { paymentMethod: string; cartaoId: string | null; cartaoName: string | null; + cartaoDueDay: string | null; totalInstallments: number; paidInstallments: number; pendingInstallments: InstallmentDetail[]; @@ -31,38 +49,15 @@ export type InstallmentGroup = { 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 + // 1. Buscar todos os lançamentos parcelados não antecipados const installmentRows = await db .select({ id: lancamentos.id, @@ -75,9 +70,11 @@ export async function fetchInstallmentAnalysis( dueDate: lancamentos.dueDate, period: lancamentos.period, isAnticipated: lancamentos.isAnticipated, + isSettled: lancamentos.isSettled, purchaseDate: lancamentos.purchaseDate, cartaoId: lancamentos.cartaoId, cartaoName: cartoes.name, + cartaoDueDay: cartoes.dueDay, }) .from(lancamentos) .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) @@ -106,14 +103,21 @@ export async function fetchInstallmentAnalysis( if (!row.seriesId) continue; const amount = Math.abs(toNumber(row.amount)); + + // Calcular vencimento correto baseado no período e dia de vencimento do cartão + const calculatedDueDate = row.cartaoDueDay + ? calculateDueDate(row.period, row.cartaoDueDay) + : row.dueDate; + const installmentDetail: InstallmentDetail = { id: row.id, currentInstallment: row.currentInstallment ?? 1, amount, - dueDate: row.dueDate, + dueDate: calculatedDueDate, period: row.period, isAnticipated: row.isAnticipated ?? false, purchaseDate: row.purchaseDate, + isSettled: row.isSettled ?? false, }; if (seriesMap.has(row.seriesId)) { @@ -127,6 +131,7 @@ export async function fetchInstallmentAnalysis( paymentMethod: row.paymentMethod, cartaoId: row.cartaoId, cartaoName: row.cartaoName, + cartaoDueDay: row.cartaoDueDay, totalInstallments: row.installmentCount ?? 0, paidInstallments: 0, pendingInstallments: [installmentDetail], @@ -145,92 +150,14 @@ export async function fetchInstallmentAnalysis( 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, }; } diff --git a/lib/dashboard/widgets/widgets-config.tsx b/lib/dashboard/widgets/widgets-config.tsx index d433123..11a86bf 100644 --- a/lib/dashboard/widgets/widgets-config.tsx +++ b/lib/dashboard/widgets/widgets-config.tsx @@ -13,6 +13,7 @@ import { RecentTransactionsWidget } from "@/components/dashboard/recent-transact import { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget"; import { TopEstablishmentsWidget } from "@/components/dashboard/top-establishments-widget"; import { TopExpensesWidget } from "@/components/dashboard/top-expenses-widget"; +import Link from "next/link"; import { RiArrowUpDoubleLine, RiBarChartBoxLine, @@ -38,6 +39,7 @@ export type WidgetConfig = { subtitle: string; icon: ReactNode; component: (props: { data: DashboardData; period: string }) => ReactNode; + action?: ReactNode; }; export const widgetsConfig: WidgetConfig[] = [ @@ -134,6 +136,14 @@ export const widgetsConfig: WidgetConfig[] = [ component: ({ data }) => ( ), + action: ( + + Análise + + ), }, { id: "top-expenses",