forked from git.gladyson/openmonetis
- 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
562 lines
20 KiB
TypeScript
562 lines
20 KiB
TypeScript
"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>
|
|
</>
|
|
);
|
|
}
|