From 5f70421f5ad90d3e3f266bdd6f62c1c2febc5251 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Wed, 25 Mar 2026 00:29:24 +0000 Subject: [PATCH] feat(dashboard): persist notification center state --- drizzle/0023_next_blue_blade.sql | 2 + drizzle/0024_glorious_mindworm.sql | 14 + src/app/(dashboard)/layout.tsx | 23 +- src/db/schema.ts | 36 +- src/features/dashboard/navbar-queries.ts | 12 +- .../dashboard/notifications-actions.ts | 252 ++++++++++ .../dashboard/notifications-queries.ts | 232 +++++++-- .../navigation/navbar/app-navbar.tsx | 5 +- .../navigation/navbar/navbar-user.tsx | 2 +- .../navigation/navbar/notification-bell.tsx | 388 +++------------ .../notification-bell-content.tsx | 457 ++++++++++++++++++ .../notification-bell-empty-state.tsx | 47 ++ .../notification-bell-header.tsx | 75 +++ .../notification-bell-trigger.tsx | 67 +++ .../navbar/notification-bell/types.ts | 38 ++ .../use-notification-bell.ts | 319 ++++++++++++ src/shared/lib/actions/helpers.ts | 2 + .../lib/notifications/is-table-missing.ts | 16 + src/shared/lib/types/index.ts | 1 + src/shared/lib/types/notifications.ts | 39 ++ 20 files changed, 1620 insertions(+), 407 deletions(-) create mode 100644 drizzle/0023_next_blue_blade.sql create mode 100644 drizzle/0024_glorious_mindworm.sql create mode 100644 src/features/dashboard/notifications-actions.ts create mode 100644 src/shared/components/navigation/navbar/notification-bell/notification-bell-content.tsx create mode 100644 src/shared/components/navigation/navbar/notification-bell/notification-bell-empty-state.tsx create mode 100644 src/shared/components/navigation/navbar/notification-bell/notification-bell-header.tsx create mode 100644 src/shared/components/navigation/navbar/notification-bell/notification-bell-trigger.tsx create mode 100644 src/shared/components/navigation/navbar/notification-bell/types.ts create mode 100644 src/shared/components/navigation/navbar/notification-bell/use-notification-bell.ts create mode 100644 src/shared/lib/notifications/is-table-missing.ts create mode 100644 src/shared/lib/types/notifications.ts diff --git a/drizzle/0023_next_blue_blade.sql b/drizzle/0023_next_blue_blade.sql new file mode 100644 index 0000000..5526b21 --- /dev/null +++ b/drizzle/0023_next_blue_blade.sql @@ -0,0 +1,2 @@ +ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint +ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font"; diff --git a/drizzle/0024_glorious_mindworm.sql b/drizzle/0024_glorious_mindworm.sql new file mode 100644 index 0000000..15ee75b --- /dev/null +++ b/drizzle/0024_glorious_mindworm.sql @@ -0,0 +1,14 @@ +CREATE TABLE "dashboard_notification_states" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "notification_key" text NOT NULL, + "fingerprint" text NOT NULL, + "read_at" timestamp with time zone, + "archived_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint +CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at"); diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index c177745..ac226f3 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -3,33 +3,14 @@ import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { DotPattern } from "@/shared/components/ui/dot-pattern"; import { getUserSession } from "@/shared/lib/auth/server"; -import { parsePeriodParam } from "@/shared/utils/period"; export default async function DashboardLayout({ children, - searchParams, }: Readonly<{ children: React.ReactNode; - searchParams?: Promise>; }>) { const session = await getUserSession(); - - // Buscar notificações para o período atual - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = resolvedSearchParams?.periodo; - const singlePeriodoParam = - typeof periodoParam === "string" - ? periodoParam - : Array.isArray(periodoParam) - ? periodoParam[0] - : null; - const { period: currentPeriod } = parsePeriodParam( - singlePeriodoParam ?? null, - ); - const navbarData = await fetchDashboardNavbarData( - session.user.id, - currentPeriod, - ); + const navbarData = await fetchDashboardNavbarData(session.user.id); return ( @@ -40,7 +21,7 @@ export default async function DashboardLayout({ notificationsSnapshot={navbarData.notificationsSnapshot} />
-
+
(), @@ -527,6 +525,40 @@ export const inboxItems = pgTable( }), ); +export const dashboardNotificationStates = pgTable( + "dashboard_notification_states", + { + id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + notificationKey: text("notification_key").notNull(), + fingerprint: text("fingerprint").notNull(), + readAt: timestamp("read_at", { + mode: "date", + withTimezone: true, + }), + archivedAt: timestamp("archived_at", { + mode: "date", + withTimezone: true, + }), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + userIdNotificationKeyUnique: uniqueIndex( + "dashboard_notification_states_user_id_key_unique", + ).on(table.userId, table.notificationKey), + userIdArchivedAtIdx: index( + "dashboard_notification_states_user_id_archived_idx", + ).on(table.userId, table.archivedAt), + }), +); + export const installmentAnticipations = pgTable( "antecipacoes_parcelas", { diff --git a/src/features/dashboard/navbar-queries.ts b/src/features/dashboard/navbar-queries.ts index 3e38a11..dd914cd 100644 --- a/src/features/dashboard/navbar-queries.ts +++ b/src/features/dashboard/navbar-queries.ts @@ -4,6 +4,7 @@ import { payers } from "@/db/schema"; import { fetchPendingInboxCount } from "@/features/inbox/queries"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; +import { getBusinessDateString } from "@/shared/utils/date"; import { type DashboardNotificationsSnapshot, fetchDashboardNotifications, @@ -36,8 +37,8 @@ async function fetchAdminPayerAvatarUrl( async function fetchDashboardNavbarDataInternal( userId: string, - currentPeriod: string, ): Promise { + const currentPeriod = getBusinessDateString().slice(0, 7); const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] = await Promise.all([ fetchAdminPayerAvatarUrl(userId), @@ -52,12 +53,11 @@ async function fetchDashboardNavbarDataInternal( }; } -export function fetchDashboardNavbarData( - userId: string, - currentPeriod: string, -) { +export function fetchDashboardNavbarData(userId: string) { + const currentPeriod = getBusinessDateString().slice(0, 7); + return unstable_cache( - () => fetchDashboardNavbarDataInternal(userId, currentPeriod), + () => fetchDashboardNavbarDataInternal(userId), [`dashboard-navbar-${userId}-${currentPeriod}`], { tags: [`dashboard-${userId}`], diff --git a/src/features/dashboard/notifications-actions.ts b/src/features/dashboard/notifications-actions.ts new file mode 100644 index 0000000..b92cc77 --- /dev/null +++ b/src/features/dashboard/notifications-actions.ts @@ -0,0 +1,252 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { dashboardNotificationStates } from "@/db/schema"; +import { + handleActionError, + revalidateForEntity, +} from "@/shared/lib/actions/helpers"; +import { getUser } from "@/shared/lib/auth/server"; +import { db } from "@/shared/lib/db"; +import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing"; +import type { ActionResult } from "@/shared/lib/types/actions"; + +const notificationStateSchema = z.object({ + notificationKey: z + .string({ message: "Chave da notificação inválida." }) + .trim() + .min(1, "Chave da notificação inválida."), + fingerprint: z + .string({ message: "Fingerprint da notificação inválido." }) + .trim() + .min(1, "Fingerprint da notificação inválido."), +}); + +type DashboardNotificationStateInput = z.infer; + +function revalidateNotifications(userId: string) { + revalidateForEntity("notifications", userId); +} + +async function getExistingNotificationState( + userId: string, + notificationKey: string, +) { + const [existing] = await db + .select({ + id: dashboardNotificationStates.id, + archivedAt: dashboardNotificationStates.archivedAt, + }) + .from(dashboardNotificationStates) + .where( + and( + eq(dashboardNotificationStates.userId, userId), + eq(dashboardNotificationStates.notificationKey, notificationKey), + ), + ) + .limit(1); + + return existing ?? null; +} + +export async function markDashboardNotificationAsReadAction( + input: DashboardNotificationStateInput, +): Promise { + try { + const user = await getUser(); + const data = notificationStateSchema.parse(input); + const now = new Date(); + const existing = await getExistingNotificationState( + user.id, + data.notificationKey, + ); + + if (existing) { + await db + .update(dashboardNotificationStates) + .set({ + fingerprint: data.fingerprint, + readAt: now, + archivedAt: existing.archivedAt, + updatedAt: now, + }) + .where( + and( + eq(dashboardNotificationStates.userId, user.id), + eq( + dashboardNotificationStates.notificationKey, + data.notificationKey, + ), + ), + ); + } else { + await db.insert(dashboardNotificationStates).values({ + userId: user.id, + notificationKey: data.notificationKey, + fingerprint: data.fingerprint, + readAt: now, + archivedAt: null, + updatedAt: now, + }); + } + + revalidateNotifications(user.id); + + return { success: true, message: "Notificação marcada como lida." }; + } catch (error) { + if (isNotificationStatesTableMissing(error)) { + return { + success: false, + error: + "A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.", + }; + } + + return handleActionError(error); + } +} + +export async function markDashboardNotificationAsUnreadAction( + input: DashboardNotificationStateInput, +): Promise { + try { + const user = await getUser(); + const data = notificationStateSchema.parse(input); + const now = new Date(); + const existing = await getExistingNotificationState( + user.id, + data.notificationKey, + ); + + if (!existing) { + return { success: true, message: "Notificação marcada como não lida." }; + } + + await db + .update(dashboardNotificationStates) + .set({ + fingerprint: data.fingerprint, + readAt: null, + archivedAt: existing.archivedAt, + updatedAt: now, + }) + .where( + and( + eq(dashboardNotificationStates.userId, user.id), + eq(dashboardNotificationStates.notificationKey, data.notificationKey), + ), + ); + + revalidateNotifications(user.id); + + return { success: true, message: "Notificação marcada como não lida." }; + } catch (error) { + if (isNotificationStatesTableMissing(error)) { + return { + success: false, + error: + "A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.", + }; + } + + return handleActionError(error); + } +} + +export async function archiveDashboardNotificationAction( + input: DashboardNotificationStateInput, +): Promise { + try { + const user = await getUser(); + const data = notificationStateSchema.parse(input); + const now = new Date(); + + await db + .insert(dashboardNotificationStates) + .values({ + userId: user.id, + notificationKey: data.notificationKey, + fingerprint: data.fingerprint, + readAt: now, + archivedAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + dashboardNotificationStates.userId, + dashboardNotificationStates.notificationKey, + ], + set: { + fingerprint: data.fingerprint, + readAt: now, + archivedAt: now, + updatedAt: now, + }, + }); + + revalidateNotifications(user.id); + + return { success: true, message: "Notificação arquivada." }; + } catch (error) { + if (isNotificationStatesTableMissing(error)) { + return { + success: false, + error: + "A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.", + }; + } + + return handleActionError(error); + } +} + +export async function unarchiveDashboardNotificationAction( + input: DashboardNotificationStateInput, +): Promise { + try { + const user = await getUser(); + const data = notificationStateSchema.parse(input); + const now = new Date(); + const existing = await getExistingNotificationState( + user.id, + data.notificationKey, + ); + + if (!existing) { + return { + success: false, + error: "Notificação não encontrada para restaurar.", + }; + } + + await db + .update(dashboardNotificationStates) + .set({ + fingerprint: data.fingerprint, + archivedAt: null, + readAt: now, + updatedAt: now, + }) + .where( + and( + eq(dashboardNotificationStates.userId, user.id), + eq(dashboardNotificationStates.notificationKey, data.notificationKey), + ), + ); + + revalidateNotifications(user.id); + + return { success: true, message: "Notificação restaurada." }; + } catch (error) { + if (isNotificationStatesTableMissing(error)) { + return { + success: false, + error: + "A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.", + }; + } + + return handleActionError(error); + } +} diff --git a/src/features/dashboard/notifications-queries.ts b/src/features/dashboard/notifications-queries.ts index 2034bd3..291b0bc 100644 --- a/src/features/dashboard/notifications-queries.ts +++ b/src/features/dashboard/notifications-queries.ts @@ -1,16 +1,24 @@ "use server"; -import { and, eq, lt, ne, sql } from "drizzle-orm"; +import { and, eq, inArray, lt, ne, sql } from "drizzle-orm"; import { budgets, cards, categories, + dashboardNotificationStates, invoices, transactions, } from "@/db/schema"; +import { buildInvoiceDetailsHref } from "@/features/dashboard/invoices-helpers"; import { db } from "@/shared/lib/db"; import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices"; +import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; +import type { + BudgetNotification, + DashboardNotification, + DashboardNotificationsSnapshot, +} from "@/shared/lib/types/notifications"; import { buildDateOnlyStringFromPeriodDay, getBusinessDateString, @@ -19,41 +27,65 @@ import { toDateOnlyString, } from "@/shared/utils/date"; import { safeToNumber as toNumber } from "@/shared/utils/number"; +import { formatPeriodForUrl } from "@/shared/utils/period"; -export type NotificationType = "overdue" | "due_soon"; - -export type DashboardNotification = { - id: string; - type: "invoice" | "boleto"; - name: string; - dueDate: string; - status: NotificationType; - amount: number; - period?: string; - showAmount: boolean; - cardLogo?: string | null; -}; - -export type BudgetStatus = "exceeded" | "critical"; - -export type BudgetNotification = { - id: string; - categoryName: string; - budgetAmount: number; - spentAmount: number; - usedPercentage: number; - status: BudgetStatus; -}; - -export type DashboardNotificationsSnapshot = { - notifications: DashboardNotification[]; - totalCount: number; - budgetNotifications: BudgetNotification[]; -}; +export type { + BudgetNotification, + BudgetStatus, + DashboardNotification, + DashboardNotificationsSnapshot, + NotificationType, +} from "@/shared/lib/types/notifications"; const PAYMENT_METHOD_BOLETO = "Boleto"; const BUDGET_CRITICAL_THRESHOLD = 80; +type PersistedNotificationState = { + notificationKey: string; + fingerprint: string; + readAt: Date | null; + archivedAt: Date | null; +}; + +const buildInvoiceNotificationKey = (cardId: string, period: string) => + `invoice-${cardId}-${period}`; + +const buildBoletoNotificationKey = (transactionId: string) => + `boleto-${transactionId}`; + +const buildBudgetNotificationKey = ( + categoryId: string | null, + budgetId: string, + period: string, +) => (categoryId ? `budget-${categoryId}-${period}` : `budget-${budgetId}`); + +function mergeNotificationState< + T extends { + notificationKey: string; + fingerprint: string; + isRead: boolean; + isArchived: boolean; + readAt: Date | null; + archivedAt: Date | null; + }, +>(items: T[], stateByKey: Map): T[] { + return items.map((item) => { + const persisted = stateByKey.get(item.notificationKey); + + if (!persisted || persisted.fingerprint !== item.fingerprint) { + return item; + } + + return { + ...item, + isRead: persisted.readAt !== null, + isArchived: persisted.archivedAt !== null, + readAt: persisted.readAt, + archivedAt: persisted.archivedAt, + }; + }); +} + /** * Busca todas as notificações do dashboard: * - Faturas de cartão atrasadas ou com vencimento próximo @@ -188,7 +220,9 @@ export async function fetchDashboardNotifications( db .select({ orcamentoId: budgets.id, + categoryId: budgets.categoryId, budgetAmount: budgets.amount, + period: budgets.period, categoriaName: categories.name, spentAmount: sql`COALESCE(SUM(ABS(${transactions.amount})), 0)`, }) @@ -216,12 +250,12 @@ export async function fetchDashboardNotifications( ); if (!dueDate) continue; const amount = toNumber(invoice.totalAmount); - const notificationId = invoice.invoiceId - ? `invoice-${invoice.invoiceId}` - : `invoice-${invoice.cardId}-${invoice.period}`; + const notificationKey = buildInvoiceNotificationKey( + invoice.cardId, + invoice.period, + ); notifications.push({ - id: notificationId, type: "invoice", name: invoice.cardName, dueDate, @@ -230,6 +264,13 @@ export async function fetchDashboardNotifications( period: invoice.period, showAmount: true, cardLogo: invoice.cardLogo, + notificationKey, + fingerprint: "overdue", + href: buildInvoiceDetailsHref(invoice.cardId, invoice.period), + isRead: false, + isArchived: false, + readAt: null, + archivedAt: null, }); } @@ -261,20 +302,28 @@ export async function fetchDashboardNotifications( ); if (!invoiceIsOverdue && !invoiceIsDueSoon) continue; - const notificationId = invoice.invoiceId - ? `invoice-${invoice.invoiceId}` - : `invoice-${invoice.cardId}-${invoice.period}`; + const notificationStatus = invoiceIsOverdue ? "overdue" : "due_soon"; + const notificationKey = buildInvoiceNotificationKey( + invoice.cardId, + invoice.period, + ); notifications.push({ - id: notificationId, type: "invoice", name: invoice.cardName, dueDate, - status: invoiceIsOverdue ? "overdue" : "due_soon", + status: notificationStatus, amount: Math.abs(amount), period: invoice.period, showAmount: invoiceIsOverdue, cardLogo: invoice.cardLogo, + notificationKey, + fingerprint: notificationStatus, + href: buildInvoiceDetailsHref(invoice.cardId, invoice.period), + isRead: false, + isArchived: false, + readAt: null, + archivedAt: null, }); } @@ -292,10 +341,11 @@ export async function fetchDashboardNotifications( const isOldPeriod = boleto.period < currentPeriod; const isCurrentPeriod = boleto.period === currentPeriod; const amount = toNumber(boleto.amount); + const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`; + const notificationKey = buildBoletoNotificationKey(boleto.id); if (isOldPeriod) { notifications.push({ - id: `boleto-${boleto.id}`, type: "boleto", name: boleto.name, dueDate, @@ -303,17 +353,32 @@ export async function fetchDashboardNotifications( amount: Math.abs(amount), period: boleto.period, showAmount: true, + notificationKey, + fingerprint: "overdue", + href, + isRead: false, + isArchived: false, + readAt: null, + archivedAt: null, }); } else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) { + const notificationStatus = boletoIsOverdue ? "overdue" : "due_soon"; + notifications.push({ - id: `boleto-${boleto.id}`, type: "boleto", name: boleto.name, dueDate, - status: boletoIsOverdue ? "overdue" : "due_soon", + status: notificationStatus, amount: Math.abs(amount), period: boleto.period, showAmount: boletoIsOverdue, + notificationKey, + fingerprint: notificationStatus, + href, + isRead: false, + isArchived: false, + readAt: null, + archivedAt: null, }); } } @@ -335,14 +400,26 @@ export async function fetchDashboardNotifications( const usedPercentage = (spentAmount / budgetAmount) * 100; if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue; + const notificationStatus = usedPercentage >= 100 ? "exceeded" : "critical"; + const notificationKey = buildBudgetNotificationKey( + row.categoryId, + row.orcamentoId, + row.period, + ); budgetNotifications.push({ - id: `budget-${row.orcamentoId}`, categoryName: row.categoriaName, budgetAmount, spentAmount, usedPercentage, - status: usedPercentage >= 100 ? "exceeded" : "critical", + status: notificationStatus, + notificationKey, + fingerprint: notificationStatus, + href: `/budgets?periodo=${formatPeriodForUrl(row.period)}`, + isRead: false, + isArchived: false, + readAt: null, + archivedAt: null, }); } @@ -353,9 +430,68 @@ export async function fetchDashboardNotifications( return b.usedPercentage - a.usedPercentage; }); - return { - notifications, - totalCount: notifications.length, + const notificationKeys = [ + ...notifications.map((notification) => notification.notificationKey), + ...budgetNotifications.map((notification) => notification.notificationKey), + ]; + + let persistedStates: PersistedNotificationState[] = []; + + if (notificationKeys.length > 0) { + try { + persistedStates = await db + .select({ + notificationKey: dashboardNotificationStates.notificationKey, + fingerprint: dashboardNotificationStates.fingerprint, + readAt: dashboardNotificationStates.readAt, + archivedAt: dashboardNotificationStates.archivedAt, + }) + .from(dashboardNotificationStates) + .where( + and( + eq(dashboardNotificationStates.userId, userId), + inArray( + dashboardNotificationStates.notificationKey, + notificationKeys, + ), + ), + ); + } catch (error) { + if (isNotificationStatesTableMissing(error)) { + console.warn( + "[DashboardNotifications] Tabela dashboard_notification_states ainda não existe. Voltando ao modo sem persistência.", + ); + } else { + throw error; + } + } + } + + const stateByKey = new Map( + persistedStates.map((state) => [state.notificationKey, state]), + ); + + const mergedNotifications = mergeNotificationState(notifications, stateByKey); + const mergedBudgetNotifications = mergeNotificationState( budgetNotifications, + stateByKey, + ); + const visibleNotifications = mergedNotifications.filter( + (notification) => !notification.isArchived, + ); + const visibleBudgetNotifications = mergedBudgetNotifications.filter( + (notification) => !notification.isArchived, + ); + const unreadCount = [ + ...visibleNotifications, + ...visibleBudgetNotifications, + ].filter((notification) => !notification.isRead).length; + + return { + notifications: mergedNotifications, + budgetNotifications: mergedBudgetNotifications, + unreadCount, + visibleCount: + visibleNotifications.length + visibleBudgetNotifications.length, }; } diff --git a/src/shared/components/navigation/navbar/app-navbar.tsx b/src/shared/components/navigation/navbar/app-navbar.tsx index ecd801f..fb4acf4 100644 --- a/src/shared/components/navigation/navbar/app-navbar.tsx +++ b/src/shared/components/navigation/navbar/app-navbar.tsx @@ -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({
diff --git a/src/shared/components/navigation/navbar/navbar-user.tsx b/src/shared/components/navigation/navbar/navbar-user.tsx index c0ef339..7b524e4 100644 --- a/src/shared/components/navigation/navbar/navbar-user.tsx +++ b/src/shared/components/navigation/navbar/navbar-user.tsx @@ -63,7 +63,7 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
- ); -} - -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 ( - - {icon} - - - {title} - - - {detail} - - - - - ); -} - -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 ( - - - - - - - - Notificações - - + - {/* Header */} - - Notificações - {hasNotifications && ( - - {effectiveTotalCount}{" "} - {effectiveTotalCount === 1 ? "item" : "itens"} - - )} - + - {!hasNotifications ? ( -
- - - - - Nenhuma notificação - - Você está em dia com seus pagamentos! - - -
+ {hasVisibleItems ? ( + ) : ( -
- {/* Pré-lançamentos */} - {preLancamentosCount > 0 && ( -
- } - title="Pré-lançamentos" - /> - } - title={ - preLancamentosCount === 1 - ? "1 pré-lançamento pendente" - : `${preLancamentosCount} pré-lançamentos pendentes` - } - detail="Aguardando revisão" - onClose={() => setOpen(false)} - /> -
- )} - - {/* Orçamentos */} - {budgetNotifications.length > 0 && ( -
- } - title="Orçamentos" - /> - {budgetNotifications.map((n) => ( - - ) : ( - - ) - } - 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)} - /> - ))} -
- )} - - {/* Cartão de Crédito */} - {invoiceNotifications.length > 0 && ( -
- } - title="Cartão de Crédito" - /> - {invoiceNotifications.map((n) => { - const logo = resolveLogoSrc(n.cardLogo); - return ( - - ) : n.status === "overdue" ? ( - - ) : ( - - ) - } - 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)} - /> - ); - })} -
- )} - - {/* Boletos */} - {boletoNotifications.length > 0 && ( -
- } - title="Boletos" - /> - {boletoNotifications.map((n) => ( - - } - 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)} - /> - ))} -
- )} -
+ )}
diff --git a/src/shared/components/navigation/navbar/notification-bell/notification-bell-content.tsx b/src/shared/components/navigation/navbar/notification-bell/notification-bell-content.tsx new file mode 100644 index 0000000..1faf4c3 --- /dev/null +++ b/src/shared/components/navigation/navbar/notification-bell/notification-bell-content.tsx @@ -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; + onToggleRead: (notification: StatefulNotification) => Promise; + onToggleArchive: (notification: StatefulNotification) => Promise; +}; + +// --------------------------------------------------------------------------- +// 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 ? ( + + ) : ( + + ), + }; +} + +function getArchiveAction(notification: StatefulNotification) { + return { + label: notification.isArchived + ? "Desarquivar notificação" + : "Arquivar notificação", + icon: notification.isArchived ? ( + + ) : ( + + ), + }; +} + +// --------------------------------------------------------------------------- +// SectionLabel +// --------------------------------------------------------------------------- + +function SectionLabel({ + icon, + title, +}: { + icon: React.ReactNode; + title: string; +}) { + return ( +
+ {icon} + + {title} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Action button +// --------------------------------------------------------------------------- + +function NotificationActionButton({ + label, + icon, + onClick, + disabled = false, +}: { + label: string; + icon: React.ReactNode; + onClick?: () => void | Promise; + disabled?: boolean; +}) { + return ( + + + + + + {label} + + + ); +} + +// --------------------------------------------------------------------------- +// NotificationItem +// --------------------------------------------------------------------------- + +type NotificationItemProps = { + icon: React.ReactNode; + isOverdue: boolean; + isRead?: boolean; + isArchived?: boolean; + isBusy?: boolean; + showUnreadIndicator?: boolean; + title: string; + detail: string; + onNavigate: () => void | Promise; + onToggleRead?: () => void | Promise; + onToggleArchive?: () => void | Promise; + 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 ( +
+ + + {(readAction || archiveAction) && ( +
+ {readAction && onToggleRead && ( + + )} + {archiveAction && onToggleArchive && ( + + )} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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; + onToggleRead: (item: T) => void | Promise; + onToggleArchive: (item: T) => void | Promise; +}; + +function NotificationSection< + T extends StatefulNotification & { isBusy: boolean }, +>({ + icon, + title, + items, + renderIcon, + renderTitle, + renderDetail, + isOverdue, + showUnreadIndicator = false, + onNavigate, + onToggleRead, + onToggleArchive, +}: NotificationSectionProps) { + if (items.length === 0) return null; + + return ( +
+ + {items.map((item) => ( + onNavigate(item)} + onToggleRead={() => onToggleRead(item)} + onToggleArchive={() => onToggleArchive(item)} + notification={item} + /> + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Icon helpers (consistent between invoice / boleto) +// --------------------------------------------------------------------------- + +function DueDateIcon({ isOverdue }: { isOverdue: boolean }) { + return isOverdue ? ( + + ) : ( + + ); +} + +function InvoiceIcon({ + cardLogo, + isOverdue, +}: { + cardLogo?: string | null; + isOverdue: boolean; +}) { + const logo = resolveLogoSrc(cardLogo); + + if (logo) { + return ( + + ); + } + + return ; +} + +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 ( +
+ {displayedPreLancamentosCount > 0 && ( +
+ } + title="Pré-lançamentos" + /> + } + isOverdue={false} + title={ + displayedPreLancamentosCount === 1 + ? "1 pré-lançamento pendente" + : `${displayedPreLancamentosCount} pré-lançamentos pendentes` + } + detail="Aguardando revisão" + onNavigate={onInboxNavigate} + /> +
+ )} + + } + title="Orçamentos" + items={displayedBudgetNotifications} + isOverdue={(n) => n.status === "exceeded"} + showUnreadIndicator + renderIcon={(n) => + n.status === "exceeded" ? ( + + ) : ( + + ) + } + 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)} + /> + + } + title="Cartão de Crédito" + items={invoiceNotifications} + isOverdue={(n) => n.status === "overdue"} + showUnreadIndicator + renderIcon={(n) => ( + + )} + 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)} + /> + + } + title="Boletos" + items={boletoNotifications} + isOverdue={(n) => n.status === "overdue"} + showUnreadIndicator + renderIcon={(n) => } + 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)} + /> +
+ ); +} diff --git a/src/shared/components/navigation/navbar/notification-bell/notification-bell-empty-state.tsx b/src/shared/components/navigation/navbar/notification-bell/notification-bell-empty-state.tsx new file mode 100644 index 0000000..a44d508 --- /dev/null +++ b/src/shared/components/navigation/navbar/notification-bell/notification-bell-empty-state.tsx @@ -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 ( +
+ + + {showArchived ? ( + + ) : ( + + )} + + + {showArchived + ? "Nenhuma notificação arquivada" + : hasArchivedItems + ? "Nenhuma notificação ativa" + : "Nenhuma notificação"} + + + {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!"} + + +
+ ); +} diff --git a/src/shared/components/navigation/navbar/notification-bell/notification-bell-header.tsx b/src/shared/components/navigation/navbar/notification-bell/notification-bell-header.tsx new file mode 100644 index 0000000..99bb3d1 --- /dev/null +++ b/src/shared/components/navigation/navbar/notification-bell/notification-bell-header.tsx @@ -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 ( +
+
+ Notificações + {hasAnySourceItems ? ( + + {headerCountLabel} + + ) : null} +
+ {hasDashboardNotificationItems ? ( +
+ { + 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" + > + + Ativas + + + Arquivadas + {hasArchivedItems ? ` (${archivedDashboardCount})` : ""} + + +
+ ) : null} +
+ ); +} diff --git a/src/shared/components/navigation/navbar/notification-bell/notification-bell-trigger.tsx b/src/shared/components/navigation/navbar/notification-bell/notification-bell-trigger.tsx new file mode 100644 index 0000000..b1f892b --- /dev/null +++ b/src/shared/components/navigation/navbar/notification-bell/notification-bell-trigger.tsx @@ -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 ( + + + + + + + + Notificações + + + ); +} diff --git a/src/shared/components/navigation/navbar/notification-bell/types.ts b/src/shared/components/navigation/navbar/notification-bell/types.ts new file mode 100644 index 0000000..37e2506 --- /dev/null +++ b/src/shared/components/navigation/navbar/notification-bell/types.ts @@ -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; +}; diff --git a/src/shared/components/navigation/navbar/notification-bell/use-notification-bell.ts b/src/shared/components/navigation/navbar/notification-bell/use-notification-bell.ts new file mode 100644 index 0000000..49dd8d0 --- /dev/null +++ b/src/shared/components/navigation/navbar/notification-bell/use-notification-bell.ts @@ -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; + handleToggleRead: (notification: StatefulNotification) => Promise; + handleToggleArchive: (notification: StatefulNotification) => Promise; + 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("active"); + const [notificationActions, setNotificationActions] = useState< + Record + >({}); + 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 = ( + 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 => { + 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, + }; +} diff --git a/src/shared/lib/actions/helpers.ts b/src/shared/lib/actions/helpers.ts index fdf27aa..0e2caf6 100644 --- a/src/shared/lib/actions/helpers.ts +++ b/src/shared/lib/actions/helpers.ts @@ -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 = new Set([ "budgets", "payers", "notes", + "notifications", "inbox", "recurring", ]); diff --git a/src/shared/lib/notifications/is-table-missing.ts b/src/shared/lib/notifications/is-table-missing.ts new file mode 100644 index 0000000..e776a89 --- /dev/null +++ b/src/shared/lib/notifications/is-table-missing.ts @@ -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")) + ); +} diff --git a/src/shared/lib/types/index.ts b/src/shared/lib/types/index.ts index adaf110..da3ff06 100644 --- a/src/shared/lib/types/index.ts +++ b/src/shared/lib/types/index.ts @@ -1,3 +1,4 @@ export * from "./actions"; export * from "./calendar"; +export * from "./notifications"; export * from "./reports"; diff --git a/src/shared/lib/types/notifications.ts b/src/shared/lib/types/notifications.ts new file mode 100644 index 0000000..08ba9b8 --- /dev/null +++ b/src/shared/lib/types/notifications.ts @@ -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; +};