From b26c3e58d156d0b6847f55e7f0045de000939cff Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Tue, 21 Jul 2026 21:51:13 -0300 Subject: [PATCH] feat: implement notification dropdown in header with centralized navigation logic --- txclient/src/app/components/Header/Header.tsx | 217 +++++++++++++++++- .../app/components/Header/header.module.css | 190 ++++++++++++++- .../app/user/profile/UserProfile.module.css | 7 + .../notifications/Notifications.Navigation.ts | 79 +++++++ .../app/user/profile/notifications/page.tsx | 189 ++++++++------- 5 files changed, 580 insertions(+), 102 deletions(-) create mode 100644 txclient/src/app/user/profile/notifications/Notifications.Navigation.ts diff --git a/txclient/src/app/components/Header/Header.tsx b/txclient/src/app/components/Header/Header.tsx index a79e7dc..b083d1f 100644 --- a/txclient/src/app/components/Header/Header.tsx +++ b/txclient/src/app/components/Header/Header.tsx @@ -6,7 +6,7 @@ import NotificationIcon from "@mui/icons-material/NotificationsNone"; import Badge from "@mui/material/Badge"; import { useUserMenuStore } from "@store/UserMenu.Store"; import { HEADER_MODES, HEADER_WIDGETS, useHeaderStore } from "@store/Header.Store"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import useWindowSize from "@hooks/WindowSize"; import Textbox from "../Textbox/Textbox"; import { useNavigation } from "@core/app/hooks/goto"; @@ -15,6 +15,18 @@ import { NO_CATEGORY } from "@core/Models/Categories.type"; import { IconButton } from "@mui/material"; import { useSessionStore } from "@core/Store/Sesion.Store"; import { blurElementsOnLoad } from "@core/helpers/blur"; +import { + ISystemNotification, + PaginateSystemNotificationsParams, +} from "@core/Models/SystemNotifications.model"; +import { + findSystemNotifications, + updateSystemNotification, +} from "@core/app/user/profile/notifications/Notifications.Service"; +import { + NOTIFICATIONS_ROUTE, + resolveNotificationRoute, +} from "@core/app/user/profile/notifications/Notifications.Navigation"; //import { useSessionStore, useSessionTokenStore } from "@core/Store/Sesion.Store"; @@ -27,6 +39,11 @@ export default function Header(): React.ReactElement { const [windowWidth, windowHeight] = useWindowSize(); const [filterText, setFilterText] = useState(""); const [publicFilterText, setPublicFilterText] = useState(""); + const [notificationsOpen, setNotificationsOpen] = useState(false); + const [notificationsLoading, setNotificationsLoading] = useState(false); + const [notificationsError, setNotificationsError] = useState(""); + const [notifications, setNotifications] = useState([]); + const notificationsRef = useRef(null); const { goTo } = useNavigation(); const pathName = usePathname(); @@ -78,6 +95,35 @@ export default function Header(): React.ReactElement { setPublicFilterText(headerState.publicFindText); }, [headerState.publicFindText]); + useEffect(() => { + if (!notificationsOpen) { + return; + } + + const handleClickOutside = (event: MouseEvent) => { + if ( + notificationsRef.current && + !notificationsRef.current.contains(event.target as Node) + ) { + setNotificationsOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setNotificationsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [notificationsOpen]); + const toggleMenu = () => { menuState.setVisible(!menuState.visible); }; @@ -126,6 +172,85 @@ export default function Header(): React.ReactElement { } }; + const loadLatestNotifications = () => { + if (!SessionInfo.userId) { + return; + } + + const filterData: PaginateSystemNotificationsParams = { + userId: SessionInfo.userId, + sessionUser: SessionInfo.userId, + page: 1, + limit: 10, + }; + + setNotificationsLoading(true); + setNotificationsError(""); + + findSystemNotifications(filterData) + .then((res) => { + setNotifications(res.data || []); + }) + .catch(() => { + setNotifications([]); + setNotificationsError("No pudimos cargar tus notificaciones."); + }) + .finally(() => { + setNotificationsLoading(false); + }); + }; + + const toggleNotifications = () => { + const nextOpen = !notificationsOpen; + setNotificationsOpen(nextOpen); + + if (nextOpen) { + loadLatestNotifications(); + } + }; + + const viewAllNotifications = () => { + setNotificationsOpen(false); + goTo(NOTIFICATIONS_ROUTE); + }; + + const openNotification = async (notification: ISystemNotification) => { + setNotificationsOpen(false); + + try { + if (!notification.readed) { + await updateSystemNotification({ + id: notification._id, + readed: true, + sessionUser: SessionInfo.userId, + }); + const currentCount = useHeaderStore.getState().notificationsCount; + headerState.setNotificationsCount(Math.max(currentCount - 1, 0)); + } + + const route = await resolveNotificationRoute({ + notification, + sessionUser: SessionInfo.userId, + organizationSubscriptions: SessionInfo.organizationSubscriptions, + }); + goTo(route); + } catch (error) { + console.error("Error navigating from notification", error); + goTo(NOTIFICATIONS_ROUTE); + } + }; + + const formatNotificationDate = (date: Date) => { + if (!date) { + return ""; + } + + return new Date(date).toLocaleDateString("es-AR", { + day: "2-digit", + month: "short", + }); + }; + return ( <>
@@ -197,18 +322,86 @@ export default function Header(): React.ReactElement { )} {showNotificationIcon && ( - goTo("/user/profile/notifications")} - > - + - - - + + + + + + {notificationsOpen && ( +
+
+
+ Notificaciones + Últimas novedades +
+ {headerState.notificationsCount > 0 && ( + {headerState.notificationsCount} + )} +
+ +
+ {notificationsLoading && ( +
Cargando...
+ )} + + {!notificationsLoading && notificationsError && ( +
{notificationsError}
+ )} + + {!notificationsLoading && !notificationsError && notifications.length === 0 && ( +
+ No tenés notificaciones recientes. +
+ )} + + {!notificationsLoading && + !notificationsError && + notifications.map((notification) => ( + + ))} +
+ + +
+ )} +
)} diff --git a/txclient/src/app/components/Header/header.module.css b/txclient/src/app/components/Header/header.module.css index 19102d5..4c49d2a 100644 --- a/txclient/src/app/components/Header/header.module.css +++ b/txclient/src/app/components/Header/header.module.css @@ -8,7 +8,7 @@ .header { position: fixed; - overflow: hidden; + overflow: visible; left: 0px; top: 0px; right: 0px; @@ -25,3 +25,191 @@ .menuIcon { cursor: pointer; } + +.notificationsWrap { + position: relative; +} + +.notificationsMenu { + position: absolute; + top: calc(100% + 12px); + right: 12px; + width: min(380px, calc(100vw - 32px)); + max-height: calc(100vh - 90px); + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 22px; + background: var(--white); + box-shadow: 0 22px 60px rgba(0, 0, 0, 0.26); + color: var(--black-light); +} + +.notificationsMenu::before { + content: ""; + position: absolute; + top: -7px; + right: 28px; + width: 14px; + height: 14px; + transform: rotate(45deg); + background: var(--wine-darkest); +} + +.notificationsHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + background: linear-gradient(135deg, var(--wine-darkest), var(--wine-dark)); + color: var(--white); +} + +.notificationsHeader strong, +.notificationsHeader span { + display: block; +} + +.notificationsHeader strong { + font-size: 17px; +} + +.notificationsHeader span { + margin-top: 3px; + color: var(--white-dark); + font-size: 12px; +} + +.notificationsHeader b { + min-width: 28px; + padding: 5px 8px; + border-radius: 999px; + background: var(--wine-lighterX2); + color: var(--wine-superdark); + font-size: 13px; + text-align: center; +} + +.notificationsList { + max-height: 390px; + overflow-y: auto; + padding: 8px; + background: var(--white-dark); +} + +.notificationsState { + padding: 26px 18px; + color: var(--gray-dark); + font-size: 14px; + text-align: center; +} + +.notificationItem { + width: 100%; + display: flex; + gap: 11px; + padding: 13px 12px; + border: 1px solid transparent; + border-radius: 16px; + background: var(--white); + color: inherit; + cursor: pointer; + text-align: left; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} + +.notificationItem + .notificationItem { + margin-top: 7px; +} + +.notificationItem:hover { + transform: translateY(-1px); + border-color: var(--wine-lighterX2); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); +} + +.notificationDot { + flex: 0 0 auto; + width: 9px; + height: 9px; + margin-top: 5px; + border-radius: 999px; + background: var(--wine-red); + box-shadow: 0 0 0 4px var(--wine-lighterX2); +} + +.notificationContent { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; +} + +.notificationTitle { + overflow: hidden; + color: var(--wine-darkest); + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notificationMessage { + display: -webkit-box; + overflow: hidden; + color: var(--black-light); + font-size: 13px; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.notificationDate { + color: var(--gray-dark); + font-size: 11px; +} + +.notificationRead { + background: var(--white); + opacity: 0.76; +} + +.notificationRead .notificationDot { + background: var(--gray-light); + box-shadow: 0 0 0 4px var(--gray-lighter); +} + +.notificationRead .notificationTitle { + color: var(--black-light); + font-weight: 600; +} + +.notificationsFooter { + width: 100%; + padding: 14px 18px; + border: 0; + border-top: 1px solid var(--gray-lighter); + background: var(--white); + color: var(--wine-dark); + cursor: pointer; + font-size: 14px; + font-weight: 700; + transition: background-color 0.18s ease, color 0.18s ease; +} + +.notificationsFooter:hover { + background: var(--wine-lighterX2); + color: var(--wine-darkest); +} + +@media (max-width: 600px) { + .header { + padding-left: 18px; + padding-right: 18px; + } + + .notificationsMenu { + right: -48px; + } +} diff --git a/txclient/src/app/user/profile/UserProfile.module.css b/txclient/src/app/user/profile/UserProfile.module.css index e0434d7..fcaf7ed 100644 --- a/txclient/src/app/user/profile/UserProfile.module.css +++ b/txclient/src/app/user/profile/UserProfile.module.css @@ -40,3 +40,10 @@ .userProfileOptionsContainer a { margin-top: 10px; } + +@media (max-width: 640px) { + .userProfileOptionsContainer { + left: 10px; + right: 10px; + } +} diff --git a/txclient/src/app/user/profile/notifications/Notifications.Navigation.ts b/txclient/src/app/user/profile/notifications/Notifications.Navigation.ts new file mode 100644 index 0000000..cead9b5 --- /dev/null +++ b/txclient/src/app/user/profile/notifications/Notifications.Navigation.ts @@ -0,0 +1,79 @@ +import { ISystemNotification, NotificationType } from "@core/Models/SystemNotifications.model"; +import API from "@services/Api.Service"; + +type ResolveNotificationRouteParams = { + notification: ISystemNotification; + sessionUser: string; + organizationSubscriptions?: Record; +}; + +export const NOTIFICATIONS_ROUTE = "/user/profile/notifications"; + +export const resolveNotificationRoute = async ({ + notification, + sessionUser, + organizationSubscriptions, +}: ResolveNotificationRouteParams): Promise => { + if (!notification.type || !notification.code) { + return NOTIFICATIONS_ROUTE; + } + + let companyId = notification.companyId; + + switch (notification.type) { + case NotificationType.APPOINTMENT: { + const aptRes = await API.post("appointments/get-event", { + appointmentId: notification.code, + sessionUser, + }); + + companyId = companyId || aptRes?.companyId; + if (aptRes?.clientId && companyId) { + return `/admin/org/${companyId}/client/${aptRes.clientId}/appointments/${notification.code}`; + } + break; + } + case NotificationType.COMPANY: + return `/admin/org/profile/${notification.code}`; + case NotificationType.EMPLOYEE: + case NotificationType.SCHEDULE: + case NotificationType.SCHEDULE_EXCEPTION: + case NotificationType.SCHEDULE_RESTRICTION: + if (!companyId) { + const empRes = await API.post("employees/get-by-id", { + id: notification.code, + sessionUser, + }); + companyId = empRes?.companyId; + } + + if (companyId) { + if ( + notification.type === NotificationType.EMPLOYEE || + notification.type === NotificationType.SCHEDULE + ) { + return `/admin/org/profile/${companyId}/collaborators/${notification.code}`; + } + + if (notification.type === NotificationType.SCHEDULE_EXCEPTION) { + return `/admin/org/profile/${companyId}/collaborators/${notification.code}/exceptions`; + } + + return `/admin/org/profile/${companyId}/collaborators/${notification.code}/restrictions`; + } + break; + case NotificationType.BILLING: + companyId = companyId || Object.keys(organizationSubscriptions || {})[0]; + if (companyId) { + return `/admin/org/profile/${companyId}/dashboard`; + } + break; + case NotificationType.MESSAGE: + if (notification.conversationId) { + return `/messages?conversation=${notification.conversationId}`; + } + break; + } + + return NOTIFICATIONS_ROUTE; +}; diff --git a/txclient/src/app/user/profile/notifications/page.tsx b/txclient/src/app/user/profile/notifications/page.tsx index b14188e..8490e53 100644 --- a/txclient/src/app/user/profile/notifications/page.tsx +++ b/txclient/src/app/user/profile/notifications/page.tsx @@ -14,7 +14,6 @@ import { UpdateSystemNotificationParams, NotificationType, } from "@core/Models/SystemNotifications.model"; -import API from "@services/Api.Service"; import { useRouter } from "next/navigation"; import { deleteNotificationsByDate, @@ -23,18 +22,19 @@ import { setStateNotificationByUser, updateSystemNotification, } from "./Notifications.Service"; +import { resolveNotificationRoute } from "./Notifications.Navigation"; import DeleteIcon from "@mui/icons-material/DeleteOutlined"; import MarkReadIcon from "@mui/icons-material/MarkChatReadOutlined"; import MarkUnReadIcon from "@mui/icons-material/MarkUnreadChatAltOutlined"; import ReloadIcon from "@mui/icons-material/Autorenew"; import DoneAllIcon from "@mui/icons-material/DoneAll"; -import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive"; import InboxIcon from "@mui/icons-material/Inbox"; import IconButton from "@mui/material/IconButton"; import Pagination from "@mui/material/Pagination"; import { useConfirmStore } from "@core/Store/Confirm.Store"; import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider"; import { useMenuStore } from "@core/Store/Menu.Store"; +import useWindowSize from "@hooks/WindowSize"; import dayjs from "dayjs"; import "dayjs/locale/es"; import { motion, AnimatePresence } from "motion/react"; @@ -49,6 +49,7 @@ export default function EditProfile() { const location = useLocationStore(); const menu = useMenuStore(); const router = useRouter(); + const [windowWidth] = useWindowSize(); const [filterType, setFilterType] = useState("ALL"); const [pageSize] = useState( parseInt( @@ -129,66 +130,22 @@ export default function EditProfile() { }; const handleNotificationClick = async (notif: SystemNotificationRow) => { - if (!notif.readed) { - handleMarkRead(notif); - } - - if (!notif.type || !notif.code) return; - eventHandler.setEventType(EVENT_TYPES.LOADING); try { - let companyId = notif.companyId; - - switch (notif.type) { - case NotificationType.APPOINTMENT: - // Fetch appointment to get clientId and companyId - const aptRes = await API.post("appointments/get-event", { - appointmentId: notif.code, - sessionUser: SessionInfo.userId, - }); - if (aptRes && aptRes.clientId) { - companyId = companyId || aptRes.companyId; - if (companyId) { - router.push(`/admin/org/${companyId}/client/${aptRes.clientId}/appointments/${notif.code}`); - } - } - break; - case NotificationType.COMPANY: - router.push(`/admin/org/profile/${notif.code}`); - break; - case NotificationType.EMPLOYEE: - case NotificationType.SCHEDULE: - case NotificationType.SCHEDULE_EXCEPTION: - case NotificationType.SCHEDULE_RESTRICTION: - if (!companyId) { - const empRes = await API.post("employees/get-by-id", { - id: notif.code, - sessionUser: SessionInfo.userId, - }); - companyId = companyId || empRes?.companyId; - } - if (companyId) { - if (notif.type === NotificationType.EMPLOYEE || notif.type === NotificationType.SCHEDULE) { - router.push(`/admin/org/profile/${companyId}/collaborators/${notif.code}`); - } else if (notif.type === NotificationType.SCHEDULE_EXCEPTION) { - router.push(`/admin/org/profile/${companyId}/collaborators/${notif.code}/exceptions`); - } else { - router.push(`/admin/org/profile/${companyId}/collaborators/${notif.code}/restrictions`); - } - } - break; - case NotificationType.BILLING: - companyId = companyId || Object.keys(SessionInfo.organizationSubscriptions || {})[0]; - if (companyId) { - router.push(`/admin/org/profile/${companyId}/dashboard`); - } - break; - case NotificationType.MESSAGE: - if (notif.conversationId) { - router.push(`/messages?conversation=${notif.conversationId}`); - } - break; + if (!notif.readed) { + await updateSystemNotification({ + id: notif._id, + readed: true, + sessionUser: SessionInfo.userId, + }); } + + const route = await resolveNotificationRoute({ + notification: notif, + sessionUser: SessionInfo.userId, + organizationSubscriptions: SessionInfo.organizationSubscriptions, + }); + router.push(route); } catch (e: any) { console.error("Error navigating from notification", e); alert.showError(e.format ? e.format() : "Error al navegar: " + (e.message || "Desconocido")); @@ -332,7 +289,7 @@ export default function EditProfile() { }); location.add({ - url: "/user/profile/edit", + url: "/user/profile/notifications", title: "Notificaciones", icon: , }); @@ -395,27 +352,55 @@ export default function EditProfile() { const unreadCount = allDataList.filter(n => !n.readed).length; const isInboxZero = allDataList.length > 0 && unreadCount === 0; + const isMobile = windowWidth < 640; return ( <> + -
+
- {/* Header and Controls */} -
-
-

- - Bandeja de Entrada -

-

- {unreadCount > 0 ? `Tienes ${unreadCount} notificación(es) sin leer.` : "No tienes notificaciones pendientes."} -

-
- -
+ {/* Controls */} +
+
{unreadCount > 0 && (