refactor(dashboard): reorganiza widgets e remove magnet-lines

This commit is contained in:
Felipe Coutinho
2026-03-09 17:12:44 +00:00
parent 3e06a1d056
commit 69da27276c
106 changed files with 6072 additions and 3601 deletions

View File

@@ -0,0 +1,148 @@
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
import Link from "next/link";
import MoneyValues from "@/components/shared/money-values";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import {
buildInvoiceDetailsHref,
buildInvoiceInitials,
formatInvoicePaymentDate,
getInvoiceShareLabel,
parseInvoiceDueDate,
} from "@/lib/dashboard/invoices-helpers";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { isDateOnlyPast } from "@/lib/utils/date";
import { InvoiceLogo } from "./invoice-logo";
type InvoiceListItemProps = {
invoice: DashboardInvoice;
onPay: (invoiceId: string) => void;
};
export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
const isOverdue =
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
const breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0;
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
const linkNode = (
<Link
prefetch
href={detailHref}
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate">{invoice.cardName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
);
return (
<li className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0">
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
<InvoiceLogo
cardName={invoice.cardName}
logo={invoice.logo}
size={36}
containerClassName="size-9.5"
/>
<div className="min-w-0">
{hasBreakdown ? (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
<HoverCardContent align="start" className="w-72 space-y-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Distribuição por pagador
</p>
<ul className="space-y-2">
{breakdown.map((share, index) => (
<li
key={`${invoice.id}-${
share.pagadorId ?? share.pagadorName ?? index
}`}
className="flex items-center gap-3"
>
<Avatar className="size-9">
<AvatarImage
src={getAvatarSrc(share.pagadorAvatar)}
alt={`Avatar de ${share.pagadorName}`}
/>
<AvatarFallback>
{buildInvoiceInitials(share.pagadorName)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{share.pagadorName}
</p>
<p className="text-xs text-muted-foreground">
{getInvoiceShareLabel(
share.amount,
Math.abs(invoice.totalAmount),
)}
</p>
</div>
<div className="text-sm font-semibold text-foreground">
<MoneyValues amount={share.amount} />
</div>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
) : (
linkNode
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{!isPaid ? <span>{dueInfo.label}</span> : null}
{isPaid && paymentInfo ? (
<span className="text-success">{paymentInfo.label}</span>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
<Button
type="button"
size="sm"
variant="link"
className="h-auto p-0 disabled:opacity-100"
disabled={isPaid}
onClick={() => onPay(invoice.id)}
>
{isPaid ? (
<span className="flex items-center gap-1 text-success">
<RiCheckboxCircleFill className="size-3" /> Pago
</span>
) : isOverdue ? (
<span className="overdue-blink">
<span className="overdue-blink-primary text-destructive">
Atrasado
</span>
<span className="overdue-blink-secondary">Pagar</span>
</span>
) : (
<span>Pagar</span>
)}
</Button>
</div>
</li>
);
}

View File

@@ -0,0 +1,59 @@
import Image from "next/image";
import {
buildInvoiceInitials,
type InvoiceLogoTone,
} from "@/lib/dashboard/invoices-helpers";
import { resolveLogoSrc } from "@/lib/logo";
import { cn } from "@/lib/utils/ui";
type InvoiceLogoProps = {
cardName: string;
logo: string | null;
size: number;
containerClassName?: string;
imageClassName?: string;
fallbackClassName?: string;
tone?: InvoiceLogoTone;
};
export function InvoiceLogo({
cardName,
logo,
size,
containerClassName,
imageClassName,
fallbackClassName,
tone = "muted",
}: InvoiceLogoProps) {
const resolvedLogo = resolveLogoSrc(logo);
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-full",
tone === "accent" && "bg-primary/10",
containerClassName,
)}
>
{resolvedLogo ? (
<Image
src={resolvedLogo}
alt={`Logo do cartão ${cardName}`}
width={size}
height={size}
className={cn("h-full w-full object-contain", imageClassName)}
/>
) : (
<span
className={cn(
"text-sm font-semibold uppercase text-muted-foreground",
tone === "accent" && "text-primary",
fallbackClassName,
)}
>
{buildInvoiceInitials(cardName)}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,203 @@
import {
RiCheckboxCircleLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
} from "@remixicon/react";
import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import {
formatInvoicePaymentDate,
getInvoiceStatusBadgeVariant,
type InvoiceDialogState,
parseInvoiceDueDate,
} from "@/lib/dashboard/invoices-helpers";
import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas";
import { InvoiceLogo } from "./invoice-logo";
type InvoicePaymentDialogProps = {
invoice: DashboardInvoice | null;
open: boolean;
modalState: InvoiceDialogState;
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function InvoicePaymentDialog({
invoice,
open,
modalState,
isPending,
onClose,
onConfirm,
}: InvoicePaymentDialogProps) {
const isProcessing = modalState === "processing" || isPending;
const paymentInfo = invoice ? formatInvoicePaymentDate(invoice.paidAt) : null;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen || isProcessing) {
return;
}
onClose();
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
onPointerDownOutside={(event) => {
if (isProcessing) {
event.preventDefault();
}
}}
>
{modalState === "success" ? (
<div className="flex flex-col items-center gap-4 py-6 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
<RiCheckboxCircleLine className="size-8" />
</div>
<div className="space-y-2">
<DialogTitle className="text-base">
Pagamento confirmado!
</DialogTitle>
<DialogDescription className="text-sm">
Atualizamos o status da fatura. O lançamento do pagamento
aparecerá no extrato em instantes.
</DialogDescription>
</div>
<DialogFooter className="sm:justify-center">
<Button type="button" onClick={onClose} className="sm:w-auto">
Fechar
</Button>
</DialogFooter>
</div>
) : (
<>
<DialogHeader>
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogDescription>
Revise os dados antes de confirmar. Vamos registrar a fatura
como paga.
</DialogDescription>
</DialogHeader>
{invoice ? (
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<InvoiceLogo
cardName={invoice.cardName}
logo={invoice.logo}
size={40}
tone="accent"
containerClassName="size-10"
fallbackClassName="text-xs"
/>
<div>
<p className="text-sm font-medium text-muted-foreground">
Cartão
</p>
<p className="text-lg font-bold text-foreground">
{invoice.cardName}
</p>
</div>
</div>
<div className="text-right">
{invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PAID ? (
<p className="text-sm text-muted-foreground">
{
parseInvoiceDueDate(invoice.period, invoice.dueDay)
.label
}
</p>
) : null}
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID &&
paymentInfo ? (
<p className="text-sm text-success">
{paymentInfo.label}
</p>
) : null}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Valor da Fatura
</span>
</div>
<MoneyValues
amount={Math.abs(invoice.totalAmount)}
className="text-lg font-bold"
/>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiCheckboxCircleLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Status
</span>
</div>
<Badge
variant={getInvoiceStatusBadgeVariant(
INVOICE_STATUS_LABEL[invoice.paymentStatus],
)}
>
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
</Badge>
</div>
</div>
</div>
) : null}
<DialogFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isProcessing}
>
Cancelar
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={isProcessing || !invoice}
className="relative"
>
{isProcessing ? (
<>
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
Processando...
</>
) : (
"Confirmar pagamento"
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,29 @@
import { RiBillLine } from "@remixicon/react";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import { InvoiceListItem } from "./invoice-list-item";
type InvoicesListProps = {
invoices: DashboardInvoice[];
onPay: (invoiceId: string) => void;
};
export function InvoicesList({ invoices, onPay }: InvoicesListProps) {
if (invoices.length === 0) {
return (
<WidgetEmptyState
icon={<RiBillLine className="size-6 text-muted-foreground" />}
title="Nenhuma fatura para o período selecionado"
description="Quando houver cartões com compras registradas, eles aparecerão aqui."
/>
);
}
return (
<ul className="flex flex-col">
{invoices.map((invoice) => (
<InvoiceListItem key={invoice.id} invoice={invoice} onPay={onPay} />
))}
</ul>
);
}

View File

@@ -0,0 +1,43 @@
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
import type { InvoiceDialogState } from "@/lib/dashboard/invoices-helpers";
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
import { InvoicesList } from "./invoices-list";
type InvoicesWidgetViewProps = {
invoices: DashboardInvoice[];
selectedInvoice: DashboardInvoice | null;
isModalOpen: boolean;
modalState: InvoiceDialogState;
isPending: boolean;
onOpenPaymentDialog: (invoiceId: string) => void;
onClosePaymentDialog: () => void;
onConfirmPayment: () => void;
};
export function InvoicesWidgetView({
invoices,
selectedInvoice,
isModalOpen,
modalState,
isPending,
onOpenPaymentDialog,
onClosePaymentDialog,
onConfirmPayment,
}: InvoicesWidgetViewProps) {
return (
<>
<div className="flex flex-col gap-4">
<InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} />
</div>
<InvoicePaymentDialog
invoice={selectedInvoice}
open={isModalOpen}
modalState={modalState}
isPending={isPending}
onClose={onClosePaymentDialog}
onConfirm={onConfirmPayment}
/>
</>
);
}