style: redesenha cards-resumo de conta e fatura

This commit is contained in:
Felipe Coutinho
2026-03-15 23:23:35 +00:00
parent df3d0134be
commit ca67d36f33
2 changed files with 258 additions and 295 deletions

View File

@@ -1,15 +1,11 @@
"use client";
import { RiInformationLine } from "@remixicon/react";
import Image from "next/image";
import type { ReactNode } from "react";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Tooltip,
TooltipContent,
@@ -19,8 +15,6 @@ import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency";
import { cn } from "@/shared/utils/ui";
type DetailValue = string | number | ReactNode;
type AccountStatementCardProps = {
accountName: string;
accountType: string;
@@ -37,11 +31,7 @@ type AccountStatementCardProps = {
const getAccountStatusBadgeVariant = (
status: string,
): "success" | "outline" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativa") {
return "success";
}
return "outline";
return status.toLowerCase() === "ativa" ? "success" : "outline";
};
export function AccountStatementCard({
@@ -57,149 +47,133 @@ export function AccountStatementCard({
actions,
}: AccountStatementCardProps) {
const logoPath = resolveLogoSrc(logo);
const resultado = totalIncomes - totalExpenses;
return (
<Card className="border">
<CardHeader className="flex flex-col gap-3">
<div className="flex items-start gap-3">
<Card className="gap-0 py-0">
<CardContent className="px-4 py-4 sm:px-5 sm:py-5">
<div className="flex flex-col gap-4">
{/* Linha 1 — identidade */}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-background">
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full">
<Image
src={logoPath}
alt={`Logo da conta ${accountName}`}
width={48}
height={48}
alt={`Logo ${accountName}`}
width={42}
height={42}
className="h-full w-full object-contain"
/>
</div>
) : null}
<div className="flex w-full items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-foreground">
{accountName}
</CardTitle>
<p className="text-sm text-muted-foreground">
</h2>
<p className="text-xs text-muted-foreground">
Extrato de {periodLabel}
</p>
</div>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
{/* Composição do Saldo */}
<div className="space-y-3">
<DetailItem
label="Saldo no início do período"
value={<MoneyValues amount={openingBalance} className="text-2xl" />}
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DetailItem
label="Entradas"
value={
<span className="font-medium text-success">
{formatCurrency(totalIncomes)}
</span>
}
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
/>
<DetailItem
label="Saídas"
value={
<span className="font-medium text-destructive">
{formatCurrency(totalExpenses)}
</span>
}
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
/>
<DetailItem
label="Resultado do período"
value={
{/* Linha 2 — saldo final (hero) */}
<div className="space-y-4">
<p className="text-sm font-medium text-muted-foreground ">
Saldo ao final do período
</p>
<MoneyValues
amount={totalIncomes - totalExpenses}
className={cn(
"font-semibold text-xl",
totalIncomes - totalExpenses >= 0
? "text-success"
: "text-destructive",
)}
amount={currentBalance}
className="text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]"
/>
}
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
/>
</div>
{/* Saldo Atual - Destaque Principal */}
<DetailItem
label="Saldo ao final do período"
value={<MoneyValues amount={currentBalance} className="text-2xl" />}
tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês."
/>
</div>
{/* Informações da FinancialAccount */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 pt-2 border-t border-border/60 border-dashed">
<DetailItem
label="Tipo da conta"
value={accountType}
tooltip="Classificação definida na criação da conta (corrente, poupança, etc.)."
/>
<DetailItem
label="Status da conta"
value={
<div className="flex items-center">
<div className="flex items-center gap-2">
<Badge
variant={getAccountStatusBadgeVariant(status)}
className="text-xs"
className="text-[11px]"
>
{status}
</Badge>
<span className="text-xs text-muted-foreground">
{accountType}
</span>
</div>
</div>
{/* Linha 3 — breakdown financeiro */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<MetaItem
label="Saldo inicial"
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
>
<span className="text-sm font-semibold text-foreground">
{formatCurrency(openingBalance)}
</span>
</MetaItem>
<MetaItem
label="Entradas"
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
>
<span className="text-sm font-semibold text-success">
{formatCurrency(totalIncomes)}
</span>
</MetaItem>
<MetaItem
label="Saídas"
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
>
<span className="text-sm font-semibold text-destructive">
{formatCurrency(totalExpenses)}
</span>
</MetaItem>
<MetaItem
label="Resultado"
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
>
<span
className={cn(
"text-sm font-semibold",
resultado >= 0 ? "text-success" : "text-destructive",
)}
>
{resultado >= 0 ? "+" : ""}
{formatCurrency(resultado)}
</span>
</MetaItem>
</div>
}
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada."
/>
</div>
</CardContent>
</Card>
);
}
function DetailItem({
function MetaItem({
label,
value,
className,
tooltip,
children,
}: {
label: string;
value: DetailValue;
className?: string;
tooltip?: string;
tooltip: string;
children: ReactNode;
}) {
return (
<div className={cn("space-y-1", className)}>
<span className="flex items-center gap-1 text-xs font-medium uppercase text-muted-foreground/80">
<div className="rounded-md border border-border/60 px-3 py-2">
<span className="flex items-center gap-1 text-sm font-medium text-muted-foreground ">
{label}
{tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<RiInformationLine className="size-3.5 cursor-help text-muted-foreground/60" />
<RiInformationLine className="size-3 shrink-0 cursor-help text-muted-foreground/50" />
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs text-xs"
>
<TooltipContent side="top" align="start" className="max-w-xs text-xs">
{tooltip}
</TooltipContent>
</Tooltip>
) : null}
</span>
<div className="text-base text-foreground">{value}</div>
<div className="mt-1">{children}</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { RiEditLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import {
@@ -13,12 +14,7 @@ import MoneyValues from "@/shared/components/money-values";
import StatusDot from "@/shared/components/status-dot";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Card, CardContent } from "@/shared/components/ui/card";
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
import {
INVOICE_PAYMENT_STATUS,
@@ -29,6 +25,7 @@ import {
} from "@/shared/lib/invoices";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDateOnly } from "@/shared/utils/date";
import { cn } from "@/shared/utils/ui";
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
@@ -66,13 +63,17 @@ const formatDay = (value: string) => value.padStart(2, "0");
const getCardStatusDotColor = (status: string | null) => {
if (!status) return "bg-gray-400";
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativo" || normalizedStatus === "active") {
return "bg-success";
}
return "bg-gray-400";
const s = status.toLowerCase();
return s === "ativo" || s === "active" ? "bg-success" : "bg-gray-400";
};
const formatPaymentDate = (value: Date | null) =>
formatDateOnly(value, {
day: "2-digit",
month: "short",
year: "numeric",
}) ?? "data não informada";
export function InvoiceSummaryCard({
cardId,
period,
@@ -95,18 +96,19 @@ export function InvoiceSummaryCard({
initialPaymentDate ?? new Date(),
);
// Atualizar estado quando initialPaymentDate mudar
useEffect(() => {
setPaymentDate(initialPaymentDate ?? new Date());
}, [initialPaymentDate]);
const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(cardBrand);
const limitLabel =
typeof limitAmount === "number" ? formatCurrency(limitAmount) : "—";
const isPaid = invoiceStatus === INVOICE_PAYMENT_STATUS.PAID;
const paymentDateLabel = isPaid ? formatPaymentDate(paymentDate) : null;
const actionDescription = isPaid
? `Pagamento registrado em ${paymentDateLabel}.`
: INVOICE_STATUS_DESCRIPTION[invoiceStatus];
const targetStatus =
invoiceStatus === INVOICE_PAYMENT_STATUS.PAID
const targetStatus = isPaid
? INVOICE_PAYMENT_STATUS.PENDING
: INVOICE_PAYMENT_STATUS.PAID;
@@ -152,141 +154,135 @@ export function InvoiceSummaryCard({
};
return (
<Card className="border">
<CardHeader className="flex flex-col gap-3">
<div className="flex items-start gap-3">
<Card className="gap-0 py-0">
<CardContent className="px-4 py-4 sm:px-5 sm:py-5">
<div className="flex flex-col gap-4">
{/* Linha 1 — identidade */}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-background">
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full">
<Image
src={logoPath}
alt={`Logo do cartão ${cardName}`}
width={48}
height={48}
alt={`Logo ${cardName}`}
width={42}
height={42}
className="h-full w-full object-contain"
/>
</div>
) : cardBrand ? (
<span className="flex size-12 shrink-0 items-center justify-center rounded-full border border-border/60 bg-background text-sm font-semibold text-muted-foreground">
{cardBrand}
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-semibold text-muted-foreground">
{cardBrand.slice(0, 2).toUpperCase()}
</span>
) : null}
<div className="flex w-full items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-foreground">
{cardName}
</CardTitle>
<p className="text-sm text-muted-foreground">
Invoice de {periodLabel}
</h2>
<p className="text-xs text-muted-foreground">
Fatura de {periodLabel}
</p>
</div>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4">
{/* Destaque Principal */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<DetailItem
label="Valor total"
value={
{/* Linha 2 — valor da fatura (hero) */}
<div className="space-y-4">
<p className="text-sm font-medium text-muted-foreground">
Valor da fatura
</p>
<MoneyValues
amount={totalAmount}
className="text-2xl text-foreground"
className={cn(
"text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]",
isPaid ? "text-success" : "text-foreground",
)}
/>
}
/>
<DetailItem
label="Status da fatura"
value={
<div className="flex items-center gap-2">
<Badge
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
className="text-xs"
className="text-[11px]"
>
{INVOICE_STATUS_LABEL[invoiceStatus]}
</Badge>
}
/>
{cardStatus ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<StatusDot color={getCardStatusDotColor(cardStatus)} />
<span>{cardStatus}</span>
</div>
) : null}
</div>
</div>
{/* Informações Gerais */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DetailItem
label="Fechamento"
value={
<span className="font-medium">Dia {formatDay(closingDay)}</span>
}
/>
<DetailItem
label="Vencimento"
value={<span className="font-medium">Dia {formatDay(dueDay)}</span>}
/>
<DetailItem
label="Bandeira"
value={
brandAsset ? (
<div className="flex items-center gap-2">
{/* Linha 3 — metadados do cartão */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<MetaItem label="Vencimento">
<span className="text-sm font-semibold text-foreground">
Dia {formatDay(dueDay)}
</span>
</MetaItem>
<MetaItem label="Fechamento">
<span className="text-sm font-semibold text-foreground">
Dia {formatDay(closingDay)}
</span>
</MetaItem>
{typeof limitAmount === "number" ? (
<MetaItem label="Limite">
<span className="text-sm font-semibold text-foreground">
{formatCurrency(limitAmount)}
</span>
</MetaItem>
) : null}
{cardBrand ? (
<MetaItem label="Bandeira">
<div className="flex items-center gap-1.5">
{brandAsset ? (
<Image
src={brandAsset}
alt={`Bandeira ${cardBrand}`}
width={32}
height={32}
className="h-5 w-auto rounded"
alt={cardBrand}
width={24}
height={24}
className="h-4 w-auto shrink-0"
/>
<span className="truncate">{cardBrand}</span>
) : null}
<span className="text-sm font-semibold text-foreground truncate">
{cardBrand}
</span>
</div>
) : cardBrand ? (
<span className="truncate">{cardBrand}</span>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
<DetailItem
label="Status cartão"
value={
cardStatus ? (
<div className="flex items-center gap-1.5">
<StatusDot color={getCardStatusDotColor(cardStatus)} />
<span className="truncate">{cardStatus}</span>
</div>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
</MetaItem>
) : null}
</div>
<DetailItem
label="Limite do cartão"
value={limitLabel}
className="sm:w-1/2"
/>
{/* Ações */}
<div className="flex flex-col gap-2 pt-2 sm:flex-row sm:items-center sm:justify-between">
{/* Linha 4 — ação */}
<div className="flex flex-col gap-3 rounded-md border border-dashed bg-muted/30 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{INVOICE_STATUS_DESCRIPTION[invoiceStatus]}
{actionDescription}
</p>
<div className="flex items-center gap-2">
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
type="button"
size="sm"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
onClick={handleAction}
className="w-full shrink-0 sm:w-auto"
className="min-w-32"
>
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
</Button>
{invoiceStatus === INVOICE_PAYMENT_STATUS.PAID && (
{isPaid ? (
<EditPaymentDateDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Editar data de pagamento"
>
<RiEditLine className="size-4" />
@@ -295,7 +291,8 @@ export function InvoiceSummaryCard({
currentDate={paymentDate}
onDateChange={handleDateChange}
/>
)}
) : null}
</div>
</div>
</div>
</CardContent>
@@ -303,21 +300,13 @@ export function InvoiceSummaryCard({
);
}
type DetailItemProps = {
label?: string;
value: React.ReactNode;
className?: string;
};
function DetailItem({ label, value, className }: DetailItemProps) {
function MetaItem({ label, children }: { label: string; children: ReactNode }) {
return (
<div className={cn("space-y-1", className)}>
{label && (
<span className="block text-xs font-medium uppercase text-muted-foreground/80">
<div className="rounded-md border border-border/60 px-3 py-2">
<span className="block text-sm font-medium text-muted-foreground">
{label}
</span>
)}
<div className="text-base text-foreground">{value}</div>
<div className="mt-1">{children}</div>
</div>
);
}