feat(cartoes): exibe fatura atual e ajusta indicadores

This commit is contained in:
Felipe Coutinho
2026-05-21 13:47:30 +00:00
parent 4e8f9cc5fa
commit 6b044f3bc5
6 changed files with 154 additions and 70 deletions

View File

@@ -134,6 +134,8 @@ export default async function Page({ params, searchParams }: PageProps) {
accountName, accountName,
limitInUse: 0, limitInUse: 0,
limitAvailable: limitAmount, limitAvailable: limitAmount,
currentInvoiceAmount: 0,
currentInvoiceLabel: "",
}; };
const { totalAmount, invoiceStatus, paymentDate } = invoiceData; const { totalAmount, invoiceStatus, paymentDate } = invoiceData;

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { import {
RiCalendarCloseLine,
RiCalendarScheduleLine,
RiChat3Line, RiChat3Line,
RiDeleteBin5Line, RiDeleteBin5Line,
RiFileList2Line, RiFileList2Line,
@@ -33,6 +35,8 @@ interface CardItemProps {
limit: number; limit: number;
limitInUse?: number; limitInUse?: number;
limitAvailable?: number; limitAvailable?: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
accountName: string; accountName: string;
logo?: string | null; logo?: string | null;
note?: string | null; note?: string | null;
@@ -52,6 +56,8 @@ export function CardItem({
limit, limit,
limitInUse, limitInUse,
limitAvailable, limitAvailable,
currentInvoiceAmount,
currentInvoiceLabel,
accountName: _accountName, accountName: _accountName,
logo, logo,
note, note,
@@ -77,7 +83,7 @@ export function CardItem({
return ( return (
<Card className="flex flex-col p-6 w-full"> <Card className="flex flex-col p-6 w-full">
<CardHeader className="space-y-2 p-0"> <CardHeader className="space-y-1 p-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex flex-1 items-center gap-2"> <div className="flex flex-1 items-center gap-2">
{logoPath ? ( {logoPath ? (
@@ -146,15 +152,17 @@ export function CardItem({
)} )}
</div> </div>
<div className="flex items-center justify-between border-y py-3 text-sm text-muted-foreground"> <div className="flex items-center justify-between text-sm text-muted-foreground rounded-lg py-4 px-2 bg-primary/5">
<span> <span className="inline-flex items-center gap-1">
Fecha em{" "} <RiCalendarCloseLine className="size-4" aria-hidden />
Fecha{" "}
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
dia {formatDay(closingDay)} dia {formatDay(closingDay)}
</span> </span>
</span> </span>
<span> <span className="inline-flex items-center gap-1">
Vence em{" "} <RiCalendarScheduleLine className="size-4" aria-hidden />
Vence{" "}
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
dia {formatDay(dueDay)} dia {formatDay(dueDay)}
</span> </span>
@@ -165,29 +173,40 @@ export function CardItem({
<CardContent className="flex flex-1 flex-col gap-4 px-0"> <CardContent className="flex flex-1 flex-col gap-4 px-0">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Limite disponível {currentInvoiceLabel}
</span> </span>
<MoneyValues <MoneyValues
amount={available} amount={currentInvoiceAmount}
className="text-xl font-semibold text-success" className="text-xl font-semibold text-info"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="flex gap-2 justify-between w-full">
<div className="flex flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Limite total</span> <span className="text-xs text-muted-foreground">Limite total</span>
<MoneyValues <MoneyValues
amount={limit} amount={limit}
className="text-sm font-semibold text-foreground" className="text-sm font-semibold text-foreground"
/> />
</div> </div>
<div className="flex flex-col gap-0.5">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Limite utilizado Limite utilizado
</span> </span>
<MoneyValues <MoneyValues
amount={used} amount={used}
className="text-sm font-semibold text-destructive" className="text-sm font-semibold text-primary"
/>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite disponível
</span>
<MoneyValues
amount={available}
className="text-sm font-semibold text-success"
/> />
</div> </div>
</div> </div>
@@ -200,7 +219,7 @@ export function CardItem({
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`} aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/> />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado {usagePercent.toFixed(0)}% utilizado
</span> </span>
</div> </div>
</CardContent> </CardContent>
@@ -220,7 +239,7 @@ export function CardItem({
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80" className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
> >
<RiFileList2Line className="size-4" aria-hidden /> <RiFileList2Line className="size-4" aria-hidden />
ver fatura fatura
</button> </button>
<button <button
type="button" type="button"

View File

@@ -130,7 +130,7 @@ export function CardsPage({
} }
return ( return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-1 xl:grid-cols-3">
{list.map((card) => ( {list.map((card) => (
<CardItem <CardItem
key={card.id} key={card.id}
@@ -142,6 +142,8 @@ export function CardsPage({
limit={card.limit} limit={card.limit}
limitInUse={card.limitInUse ?? null} limitInUse={card.limitInUse ?? null}
limitAvailable={card.limitAvailable ?? card.limit ?? null} limitAvailable={card.limitAvailable ?? card.limit ?? null}
currentInvoiceAmount={card.currentInvoiceAmount}
currentInvoiceLabel={card.currentInvoiceLabel}
accountName={card.accountName} accountName={card.accountName}
logo={card.logo} logo={card.logo}
note={card.note} note={card.note}

View File

@@ -12,6 +12,8 @@ export type Card = {
accountName: string; accountName: string;
limitInUse: number; limitInUse: number;
limitAvailable: number; limitAvailable: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
}; };
export type CardFormValues = { export type CardFormValues = {

View File

@@ -1,7 +1,23 @@
import { and, eq, ilike, isNull, ne, not, or, sql } from "drizzle-orm"; import {
import { cards, financialAccounts, transactions } from "@/db/schema"; and,
eq,
ilike,
isNotNull,
isNull,
ne,
not,
or,
sql,
} from "drizzle-orm";
import { cards, financialAccounts, invoices, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { loadLogoOptions } from "@/shared/lib/logo/options"; import { loadLogoOptions } from "@/shared/lib/logo/options";
import {
formatPeriodMonthShort,
getCurrentPeriod,
parsePeriod,
} from "@/shared/utils/period";
type CardData = { type CardData = {
id: string; id: string;
@@ -15,6 +31,8 @@ type CardData = {
limit: number; limit: number;
limitInUse: number; limitInUse: number;
limitAvailable: number; limitAvailable: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
accountId: string; accountId: string;
accountName: string; accountName: string;
}; };
@@ -25,6 +43,11 @@ type AccountSimple = {
logo: string | null; logo: string | null;
}; };
function formatCurrentInvoiceLabel(period: string) {
const { year } = parsePeriod(period);
return `Fatura ${formatPeriodMonthShort(period)}. ${year}`;
}
async function fetchCardsByStatus( async function fetchCardsByStatus(
userId: string, userId: string,
archived: boolean, archived: boolean,
@@ -33,59 +56,94 @@ async function fetchCardsByStatus(
accounts: AccountSimple[]; accounts: AccountSimple[];
logoOptions: string[]; logoOptions: string[];
}> { }> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ const currentPeriod = getCurrentPeriod();
db.query.cards.findMany({ const currentInvoiceLabel = formatCurrentInvoiceLabel(currentPeriod);
orderBy: (table, { desc }) => [desc(table.name)], const [cardRows, accountRows, logoOptions, usageRows, invoiceRows] =
where: and( await Promise.all([
eq(cards.userId, userId), db.query.cards.findMany({
archived orderBy: (table, { desc }) => [desc(table.name)],
? ilike(cards.status, "inativo") where: and(
: not(ilike(cards.status, "inativo")), eq(cards.userId, userId),
), archived
with: { ? ilike(cards.status, "inativo")
financialAccount: { : not(ilike(cards.status, "inativo")),
columns: { ),
id: true, with: {
name: true, financialAccount: {
columns: {
id: true,
name: true,
},
}, },
}, },
}, }),
}), db.query.financialAccounts.findMany({
db.query.financialAccounts.findMany({ orderBy: (table, { desc }) => [desc(table.name)],
orderBy: (table, { desc }) => [desc(table.name)], where: eq(financialAccounts.userId, userId),
where: eq(financialAccounts.userId, userId), columns: {
columns: { id: true,
id: true, name: true,
name: true, logo: true,
logo: true, },
}, }),
}), loadLogoOptions(),
loadLogoOptions(), db
db .select({
.select({ cardId: transactions.cardId,
cardId: transactions.cardId, total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`, })
}) .from(transactions)
.from(transactions) .leftJoin(
.where( invoices,
and( and(
eq(transactions.userId, userId), eq(invoices.userId, transactions.userId),
or(isNull(transactions.isSettled), eq(transactions.isSettled, false)), eq(invoices.cardId, transactions.cardId),
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou eq(invoices.period, transactions.period),
or(
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
), ),
), )
) .where(
.groupBy(transactions.cardId), and(
]); eq(transactions.userId, userId),
isNotNull(transactions.cardId),
or(
isNull(invoices.paymentStatus),
ne(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
),
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
or(
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
),
),
)
.groupBy(transactions.cardId),
db
.select({
cardId: transactions.cardId,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
),
)
.groupBy(transactions.cardId),
]);
const usageMap = new Map<string, number>(); const usageMap = new Map<string, number>();
usageRows.forEach((row: { cardId: string | null; total: number | null }) => { usageRows.forEach((row: { cardId: string | null; total: number | null }) => {
if (!row.cardId) return; if (!row.cardId) return;
usageMap.set(row.cardId, Number(row.total ?? 0)); usageMap.set(row.cardId, Number(row.total ?? 0));
}); });
const invoiceMap = new Map<string, number>();
invoiceRows.forEach(
(row: { cardId: string | null; total: number | null }) => {
if (!row.cardId) return;
invoiceMap.set(row.cardId, Math.abs(Number(row.total ?? 0)));
},
);
const cardList = cardRows.map((card) => ({ const cardList = cardRows.map((card) => ({
id: card.id, id: card.id,
@@ -99,13 +157,15 @@ async function fetchCardsByStatus(
limit: Number(card.limit), limit: Number(card.limit),
limitInUse: (() => { limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0; return Math.abs(total);
})(), })(),
limitAvailable: (() => { limitAvailable: (() => {
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0; const inUse = Math.abs(total);
return Math.max(Number(card.limit) - inUse, 0); return Math.max(Number(card.limit) - inUse, 0);
})(), })(),
currentInvoiceAmount: invoiceMap.get(card.id) ?? 0,
currentInvoiceLabel,
accountId: card.accountId, accountId: card.accountId,
accountName: accountName:
(card.financialAccount as { name?: string } | null)?.name ?? (card.financialAccount as { name?: string } | null)?.name ??

View File

@@ -274,15 +274,14 @@ const buildPaymentStatusData = (
continue; continue;
} }
const target = const isExpense = row.transactionType === TRANSACTION_TYPE_EXPENSE;
row.transactionType === TRANSACTION_TYPE_INCOME const target = isExpense ? result.expenses : result.income;
? result.income const displayAmount = isExpense ? Math.abs(amount) : amount;
: result.expenses;
if (row.isSettled === true) { if (row.isSettled === true) {
target.confirmed += amount; target.confirmed += displayAmount;
} else { } else {
target.pending += amount; target.pending += displayAmount;
} }
} }