feat: adição de novos ícones SVG e configuração do ambiente
- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter - Implementados ícones para modos claro e escuro do ChatGPT - Criado script de inicialização para PostgreSQL com extensão pgcrypto - Adicionado script de configuração de ambiente que faz backup do .env - Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
561
components/dashboard/invoices-widget.tsx
Normal file
561
components/dashboard/invoices-widget.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
"use client";
|
||||
import { updateInvoicePaymentStatusAction } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
|
||||
import MoneyValues from "@/components/money-values";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter as ModalFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { DashboardInvoice } from "@/lib/dashboard/invoices";
|
||||
import { INVOICE_PAYMENT_STATUS, INVOICE_STATUS_LABEL } from "@/lib/faturas";
|
||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||
import {
|
||||
RiBillLine,
|
||||
RiCheckboxCircleFill,
|
||||
RiCheckboxCircleLine,
|
||||
RiExternalLinkLine,
|
||||
RiLoader4Line,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "../ui/hover-card";
|
||||
import { WidgetEmptyState } from "../widget-empty-state";
|
||||
|
||||
type InvoicesWidgetProps = {
|
||||
invoices: DashboardInvoice[];
|
||||
};
|
||||
|
||||
type ModalState = "idle" | "processing" | "success";
|
||||
|
||||
const DUE_DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
const resolveLogoPath = (logo: string | null) => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
if (/^(https?:\/\/|data:)/.test(logo)) {
|
||||
return logo;
|
||||
}
|
||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
||||
};
|
||||
|
||||
const buildInitials = (value: string) => {
|
||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return "CC";
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
const firstPart = parts[0];
|
||||
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CC";
|
||||
}
|
||||
const firstChar = parts[0]?.[0] ?? "";
|
||||
const secondChar = parts[1]?.[0] ?? "";
|
||||
return `${firstChar}${secondChar}`.toUpperCase() || "CC";
|
||||
};
|
||||
|
||||
const parseDueDate = (period: string, dueDay: string) => {
|
||||
const [yearStr, monthStr] = period.split("-");
|
||||
const dayNumber = Number.parseInt(dueDay, 10);
|
||||
const year = Number.parseInt(yearStr ?? "", 10);
|
||||
const month = Number.parseInt(monthStr ?? "", 10);
|
||||
|
||||
if (
|
||||
Number.isNaN(dayNumber) ||
|
||||
Number.isNaN(year) ||
|
||||
Number.isNaN(month) ||
|
||||
period.length !== 7
|
||||
) {
|
||||
return {
|
||||
label: `Vence dia ${dueDay}`,
|
||||
};
|
||||
}
|
||||
|
||||
const date = new Date(Date.UTC(year, month - 1, dayNumber));
|
||||
return {
|
||||
label: `Vence em ${DUE_DATE_FORMATTER.format(date)}`,
|
||||
};
|
||||
};
|
||||
|
||||
const formatPaymentDate = (value: string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [yearStr, monthStr, dayStr] = value.split("-");
|
||||
const year = Number.parseInt(yearStr ?? "", 10);
|
||||
const month = Number.parseInt(monthStr ?? "", 10);
|
||||
const day = Number.parseInt(dayStr ?? "", 10);
|
||||
|
||||
if (
|
||||
Number.isNaN(year) ||
|
||||
Number.isNaN(month) ||
|
||||
Number.isNaN(day) ||
|
||||
yearStr?.length !== 4 ||
|
||||
monthStr?.length !== 2 ||
|
||||
dayStr?.length !== 2
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return {
|
||||
label: `Pago em ${DUE_DATE_FORMATTER.format(date)}`,
|
||||
};
|
||||
};
|
||||
|
||||
const getTodayDateString = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatSharePercentage = (value: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return "0%";
|
||||
}
|
||||
const digits = value >= 10 ? 0 : value >= 1 ? 1 : 2;
|
||||
return (
|
||||
value.toLocaleString("pt-BR", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}) + "%"
|
||||
);
|
||||
};
|
||||
|
||||
const getShareLabel = (amount: number, total: number) => {
|
||||
if (total <= 0) {
|
||||
return "0% do total";
|
||||
}
|
||||
const percentage = (amount / total) * 100;
|
||||
return `${formatSharePercentage(percentage)} do total`;
|
||||
};
|
||||
|
||||
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [items, setItems] = useState(invoices);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [modalState, setModalState] = useState<ModalState>("idle");
|
||||
|
||||
useEffect(() => {
|
||||
setItems(invoices);
|
||||
}, [invoices]);
|
||||
|
||||
const selectedInvoice = useMemo(
|
||||
() => items.find((invoice) => invoice.id === selectedId) ?? null,
|
||||
[items, selectedId]
|
||||
);
|
||||
|
||||
const selectedLogo = useMemo(
|
||||
() => (selectedInvoice ? resolveLogoPath(selectedInvoice.logo) : null),
|
||||
[selectedInvoice]
|
||||
);
|
||||
|
||||
const selectedPaymentInfo = useMemo(
|
||||
() => (selectedInvoice ? formatPaymentDate(selectedInvoice.paidAt) : null),
|
||||
[selectedInvoice]
|
||||
);
|
||||
|
||||
const handleOpenModal = (invoiceId: string) => {
|
||||
setSelectedId(invoiceId);
|
||||
setModalState("idle");
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setModalState("idle");
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const handleConfirmPayment = () => {
|
||||
if (!selectedInvoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
setModalState("processing");
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateInvoicePaymentStatusAction({
|
||||
cartaoId: selectedInvoice.cardId,
|
||||
period: selectedInvoice.period,
|
||||
status: INVOICE_PAYMENT_STATUS.PAID,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setItems((previous) =>
|
||||
previous.map((invoice) =>
|
||||
invoice.id === selectedInvoice.id
|
||||
? {
|
||||
...invoice,
|
||||
paymentStatus: INVOICE_PAYMENT_STATUS.PAID,
|
||||
paidAt: getTodayDateString(),
|
||||
}
|
||||
: invoice
|
||||
)
|
||||
);
|
||||
setModalState("success");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
setModalState("idle");
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string): "success" | "info" => {
|
||||
const normalizedStatus = status.toLowerCase();
|
||||
if (normalizedStatus === "em aberto") {
|
||||
return "info";
|
||||
}
|
||||
return "success";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardContent className="flex flex-col gap-4 px-0">
|
||||
{items.length === 0 ? (
|
||||
<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."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{items.map((invoice) => {
|
||||
const logo = resolveLogoPath(invoice.logo);
|
||||
const initials = buildInitials(invoice.cardName);
|
||||
const dueInfo = parseDueDate(invoice.period, invoice.dueDay);
|
||||
const isPaid =
|
||||
invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||
const paymentInfo = formatPaymentDate(invoice.paidAt);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={invoice.id}
|
||||
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">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{logo ? (
|
||||
<Image
|
||||
src={logo}
|
||||
alt={`Logo do cartão ${invoice.cardName}`}
|
||||
width={44}
|
||||
height={44}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-semibold uppercase text-muted-foreground">
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{(() => {
|
||||
const breakdown = invoice.pagadorBreakdown ?? [];
|
||||
const hasBreakdown = breakdown.length > 0;
|
||||
const linkNode = (
|
||||
<Link
|
||||
prefetch
|
||||
href={`/cartoes/${
|
||||
invoice.cardId
|
||||
}/fatura?periodo=${formatPeriodForUrl(
|
||||
invoice.period
|
||||
)}`}
|
||||
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>
|
||||
);
|
||||
|
||||
if (!hasBreakdown) {
|
||||
return linkNode;
|
||||
}
|
||||
|
||||
const totalForShare = Math.abs(invoice.totalAmount);
|
||||
|
||||
return (
|
||||
<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>
|
||||
{buildInitials(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">
|
||||
{getShareLabel(
|
||||
share.amount,
|
||||
totalForShare
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
<MoneyValues amount={share.amount} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
})()}
|
||||
<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-green-600 dark:text-green-400">
|
||||
{paymentInfo.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={invoice.totalAmount} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isPaid}
|
||||
onClick={() => handleOpenModal(invoice.id)}
|
||||
variant={"link"}
|
||||
className="p-0 h-auto disabled:opacity-100"
|
||||
>
|
||||
{isPaid ? (
|
||||
<span className="text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<RiCheckboxCircleFill className="size-3" /> Pago
|
||||
</span>
|
||||
) : (
|
||||
<span>Pagar</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCloseModal();
|
||||
return;
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (modalState === "processing") {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
handleCloseModal();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (modalState === "processing") {
|
||||
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-emerald-500/10 text-emerald-500">
|
||||
<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>
|
||||
<ModalFooter className="sm:justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="sm:w-auto"
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader className="gap-3">
|
||||
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revise os dados antes de confirmar. Vamos registrar a fatura
|
||||
como paga.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedInvoice ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-muted/50 p-3">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-background">
|
||||
{selectedLogo ? (
|
||||
<Image
|
||||
src={selectedLogo}
|
||||
alt={`Logo do cartão ${selectedInvoice.cardName}`}
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-semibold uppercase text-muted-foreground">
|
||||
{buildInitials(selectedInvoice.cardName)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Cartão</p>
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
{selectedInvoice.cardName}
|
||||
</p>
|
||||
{selectedInvoice.paymentStatus !==
|
||||
INVOICE_PAYMENT_STATUS.PAID ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{
|
||||
parseDueDate(
|
||||
selectedInvoice.period,
|
||||
selectedInvoice.dueDay
|
||||
).label
|
||||
}
|
||||
</p>
|
||||
) : null}
|
||||
{selectedInvoice.paymentStatus ===
|
||||
INVOICE_PAYMENT_STATUS.PAID && selectedPaymentInfo ? (
|
||||
<p className="text-xs text-emerald-600">
|
||||
{selectedPaymentInfo.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-1">
|
||||
<div className="rounded border border-border/60 px-3 items-center py-2 flex justify-between">
|
||||
<span className="text-xs uppercase text-muted-foreground/80">
|
||||
Valor da fatura
|
||||
</span>
|
||||
<MoneyValues
|
||||
amount={selectedInvoice.totalAmount}
|
||||
className="text-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded border border-border/60 px-3 py-2 flex justify-between items-center">
|
||||
<span className="text-xs uppercase text-muted-foreground/80">
|
||||
Status atual
|
||||
</span>
|
||||
<span className="block text-sm">
|
||||
<Badge
|
||||
variant={getStatusBadgeVariant(
|
||||
INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]
|
||||
)}
|
||||
className="text-xs"
|
||||
>
|
||||
{INVOICE_STATUS_LABEL[selectedInvoice.paymentStatus]}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ModalFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCloseModal}
|
||||
disabled={modalState === "processing"}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirmPayment}
|
||||
disabled={modalState === "processing" || isPending}
|
||||
className="relative"
|
||||
>
|
||||
{modalState === "processing" || isPending ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-1.5 size-4 animate-spin" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar pagamento"
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user