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,
limitInUse: 0,
limitAvailable: limitAmount,
currentInvoiceAmount: 0,
currentInvoiceLabel: "",
};
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;

View File

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

View File

@@ -130,7 +130,7 @@ export function CardsPage({
}
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) => (
<CardItem
key={card.id}
@@ -142,6 +142,8 @@ export function CardsPage({
limit={card.limit}
limitInUse={card.limitInUse ?? null}
limitAvailable={card.limitAvailable ?? card.limit ?? null}
currentInvoiceAmount={card.currentInvoiceAmount}
currentInvoiceLabel={card.currentInvoiceLabel}
accountName={card.accountName}
logo={card.logo}
note={card.note}

View File

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

View File

@@ -1,7 +1,23 @@
import { and, eq, ilike, isNull, ne, not, or, sql } from "drizzle-orm";
import { cards, financialAccounts, transactions } from "@/db/schema";
import {
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 { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { loadLogoOptions } from "@/shared/lib/logo/options";
import {
formatPeriodMonthShort,
getCurrentPeriod,
parsePeriod,
} from "@/shared/utils/period";
type CardData = {
id: string;
@@ -15,6 +31,8 @@ type CardData = {
limit: number;
limitInUse: number;
limitAvailable: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
accountId: string;
accountName: string;
};
@@ -25,6 +43,11 @@ type AccountSimple = {
logo: string | null;
};
function formatCurrentInvoiceLabel(period: string) {
const { year } = parsePeriod(period);
return `Fatura ${formatPeriodMonthShort(period)}. ${year}`;
}
async function fetchCardsByStatus(
userId: string,
archived: boolean,
@@ -33,59 +56,94 @@ async function fetchCardsByStatus(
accounts: AccountSimple[];
logoOptions: string[];
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cards.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: and(
eq(cards.userId, userId),
archived
? ilike(cards.status, "inativo")
: not(ilike(cards.status, "inativo")),
),
with: {
financialAccount: {
columns: {
id: true,
name: true,
const currentPeriod = getCurrentPeriod();
const currentInvoiceLabel = formatCurrentInvoiceLabel(currentPeriod);
const [cardRows, accountRows, logoOptions, usageRows, invoiceRows] =
await Promise.all([
db.query.cards.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: and(
eq(cards.userId, userId),
archived
? ilike(cards.status, "inativo")
: not(ilike(cards.status, "inativo")),
),
with: {
financialAccount: {
columns: {
id: true,
name: true,
},
},
},
},
}),
db.query.financialAccounts.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: eq(financialAccounts.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cardId: transactions.cardId,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
or(isNull(transactions.isSettled), eq(transactions.isSettled, false)),
// 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`,
}),
db.query.financialAccounts.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: eq(financialAccounts.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cardId: transactions.cardId,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.leftJoin(
invoices,
and(
eq(invoices.userId, transactions.userId),
eq(invoices.cardId, transactions.cardId),
eq(invoices.period, transactions.period),
),
),
)
.groupBy(transactions.cardId),
]);
)
.where(
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>();
usageRows.forEach((row: { cardId: string | null; total: number | null }) => {
if (!row.cardId) return;
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) => ({
id: card.id,
@@ -99,13 +157,15 @@ async function fetchCardsByStatus(
limit: Number(card.limit),
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
return Math.abs(total);
})(),
limitAvailable: (() => {
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);
})(),
currentInvoiceAmount: invoiceMap.get(card.id) ?? 0,
currentInvoiceLabel,
accountId: card.accountId,
accountName:
(card.financialAccount as { name?: string } | null)?.name ??

View File

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