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">
{logoPath ? ( {/* Linha 1 — identidade */}
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-background"> <div className="flex items-center justify-between gap-3">
<Image <div className="flex min-w-0 items-center gap-3">
src={logoPath} {logoPath ? (
alt={`Logo da conta ${accountName}`} <div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full">
width={48} <Image
height={48} src={logoPath}
className="h-full w-full object-contain" alt={`Logo ${accountName}`}
/> width={42}
</div> height={42}
) : null} className="h-full w-full object-contain"
/>
<div className="flex w-full items-start justify-between gap-3"> </div>
<div className="space-y-1"> ) : null}
<CardTitle className="text-xl font-semibold text-foreground"> <div className="min-w-0">
{accountName} <h2 className="truncate text-sm font-semibold text-foreground">
</CardTitle> {accountName}
<p className="text-sm text-muted-foreground"> </h2>
Extrato de {periodLabel} <p className="text-xs text-muted-foreground">
</p> Extrato de {periodLabel}
</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" />} <MoneyValues
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês." amount={currentBalance}
/> className="text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]"
<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
amount={totalIncomes - totalExpenses}
className={cn(
"font-semibold text-xl",
totalIncomes - totalExpenses >= 0
? "text-success"
: "text-destructive",
)}
/>
}
tooltip="Diferença entre entradas e saídas do mês; positivo indica saldo crescente."
/> />
<div className="flex items-center gap-2">
<Badge
variant={getAccountStatusBadgeVariant(status)}
className="text-[11px]"
>
{status}
</Badge>
<span className="text-xs text-muted-foreground">
{accountType}
</span>
</div>
</div> </div>
{/* Saldo Atual - Destaque Principal */} {/* Linha 3 — breakdown financeiro */}
<DetailItem <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
label="Saldo ao final do período" <MetaItem
value={<MoneyValues amount={currentBalance} className="text-2xl" />} label="Saldo inicial"
tooltip="Saldo inicial do período + entradas - saídas realizadas neste mês." tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
/> >
</div> <span className="text-sm font-semibold text-foreground">
{formatCurrency(openingBalance)}
</span>
</MetaItem>
{/* Informações da FinancialAccount */} <MetaItem
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 pt-2 border-t border-border/60 border-dashed"> label="Entradas"
<DetailItem tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
label="Tipo da conta" >
value={accountType} <span className="text-sm font-semibold text-success">
tooltip="Classificação definida na criação da conta (corrente, poupança, etc.)." {formatCurrency(totalIncomes)}
/> </span>
<DetailItem </MetaItem>
label="Status da conta"
value={ <MetaItem
<div className="flex items-center"> label="Saídas"
<Badge tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
variant={getAccountStatusBadgeVariant(status)} >
className="text-xs" <span className="text-sm font-semibold text-destructive">
> {formatCurrency(totalExpenses)}
{status} </span>
</Badge> </MetaItem>
</div>
} <MetaItem
tooltip="Indica se a conta está ativa para lançamentos ou foi desativada." 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> </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 shrink-0 cursor-help text-muted-foreground/50" />
<RiInformationLine className="size-3.5 cursor-help text-muted-foreground/60" /> </TooltipTrigger>
</TooltipTrigger> <TooltipContent side="top" align="start" className="max-w-xs text-xs">
<TooltipContent {tooltip}
side="top" </TooltipContent>
align="start" </Tooltip>
className="max-w-xs text-xs"
>
{tooltip}
</TooltipContent>
</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,20 +96,21 @@ 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;
const handleAction = () => { const handleAction = () => {
startTransition(async () => { startTransition(async () => {
@@ -152,150 +154,145 @@ 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">
{logoPath ? ( {/* Linha 1 — identidade */}
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-background"> <div className="flex items-center justify-between gap-3">
<Image <div className="flex min-w-0 items-center gap-3">
src={logoPath} {logoPath ? (
alt={`Logo do cartão ${cardName}`} <div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full">
width={48} <Image
height={48} src={logoPath}
className="h-full w-full object-contain" alt={`Logo ${cardName}`}
/> width={42}
</div> height={42}
) : cardBrand ? ( className="h-full w-full object-contain"
<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} </div>
</span> ) : cardBrand ? (
) : null} <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()}
<div className="flex w-full items-start justify-between gap-3"> </span>
<div className="space-y-1"> ) : null}
<CardTitle className="text-xl font-semibold text-foreground"> <div className="min-w-0">
{cardName} <h2 className="truncate text-sm font-semibold text-foreground">
</CardTitle> {cardName}
<p className="text-sm text-muted-foreground"> </h2>
Invoice de {periodLabel} <p className="text-xs text-muted-foreground">
</p> Fatura de {periodLabel}
</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={cn(
className="text-2xl text-foreground" "text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]",
/> isPaid ? "text-success" : "text-foreground",
} )}
/> />
<DetailItem <div className="flex items-center gap-2">
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">
</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">
<Image
src={brandAsset}
alt={`Bandeira ${cardBrand}`}
width={32}
height={32}
className="h-5 w-auto rounded"
/>
<span className="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)} /> <StatusDot color={getCardStatusDotColor(cardStatus)} />
<span className="truncate">{cardStatus}</span> <span>{cardStatus}</span>
</div> </div>
) : ( ) : null}
<span className="text-muted-foreground"></span> </div>
) </div>
}
/>
</div>
<DetailItem {/* Linha 3 — metadados do cartão */}
label="Limite do cartão" <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
value={limitLabel} <MetaItem label="Vencimento">
className="sm:w-1/2" <span className="text-sm font-semibold text-foreground">
/> Dia {formatDay(dueDay)}
</span>
</MetaItem>
{/* Ações */} <MetaItem label="Fechamento">
<div className="flex flex-col gap-2 pt-2 sm:flex-row sm:items-center sm:justify-between"> <span className="text-sm font-semibold text-foreground">
<p className="text-xs text-muted-foreground"> Dia {formatDay(closingDay)}
{INVOICE_STATUS_DESCRIPTION[invoiceStatus]} </span>
</p> </MetaItem>
<div className="flex items-center gap-2">
<Button {typeof limitAmount === "number" ? (
type="button" <MetaItem label="Limite">
variant={actionVariantByStatus[invoiceStatus]} <span className="text-sm font-semibold text-foreground">
disabled={isPending} {formatCurrency(limitAmount)}
onClick={handleAction} </span>
className="w-full shrink-0 sm:w-auto" </MetaItem>
> ) : null}
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
</Button> {cardBrand ? (
{invoiceStatus === INVOICE_PAYMENT_STATUS.PAID && ( <MetaItem label="Bandeira">
<EditPaymentDateDialog <div className="flex items-center gap-1.5">
trigger={ {brandAsset ? (
<Button <Image
type="button" src={brandAsset}
variant="ghost" alt={cardBrand}
size="icon" width={24}
className="shrink-0" height={24}
aria-label="Editar data de pagamento" className="h-4 w-auto shrink-0"
> />
<RiEditLine className="size-4" /> ) : null}
</Button> <span className="text-sm font-semibold text-foreground truncate">
} {cardBrand}
currentDate={paymentDate} </span>
onDateChange={handleDateChange} </div>
/> </MetaItem>
)} ) : null}
</div>
{/* 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">
{actionDescription}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
type="button"
size="sm"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
onClick={handleAction}
className="min-w-32"
>
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
</Button>
{isPaid ? (
<EditPaymentDateDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Editar data de pagamento"
>
<RiEditLine className="size-4" />
</Button>
}
currentDate={paymentDate}
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>
); );
} }