feat(dashboard): persist notification center state

This commit is contained in:
Felipe Coutinho
2026-03-25 00:29:24 +00:00
parent 50477fb1be
commit 5f70421f5a
20 changed files with 1620 additions and 407 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
};

View File

@@ -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,
};
}