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"; "use client";
import { RiInformationLine } from "@remixicon/react"; import { RiInformationLine } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { import { Card, CardContent } from "@/shared/components/ui/card";
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -19,8 +15,6 @@ import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency"; import { formatCurrency } from "@/shared/utils/currency";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
type DetailValue = string | number | ReactNode;
type AccountStatementCardProps = { type AccountStatementCardProps = {
accountName: string; accountName: string;
accountType: string; accountType: string;
@@ -37,11 +31,7 @@ type AccountStatementCardProps = {
const getAccountStatusBadgeVariant = ( const getAccountStatusBadgeVariant = (
status: string, status: string,
): "success" | "outline" => { ): "success" | "outline" => {
const normalizedStatus = status.toLowerCase(); return status.toLowerCase() === "ativa" ? "success" : "outline";
if (normalizedStatus === "ativa") {
return "success";
}
return "outline";
}; };
export function AccountStatementCard({ export function AccountStatementCard({
@@ -57,149 +47,133 @@ export function AccountStatementCard({
actions, actions,
}: AccountStatementCardProps) { }: AccountStatementCardProps) {
const logoPath = resolveLogoSrc(logo); const logoPath = resolveLogoSrc(logo);
const resultado = totalIncomes - totalExpenses;
return ( return (
<Card className="border"> <Card className="gap-0 py-0">
<CardHeader className="flex flex-col gap-3"> <CardContent className="px-4 py-4 sm:px-5 sm:py-5">
<div className="flex items-start gap-3"> <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 ? ( {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 <Image
src={logoPath} src={logoPath}
alt={`Logo da conta ${accountName}`} alt={`Logo ${accountName}`}
width={48} width={42}
height={48} height={42}
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
</div> </div>
) : null} ) : null}
<div className="min-w-0">
<div className="flex w-full items-start justify-between gap-3"> <h2 className="truncate text-sm font-semibold text-foreground">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold text-foreground">
{accountName} {accountName}
</CardTitle> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
Extrato de {periodLabel} Extrato de {periodLabel}
</p> </p>
</div> </div>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null} {actions ? <div className="shrink-0">{actions}</div> : null}
</div> </div>
</div>
</CardHeader>
<CardContent className="space-y-4 border-t border-border/60 border-dashed pt-4"> {/* Linha 2 — saldo final (hero) */}
{/* Composição do Saldo */} <div className="space-y-4">
<div className="space-y-3"> <p className="text-sm font-medium text-muted-foreground ">
<DetailItem Saldo ao final do período
label="Saldo no início do período" </p>
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={
<MoneyValues <MoneyValues
amount={totalIncomes - totalExpenses} amount={currentBalance}
className={cn( className="text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]"
"font-semibold text-xl",
totalIncomes - totalExpenses >= 0
? "text-success"
: "text-destructive",
)}
/> />
} <div className="flex items-center gap-2">
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">
<Badge <Badge
variant={getAccountStatusBadgeVariant(status)} variant={getAccountStatusBadgeVariant(status)}
className="text-xs" className="text-[11px]"
> >
{status} {status}
</Badge> </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> </div>
}
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada."
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
function DetailItem({ function MetaItem({
label, label,
value,
className,
tooltip, tooltip,
children,
}: { }: {
label: string; label: string;
value: DetailValue; tooltip: string;
className?: string; children: ReactNode;
tooltip?: string;
}) { }) {
return ( return (
<div className={cn("space-y-1", className)}> <div className="rounded-md border border-border/60 px-3 py-2">
<span className="flex items-center gap-1 text-xs font-medium uppercase text-muted-foreground/80"> <span className="flex items-center gap-1 text-sm font-medium text-muted-foreground ">
{label} {label}
{tooltip ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <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> </TooltipTrigger>
<TooltipContent <TooltipContent side="top" align="start" className="max-w-xs text-xs">
side="top"
align="start"
className="max-w-xs text-xs"
>
{tooltip} {tooltip}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null}
</span> </span>
<div className="text-base text-foreground">{value}</div> <div className="mt-1">{children}</div>
</div> </div>
); );
} }

View File

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