mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(dashboard): persist notification center state
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import type { DashboardNotificationsSnapshot } from "@/features/dashboard/notifications-queries";
|
||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||
import { Logo } from "@/shared/components/logo";
|
||||
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
|
||||
import { RefreshPageButton } from "@/shared/components/refresh-page-button";
|
||||
import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications";
|
||||
import { NavMenu } from "./nav-menu";
|
||||
import { NavbarUser } from "./navbar-user";
|
||||
|
||||
@@ -40,7 +40,8 @@ export function AppNavbar({
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<NotificationBell
|
||||
notifications={notificationsSnapshot.notifications}
|
||||
totalCount={notificationsSnapshot.totalCount}
|
||||
unreadCount={notificationsSnapshot.unreadCount}
|
||||
visibleCount={notificationsSnapshot.visibleCount}
|
||||
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
||||
preLancamentosCount={preLancamentosCount}
|
||||
/>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full shadow-none transition-colors focus-visible:ring-2 focus-visible:ring-black/20 focus-visible:outline-none"
|
||||
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-black/20 focus-visible:outline-none"
|
||||
aria-label="Menu do usuário"
|
||||
>
|
||||
<Image
|
||||
|
||||
@@ -1,347 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiArrowRightLine,
|
||||
RiAtLine,
|
||||
RiBankCardLine,
|
||||
RiBarChart2Line,
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningLine,
|
||||
RiFileListLine,
|
||||
RiNotification2Line,
|
||||
RiTimeLine,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
BudgetNotification,
|
||||
DashboardNotification,
|
||||
} from "@/features/dashboard/notifications-queries";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { buttonVariants } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/shared/components/ui/empty";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatDateOnly } from "@/shared/utils/date";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { NotificationBellContent } from "./notification-bell/notification-bell-content";
|
||||
import { NotificationBellEmptyState } from "./notification-bell/notification-bell-empty-state";
|
||||
import { NotificationBellHeader } from "./notification-bell/notification-bell-header";
|
||||
import { NotificationBellTrigger } from "./notification-bell/notification-bell-trigger";
|
||||
import type { NotificationBellProps } from "./notification-bell/types";
|
||||
import { useNotificationBell } from "./notification-bell/use-notification-bell";
|
||||
|
||||
type NotificationBellProps = {
|
||||
notifications: DashboardNotification[];
|
||||
totalCount: number;
|
||||
budgetNotifications: BudgetNotification[];
|
||||
preLancamentosCount?: number;
|
||||
};
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return (
|
||||
formatDateOnly(dateString, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}) ?? dateString
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({
|
||||
icon,
|
||||
title,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3 pb-1 pt-3">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type NotificationItemProps = {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
isOverdue: boolean;
|
||||
title: string;
|
||||
detail: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function NotificationItem({
|
||||
href,
|
||||
icon,
|
||||
isOverdue,
|
||||
title,
|
||||
detail,
|
||||
onClose,
|
||||
}: NotificationItemProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"group mx-1 mb-0.5 flex items-start gap-2.5 rounded-md px-2 py-2 transition-colors hover:bg-accent/60",
|
||||
isOverdue && "bg-destructive/5 hover:bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span className="flex flex-1 flex-col gap-0.5 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] font-medium leading-snug",
|
||||
isOverdue ? "text-destructive" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{detail}
|
||||
</span>
|
||||
</span>
|
||||
<RiArrowRightLine className="mt-0.5 size-3 shrink-0 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationBell({
|
||||
notifications,
|
||||
totalCount,
|
||||
budgetNotifications,
|
||||
preLancamentosCount = 0,
|
||||
}: NotificationBellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const effectiveTotalCount =
|
||||
totalCount + preLancamentosCount + budgetNotifications.length;
|
||||
const displayCount =
|
||||
effectiveTotalCount > 99 ? "99+" : effectiveTotalCount.toString();
|
||||
const hasNotifications = effectiveTotalCount > 0;
|
||||
|
||||
const invoiceNotifications = notifications.filter(
|
||||
(n) => n.type === "invoice",
|
||||
);
|
||||
const boletoNotifications = notifications.filter((n) => n.type === "boleto");
|
||||
export function NotificationBell(props: NotificationBellProps) {
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
displayCount,
|
||||
hasUnreadNotifications,
|
||||
hasAnySourceItems,
|
||||
headerCountLabel,
|
||||
hasDashboardNotificationItems,
|
||||
hasArchivedItems,
|
||||
archivedDashboardCount,
|
||||
hasVisibleItems,
|
||||
displayedPreLancamentosCount,
|
||||
displayedBudgetNotifications,
|
||||
invoiceNotifications,
|
||||
boletoNotifications,
|
||||
handleInboxNavigate,
|
||||
handleNotificationNavigate,
|
||||
handleToggleRead,
|
||||
handleToggleArchive,
|
||||
showArchived,
|
||||
} = useNotificationBell(props);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notificações"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"group relative shadow-none transition-all duration-200",
|
||||
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20",
|
||||
"data-[state=open]:bg-black/10 data-[state=open]:text-black",
|
||||
hasNotifications ? "text-black" : "text-black/75",
|
||||
)}
|
||||
>
|
||||
<RiNotification2Line
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
open ? "scale-90" : "scale-100",
|
||||
)}
|
||||
/>
|
||||
{hasNotifications && (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[12px] font-semibold text-destructive-foreground"
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/40" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
Notificações
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<NotificationBellTrigger
|
||||
open={open}
|
||||
hasAnySourceItems={hasAnySourceItems}
|
||||
hasUnreadNotifications={hasUnreadNotifications}
|
||||
displayCount={displayCount}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="w-80 overflow-hidden rounded-lg border border-border/60 bg-popover p-0 shadow-none"
|
||||
className="w-96 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg p-0 shadow-lg"
|
||||
>
|
||||
{/* Header */}
|
||||
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-[12px] font-semibold">
|
||||
<span>Notificações</span>
|
||||
{hasNotifications && (
|
||||
<Badge variant="outline" className="text-[10px] font-semibold">
|
||||
{effectiveTotalCount}{" "}
|
||||
{effectiveTotalCount === 1 ? "item" : "itens"}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<NotificationBellHeader
|
||||
hasAnySourceItems={hasAnySourceItems}
|
||||
headerCountLabel={headerCountLabel}
|
||||
hasDashboardNotificationItems={hasDashboardNotificationItems}
|
||||
viewMode={viewMode}
|
||||
hasArchivedItems={hasArchivedItems}
|
||||
archivedDashboardCount={archivedDashboardCount}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{!hasNotifications ? (
|
||||
<div className="px-4 py-8">
|
||||
<Empty>
|
||||
<EmptyMedia>
|
||||
<RiCheckboxCircleFill color="green" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma notificação</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Você está em dia com seus pagamentos!
|
||||
</EmptyDescription>
|
||||
</Empty>
|
||||
</div>
|
||||
{hasVisibleItems ? (
|
||||
<NotificationBellContent
|
||||
displayedPreLancamentosCount={displayedPreLancamentosCount}
|
||||
displayedBudgetNotifications={displayedBudgetNotifications}
|
||||
invoiceNotifications={invoiceNotifications}
|
||||
boletoNotifications={boletoNotifications}
|
||||
onInboxNavigate={handleInboxNavigate}
|
||||
onNotificationNavigate={handleNotificationNavigate}
|
||||
onToggleRead={handleToggleRead}
|
||||
onToggleArchive={handleToggleArchive}
|
||||
/>
|
||||
) : (
|
||||
<div className="max-h-[460px] overflow-y-auto pb-2">
|
||||
{/* Pré-lançamentos */}
|
||||
{preLancamentosCount > 0 && (
|
||||
<div>
|
||||
<SectionLabel
|
||||
icon={<RiAtLine className="size-3" />}
|
||||
title="Pré-lançamentos"
|
||||
/>
|
||||
<NotificationItem
|
||||
href="/inbox"
|
||||
isOverdue={false}
|
||||
icon={<RiAtLine className="size-5 text-primary" />}
|
||||
title={
|
||||
preLancamentosCount === 1
|
||||
? "1 pré-lançamento pendente"
|
||||
: `${preLancamentosCount} pré-lançamentos pendentes`
|
||||
}
|
||||
detail="Aguardando revisão"
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orçamentos */}
|
||||
{budgetNotifications.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel
|
||||
icon={<RiBarChart2Line className="size-3" />}
|
||||
title="Orçamentos"
|
||||
/>
|
||||
{budgetNotifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
href="/budgets"
|
||||
isOverdue={n.status === "exceeded"}
|
||||
icon={
|
||||
n.status === "exceeded" ? (
|
||||
<RiAlertFill className="size-5 text-destructive" />
|
||||
) : (
|
||||
<RiErrorWarningLine className="size-5 text-amber-500" />
|
||||
)
|
||||
}
|
||||
title={n.categoryName}
|
||||
detail={
|
||||
n.status === "exceeded"
|
||||
? `Excedido — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)} (${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })})`
|
||||
: `${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })} utilizado — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)}`
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cartão de Crédito */}
|
||||
{invoiceNotifications.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel
|
||||
icon={<RiBankCardLine className="size-3" />}
|
||||
title="Cartão de Crédito"
|
||||
/>
|
||||
{invoiceNotifications.map((n) => {
|
||||
const logo = resolveLogoSrc(n.cardLogo);
|
||||
return (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
href="/cards"
|
||||
isOverdue={n.status === "overdue"}
|
||||
icon={
|
||||
logo ? (
|
||||
<Image
|
||||
src={logo}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 rounded-full object-contain"
|
||||
/>
|
||||
) : n.status === "overdue" ? (
|
||||
<RiAlertFill className="size-5 text-destructive" />
|
||||
) : (
|
||||
<RiTimeLine className="size-5 text-amber-500" />
|
||||
)
|
||||
}
|
||||
title={n.name}
|
||||
detail={
|
||||
n.status === "overdue"
|
||||
? `Venceu em ${formatDate(n.dueDate)}${n.showAmount && n.amount > 0 ? ` — ${formatCurrency(n.amount)}` : ""}`
|
||||
: `Vence em ${formatDate(n.dueDate)}${n.showAmount && n.amount > 0 ? ` — ${formatCurrency(n.amount)}` : ""}`
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Boletos */}
|
||||
{boletoNotifications.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel
|
||||
icon={<RiFileListLine className="size-3" />}
|
||||
title="Boletos"
|
||||
/>
|
||||
{boletoNotifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
href="/transactions"
|
||||
isOverdue={n.status === "overdue"}
|
||||
icon={
|
||||
<RiAlertFill
|
||||
className={cn(
|
||||
"size-5",
|
||||
n.status === "overdue"
|
||||
? "text-destructive"
|
||||
: "text-amber-500",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
title={n.name}
|
||||
detail={
|
||||
n.status === "overdue"
|
||||
? `Venceu em ${formatDate(n.dueDate)}${n.amount > 0 ? ` — ${formatCurrency(n.amount)}` : ""}`
|
||||
: `Vence em ${formatDate(n.dueDate)}${n.amount > 0 ? ` — ${formatCurrency(n.amount)}` : ""}`
|
||||
}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NotificationBellEmptyState
|
||||
showArchived={showArchived}
|
||||
hasArchivedItems={hasArchivedItems}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiArchiveLine,
|
||||
RiArrowGoBackLine,
|
||||
RiAtLine,
|
||||
RiBankCardLine,
|
||||
RiBarChart2Line,
|
||||
RiCheckLine,
|
||||
RiErrorWarningLine,
|
||||
RiFileListLine,
|
||||
RiInboxUnarchiveLine,
|
||||
RiTimeLine,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
import { buttonVariants } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatDateOnly } from "@/shared/utils/date";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import type {
|
||||
ResolvedBudgetNotification,
|
||||
ResolvedDashboardNotification,
|
||||
StatefulNotification,
|
||||
} from "./types";
|
||||
|
||||
type NotificationBellContentProps = {
|
||||
displayedPreLancamentosCount: number;
|
||||
displayedBudgetNotifications: ResolvedBudgetNotification[];
|
||||
invoiceNotifications: ResolvedDashboardNotification[];
|
||||
boletoNotifications: ResolvedDashboardNotification[];
|
||||
onInboxNavigate: () => void;
|
||||
onNotificationNavigate: (notification: StatefulNotification) => Promise<void>;
|
||||
onToggleRead: (notification: StatefulNotification) => Promise<void>;
|
||||
onToggleArchive: (notification: StatefulNotification) => Promise<void>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return (
|
||||
formatDateOnly(dateString, { day: "2-digit", month: "short" }) ?? dateString
|
||||
);
|
||||
}
|
||||
|
||||
function getReadAction(notification: StatefulNotification) {
|
||||
return {
|
||||
label: notification.isRead ? "Marcar como não lida" : "Marcar como lida",
|
||||
icon: notification.isRead ? (
|
||||
<RiArrowGoBackLine className="size-4" />
|
||||
) : (
|
||||
<RiCheckLine className="size-4" />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getArchiveAction(notification: StatefulNotification) {
|
||||
return {
|
||||
label: notification.isArchived
|
||||
? "Desarquivar notificação"
|
||||
: "Arquivar notificação",
|
||||
icon: notification.isArchived ? (
|
||||
<RiInboxUnarchiveLine className="size-4" />
|
||||
) : (
|
||||
<RiArchiveLine className="size-4" />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SectionLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionLabel({
|
||||
icon,
|
||||
title,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 p-2 first:pt-1">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NotificationActionButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick?: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onClick?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"size-7 text-muted-foreground opacity-0 transition-all group-hover/item:opacity-100 hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NotificationItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type NotificationItemProps = {
|
||||
icon: React.ReactNode;
|
||||
isOverdue: boolean;
|
||||
isRead?: boolean;
|
||||
isArchived?: boolean;
|
||||
isBusy?: boolean;
|
||||
showUnreadIndicator?: boolean;
|
||||
title: string;
|
||||
detail: string;
|
||||
onNavigate: () => void | Promise<void>;
|
||||
onToggleRead?: () => void | Promise<void>;
|
||||
onToggleArchive?: () => void | Promise<void>;
|
||||
notification?: StatefulNotification;
|
||||
};
|
||||
|
||||
function NotificationItem({
|
||||
icon,
|
||||
isOverdue,
|
||||
isRead = false,
|
||||
isArchived = false,
|
||||
isBusy = false,
|
||||
showUnreadIndicator = false,
|
||||
title,
|
||||
detail,
|
||||
onNavigate,
|
||||
onToggleRead,
|
||||
onToggleArchive,
|
||||
notification,
|
||||
}: NotificationItemProps) {
|
||||
const readAction = notification ? getReadAction(notification) : null;
|
||||
const archiveAction = notification ? getArchiveAction(notification) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/item mx-1 mb-0.5 flex items-center gap-2.5 rounded-md px-2.5 py-2 transition-colors",
|
||||
isArchived
|
||||
? "opacity-60"
|
||||
: isOverdue && !isRead
|
||||
? "bg-destructive/5"
|
||||
: "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onNavigate();
|
||||
}}
|
||||
disabled={isBusy}
|
||||
className="flex min-w-0 flex-1 items-start gap-2.5 text-left disabled:cursor-wait disabled:opacity-80"
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-xs font-medium leading-snug",
|
||||
isOverdue && !isRead
|
||||
? "text-destructive"
|
||||
: isRead
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{showUnreadIndicator && !isRead && (
|
||||
<StatusDot color="bg-destructive/80" className="size-1.5" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs leading-snug",
|
||||
isRead ? "text-muted-foreground/70" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{(readAction || archiveAction) && (
|
||||
<div className="flex w-16 shrink-0 items-center justify-end gap-0.5">
|
||||
{readAction && onToggleRead && (
|
||||
<NotificationActionButton
|
||||
label={readAction.label}
|
||||
icon={readAction.icon}
|
||||
onClick={onToggleRead}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
)}
|
||||
{archiveAction && onToggleArchive && (
|
||||
<NotificationActionButton
|
||||
label={archiveAction.label}
|
||||
icon={archiveAction.icon}
|
||||
onClick={onToggleArchive}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NotificationSection — generic wrapper to eliminate per-type repetition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type NotificationSectionProps<
|
||||
T extends StatefulNotification & { isBusy: boolean },
|
||||
> = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
items: T[];
|
||||
renderIcon: (item: T) => React.ReactNode;
|
||||
renderTitle: (item: T) => string;
|
||||
renderDetail: (item: T) => string;
|
||||
isOverdue: (item: T) => boolean;
|
||||
showUnreadIndicator?: boolean;
|
||||
onNavigate: (item: T) => void | Promise<void>;
|
||||
onToggleRead: (item: T) => void | Promise<void>;
|
||||
onToggleArchive: (item: T) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function NotificationSection<
|
||||
T extends StatefulNotification & { isBusy: boolean },
|
||||
>({
|
||||
icon,
|
||||
title,
|
||||
items,
|
||||
renderIcon,
|
||||
renderTitle,
|
||||
renderDetail,
|
||||
isOverdue,
|
||||
showUnreadIndicator = false,
|
||||
onNavigate,
|
||||
onToggleRead,
|
||||
onToggleArchive,
|
||||
}: NotificationSectionProps<T>) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel icon={icon} title={title} />
|
||||
{items.map((item) => (
|
||||
<NotificationItem
|
||||
key={item.notificationKey}
|
||||
isOverdue={isOverdue(item)}
|
||||
isRead={item.isRead}
|
||||
isArchived={item.isArchived}
|
||||
isBusy={item.isBusy}
|
||||
showUnreadIndicator={showUnreadIndicator}
|
||||
icon={renderIcon(item)}
|
||||
title={renderTitle(item)}
|
||||
detail={renderDetail(item)}
|
||||
onNavigate={() => onNavigate(item)}
|
||||
onToggleRead={() => onToggleRead(item)}
|
||||
onToggleArchive={() => onToggleArchive(item)}
|
||||
notification={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon helpers (consistent between invoice / boleto)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DueDateIcon({ isOverdue }: { isOverdue: boolean }) {
|
||||
return isOverdue ? (
|
||||
<RiAlertFill className="size-5 text-destructive" />
|
||||
) : (
|
||||
<RiTimeLine className="size-5 text-amber-500" />
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceIcon({
|
||||
cardLogo,
|
||||
isOverdue,
|
||||
}: {
|
||||
cardLogo?: string | null;
|
||||
isOverdue: boolean;
|
||||
}) {
|
||||
const logo = resolveLogoSrc(cardLogo);
|
||||
|
||||
if (logo) {
|
||||
return (
|
||||
<Image
|
||||
src={logo}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 rounded-full object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <DueDateIcon isOverdue={isOverdue} />;
|
||||
}
|
||||
|
||||
function formatDueDateDetail(
|
||||
status: string,
|
||||
dueDate: string,
|
||||
amount: number,
|
||||
showAmount: boolean,
|
||||
) {
|
||||
const verb = status === "overdue" ? "Venceu em" : "Vence em";
|
||||
const amountStr =
|
||||
showAmount && amount > 0 ? ` — ${formatCurrency(amount)}` : "";
|
||||
return `${verb} ${formatDate(dueDate)}${amountStr}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function NotificationBellContent({
|
||||
displayedPreLancamentosCount,
|
||||
displayedBudgetNotifications,
|
||||
invoiceNotifications,
|
||||
boletoNotifications,
|
||||
onInboxNavigate,
|
||||
onNotificationNavigate,
|
||||
onToggleRead,
|
||||
onToggleArchive,
|
||||
}: NotificationBellContentProps) {
|
||||
return (
|
||||
<div className="max-h-[460px] overflow-y-auto p-2">
|
||||
{displayedPreLancamentosCount > 0 && (
|
||||
<div>
|
||||
<SectionLabel
|
||||
icon={<RiAtLine className="size-3" />}
|
||||
title="Pré-lançamentos"
|
||||
/>
|
||||
<NotificationItem
|
||||
icon={<RiAtLine className="size-5 text-primary" />}
|
||||
isOverdue={false}
|
||||
title={
|
||||
displayedPreLancamentosCount === 1
|
||||
? "1 pré-lançamento pendente"
|
||||
: `${displayedPreLancamentosCount} pré-lançamentos pendentes`
|
||||
}
|
||||
detail="Aguardando revisão"
|
||||
onNavigate={onInboxNavigate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NotificationSection
|
||||
icon={<RiBarChart2Line className="size-3" />}
|
||||
title="Orçamentos"
|
||||
items={displayedBudgetNotifications}
|
||||
isOverdue={(n) => n.status === "exceeded"}
|
||||
showUnreadIndicator
|
||||
renderIcon={(n) =>
|
||||
n.status === "exceeded" ? (
|
||||
<RiAlertFill className="size-5 text-destructive" />
|
||||
) : (
|
||||
<RiErrorWarningLine className="size-5 text-amber-500" />
|
||||
)
|
||||
}
|
||||
renderTitle={(n) => n.categoryName}
|
||||
renderDetail={(n) =>
|
||||
n.status === "exceeded"
|
||||
? `Excedido — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)} (${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })})`
|
||||
: `${formatPercentage(n.usedPercentage, { maximumFractionDigits: 0, minimumFractionDigits: 0 })} utilizado — ${formatCurrency(n.spentAmount)} de ${formatCurrency(n.budgetAmount)}`
|
||||
}
|
||||
onNavigate={(n) => onNotificationNavigate(n)}
|
||||
onToggleRead={(n) => onToggleRead(n)}
|
||||
onToggleArchive={(n) => onToggleArchive(n)}
|
||||
/>
|
||||
|
||||
<NotificationSection
|
||||
icon={<RiBankCardLine className="size-3" />}
|
||||
title="Cartão de Crédito"
|
||||
items={invoiceNotifications}
|
||||
isOverdue={(n) => n.status === "overdue"}
|
||||
showUnreadIndicator
|
||||
renderIcon={(n) => (
|
||||
<InvoiceIcon
|
||||
cardLogo={n.cardLogo}
|
||||
isOverdue={n.status === "overdue"}
|
||||
/>
|
||||
)}
|
||||
renderTitle={(n) => n.name}
|
||||
renderDetail={(n) =>
|
||||
formatDueDateDetail(n.status, n.dueDate, n.amount, n.showAmount)
|
||||
}
|
||||
onNavigate={(n) => onNotificationNavigate(n)}
|
||||
onToggleRead={(n) => onToggleRead(n)}
|
||||
onToggleArchive={(n) => onToggleArchive(n)}
|
||||
/>
|
||||
|
||||
<NotificationSection
|
||||
icon={<RiFileListLine className="size-3" />}
|
||||
title="Boletos"
|
||||
items={boletoNotifications}
|
||||
isOverdue={(n) => n.status === "overdue"}
|
||||
showUnreadIndicator
|
||||
renderIcon={(n) => <DueDateIcon isOverdue={n.status === "overdue"} />}
|
||||
renderTitle={(n) => n.name}
|
||||
renderDetail={(n) =>
|
||||
formatDueDateDetail(n.status, n.dueDate, n.amount, true)
|
||||
}
|
||||
onNavigate={(n) => onNotificationNavigate(n)}
|
||||
onToggleRead={(n) => onToggleRead(n)}
|
||||
onToggleArchive={(n) => onToggleArchive(n)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { RiArchiveLine, RiCheckboxCircleFill } from "@remixicon/react";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/shared/components/ui/empty";
|
||||
|
||||
type NotificationBellEmptyStateProps = {
|
||||
showArchived: boolean;
|
||||
hasArchivedItems: boolean;
|
||||
};
|
||||
|
||||
export function NotificationBellEmptyState({
|
||||
showArchived,
|
||||
hasArchivedItems,
|
||||
}: NotificationBellEmptyStateProps) {
|
||||
return (
|
||||
<div className="px-4 py-8">
|
||||
<Empty>
|
||||
<EmptyMedia>
|
||||
{showArchived ? (
|
||||
<RiArchiveLine className="text-muted-foreground" />
|
||||
) : (
|
||||
<RiCheckboxCircleFill color="green" />
|
||||
)}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>
|
||||
{showArchived
|
||||
? "Nenhuma notificação arquivada"
|
||||
: hasArchivedItems
|
||||
? "Nenhuma notificação ativa"
|
||||
: "Nenhuma notificação"}
|
||||
</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{showArchived
|
||||
? "Você ainda não arquivou nenhuma notificação."
|
||||
: hasArchivedItems
|
||||
? "As demais notificações estão arquivadas. Ative o filtro para revê-las."
|
||||
: "Você está em dia com seus pagamentos!"}
|
||||
</EmptyDescription>
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/shared/components/ui/toggle-group";
|
||||
import type { NotificationViewMode } from "./types";
|
||||
|
||||
type NotificationBellHeaderProps = {
|
||||
hasAnySourceItems: boolean;
|
||||
headerCountLabel: string;
|
||||
hasDashboardNotificationItems: boolean;
|
||||
viewMode: NotificationViewMode;
|
||||
hasArchivedItems: boolean;
|
||||
archivedDashboardCount: number;
|
||||
onViewModeChange: (viewMode: NotificationViewMode) => void;
|
||||
};
|
||||
|
||||
export function NotificationBellHeader({
|
||||
hasAnySourceItems,
|
||||
headerCountLabel,
|
||||
hasDashboardNotificationItems,
|
||||
viewMode,
|
||||
hasArchivedItems,
|
||||
archivedDashboardCount,
|
||||
onViewModeChange,
|
||||
}: NotificationBellHeaderProps) {
|
||||
return (
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2 text-sm font-semibold">
|
||||
<span>Notificações</span>
|
||||
{hasAnySourceItems ? (
|
||||
<Badge variant="outline" className="text-xs font-medium">
|
||||
{headerCountLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{hasDashboardNotificationItems ? (
|
||||
<div className="pt-2.5">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={viewMode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
if (value === "archived" && !hasArchivedItems) return;
|
||||
onViewModeChange(value as NotificationViewMode);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full rounded-md bg-muted/30 p-0.5"
|
||||
aria-label="Filtro da lista de notificações"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="active"
|
||||
className="flex-1 text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||
aria-label="Mostrar notificações ativas"
|
||||
>
|
||||
Ativas
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="archived"
|
||||
className="flex-1 text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||
aria-label="Mostrar notificações arquivadas"
|
||||
disabled={!hasArchivedItems && viewMode !== "archived"}
|
||||
>
|
||||
Arquivadas
|
||||
{hasArchivedItems ? ` (${archivedDashboardCount})` : ""}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { RiNotification2Line } from "@remixicon/react";
|
||||
import { buttonVariants } from "@/shared/components/ui/button";
|
||||
import { DropdownMenuTrigger } from "@/shared/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type NotificationBellTriggerProps = {
|
||||
open: boolean;
|
||||
hasAnySourceItems: boolean;
|
||||
hasUnreadNotifications: boolean;
|
||||
displayCount: string;
|
||||
};
|
||||
|
||||
export function NotificationBellTrigger({
|
||||
open,
|
||||
hasAnySourceItems,
|
||||
hasUnreadNotifications,
|
||||
displayCount,
|
||||
}: NotificationBellTriggerProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notificações"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"group relative shadow-none transition-all duration-200",
|
||||
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20",
|
||||
"data-[state=open]:bg-black/10 data-[state=open]:text-black",
|
||||
hasAnySourceItems ? "text-black" : "text-black/75",
|
||||
)}
|
||||
>
|
||||
<RiNotification2Line
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
open ? "scale-90" : "scale-100",
|
||||
)}
|
||||
/>
|
||||
{hasUnreadNotifications ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs text-white"
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/5 [animation-iteration-count:3]" />
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
Notificações
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
BudgetNotification,
|
||||
DashboardNotification,
|
||||
} from "@/shared/lib/types/notifications";
|
||||
|
||||
export type StatefulNotification = {
|
||||
notificationKey: string;
|
||||
fingerprint: string;
|
||||
href: string;
|
||||
isRead: boolean;
|
||||
isArchived: boolean;
|
||||
readAt: Date | null;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
export type NotificationActionState = {
|
||||
isRead: boolean;
|
||||
isArchived: boolean;
|
||||
isBusy: boolean;
|
||||
};
|
||||
|
||||
export type NotificationViewMode = "active" | "archived";
|
||||
|
||||
export type ResolvedDashboardNotification = DashboardNotification & {
|
||||
isBusy: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedBudgetNotification = BudgetNotification & {
|
||||
isBusy: boolean;
|
||||
};
|
||||
|
||||
export type NotificationBellProps = {
|
||||
notifications: DashboardNotification[];
|
||||
unreadCount: number;
|
||||
visibleCount: number;
|
||||
budgetNotifications: BudgetNotification[];
|
||||
preLancamentosCount?: number;
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
archiveDashboardNotificationAction,
|
||||
markDashboardNotificationAsReadAction,
|
||||
markDashboardNotificationAsUnreadAction,
|
||||
unarchiveDashboardNotificationAction,
|
||||
} from "@/features/dashboard/notifications-actions";
|
||||
import type {
|
||||
NotificationActionState,
|
||||
NotificationBellProps,
|
||||
NotificationViewMode,
|
||||
ResolvedBudgetNotification,
|
||||
ResolvedDashboardNotification,
|
||||
StatefulNotification,
|
||||
} from "./types";
|
||||
|
||||
type NotificationAction = "read" | "unread" | "archive" | "unarchive";
|
||||
|
||||
type UseNotificationBellReturn = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
viewMode: NotificationViewMode;
|
||||
setViewMode: (viewMode: NotificationViewMode) => void;
|
||||
displayCount: string;
|
||||
hasUnreadNotifications: boolean;
|
||||
hasAnySourceItems: boolean;
|
||||
headerCountLabel: string;
|
||||
hasDashboardNotificationItems: boolean;
|
||||
hasArchivedItems: boolean;
|
||||
archivedDashboardCount: number;
|
||||
hasVisibleItems: boolean;
|
||||
displayedPreLancamentosCount: number;
|
||||
displayedBudgetNotifications: ResolvedBudgetNotification[];
|
||||
invoiceNotifications: ResolvedDashboardNotification[];
|
||||
boletoNotifications: ResolvedDashboardNotification[];
|
||||
handleInboxNavigate: () => void;
|
||||
handleNotificationNavigate: (
|
||||
notification: StatefulNotification,
|
||||
) => Promise<void>;
|
||||
handleToggleRead: (notification: StatefulNotification) => Promise<void>;
|
||||
handleToggleArchive: (notification: StatefulNotification) => Promise<void>;
|
||||
showArchived: boolean;
|
||||
};
|
||||
|
||||
const optimisticStateByAction: Record<
|
||||
NotificationAction,
|
||||
(notification: StatefulNotification) => NotificationActionState
|
||||
> = {
|
||||
archive: () => ({ isRead: true, isArchived: true, isBusy: true }),
|
||||
unarchive: () => ({ isRead: true, isArchived: false, isBusy: true }),
|
||||
read: (n) => ({
|
||||
isRead: true,
|
||||
isArchived: n.isArchived,
|
||||
isBusy: true,
|
||||
}),
|
||||
unread: (n) => ({
|
||||
isRead: false,
|
||||
isArchived: n.isArchived,
|
||||
isBusy: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const serverActionByType: Record<
|
||||
NotificationAction,
|
||||
(input: {
|
||||
notificationKey: string;
|
||||
fingerprint: string;
|
||||
}) => Promise<{ success: boolean; message?: string; error?: string }>
|
||||
> = {
|
||||
archive: archiveDashboardNotificationAction,
|
||||
unarchive: unarchiveDashboardNotificationAction,
|
||||
read: markDashboardNotificationAsReadAction,
|
||||
unread: markDashboardNotificationAsUnreadAction,
|
||||
};
|
||||
|
||||
export function useNotificationBell({
|
||||
notifications,
|
||||
unreadCount: initialUnreadCount,
|
||||
visibleCount: initialVisibleCount,
|
||||
budgetNotifications,
|
||||
preLancamentosCount = 0,
|
||||
}: NotificationBellProps): UseNotificationBellReturn {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<NotificationViewMode>("active");
|
||||
const [notificationActions, setNotificationActions] = useState<
|
||||
Record<string, NotificationActionState>
|
||||
>({});
|
||||
const router = useRouter();
|
||||
const showArchived = viewMode === "archived";
|
||||
|
||||
// Limpar estado otimista quando o server retorna dados novos (via router.refresh)
|
||||
const prevNotificationsRef = useRef(notifications);
|
||||
const prevBudgetRef = useRef(budgetNotifications);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevNotificationsRef.current !== notifications ||
|
||||
prevBudgetRef.current !== budgetNotifications
|
||||
) {
|
||||
prevNotificationsRef.current = notifications;
|
||||
prevBudgetRef.current = budgetNotifications;
|
||||
setNotificationActions({});
|
||||
}
|
||||
}, [notifications, budgetNotifications]);
|
||||
|
||||
const resolveNotificationState = <T extends StatefulNotification>(
|
||||
notification: T,
|
||||
): T & { isBusy: boolean } => {
|
||||
const actionState = notificationActions[notification.notificationKey];
|
||||
|
||||
if (!actionState) {
|
||||
return { ...notification, isBusy: false };
|
||||
}
|
||||
|
||||
return {
|
||||
...notification,
|
||||
isRead: actionState.isRead,
|
||||
isArchived: actionState.isArchived,
|
||||
isBusy: actionState.isBusy,
|
||||
};
|
||||
};
|
||||
|
||||
const allResolvedNotifications = notifications.map((notification) =>
|
||||
resolveNotificationState(notification),
|
||||
);
|
||||
const allResolvedBudgetNotifications = budgetNotifications.map(
|
||||
(notification) => resolveNotificationState(notification),
|
||||
);
|
||||
const activeNotifications = allResolvedNotifications.filter(
|
||||
(notification) => !notification.isArchived,
|
||||
);
|
||||
const activeBudgetNotifications = allResolvedBudgetNotifications.filter(
|
||||
(notification) => !notification.isArchived,
|
||||
);
|
||||
const archivedNotifications = allResolvedNotifications.filter(
|
||||
(notification) => notification.isArchived,
|
||||
);
|
||||
const archivedBudgetNotifications = allResolvedBudgetNotifications.filter(
|
||||
(notification) => notification.isArchived,
|
||||
);
|
||||
const displayedNotifications = showArchived
|
||||
? archivedNotifications
|
||||
: activeNotifications;
|
||||
const displayedBudgetNotifications = showArchived
|
||||
? archivedBudgetNotifications
|
||||
: activeBudgetNotifications;
|
||||
const invoiceNotifications = displayedNotifications.filter(
|
||||
(notification) => notification.type === "invoice",
|
||||
);
|
||||
const boletoNotifications = displayedNotifications.filter(
|
||||
(notification) => notification.type === "boleto",
|
||||
);
|
||||
const unreadDashboardCount = [
|
||||
...activeNotifications,
|
||||
...activeBudgetNotifications,
|
||||
].filter((notification) => !notification.isRead).length;
|
||||
const activeDashboardCountFromItems =
|
||||
activeNotifications.length + activeBudgetNotifications.length;
|
||||
const displayedDashboardCountFromItems =
|
||||
displayedNotifications.length + displayedBudgetNotifications.length;
|
||||
const archivedDashboardCount =
|
||||
allResolvedNotifications.length +
|
||||
allResolvedBudgetNotifications.length -
|
||||
activeDashboardCountFromItems;
|
||||
const dashboardNotificationCount =
|
||||
allResolvedNotifications.length + allResolvedBudgetNotifications.length;
|
||||
const hasOptimisticState = Object.keys(notificationActions).length > 0;
|
||||
const unreadDashboardCountValue = hasOptimisticState
|
||||
? unreadDashboardCount
|
||||
: initialUnreadCount;
|
||||
const activeDashboardCount = hasOptimisticState
|
||||
? activeDashboardCountFromItems
|
||||
: initialVisibleCount;
|
||||
const displayedDashboardCount = showArchived
|
||||
? displayedDashboardCountFromItems
|
||||
: activeDashboardCount;
|
||||
const displayedPreLancamentosCount = showArchived ? 0 : preLancamentosCount;
|
||||
const effectiveUnreadCount = unreadDashboardCountValue + preLancamentosCount;
|
||||
const displayCount =
|
||||
effectiveUnreadCount > 99 ? "99+" : effectiveUnreadCount.toString();
|
||||
const hasUnreadNotifications = effectiveUnreadCount > 0;
|
||||
const hasVisibleItems =
|
||||
displayedDashboardCount + displayedPreLancamentosCount > 0;
|
||||
const hasArchivedItems = archivedDashboardCount > 0;
|
||||
const hasDashboardNotificationItems = dashboardNotificationCount > 0;
|
||||
const hasAnySourceItems =
|
||||
allResolvedNotifications.length +
|
||||
allResolvedBudgetNotifications.length +
|
||||
preLancamentosCount >
|
||||
0;
|
||||
const headerCountLabel = `${effectiveUnreadCount} ${effectiveUnreadCount === 1 ? "pendente" : "pendentes"}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (showArchived && !hasArchivedItems) {
|
||||
setViewMode("active");
|
||||
}
|
||||
}, [hasArchivedItems, showArchived]);
|
||||
|
||||
const persistNotificationState = async (
|
||||
notification: StatefulNotification,
|
||||
action: NotificationAction,
|
||||
options?: { showToast?: boolean; refreshAfter?: boolean },
|
||||
): Promise<boolean> => {
|
||||
const showToast = options?.showToast ?? true;
|
||||
const refreshAfter = options?.refreshAfter ?? true;
|
||||
|
||||
const previousState: NotificationActionState = {
|
||||
isRead: notification.isRead,
|
||||
isArchived: notification.isArchived,
|
||||
isBusy: false,
|
||||
};
|
||||
|
||||
const optimisticState = optimisticStateByAction[action](notification);
|
||||
|
||||
setNotificationActions((current) => ({
|
||||
...current,
|
||||
[notification.notificationKey]: optimisticState,
|
||||
}));
|
||||
|
||||
const result = await serverActionByType[action]({
|
||||
notificationKey: notification.notificationKey,
|
||||
fingerprint: notification.fingerprint,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setNotificationActions((current) => ({
|
||||
...current,
|
||||
[notification.notificationKey]: previousState,
|
||||
}));
|
||||
|
||||
if (showToast) {
|
||||
toast.error(result.error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
setNotificationActions((current) => ({
|
||||
...current,
|
||||
[notification.notificationKey]: {
|
||||
isRead: optimisticState.isRead,
|
||||
isArchived: optimisticState.isArchived,
|
||||
isBusy: false,
|
||||
},
|
||||
}));
|
||||
|
||||
if (showToast) {
|
||||
toast.success(result.message);
|
||||
}
|
||||
|
||||
if (refreshAfter) {
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleInboxNavigate = () => {
|
||||
setOpen(false);
|
||||
router.push("/inbox");
|
||||
};
|
||||
|
||||
const handleNotificationNavigate = async (
|
||||
notification: StatefulNotification,
|
||||
) => {
|
||||
setOpen(false);
|
||||
|
||||
if (!notification.isRead) {
|
||||
await persistNotificationState(notification, "read", {
|
||||
showToast: false,
|
||||
refreshAfter: false,
|
||||
});
|
||||
}
|
||||
|
||||
router.push(notification.href);
|
||||
};
|
||||
|
||||
const handleToggleRead = async (notification: StatefulNotification) => {
|
||||
await persistNotificationState(
|
||||
notification,
|
||||
notification.isRead ? "unread" : "read",
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleArchive = async (notification: StatefulNotification) => {
|
||||
await persistNotificationState(
|
||||
notification,
|
||||
notification.isArchived ? "unarchive" : "archive",
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
displayCount,
|
||||
hasUnreadNotifications,
|
||||
hasAnySourceItems,
|
||||
headerCountLabel,
|
||||
hasDashboardNotificationItems,
|
||||
hasArchivedItems,
|
||||
archivedDashboardCount,
|
||||
hasVisibleItems,
|
||||
displayedPreLancamentosCount,
|
||||
displayedBudgetNotifications,
|
||||
invoiceNotifications,
|
||||
boletoNotifications,
|
||||
handleInboxNavigate,
|
||||
handleNotificationNavigate,
|
||||
handleToggleRead,
|
||||
handleToggleArchive,
|
||||
showArchived,
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export const revalidateConfig = {
|
||||
budgets: ["/budgets"],
|
||||
payers: ["/payers"],
|
||||
notes: ["/notes", "/notes/archived", "/dashboard"],
|
||||
notifications: ["/dashboard"],
|
||||
transactions: ["/transactions", "/accounts"],
|
||||
inbox: ["/inbox", "/transactions", "/dashboard"],
|
||||
} as const;
|
||||
@@ -43,6 +44,7 @@ const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
|
||||
"budgets",
|
||||
"payers",
|
||||
"notes",
|
||||
"notifications",
|
||||
"inbox",
|
||||
"recurring",
|
||||
]);
|
||||
|
||||
16
src/shared/lib/notifications/is-table-missing.ts
Normal file
16
src/shared/lib/notifications/is-table-missing.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Detecta se um erro indica que a tabela `dashboard_notification_states`
|
||||
* ainda nao existe no banco (migration pendente).
|
||||
*/
|
||||
export function isNotificationStatesTableMissing(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
return (
|
||||
message.includes("dashboard_notification_states") &&
|
||||
(message.includes("does not exist") || message.includes("relation"))
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./actions";
|
||||
export * from "./calendar";
|
||||
export * from "./notifications";
|
||||
export * from "./reports";
|
||||
|
||||
39
src/shared/lib/types/notifications.ts
Normal file
39
src/shared/lib/types/notifications.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type NotificationType = "overdue" | "due_soon";
|
||||
|
||||
export type BudgetStatus = "exceeded" | "critical";
|
||||
|
||||
export type DashboardNotificationStateFields = {
|
||||
notificationKey: string;
|
||||
fingerprint: string;
|
||||
href: string;
|
||||
isRead: boolean;
|
||||
isArchived: boolean;
|
||||
readAt: Date | null;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
export type DashboardNotification = {
|
||||
type: "invoice" | "boleto";
|
||||
name: string;
|
||||
dueDate: string;
|
||||
status: NotificationType;
|
||||
amount: number;
|
||||
period?: string;
|
||||
showAmount: boolean;
|
||||
cardLogo?: string | null;
|
||||
} & DashboardNotificationStateFields;
|
||||
|
||||
export type BudgetNotification = {
|
||||
categoryName: string;
|
||||
budgetAmount: number;
|
||||
spentAmount: number;
|
||||
usedPercentage: number;
|
||||
status: BudgetStatus;
|
||||
} & DashboardNotificationStateFields;
|
||||
|
||||
export type DashboardNotificationsSnapshot = {
|
||||
notifications: DashboardNotification[];
|
||||
budgetNotifications: BudgetNotification[];
|
||||
unreadCount: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
Reference in New Issue
Block a user